From 50f789ee3ec7f0670c46f6302a5fe8c9acb77167 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 24 Aug 2026 20:56:39 +0000 Subject: [PATCH 1/2] feat(auth): notify hosts when an interactive code is exchanged --- docs/content/4.helpers/3.interactive-auth.md | 3 +++ .../__tests__/interactive-auth.test.ts | 24 +++++++++++++++++++ .../devframe/src/recipes/interactive-auth.ts | 10 ++++++++ .../recipes/interactive-auth.snapshot.d.ts | 4 ++++ 4 files changed, 41 insertions(+) diff --git a/docs/content/4.helpers/3.interactive-auth.md b/docs/content/4.helpers/3.interactive-auth.md index 7d96ffa5..c142bbdd 100644 --- a/docs/content/4.helpers/3.interactive-auth.md +++ b/docs/content/4.helpers/3.interactive-auth.md @@ -29,6 +29,7 @@ As `auth` it wires `rpcFunctions`, `authorize`, and `onConnect` — see [Securit |--------|---------|---------| | `clientAuthTokens` | `undefined` | Pre-shared bearer tokens, always trusted. | | `banner` | a small boxed console message | Called with `{ code, url }`; prints via `printBanner()`. | +| `onTrusted` | `undefined` | Called with `{ session, authToken }` once a code exchange succeeds, so a host rendering its own banner can retract it. | | `serverUrl` | `context.host.resolveOrigin()` | Magic-link base URL. | Returns a `DevframeAuthHandler`: @@ -57,4 +58,6 @@ if (!auth.authorize(methodName, session)) auth.onConnect(peer, session) ``` +An exchange rotates the code and prints the new one, and `onTrusted` fires after that, so a host retracting a sticky notice drops that follow-up too and calls `printBanner()` when it next wants a code on screen. + Auth storage is internal, not `devframe/node/hub-internals`. diff --git a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts index 7fd7e529..d40cdd65 100644 --- a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts +++ b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts @@ -29,6 +29,7 @@ async function createTestContext(): Promise { async function startAuthenticatedServer( banners: { code: string, url: string }[] = [], preTrust = false, + onTrusted?: (info: { authToken: string }) => void, ) { const context = await createTestContext() context.rpc.register({ @@ -38,6 +39,7 @@ async function startAuthenticatedServer( }) const auth = createInteractiveAuth(context, { banner: info => banners.push(info), + onTrusted, }) const host = '127.0.0.1' @@ -135,6 +137,28 @@ describe('recipes/interactive-auth', () => { } }) + it('onTrusted() fires once a code exchange succeeds, after the rotated code is printed', async () => { + const banners: { code: string, url: string }[] = [] + const trusted: { authToken: string }[] = [] + const { server, host, port } = await startAuthenticatedServer(banners, false, info => trusted.push(info)) + + try { + const client = connectClient(host, port) + await client.$call('anonymous:devframe:auth:exchange', { code: 'wrong1', ua: 'test', origin: 'http://localhost' }) + expect(trusted).toHaveLength(0) + + const code = getTempAuthCode() + const { authToken } = await client.$call('anonymous:devframe:auth:exchange', { code, ua: 'test', origin: 'http://localhost' }) + + expect(trusted.map(info => info.authToken)).toEqual([authToken]) + expect(banners.at(-1)!.code).toBe(getTempAuthCode()) + client.$close() + } + finally { + await server.close() + } + }) + it('preserves trust established by the host before the client handshake', async () => { const { server, host, port } = await startAuthenticatedServer([], true) diff --git a/packages/devframe/src/recipes/interactive-auth.ts b/packages/devframe/src/recipes/interactive-auth.ts index 1d214185..f22259d9 100644 --- a/packages/devframe/src/recipes/interactive-auth.ts +++ b/packages/devframe/src/recipes/interactive-auth.ts @@ -23,6 +23,14 @@ export interface CreateInteractiveAuthOptions { * stdout. */ banner?: (info: { code: string, url: string }) => void + /** + * Called once a code exchange succeeds, so a host rendering its own + * banner can retract it. Fires after the rotated code is printed, so + * such a host drops that follow-up too and calls `auth.printBanner()` + * when it next wants a code on screen. Connect-time trust from a static + * or remote-dock token doesn't call this. + */ + onTrusted?: (info: { session: DevframeNodeRpcSession, authToken: string }) => void /** * The base URL the magic link should point at. Defaults to * `context.host.resolveOrigin()`. @@ -125,6 +133,8 @@ export function createInteractiveAuth( // The code was just consumed (success or a rotating failure) — the // next `printBanner()` call shows whatever code is current now. printBanner() + if (authToken) + options.onTrusted?.({ session, authToken }) return { authToken } }, }) diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts index acb99288..868b0d9b 100644 --- a/tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts @@ -8,6 +8,10 @@ export interface CreateInteractiveAuthOptions { code: string; url: string; }) => void; + onTrusted?: (_: { + session: DevframeNodeRpcSession; + authToken: string; + }) => void; serverUrl?: () => string; } // #endregion From a58eef4802bd44e1ef839b538ff244b7703040be Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 25 Aug 2026 10:34:12 +0100 Subject: [PATCH 2/2] chore: improve docs/test --- docs/content/4.helpers/3.interactive-auth.md | 2 +- .../src/recipes/__tests__/interactive-auth.test.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/content/4.helpers/3.interactive-auth.md b/docs/content/4.helpers/3.interactive-auth.md index c142bbdd..7db3505c 100644 --- a/docs/content/4.helpers/3.interactive-auth.md +++ b/docs/content/4.helpers/3.interactive-auth.md @@ -58,6 +58,6 @@ if (!auth.authorize(methodName, session)) auth.onConnect(peer, session) ``` -An exchange rotates the code and prints the new one, and `onTrusted` fires after that, so a host retracting a sticky notice drops that follow-up too and calls `printBanner()` when it next wants a code on screen. +An exchange rotates the code and prints the new one, and `onTrusted` fires after that, so a host retracting a sticky notice drops that follow-up too and calls `auth.printBanner()` when it next wants a code on screen. Auth storage is internal, not `devframe/node/hub-internals`. diff --git a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts index d40cdd65..893628d7 100644 --- a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts +++ b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts @@ -139,8 +139,12 @@ describe('recipes/interactive-auth', () => { it('onTrusted() fires once a code exchange succeeds, after the rotated code is printed', async () => { const banners: { code: string, url: string }[] = [] - const trusted: { authToken: string }[] = [] - const { server, host, port } = await startAuthenticatedServer(banners, false, info => trusted.push(info)) + const trusted: { authToken: string, bannerCountAtCall: number, lastBannerCode?: string }[] = [] + const { server, host, port } = await startAuthenticatedServer(banners, false, info => trusted.push({ + authToken: info.authToken, + bannerCountAtCall: banners.length, + lastBannerCode: banners.at(-1)?.code, + })) try { const client = connectClient(host, port) @@ -151,7 +155,8 @@ describe('recipes/interactive-auth', () => { const { authToken } = await client.$call('anonymous:devframe:auth:exchange', { code, ua: 'test', origin: 'http://localhost' }) expect(trusted.map(info => info.authToken)).toEqual([authToken]) - expect(banners.at(-1)!.code).toBe(getTempAuthCode()) + expect(trusted[0]!.bannerCountAtCall).toBe(banners.length) + expect(trusted[0]!.lastBannerCode).toBe(getTempAuthCode()) client.$close() } finally {