Skip to content

Commit fe0c2cc

Browse files
committed
feat(auth): enhance interactive auth banner customization and integration
1 parent 1443ab2 commit fe0c2cc

4 files changed

Lines changed: 119 additions & 13 deletions

File tree

packages/devframe/src/node/instance-shell.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -367,8 +367,15 @@ export interface CreateInstanceShellOptions<TContext extends DevframeNodeContext
367367
app?: H3
368368
/** Public origin, or a getter. Derived from the first request when omitted. */
369369
origin?: string | (() => string)
370-
/** Resolved auth intent: `undefined`/`true` gates, `false` opts out, a handler installs a scheme. */
371-
auth?: boolean | DevframeAuthHandler
370+
/**
371+
* Resolved auth intent: `undefined`/`true` gates with {@link createInteractiveAuth}'s
372+
* defaults, `false` opts out, a handler installs a custom scheme outright.
373+
* A function gates too, but builds the handler itself from the now-ready
374+
* `ctx` - the seam a wrapping host (e.g. `@devframes/hub`'s UI slot) uses to
375+
* hand `createInteractiveAuth` a branded `banner` while still leaving `auth`
376+
* itself unset for the caller.
377+
*/
378+
auth?: boolean | DevframeAuthHandler | ((ctx: TContext) => DevframeAuthHandler)
372379
/** Host `node:http` server to share the WS upgrade with. */
373380
server?: NodeHttpServer
374381
/** Explicit WebSocket control; see {@link DevframeWsOptions}. `false` disables the socket (SSE-only). */
@@ -632,12 +639,17 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
632639

633640
/**
634641
* Auth resolution: gate by default, `false` opts out, a handler object
635-
* installs a custom scheme. The `external` tier has no local transport to
636-
* gate (the server behind `ws.url` owns auth) so it resolves to nothing.
642+
* installs a custom scheme, a function builds one from `ctx`. The `external`
643+
* tier has no local transport to gate (the server behind `ws.url` owns
644+
* auth) so it resolves to nothing.
637645
*/
638646
function resolveAuth(): boolean | DevframeAuthHandler {
639647
if (options.auth === false)
640648
return false
649+
if (typeof options.auth === 'function') {
650+
authHandler = options.auth(ctx)
651+
return authHandler
652+
}
641653
if (typeof options.auth === 'object') {
642654
authHandler = options.auth
643655
return options.auth

packages/devframe/src/recipes/interactive-auth.ts

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DevframeNodeContext, DevframeNodeRpcSession } from 'devframe/types'
2+
import type { ColorFn } from 'devframe/utils/colors'
23
import type { DevframeAuthHandler } from '../node/auth'
34
import { colors } from 'devframe/utils/colors'
45
import { s } from 'devframe/utils/simple-schema'
@@ -18,11 +19,12 @@ export interface CreateInteractiveAuthOptions {
1819
/**
1920
* Print the current code + magic-link URL. Devframe stays headless, so
2021
* there is no default banner printed automatically; call
21-
* `auth.printBanner()` yourself once the server is listening. Override
22-
* this to customize the format; defaults to a small boxed message on
23-
* stdout.
22+
* `auth.printBanner()` yourself once the server is listening. Defaults to
23+
* {@link createAuthBanner}'s output; pass its result here directly to
24+
* rebrand the box (title / colors), or your own function to replace the
25+
* format outright.
2426
*/
25-
banner?: (info: { code: string, url: string }) => void
27+
banner?: AuthBannerFunction
2628
/**
2729
* Called once a code exchange succeeds, so a host rendering its own
2830
* banner can retract it. Fires after the rotated code is printed, so
@@ -38,9 +40,68 @@ export interface CreateInteractiveAuthOptions {
3840
serverUrl?: () => string
3941
}
4042

41-
function defaultBanner(info: { code: string, url: string }): void {
42-
// eslint-disable-next-line no-console
43-
console.log(`\n ${colors.dim('devframe auth code')} ${colors.bold(info.code)}\n ${colors.dim('or open')} ${colors.cyan(info.url)}\n`)
43+
/** Signature of `options.banner`: render the current auth code + magic-link URL. */
44+
export type AuthBannerFunction = (info: { code: string, url: string }) => void
45+
46+
/** Palette for {@link createAuthBanner}'s box - one color per part, so a host can rebrand a subset. */
47+
export interface CreateAuthBannerColorsOptions {
48+
border: ColorFn
49+
title: ColorFn
50+
label: ColorFn
51+
code: ColorFn
52+
url: ColorFn
53+
}
54+
55+
export interface CreateAuthBannerOptions {
56+
/** Box title. Defaults to `'Devframe'` - set to your product name for branding. */
57+
title?: string
58+
/** Palette overrides; unset colors fall back to dim/bold/cyan defaults. */
59+
colors?: Partial<CreateAuthBannerColorsOptions>
60+
}
61+
62+
/**
63+
* Build a {@link AuthBannerFunction} that renders the auth code + magic-link
64+
* URL as a small bordered box, its two rows label-aligned. `createInteractiveAuth`
65+
* falls back to `createAuthBanner()` when no `banner` is given; call this
66+
* yourself to rebrand the box (`title` / `colors`) and pass the result as
67+
* `options.banner`.
68+
*/
69+
export function createAuthBanner(options: CreateAuthBannerOptions = {}): AuthBannerFunction {
70+
const title = options.title ?? 'Devframe'
71+
const palette: CreateAuthBannerColorsOptions = {
72+
border: colors.dim,
73+
title: colors.bold,
74+
label: colors.dim,
75+
code: colors.bold,
76+
url: colors.cyan,
77+
...options.colors,
78+
}
79+
80+
return (info) => {
81+
const rows: [label: string, value: string, color: ColorFn][] = [
82+
['auth code', info.code, palette.code],
83+
['or open', info.url, palette.url],
84+
]
85+
const labelWidth = Math.max(...rows.map(([label]) => label.length))
86+
const contentWidth = Math.max(...rows.map(([, value]) => labelWidth + 2 + value.length))
87+
const titleBarLength = title.length + 3
88+
const lineWidth = Math.max(contentWidth, titleBarLength - 2)
89+
90+
const top = [
91+
palette.border(`╭─`),
92+
palette.title(title),
93+
palette.border(`${'─'.repeat(Math.max(lineWidth + 2 - titleBarLength, 0))}╮`),
94+
].join(' ')
95+
const bottom = `╰${'─'.repeat(lineWidth + 2)}╯`
96+
const body = rows.map(([label, value, color]) => {
97+
const plain = `${label.padEnd(labelWidth)} ${value}`
98+
const pad = ' '.repeat(lineWidth - plain.length)
99+
return `${palette.border('│')} ${palette.label(label.padEnd(labelWidth))} ${color(value)}${pad} ${palette.border('│')}`
100+
})
101+
102+
// eslint-disable-next-line no-console
103+
console.log(`\n${palette.border(top)}\n${body.join('\n')}\n${palette.border(bottom)}\n`)
104+
}
44105
}
45106

46107
/**
@@ -80,14 +141,16 @@ export function createInteractiveAuth(
80141
return options.serverUrl?.() ?? context.host.resolveOrigin()
81142
}
82143

144+
const banner = options.banner ?? createAuthBanner()
145+
83146
let bannerPrintedForCode: string | undefined
84147
function printBanner(): void {
85148
const code = getTempAuthCode()
86149
if (code === bannerPrintedForCode)
87150
return
88151
bannerPrintedForCode = code
89152
const url = buildOtpAuthUrl(resolveServerUrl(), code)
90-
;(options.banner ?? defaultBanner)({ code, url })
153+
banner({ code, url })
91154
}
92155

93156
const anonymousAuth = defineRpcFunction({

packages/hub-ui/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { DevframeBranding, DevframeDockPreferences, EmbeddedVisibility } fr
33
import { existsSync } from 'node:fs'
44
import { join } from 'node:path'
55
import { fileURLToPath } from 'node:url'
6+
import { createAuthBanner } from 'devframe/recipes/interactive-auth'
67

78
export type { ColorSchemeValue, DevframeBranding, DevframeDockPreferences, EmbeddedVisibility, ViewerBackground } from './types'
89

@@ -106,5 +107,13 @@ export function createUi(options: CreateUiOptions = {}): DevframeHubUi {
106107
...(options.dockPreferences ? { dockPreferences: options.dockPreferences } : {}),
107108
}
108109
},
110+
/**
111+
* Node-side counterpart to the browser rebrand above: the same
112+
* `productName` titles the interactive-auth box printed to the terminal,
113+
* so a rebranded hub doesn't print a stray "Devframe" code prompt.
114+
*/
115+
authBanner: createAuthBanner({
116+
title: options.branding?.productName,
117+
}),
109118
}
110119
}

packages/hub/src/node/initiate.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DevframeInstanceRecord, InstanceShellApi, ResolvedMcpConfig } from 'devframe/internal'
22
import type { DevframeAuthHandler } from 'devframe/node/auth'
3+
import type { AuthBannerFunction } from 'devframe/recipes/interactive-auth'
34
import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
45
import type { ConnectionMeta, DevframeDefinition, DevframeServiceInput, DevframeSseOptions, DevframeStorageScope, DevframeWsOptions, McpRouteOptions, McpSetting } from 'devframe/types'
56
import type { Buffer } from 'node:buffer'
@@ -12,6 +13,7 @@ import { readFile } from 'node:fs/promises'
1213
import process from 'node:process'
1314
import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_MCP_ROUTE } from 'devframe/constants'
1415
import { createH3DevframeHost, createInstanceShell, importRuntimeModule, loadAutoMcpAdapter, resolveInstanceRegister, resolveMcpConfig } from 'devframe/internal'
16+
import { createInteractiveAuth } from 'devframe/recipes/interactive-auth'
1517
import { mountStaticHandler } from 'devframe/utils/serve-static'
1618
import { H3 } from 'h3'
1719
import { resolve } from 'pathe'
@@ -120,6 +122,17 @@ export interface DevframeHubUi {
120122
* The hub stays policy-free about what the UI writes.
121123
*/
122124
setup?: (ctx: DevframeHubContext) => void | Promise<void>
125+
/**
126+
* The interactive OTP banner (auth code + magic-link URL) to print when
127+
* {@link InitHubOptions.auth} is left at its default. A node-side
128+
* counterpart to `setup`'s browser-facing branding: the reference UI's
129+
* `createUi()` derives one from its own `branding.productName` via
130+
* `createAuthBanner` (see `devframe/recipes/interactive-auth`), so the
131+
* printed code matches the rebranded viewer without the caller wiring
132+
* `auth` themselves. Ignored once `auth` is set explicitly - a `false` or
133+
* a full {@link DevframeAuthHandler} both mean the caller owns the banner.
134+
*/
135+
authBanner?: AuthBannerFunction
123136
}
124137

125138
export type DevframesInput = Array<
@@ -398,7 +411,16 @@ export function initHub(options: InitHubOptions): HubInstance {
398411
app,
399412
host: options.host,
400413
origin: options.origin,
401-
auth: options.auth,
414+
/**
415+
* `false`/an explicit handler are the caller's own call, passed through
416+
* as-is; `true`/unset both mean "gate with the defaults", so the handler
417+
* is built lazily from `ctx` either way, taking the UI slot's
418+
* `authBanner` (e.g. `createUi()`'s branded box) without the caller
419+
* wiring `auth` themselves.
420+
*/
421+
auth: options.auth === false || typeof options.auth === 'object'
422+
? options.auth
423+
: (ctx: DevframeHubContext) => createInteractiveAuth(ctx, { banner: options.ui?.authBanner }),
402424
server: options.server,
403425
ws: options.ws,
404426
sse: options.sse,

0 commit comments

Comments
 (0)