Skip to content

Commit 707edee

Browse files
authored
fix(core): make the dev-bypass token idempotent by name (#1584)
The dev setup-bypass endpoint (`?token=1`) created a fresh `dev-bypass-token` PAT on every call, so re-running it after a dev reset left duplicate rows. The e2e API-tokens page then tripped Playwright strict mode (the unscoped `dev-bypass-token` locator matched more than one). The endpoint now deletes any existing token of that name before minting a new one, via a new `deleteApiTokensByName` helper, keeping exactly one while still returning a usable raw token.
1 parent 640e60a commit 707edee

4 files changed

Lines changed: 105 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes the dev setup-bypass endpoint accumulating duplicate `dev-bypass-token` access tokens. Re-running it (for example after a dev reset) now replaces the previous token with a fresh one instead of adding another row.

packages/core/src/api/handlers/api-tokens.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,25 @@ export async function handleApiTokenRevoke(
172172
}
173173
}
174174

175+
/**
176+
* Delete every token with the given name for a user, returning how many were
177+
* removed. Token names aren't unique, so this is a bulk delete — used by the
178+
* dev-bypass flow to keep a single `dev-bypass-token` instead of minting a new
179+
* one on every reset.
180+
*/
181+
export async function deleteApiTokensByName(
182+
db: Kysely<Database>,
183+
userId: string,
184+
name: string,
185+
): Promise<number> {
186+
const result = await db
187+
.deleteFrom("_emdash_api_tokens")
188+
.where("user_id", "=", userId)
189+
.where("name", "=", name)
190+
.executeTakeFirst();
191+
return Number(result.numDeletedRows ?? 0n);
192+
}
193+
175194
/**
176195
* Resolve a raw API token (ec_pat_...) to a user ID and scopes.
177196
* Updates last_used_at on successful lookup.

packages/core/src/astro/routes/api/setup/dev-bypass.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { ulid } from "ulidx";
2323

2424
import { apiError, apiSuccess, handleError } from "#api/error.js";
2525
import { escapeHtml } from "#api/escape.js";
26-
import { handleApiTokenCreate } from "#api/handlers/api-tokens.js";
26+
import { deleteApiTokensByName, handleApiTokenCreate } from "#api/handlers/api-tokens.js";
2727
import { getPublicOrigin } from "#api/public-url.js";
2828
import { isSafeRedirect } from "#api/redirect.js";
2929
import { runMigrations } from "#db/migrations/runner.js";
@@ -134,6 +134,10 @@ async function handleDevBypass(context: Parameters<APIRoute>[0]): Promise<Respon
134134
// Optionally create a PAT token (?token=1) for headless/CLI testing.
135135
let token: string | undefined;
136136
if (url.searchParams.has("token")) {
137+
// Idempotent by name: a prior reset can leave a stale dev-bypass-token,
138+
// and the raw token is only available at creation, so drop any existing
139+
// one and mint a fresh, usable PAT rather than accumulating duplicates.
140+
await deleteApiTokensByName(emdash.db, user.id, "dev-bypass-token");
137141
const result = await handleApiTokenCreate(emdash.db, user.id, {
138142
name: "dev-bypass-token",
139143
scopes: [
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { Kysely } from "kysely";
2+
import { ulid } from "ulidx";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
5+
import {
6+
deleteApiTokensByName,
7+
handleApiTokenCreate,
8+
handleApiTokenList,
9+
} from "../../src/api/handlers/api-tokens.js";
10+
import type { Database } from "../../src/database/types.js";
11+
import { setupTestDatabase, teardownTestDatabase } from "../utils/test-db.js";
12+
13+
describe("api tokens", () => {
14+
let db: Kysely<Database>;
15+
let userId: string;
16+
17+
beforeEach(async () => {
18+
db = await setupTestDatabase();
19+
userId = ulid();
20+
const now = new Date().toISOString();
21+
// Tokens reference users (FK), so create one first.
22+
await db
23+
.insertInto("users")
24+
.values({
25+
id: userId,
26+
email: "dev@example.com",
27+
name: "Dev",
28+
avatar_url: null,
29+
role: 50,
30+
email_verified: 1,
31+
disabled: 0,
32+
data: null,
33+
created_at: now,
34+
updated_at: now,
35+
})
36+
.execute();
37+
});
38+
afterEach(async () => {
39+
await teardownTestDatabase(db);
40+
});
41+
42+
it("deleteApiTokensByName removes only the matching name for that user", async () => {
43+
await handleApiTokenCreate(db, userId, { name: "dev-bypass-token", scopes: ["admin"] });
44+
await handleApiTokenCreate(db, userId, { name: "dev-bypass-token", scopes: ["admin"] });
45+
await handleApiTokenCreate(db, userId, { name: "keepme", scopes: ["admin"] });
46+
47+
const removed = await deleteApiTokensByName(db, userId, "dev-bypass-token");
48+
expect(removed).toBe(2);
49+
50+
const list = await handleApiTokenList(db, userId);
51+
expect(list.success).toBe(true);
52+
if (list.success) {
53+
expect(list.data.items.map((t) => t.name)).toEqual(["keepme"]);
54+
}
55+
});
56+
57+
it("re-issuing the dev-bypass token leaves exactly one row (idempotent by name)", async () => {
58+
// The dev-bypass `?token=1` path drops the prior token before minting a
59+
// fresh one. Running it twice (e.g. across a reset) must not accumulate.
60+
for (let i = 0; i < 2; i++) {
61+
await deleteApiTokensByName(db, userId, "dev-bypass-token");
62+
const res = await handleApiTokenCreate(db, userId, {
63+
name: "dev-bypass-token",
64+
scopes: ["admin"],
65+
});
66+
expect(res.success).toBe(true);
67+
}
68+
69+
const list = await handleApiTokenList(db, userId);
70+
expect(list.success).toBe(true);
71+
if (list.success) {
72+
const devTokens = list.data.items.filter((t) => t.name === "dev-bypass-token");
73+
expect(devTokens).toHaveLength(1);
74+
}
75+
});
76+
});

0 commit comments

Comments
 (0)