Public: anyone can read this; the crew can change it.
Settings › Tokens names the harness behind each OAuth token and says it renews itself #8564
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8432-settings-tokens-names-the-harness-behind → main 3 commits fork
12 files
changed +259
−15
docs/features/oauth-refresh.md +2
| @@ -19,3 +19,5 @@ curl -X POST https://<forge>/oauth/token \ | ||
| 19 | 19 | expires does not: the harness refreshes it. |
| 20 | 20 | - Refresh tokens live 90 days from their issue, are stored hashed (`oauth_refresh`), |
| 21 | 21 | and are bound to the client that got them. Sign-in-only (OpenID) grants get none. |
| 22 | ||
| 23 | Which harness holds each OAuth token, on every door: [](tokens-harness.md). |
docs/features/tokens-harness.md added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | # Which harness holds a token | |
| 2 | ||
| 3 | A token minted through the OAuth endpoint (`/oauth/token`, when a harness such | |
| 4 | as Claude Code connects) is listed with the registered name of the client that | |
| 5 | holds it, so you can tell who can act as you. A token you made by hand shows | |
| 6 | nothing extra. | |
| 7 | ||
| 8 | - **Settings › Tokens**: under the token's name, "<client> · Signed in through | |
| 9 | OAuth; renews itself until you delete it". The name is escaped. | |
| 10 | - **`GET /api/v1/tokens`** and MCP **`tokens`**: each token carries | |
| 11 | `oauthClient`, the client's name, or `null` for a hand-made token. | |
| 12 | - **Terminal, sign-ins screen**: the row reads `<name> · <scope> · <client>, renews itself`. | |
| 13 | ||
| 14 | It appears for every token the code exchange or a refresh minted while the | |
| 15 | client is registered. A token from before OAuth tokens were marked is listed as | |
| 16 | hand-made. "Renews itself" means the harness swaps it with its refresh token | |
| 17 | before it lapses; deleting it ends that, see [](oauth-refresh.md). |
src/actions/signins.ts +4 −3
| @@ -14,14 +14,15 @@ import { | ||
| 14 | 14 | sessionOf, |
| 15 | 15 | type SessionInfo, |
| 16 | 16 | } from "../core/sessions.ts"; |
| 17 | import { deleteToken, listTokens, type TokenInfo } from "../core/tokens.ts"; | |
| 17 | import { listTokensWithClients, type ListedToken } from "../core/oauth.ts"; | |
| 18 | import { deleteToken } from "../core/tokens.ts"; | |
| 18 | 19 | import { getUserByName, type User } from "../core/users.ts"; |
| 19 | 20 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 20 | 21 | import { requireUser } from "./access.ts"; |
| 21 | 22 | |
| 22 | 23 | export interface SignIns { |
| 23 | 24 | readonly user: User; |
| 24 | readonly tokens: readonly TokenInfo[]; | |
| 25 | readonly tokens: readonly ListedToken[]; | |
| 25 | 26 | readonly sessions: readonly SessionInfo[]; |
| 26 | 27 | } |
| 27 | 28 | |
| @@ -53,7 +54,7 @@ export function accountSignIns( | ||
| 53 | 54 | if (!user.ok) return user; |
| 54 | 55 | return ok({ |
| 55 | 56 | user: user.value, |
| 56 | tokens: listTokens(app.db, user.value.id), | |
| 57 | tokens: listTokensWithClients(app.db, user.value.id), | |
| 57 | 58 | sessions: listSessions( |
| 58 | 59 | app.db, |
| 59 | 60 | user.value.id, |
src/contract/openapi/routes/getApiV1Tokens.ts +1 −1
| @@ -3,7 +3,7 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "get", |
| 5 | 5 | path: "/api/v1/tokens", |
| 6 | summary: "List your own tokens; a bearer token is refused with 403, call it from a browser session or with a password", | |
| 6 | summary: "List your own tokens; a bearer token is refused with 403, call it from a browser session or with a password. Each token carries `oauthClient`: the registered name of the harness that holds it, null for a hand-made one", | |
| 7 | 7 | auth: false, |
| 8 | 8 | refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" }, |
| 9 | 9 | }; |
src/core/oauth.ts +27 −1
| @@ -8,7 +8,7 @@ import { sha256 } from "../kernel/hash.ts"; | ||
| 8 | 8 | import { id, secret } from "../kernel/id.ts"; |
| 9 | 9 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 10 | 10 | import { NONCE_MAX, oidcScopes, recordGrant, signIdToken } from "./oidc.ts"; |
| 11 | import { createToken } from "./tokens.ts"; | |
| 11 | import { createToken, listTokens, type TokenInfo } from "./tokens.ts"; | |
| 12 | 12 | import { getUserById, isActive } from "./users.ts"; |
| 13 | 13 | |
| 14 | 14 | export const NAME_MAX = 100; |
| @@ -136,6 +136,32 @@ export function getClient(db: Database, clientId: string): OAuthClient | null { | ||
| 136 | 136 | }; |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | /** | |
| 140 | * The registered client name behind each of a person's tokens the OAuth | |
| 141 | * endpoint minted, keyed by token id, in one query for the whole list. | |
| 142 | * A hand-made token is absent. | |
| 143 | */ | |
| 144 | export function oauthClientNames(db: Database, userId: string): Map<string, string> { | |
| 145 | const rows = db | |
| 146 | .query<{ token_id: string; name: string }, [string]>( | |
| 147 | `SELECT o.token_id, c.name FROM oauth_token o | |
| 148 | JOIN token t ON t.id = o.token_id | |
| 149 | JOIN oauth_client c ON c.id = o.client_id | |
| 150 | WHERE t.user_id = ?1`, | |
| 151 | ) | |
| 152 | .all(userId); | |
| 153 | return new Map(rows.map((r) => [r.token_id, r.name])); | |
| 154 | } | |
| 155 | ||
| 156 | /** A token as every door lists it: `oauthClient` names the harness that holds it, null for a hand-made one. */ | |
| 157 | export type ListedToken = TokenInfo & { readonly oauthClient: string | null }; | |
| 158 | ||
| 159 | /** A person's tokens with the harness behind each: two reads for the whole list, whatever its length. */ | |
| 160 | export function listTokensWithClients(db: Database, userId: string): ListedToken[] { | |
| 161 | const clients = oauthClientNames(db, userId); | |
| 162 | return listTokens(db, userId).map((t) => ({ ...t, oauthClient: clients.get(t.id) ?? null })); | |
| 163 | } | |
| 164 | ||
| 139 | 165 | // ---- the authorization request, the code, and the exchange ---- |
| 140 | 166 | |
| 141 | 167 | /** A code is a minute's grace between consent and exchange, no more. */ |
src/http/api/tokens.ts +3 −2
| @@ -9,7 +9,8 @@ import { signOutHere } from "../../actions/signins.ts"; | ||
| 9 | 9 | import { createToken, deleteToken } from "../../actions/users.ts"; |
| 10 | 10 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 11 | 11 | import { createSession } from "../../core/sessions.ts"; |
| 12 | import { ADMIN_TOKEN_WORDS, listTokens } from "../../core/tokens.ts"; | |
| 12 | import { listTokensWithClients } from "../../core/oauth.ts"; | |
| 13 | import { ADMIN_TOKEN_WORDS } from "../../core/tokens.ts"; | |
| 13 | 14 | import { type User, verifyPasswordThrottled } from "../../core/users.ts"; |
| 14 | 15 | import { sessionCookie } from "../../web/pages/site.ts"; |
| 15 | 16 | import { cookieValue, crossOrigin, type Handler } from "../context.ts"; |
| @@ -117,7 +118,7 @@ export function tokenRoutes(router: Router<Handler>): void { | ||
| 117 | 118 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 118 | 119 | const denied = requireAccount(ctx); |
| 119 | 120 | if (denied) return denied; |
| 120 | const tokens = timePhase(ctx.request, "tokens", () => listTokens(ctx.app.db, ctx.auth.user!.id)); | |
| 121 | const tokens = timePhase(ctx.request, "tokens", () => listTokensWithClients(ctx.app.db, ctx.auth.user!.id)); | |
| 121 | 122 | return timePhase(ctx.request, "render", () => json(tokens)); |
| 122 | 123 | }); |
| 123 | 124 |
src/http/mcp/tools/tokens.ts +3 −3
| @@ -1,13 +1,13 @@ | ||
| 1 | import { listTokens } from "../../../core/tokens.ts"; | |
| 1 | import { listTokensWithClients } from "../../../core/oauth.ts"; | |
| 2 | 2 | import { good, type Tool } from "../shared.ts"; |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "tokens", |
| 6 | doc: "your own API tokens, as `GET /api/v1/tokens` lists them: id, name, scope, created and last used; never a token or its hash", | |
| 6 | doc: "your own API tokens, as `GET /api/v1/tokens` lists them: id, name, scope, created and last used, and `oauthClient`: the harness that holds a token minted through OAuth, null for a hand-made one; never a token or its hash", | |
| 7 | 7 | description: "Your API tokens, no secrets.", |
| 8 | 8 | inputSchema: { type: "object", properties: {} }, |
| 9 | 9 | writes: false, |
| 10 | 10 | run(ctx, user) { |
| 11 | return good({ items: listTokens(ctx.app.db, user.id) }); | |
| 11 | return good({ items: listTokensWithClients(ctx.app.db, user.id) }); | |
| 12 | 12 | }, |
| 13 | 13 | }; |
src/tui/screens/common.ts +3 −1
| @@ -784,7 +784,9 @@ export function live( | ||
| 784 | 784 | timer = setTimeout(() => { |
| 785 | 785 | timer = null; |
| 786 | 786 | if (signal.aborted) return; |
| 787 | void Promise.resolve(refresh()).catch(() => undefined); | |
| 787 | // A refresh that throws at once (the forge closed under it) fails as | |
| 788 | // quietly as one that rejects, never as an uncaught error on the timer. | |
| 789 | void Promise.resolve().then(refresh).catch(() => undefined); | |
| 788 | 790 | }, LIVE_COALESCE_MS); |
| 789 | 791 | }); |
| 790 | 792 | signal.addEventListener("abort", () => { |
src/tui/screens/signins.ts +1 −1
| @@ -80,7 +80,7 @@ export function signInsOf(f: Session): SignIn[] { | ||
| 80 | 80 | ...seen.value.tokens.map((t) => ({ |
| 81 | 81 | kind: "token" as const, |
| 82 | 82 | id: t.id, |
| 83 | where: `${t.name} · ${t.scope}`, | |
| 83 | where: t.oauthClient === null ? `${t.name} · ${t.scope}` : `${t.name} · ${t.scope} · ${t.oauthClient}, renews itself`, | |
| 84 | 84 | seen: t.lastUsedAt, |
| 85 | 85 | since: t.createdAt, |
| 86 | 86 | here: false, |
src/web/pages/account.ts +12 −3
| @@ -34,6 +34,7 @@ import { linkEdgeLogin, linkedLogin, unlinkEdgeLogin } from "../../core/edge_lin | ||
| 34 | 34 | import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts"; |
| 35 | 35 | import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts"; |
| 36 | 36 | import { listKeys } from "../../core/keys.ts"; |
| 37 | import { listTokensWithClients } from "../../core/oauth.ts"; | |
| 37 | 38 | import { |
| 38 | 39 | PROFILE_FALLBACK, |
| 39 | 40 | PROFILE_REFUSAL_FIELD, |
| @@ -41,7 +42,7 @@ import { | ||
| 41 | 42 | } from "../../actions/profile_words.ts"; |
| 42 | 43 | import { endAccountSession } from "../../actions/signins.ts"; |
| 43 | 44 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| 44 | import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts"; | |
| 45 | import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts"; | |
| 45 | 46 | import type { CommitEmail, User } from "../../core/users.ts"; |
| 46 | 47 | import { isStandInEmail } from "../../core/users.ts"; |
| 47 | 48 | import { cookieValue, isAccountItself, type Ctx } from "../../http/context.ts"; |
| @@ -597,7 +598,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html { | ||
| 597 | 598 | } |
| 598 | 599 | |
| 599 | 600 | function tokensView(ctx: Ctx): Html { |
| 600 | const tokens = timePhase(ctx.request, "tokens", () => listTokens(ctx.app.db, ctx.auth.user!.id)); | |
| 601 | const tokens = timePhase(ctx.request, "tokens", () => listTokensWithClients(ctx.app.db, ctx.auth.user!.id)); | |
| 601 | 602 | const sessions = timePhase(ctx.request, "sessions", () => |
| 602 | 603 | listSessions(ctx.app.db, ctx.auth.user!.id, cookieValue(ctx.request, "forge_session")), |
| 603 | 604 | ); |
| @@ -629,7 +630,15 @@ function tokensView(ctx: Ctx): Html { | ||
| 629 | 630 | ${tokens.map( |
| 630 | 631 | (t) => |
| 631 | 632 | html`<tr> |
| 632 | <td><strong>${t.name}</strong></td> | |
| 633 | <td> | |
| 634 | <strong>${t.name}</strong> | |
| 635 | ${when( | |
| 636 | t.oauthClient, | |
| 637 | (client) => html`<div class="muted small" data-oauth-client> | |
| 638 | ${client} · Signed in through OAuth; renews itself until you delete it | |
| 639 | </div>`, | |
| 640 | )} | |
| 641 | </td> | |
| 633 | 642 | <td>${badge(t.scope)}</td> |
| 634 | 643 | <td class="muted">${time(t.createdAt)}</td> |
| 635 | 644 | <td class="muted"> |
test/tokens-oauth-client.test.ts added +157
| @@ -0,0 +1,157 @@ | ||
| 1 | /** | |
| 2 | * Settings › Tokens names the harness behind each token the OAuth endpoint | |
| 3 | * minted (#8432): its client's registered name, escaped, and that it renews | |
| 4 | * itself; `GET /api/v1/tokens` and MCP `tokens` carry the same name as | |
| 5 | * `oauthClient`, and the terminal's sign-ins screen names it on the row. A hand-made token looks as it always did. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createClient, issueCode, listTokensWithClients, oauthClientNames, parseAuthRequest, redeemCode } from "../src/core/oauth.ts"; | |
| 13 | import { createToken, listTokens } from "../src/core/tokens.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { signInsOf } from "../src/tui/screens/signins.ts"; | |
| 17 | import { Session } from "../src/tui/session.ts"; | |
| 18 | import { proveAll } from "./helpers/verified.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | const REDIRECT = "https://claude.ai/api/mcp/auth_callback"; | |
| 24 | const verifier = "v".repeat(60); | |
| 25 | const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url"); | |
| 26 | const RENEWS = "Signed in through OAuth; renews itself until you delete it"; | |
| 27 | ||
| 28 | let root = ""; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let alice: User; | |
| 32 | let cookie = ""; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | root = mkdtempSync(join(tmpdir(), "forge-tokens-oauth-client-")); | |
| 36 | app = createApp({ dataDir: root, port: 0 }); | |
| 37 | const u = await createUser(app.db, { | |
| 38 | origin: "seed", | |
| 39 | trusted: true, | |
| 40 | username: "alice", | |
| 41 | email: "[email protected]", | |
| 42 | password: "a-good-passphrase", | |
| 43 | adminIfFirst: true, | |
| 44 | }); | |
| 45 | if (!u.ok) throw new Error(u.error); | |
| 46 | alice = u.value; | |
| 47 | proveAll(app); | |
| 48 | servers = await serve(app); | |
| 49 | const signin = await fetch(`${servers.url}/signin`, { | |
| 50 | method: "POST", | |
| 51 | headers: { origin: servers.url }, | |
| 52 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 53 | redirect: "manual", | |
| 54 | }); | |
| 55 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 56 | }); | |
| 57 | afterAll(() => { | |
| 58 | servers.stop(); | |
| 59 | app.close(); | |
| 60 | rmSync(root, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | async function exchange(clientName: string, tokenName: string): Promise<void> { | |
| 64 | const client = createClient(app.db, { name: clientName, redirectUris: [REDIRECT] }); | |
| 65 | const params: Record<string, string> = { | |
| 66 | client_id: client.id, | |
| 67 | redirect_uri: REDIRECT, | |
| 68 | response_type: "code", | |
| 69 | code_challenge_method: "S256", | |
| 70 | code_challenge: challenge, | |
| 71 | scope: "rw", | |
| 72 | }; | |
| 73 | const request = parseAuthRequest(app.db, (n) => params[n] ?? ""); | |
| 74 | if (!request.ok) throw new Error(request.error); | |
| 75 | const code = issueCode(app.db, alice.id, request.value, { scope: "rw", name: tokenName, lifetimeS: null }); | |
| 76 | const minted = await redeemCode(app.db, root, { | |
| 77 | code, | |
| 78 | clientId: client.id, | |
| 79 | redirectUri: REDIRECT, | |
| 80 | verifier, | |
| 81 | issuer: servers.url, | |
| 82 | }); | |
| 83 | if (!minted.ok || minted.value.token === undefined) throw new Error("no token"); | |
| 84 | } | |
| 85 | ||
| 86 | /** The table row that carries a token's name. */ | |
| 87 | function rowOf(page: string, tokenName: string): string { | |
| 88 | const row = page.split("<tr>").find((r) => r.includes(`<strong>${tokenName}</strong>`)); | |
| 89 | if (row === undefined) throw new Error(`no row for ${tokenName}`); | |
| 90 | return row; | |
| 91 | } | |
| 92 | ||
| 93 | test("a token from the code exchange names its client; a hand-made one does not", async () => { | |
| 94 | await exchange("Claude Code", "harness-token"); | |
| 95 | const made = createToken(app.db, alice.id, "laptop", "rw"); | |
| 96 | expect(made.id).toBeTruthy(); | |
| 97 | ||
| 98 | const page = await (await fetch(`${servers.url}/settings/tokens`, { headers: { cookie } })).text(); | |
| 99 | const oauthRow = rowOf(page, "harness-token"); | |
| 100 | expect(oauthRow).toContain("Claude Code"); | |
| 101 | expect(oauthRow).toContain(RENEWS); | |
| 102 | const handRow = rowOf(page, "laptop"); | |
| 103 | expect(handRow).not.toContain("data-oauth-client"); | |
| 104 | expect(handRow).not.toContain(RENEWS); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a client name with <script> is escaped", async () => { | |
| 108 | await exchange("<script>alert(1)</script>", "evil-token"); | |
| 109 | const page = await (await fetch(`${servers.url}/settings/tokens`, { headers: { cookie } })).text(); | |
| 110 | const row = rowOf(page, "evil-token"); | |
| 111 | expect(row).not.toContain("<script>alert(1)</script>"); | |
| 112 | expect(row).toContain("<script>alert(1)</script>"); | |
| 113 | }); | |
| 114 | ||
| 115 | test("the names come from one read covering only the person's own OAuth tokens", () => { | |
| 116 | const names = oauthClientNames(app.db, alice.id); | |
| 117 | const byName = new Map(listTokens(app.db, alice.id).map((t) => [t.name, t.id])); | |
| 118 | expect(names.get(byName.get("harness-token")!)).toBe("Claude Code"); | |
| 119 | expect(names.has(byName.get("laptop")!)).toBe(false); | |
| 120 | expect(oauthClientNames(app.db, "nobody").size).toBe(0); | |
| 121 | }); | |
| 122 | ||
| 123 | const harnessOf = (items: { name: string; oauthClient: string | null }[]) => | |
| 124 | Object.fromEntries(items.map((t) => [t.name, t.oauthClient])); | |
| 125 | ||
| 126 | test("GET /api/v1/tokens carries oauthClient on each token, null for a hand-made one", async () => { | |
| 127 | const res = await fetch(`${servers.url}/api/v1/tokens`, { headers: { cookie } }); | |
| 128 | expect(res.status).toBe(200); | |
| 129 | const listed = harnessOf((await res.json()) as { name: string; oauthClient: string | null }[]); | |
| 130 | expect(listed["harness-token"]).toBe("Claude Code"); | |
| 131 | expect(listed["laptop"]).toBeNull(); | |
| 132 | }); | |
| 133 | ||
| 134 | test("MCP `tokens` carries oauthClient on each token, null for a hand-made one", async () => { | |
| 135 | const bearer = createToken(app.db, alice.id, "mcp-caller", "rw").token; | |
| 136 | const res = await fetch(`${servers.url}/mcp`, { | |
| 137 | method: "POST", | |
| 138 | headers: { | |
| 139 | "content-type": "application/json", | |
| 140 | accept: "application/json, text/event-stream", | |
| 141 | authorization: `Bearer ${bearer}`, | |
| 142 | }, | |
| 143 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "tokens", arguments: {} } }), | |
| 144 | }); | |
| 145 | const body = (await res.json()) as { result: { content: { text: string }[] } }; | |
| 146 | const listed = harnessOf(JSON.parse(body.result.content[0]!.text).items); | |
| 147 | expect(listed["harness-token"]).toBe("Claude Code"); | |
| 148 | expect(listed["mcp-caller"]).toBeNull(); | |
| 149 | expect(listTokensWithClients(app.db, alice.id).length).toBe(Object.keys(listed).length); | |
| 150 | }); | |
| 151 | ||
| 152 | test("the terminal's sign-ins screen names the harness on an OAuth token's row", () => { | |
| 153 | const rows = signInsOf(new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice })); | |
| 154 | const where = (name: string) => rows.find((r) => r.kind === "token" && r.where.startsWith(`${name} · `))?.where; | |
| 155 | expect(where("harness-token")).toBe("harness-token · rw · Claude Code, renews itself"); | |
| 156 | expect(where("laptop")).toBe("laptop · rw"); | |
| 157 | }); |
test/tui-live-throw.test.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * A live screen's refresh that throws at once, as a reload does when the | |
| 3 | * forge has closed under it, fails quietly: never an uncaught error on the | |
| 4 | * timer, which bun pins on whatever test runs next. | |
| 5 | */ | |
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { live } from "../src/tui/screens/common.ts"; | |
| 8 | import type { Session } from "../src/tui/session.ts"; | |
| 9 | ||
| 10 | test("a refresh that throws synchronously raises nothing uncaught", async () => { | |
| 11 | let fire: (event: unknown) => void = () => undefined; | |
| 12 | const session = { app: { bus: { subscribe: (fn: (event: unknown) => void) => ((fire = fn), () => undefined) } } } as unknown as Session; | |
| 13 | const uncaught: unknown[] = []; | |
| 14 | const note = (e: unknown) => uncaught.push(e); | |
| 15 | process.on("uncaughtException", note); | |
| 16 | let calls = 0; | |
| 17 | try { | |
| 18 | live(session, new AbortController().signal, {}, () => { | |
| 19 | calls++; | |
| 20 | throw new RangeError("Cannot use a closed database"); | |
| 21 | }); | |
| 22 | fire({ kind: "issue.opened", repoId: "r" }); | |
| 23 | await Bun.sleep(100); | |
| 24 | } finally { | |
| 25 | process.off("uncaughtException", note); | |
| 26 | } | |
| 27 | expect(calls).toBe(1); | |
| 28 | expect(uncaught).toEqual([]); | |
| 29 | }); |
new above
offline