@jimhoyd/urlcode-auth 0.1.0-alpha.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,18 @@
1
1
  # Auth implementation status
2
2
 
3
- Status: `@jimhoyd/urlcode-auth` 0.1.0-alpha.3 is the current npm release. It supersedes 0.1.0-alpha.2, the first working one (core issue #78; 0.1.0-alpha.1 was published from an unbuilt checkout and never worked, see RELEASE-SECURITY.md in urlcode core), and carries the work merged since — the trusted project-level lifecycle hooks (`beforeRegister`, `onSignUp`, `onDelete`), the versioned JSON form-endpoint API contract, and the `ExtensionActivation.root` fixture fixes for core 0.4.0-alpha.2. It requires `@jimhoyd/urlcode` 0.4.0-alpha.2 or newer — `src/auth.ts` resolves project lifecycle hooks through `ExtensionActivation.root`, which does not exist in 0.4.0-alpha.1 — and `@jimhoyd/urlcode-ui` 0.1.x alphas. The implemented auth and shared-presentation work is merged to main. The source plan is URLCode PR #54; cross-repository release acceptance is tracked in https://github.com/jimhoyd-com/urlcode/issues/58. The generic core extension contract from PR #59 is merged. Implementation and synthetic acceptance do not establish production readiness.
3
+ Status: `@jimhoyd/urlcode-auth` is published to npm as an alpha. The current
4
+ version and the peer ranges it supports are in `package.json`; read them there
5
+ rather than from this page, and see [package and channel
6
+ alignment](../../docs/VERSION-ALIGNMENT.md) for how versions, channels and tags
7
+ relate. Core, ui, auth and admin are workspace packages in one repository, so a
8
+ single commit identifies all of them and development resolves peers through the
9
+ workspace links rather than published versions. `src/auth.ts` resolves project
10
+ lifecycle hooks through `ExtensionActivation.root`, which is the oldest core API
11
+ this package needs. The implemented auth and shared-presentation work is merged
12
+ to main. The source plan is URLCode PR #54; release acceptance is tracked in
13
+ [issue 58](https://github.com/jimhoyd-com/urlcode/issues/58), and the generic
14
+ core extension contract from PR #59 is merged. Implementation and synthetic
15
+ acceptance do not establish production readiness.
4
16
 
5
17
  Implemented and covered by automated tests: durable SQLite accounts; bounded scrypt and hash migration; email/password and numeric email codes; OIDC with explicit linking; Google/Apple adapters; WebAuthn registration, login and step-up; TOTP/recovery; opaque sessions and revocation; role ceilings; registration modes; terms and scoped metadata; email change cooldown/cancellation; deletion grace; exports; key rotation; backup/restore; operator CLI/scaffolding; SES/development senders; safe themes and locale catalogue; admin service operations including dual-approval cases and bounded impersonation. Device recognition supports notices; separate opt-in, revocable remembered-device authority can exempt ordinary MFA without granting fresh step-up. Explicit passkey second-factor enrollment requires an independent credential. Optional breach checking is an operator-selected external service.
6
18
 
@@ -8,9 +20,9 @@ Resumable verification-first password/passkey signup (including waitlist approva
8
20
 
9
21
  Mandatory verification/TOTP enrollment, operator standard/hardened presets and explicit pinned configuration migration are implemented; hardened requires email and breach-screening adapters.
10
22
 
11
- Kit adoption (urlcode-auth issue #9, core plan §7.2) is implemented: every account screen is an `auth/*` kit template with a declared view model and sample view (`authTemplates`, `authUiTemplates`, `authCatalogue`); `authExtension({ ui })` renders through `ui.kit.page` when the host supplies the `ui` extension and through the shared primitives otherwise. The HTTP suites run under both render paths; a doctor-style suite renders every template with its sample and with the view a real request computes, checks escaping of user-controlled values on kit pages and the nonce-bound CSP. A themed browser walkthrough of the account pages remains a manual acceptance step.
23
+ Kit adoption (urlcode-auth issue #9, core plan §7.2) is implemented: every account screen is an `auth/*` kit template with a declared view model and sample view (`authTemplates`, `authUiTemplates`, `authCatalogue`); `authExtension({ ui })` renders every screen through `ui.kit.page`. The `ui` extension is required: activation refuses when it is absent, or when the runtime has not activated it because `extensions.ui` is missing from `urlcode.yaml` or declared after `extensions.auth`. The shared-primitive fallback that earlier releases used without the kit has been removed, along with its compile-on-demand template cache and the `pageResponse` document helper that served it (no longer exported). The HTTP suites run once, on the kit path; a doctor-style suite renders every template with its sample and with the view a real request computes, checks escaping of user-controlled values and the nonce-bound CSP, and a separate test covers the activation refusal. A themed browser walkthrough of the account pages remains a manual acceptance step.
12
24
 
13
- Project-level lifecycle hooks (urlcode-auth#35) are implemented: `beforeRegister`, `onSignUp` and `onDelete` in `extensions.auth.config.hooks` (README.md), run trusted and in-process — the same default as any `function`/`middleware` route, no special case. A configured hook's module is resolved and imported eagerly at activation, so a missing module or a broken/missing export fails activation rather than the first request; `sandbox: true` on a hook is refused explicitly at activation (core has no dispatch primitive yet to isolate a hook call, jimhoyd-com/urlcode#151) rather than silently ignored. `beforeRegister` covers the immediate `/register` endpoint and the resumable `/signup/begin` step; `onSignUp` fires after a genuinely new account is created (not an existing-account signup attempt that resolves to sign-in); `onDelete` fires when the account owner schedules their own deletion, not yet from an administrator-initiated deletion or the background purge.
25
+ Project-level lifecycle hooks (urlcode-auth#35) are implemented: `beforeRegister`, `onSignUp` and `onDelete` in `extensions.auth.config.hooks` (README.md), run trusted and in-process — the same default as any `function`/`middleware` route, no special case. A configured hook's module is resolved and imported eagerly at activation, so a missing module or a broken/missing export fails activation rather than the first request; `sandbox: true` on a hook is refused explicitly at activation rather than silently ignored — core exports the `SandboxPool` primitive (`@jimhoyd/urlcode/sandbox`), but this package does not route a hook invocation through it yet, so the opt-in it would imply does not exist here. `beforeRegister` covers the immediate `/register` endpoint and the resumable `/signup/begin` step; `onSignUp` fires after a genuinely new account is created (not an existing-account signup attempt that resolves to sign-in); `onDelete` fires when the account owner schedules their own deletion, not yet from an administrator-initiated deletion or the background purge.
14
26
 
15
27
  ## Additional implemented acceptance
16
28
 
@@ -31,9 +43,9 @@ Live Google/Apple/SES testing is explicitly deferred by the project owner and is
31
43
 
32
44
  ## Agreed architecture corrections
33
45
 
34
- Auth/admin live in independent repositories. The core owns generic revision-pinned extension contracts and never depends on auth. SQLite and privileged transactions belong to the trusted operator service. Project YAML cannot select host modules or credentials. Safe package renderers replace arbitrary project templates. The initial auth target is Node with operator-owned durable storage; runtime adapter availability does not make this SQLite service portable to every deployment target.
46
+ Auth and admin are separate packages with their own contracts; core owns the generic extension contract and never depends on auth. SQLite and privileged transactions belong to the trusted operator service. Project YAML cannot select host modules or credentials. Safe package renderers replace arbitrary project templates. The initial auth target is Node with operator-owned durable storage; runtime adapter availability does not make this SQLite service portable to every deployment target.
35
47
 
36
48
  ## Recorded acceptance
37
49
 
38
- See [ACCEPTANCE.md](https://github.com/jimhoyd-com/urlcode-auth/blob/main/ACCEPTANCE.md) for exact merged revisions, automated coverage,
50
+ See [ACCEPTANCE.md](ACCEPTANCE.md) for exact merged revisions, automated coverage,
39
51
  clean-install evidence and the remaining operational validation boundary.
package/README.md CHANGED
@@ -8,20 +8,20 @@ The implementation is under active review. Local tests and builds are evidence o
8
8
 
9
9
  ## Install
10
10
 
11
- `@jimhoyd/urlcode-auth` is published to npm as an alpha alongside its peers. Install the three packages together; the peer ranges in `package.json` require `@jimhoyd/urlcode` 0.4.0-alpha.1 or a later 0.4.x and `@jimhoyd/urlcode-ui` 0.1.0-alpha.1 or a later 0.1.x, and the release workflow builds and tests against exactly those registry versions.
11
+ `@jimhoyd/urlcode-auth` is published to npm as an alpha alongside its peers. Install the three packages together; the peer ranges for `@jimhoyd/urlcode` and `@jimhoyd/urlcode-ui` are declared in `package.json` read them there rather than from this page — and the release workflow builds and tests against exactly those registry versions.
12
12
 
13
13
  ```sh
14
14
  npm install @jimhoyd/urlcode @jimhoyd/urlcode-ui @jimhoyd/urlcode-auth
15
- npx urlcode init my-site --with auth
15
+ npx urlcode init my-site --with ui,auth
16
16
  ```
17
17
 
18
- `urlcode init --with auth` is core's layered scaffold; `npx urlcode-auth init --directory /absolute/new-account-site` scaffolds an auth-only project. Either writes `app/urlcode.yaml`, external `host.mjs` and `operator-service.mjs`, a private `data/` directory and independent encryption/CSRF keys, and refuses an existing destination. Its README gives the exact next steps.
18
+ `urlcode init --with ui,auth` is core's layered scaffold (auth renders through the ui kit, so `ui` must be named first: the runtime activates extensions in the order the project declares them, and auth's scaffold refuses any other order); `npx urlcode-auth init --directory /absolute/new-account-site` scaffolds an auth-only project. Either writes `app/urlcode.yaml`, external `host.mjs` and `operator-service.mjs`, a private `data/` directory and independent encryption/CSRF keys, and refuses an existing destination. Its README gives the exact next steps.
19
19
 
20
20
  Alpha caveat: the source is complete for the first release and its automated checks pass, but independent security review, accessibility assessment, browser/device WebAuthn coverage and deployment/soak/recovery exercises are still pending (see [IMPLEMENTATION-STATUS.md](IMPLEMENTATION-STATUS.md)). Alpha versions may change public exports, configuration keys and the SQLite schema between releases without a migration path. Do not run an alpha on production accounts.
21
21
 
22
22
  Use a current supported Node release with a patched SQLite build. The actual runtime requirement is a Node build whose bundled SQLite (`process.versions.sqlite`) is 3.51.3 or newer, or a patched 3.50.7+ / 3.44.6+ branch release; `engines.node` alone does not encode this, and the service (`src/auth-store.ts`) refuses other builds with `patched_sqlite_required` even when the package's minimum Node version is satisfied.
23
23
 
24
- Every release tarball is attested from the tagged commit: `gh attestation verify jimhoyd-urlcode-auth-<version>.tgz --repo jimhoyd-com/urlcode-auth`. `npm view @jimhoyd/urlcode-auth` shows the published provenance.
24
+ Every release tarball is attested from the tagged commit: `gh attestation verify jimhoyd-urlcode-auth-<version>.tgz --repo jimhoyd-com/urlcode`. `npm view @jimhoyd/urlcode-auth` shows the published provenance.
25
25
 
26
26
  ## Install from reviewed source
27
27
 
@@ -42,7 +42,7 @@ the same reviewed revision of this repository. The script refuses to run if the
42
42
  checkout is not at that exact commit or has uncommitted changes, and re-checks
43
43
  both after each build and pack.
44
44
 
45
- Omit `--admin` for auth only. `--offline` forbids network package resolution and requires a populated dependency cache. `--skip-install` reuses installed third-party dependencies; local peer tarballs are still installed. The script does not alter dependency manifests or lockfiles. Run `npm run verify` in each repository separately; source packaging runs typecheck/build, not the HTTP suite.
45
+ Omit `--admin` for auth only. `--offline` forbids network package resolution and requires a populated dependency cache. `--skip-install` reuses installed third-party dependencies; local peer tarballs are still installed. The script does not alter dependency manifests or lockfiles. Run `npm run verify` for each workspace package; source packaging runs typecheck/build, not the HTTP suite.
46
46
 
47
47
  Install all required local tarballs together (core, UI and auth; admin if built) in an operator-owned directory with a private `package.json`. For example, after checking the manifest:
48
48
 
@@ -55,7 +55,7 @@ Tarball names and versions must match the generated manifest. Install the same r
55
55
 
56
56
  ## Programmatic scaffold
57
57
 
58
- `scaffold({directory, project, hostFile, names})` returns the auth pieces of a layered project (YAML fragments, host imports and entries, private files with in-memory key material, a README section and next steps) without writing anything; `initAuthentication` is assembled from it. Core's `urlcode init --with auth` calls this export and merges it with other extensions.
58
+ `scaffold({directory, project, hostFile, names})` returns the auth pieces of a layered project (YAML fragments, host imports and entries, private files with in-memory key material, a README section and next steps) without writing anything; `initAuthentication` is assembled from it. Core's `urlcode init --with ui,auth` calls this export and merges it with other extensions; it refuses a request whose `names` omit `ui` or place it after `auth`.
59
59
  Exported types: `ScaffoldRequest`, `ScaffoldResult`, `ScaffoldFile`.
60
60
 
61
61
  ## Operator activation
@@ -131,14 +131,20 @@ import, or a named export that is not a function fails **activation** —
131
131
  before this extension serves a single request — never the first request
132
132
  that happens to reach the hook.
133
133
 
134
+ Each activation re-reads the hook's **entry** module from disk, so editing a
135
+ hook file and re-activating (a dev reload) takes effect without restarting
136
+ the process. Only the entry module is refreshed: modules the hook itself
137
+ imports stay on Node's module cache for the life of the process, so a change
138
+ to a hook's own dependency still needs a restart.
139
+
134
140
  **`sandbox: true` is not implemented for these hooks and is refused
135
- explicitly at activation**, naming the hook: `hook <name>: sandbox: true is
136
- not yet supported for project-level hooks, see jimhoyd-com/urlcode-auth#35`.
137
- Core's trusted/sandboxed dispatch is wired to route dispatch, not exposed to
138
- extensions (jimhoyd-com/urlcode#151), so this package has no way to actually
139
- isolate a hook call yet; accepting the field and running it trusted anyway
140
- would misrepresent the isolation a project believes it configured. Declare a
141
- hook without `sandbox` (or with `sandbox: false`) to use it today.
141
+ explicitly at activation**, naming the hook. Core exports the isolate itself —
142
+ `SandboxPool` from `@jimhoyd/urlcode/sandbox`, the same engine a sandboxed
143
+ route uses ([extensions](../../docs/EXTENSIONS.md)) but this package does not
144
+ route a hook invocation through it, so the opt-in does not exist here yet.
145
+ Accepting the field and running it trusted anyway would misrepresent the
146
+ isolation a project believes it configured. Declare a hook without `sandbox`
147
+ (or with `sandbox: false`) to use it today.
142
148
 
143
149
  ## Authentication and presentation
144
150
 
@@ -208,9 +214,9 @@ Migration preserves accounts, enrolled credentials and history, while revoking s
208
214
 
209
215
  ## Presentation
210
216
 
211
- Every account screen is an `auth/*` template in the urlcode-ui kit language with a declared view model (`authTemplates`, each with a sample view; `authUiTemplates` is the block the `ui` extension takes). The extension computes the view and the template only places it: a template cannot change which steps a flow has, what a form validates, what is escaped, or the CSRF field and headers a page sends. Forms, fields and buttons arrive in the view as renderer-produced markup built by the shared primitives.
217
+ Every account screen is an `auth/*` template in the urlcode-ui kit language with a declared view model (`authTemplates`, each with a sample view; `authUiTemplates` is the block the `ui` extension takes). The extension computes the view and the template only places it: a template cannot change which steps a flow has, what a form validates, what is escaped, or the CSRF field and headers a page sends. Forms, fields and buttons arrive in the view as renderer-produced markup built by the kit's shared form primitives (`field`, `postForm` and friends from `@jimhoyd/urlcode-ui`).
212
218
 
213
- `authExtension` takes an optional `ui`, the object `createUiExtension` returns. Declare `ui` first in the host file so the runtime activates it before auth; auth reads `ui.kit` per request and never captures it at activation.
219
+ `authExtension` requires `ui`, the object `createUiExtension` returns: the kit is the only render path. Declare `ui` before `auth` in `urlcode.yaml` and list `ui.registration` before `authExtension` in the host the runtime activates extensions in the order `urlcode.yaml` declares them, and auth refuses activation when `ui` is missing or not yet activated. Auth reads `ui.kit` per request and never captures it at activation. `@jimhoyd/urlcode-ui` is already a required peer dependency, so this adds nothing to install.
214
220
 
215
221
  ```js
216
222
  import { createUiExtension } from '@jimhoyd/urlcode-ui/host';
@@ -222,13 +228,15 @@ export default { extensions: [ui.registration, authExtension({ service, csrfKey,
222
228
  ```yaml
223
229
  extensions:
224
230
  ui: { version: "1", config: { theme: { name: Acme }, templates: ui/templates } }
231
+ auth: { version: "1", config: { registration: "off" } }
225
232
  routes:
226
233
  /assets/ui/*: { extension: ui, methods: [GET, HEAD] }
234
+ /account/*: { extension: auth, methods: [GET, HEAD, POST] }
227
235
  ```
228
236
 
229
- With `ui`, screens render through `ui.kit.page`: the project's theme, layout, hashed stylesheet and copy apply, a project file `ui/templates/auth/<screen>.html` shadows the shipped template, and `urlcode-ui doctor` reports every `auth/*` template behind its view model. Copy then resolves through the kit's presentation, which carries the kit catalogue, the auth catalogue and the project's `extensions.ui` copy; omit `presentation` in that case. If both are given, `presentation` wins and must register the kit catalogue for the layout's own keys.
237
+ Screens render through `ui.kit.page`: the project's theme, layout, hashed stylesheet and copy apply, a project file `ui/templates/auth/<screen>.html` shadows the shipped template, and `urlcode-ui doctor --extensions @jimhoyd/urlcode-auth` reports every `auth/*` template behind its view model (the CLI loads the namespace, copy and samples from this package's `authUiTemplates` export; without the flag it sees the kit alone). Copy then resolves through the kit's presentation, which carries the kit catalogue, the auth catalogue and the project's `extensions.ui` copy; omit `presentation` in that case. If both are given, `presentation` wins and must register the kit catalogue for the layout's own keys.
230
238
 
231
- Without `ui`, nothing changes: screens render the same templates through the shared primitives with `presentation` (or the bundled English catalogue). The `presentation` option remains the fallback; core plans to retire it one minor version after the kit path ships. The auth passkey script and the optional challenge widget are nonce-bound on both paths and the page CSP admits only that nonce (plus the challenge origin when configured).
239
+ There is no fallback render path: earlier releases rendered the same templates through the shared primitives when `ui` was absent, and that branch has been removed. The auth passkey script and the optional challenge widget are nonce-bound to the kit's page nonce and the page CSP admits only that nonce (plus the challenge origin when configured).
232
240
 
233
241
  Changing `configurationTag` deliberately advances the approved configuration revision for provider/callback/profile-policy deployments that cannot be fingerprinted as simple data. The service does not automatically fingerprint executable callbacks. Session idle and absolute limits do participate in the declared configuration fingerprint.
234
242
 
@@ -5,4 +5,4 @@ import { AuthHttp } from './auth-ui.ts';
5
5
  import type { UiHost } from './auth-ui.ts';
6
6
  import type { PresentationContext } from './presentation.ts';
7
7
  /** Entry requests only: callback and code/token redemption keep their own bound proofs. */
8
- export declare function createAbuseGuard(service: AuthService, http: AuthHttp, mount: string, challenge?: AuthChallenge, ui?: UiHost): (request: ExtensionRequest, presentation?: PresentationContext) => Promise<import("./auth-ui.ts").AuthHttpResponse | undefined>;
8
+ export declare function createAbuseGuard(service: AuthService, http: AuthHttp, mount: string, challenge: AuthChallenge | undefined, ui: UiHost): (request: ExtensionRequest, presentation?: PresentationContext) => Promise<import("./auth-ui.ts").AuthHttpResponse | undefined>;
@@ -66,11 +66,14 @@ async function probe(root) {
66
66
  let runtime, service;
67
67
  try {
68
68
  const { createRuntime } = await import('@jimhoyd/urlcode'), { inspectExtensionRevision } = await import('@jimhoyd/urlcode/extensions'), { createAuthService } = await import("./auth-core.js"), { authExtension } = await import("./auth.js");
69
+ // Account screens render only through the kit, so the synthetic project declares and registers `ui` exactly as a real host must.
70
+ const { createUiExtension } = await import('@jimhoyd/urlcode-ui/host'), { authUiTemplates } = await import("./auth-templates.js"), { englishCatalogue } = await import("./presentation.js");
69
71
  const project = join(root, 'project'), operator = join(root, 'operator'), origin = 'https://baseline.invalid';
70
72
  await mkdir(project, { mode: 0o700 });
71
73
  await mkdir(operator, { mode: 0o700 });
72
- await writeFile(join(project, 'urlcode.yaml'), JSON.stringify({ version: '1', extensions: { auth: { version: '1', config: { registration: 'open' } } }, routes: {
74
+ await writeFile(join(project, 'urlcode.yaml'), JSON.stringify({ version: '1', extensions: { ui: { version: '1', config: {} }, auth: { version: '1', config: { registration: 'open' } } }, routes: {
73
75
  '/account/*': { extension: 'auth', methods: ['GET', 'HEAD', 'POST'] },
76
+ '/assets/ui/*': { extension: 'ui', methods: ['GET', 'HEAD'] },
74
77
  '/protected': { respond: { json: { authorized: true } }, methods: ['GET', 'POST'], policies: { extensions: { auth: { permission: 'baseline.read' } } } },
75
78
  '/public-guest': { parameters: [{ name: 'cookie', in: 'header', schema: { type: 'string', default: 'untrusted-default' } }, { name: 'authorization', in: 'header', schema: { type: 'string' } }], function: { source: 'guest.mjs' } },
76
79
  } }), { mode: 0o600 });
@@ -78,7 +81,8 @@ async function probe(root) {
78
81
  const revision = await inspectExtensionRevision(project), csrfKey = randomBytes(32), encryptionKey = randomBytes(32), password = 'synthetic-baseline-' + randomBytes(16).toString('hex');
79
82
  service = await createAuthService({ database: join(operator, 'ordinary.sqlite'), encryptionKey, roles: { member: ['baseline.read'], admin: ['*'] }, defaultRole: 'member' });
80
83
  const account = await service.register({ email: 'synthetic-baseline@example.test', password });
81
- const startRuntime = () => createRuntime(project, { origin, environment: {}, workers: 1, timeoutMs: 1000, extensions: [authExtension({ service: service, csrfKey, projectSha256: revision })], log: () => { } });
84
+ const ui = createUiExtension({ projectSha256: revision, projectRoot: project, sources: [englishCatalogue], extensions: [authUiTemplates] });
85
+ const startRuntime = () => createRuntime(project, { origin, environment: {}, workers: 1, timeoutMs: 1000, extensions: [ui.registration, authExtension({ service: service, csrfKey, projectSha256: revision, ui })], log: () => { } });
82
86
  runtime = await startRuntime();
83
87
  const text = (response) => typeof response.body === 'string' ? response.body : response.body ? Buffer.from(response.body).toString('utf8') : '';
84
88
  const cookies = new Map();
@@ -10,7 +10,7 @@ import type { AuthHttpResponse, UiHost } from './auth-ui.ts';
10
10
  export interface AuthFlowOptions {
11
11
  service: AuthService;
12
12
  presentation?: Presentation;
13
- ui?: UiHost;
13
+ ui: UiHost;
14
14
  onSession?: (request: ExtensionRequest, result: AuthSessionResult) => Promise<[
15
15
  string,
16
16
  string
@@ -1,9 +1,10 @@
1
1
  import type { AuthAbusePolicy } from './abuse.ts';
2
2
  import type { RegistrationProfile } from './registration.ts';
3
+ import type { EventEmitter } from 'node:events';
3
4
  export declare class AuthError extends Error {
4
5
  readonly status: number;
5
6
  readonly code: string;
6
- constructor(status: number, code: string);
7
+ constructor(status: number, code: string, cause?: unknown);
7
8
  }
8
9
  export interface AuthRecord {
9
10
  mfaPasskeys?: string[];
@@ -77,5 +78,20 @@ export interface AuthStore {
77
78
  call<T = unknown>(operation: string, args?: Record<string, unknown>): Promise<T>;
78
79
  close(): Promise<void>;
79
80
  }
81
+ /**
82
+ * Describes the phase a worker was still in when its startup bound elapsed.
83
+ * Thread scheduling and database initialization fail for unrelated reasons, and a
84
+ * bare timeout cannot tell them apart; naming the phase makes the next occurrence
85
+ * diagnosable from the failure alone. `onlineMs` is undefined when the worker
86
+ * thread never began executing JavaScript.
87
+ */
88
+ export declare function startupPhase(onlineMs: number | undefined, elapsedMs: number): string;
89
+ /**
90
+ * Resolves once the worker reports readiness, and otherwise rejects with what it
91
+ * reached. A worker that fails or exits before reporting is rejected at once
92
+ * rather than waiting out `boundMs`, whose expiry is reported with its phase and
93
+ * timings. Separated from Worker construction so every branch is testable.
94
+ */
95
+ export declare function awaitStoreStartup(worker: EventEmitter, boundMs: number): Promise<void>;
80
96
  export declare function patched(version: string): boolean;
81
97
  export declare function openAuthStore(options: StoreOptions): Promise<AuthStore>;
@@ -11,7 +11,49 @@ import { resolve, dirname, basename, join } from 'node:path';
11
11
  export class AuthError extends Error {
12
12
  status;
13
13
  code;
14
- constructor(status, code) { super(code); this.status = status; this.code = code; }
14
+ // `cause` carries operator diagnostics only. Responses are built from `status`
15
+ // and `code`, so nothing recorded here reaches a client.
16
+ constructor(status, code, cause) { super(code, cause === undefined ? undefined : { cause }); this.status = status; this.code = code; }
17
+ }
18
+ /** Startup codes the worker reports for itself; anything else is an unavailable store. */
19
+ const startupCodes = ['auth_configuration_changed', 'configuration_approval_mismatch', 'configuration_roles_invalid', 'configuration_admin_required'];
20
+ /**
21
+ * Describes the phase a worker was still in when its startup bound elapsed.
22
+ * Thread scheduling and database initialization fail for unrelated reasons, and a
23
+ * bare timeout cannot tell them apart; naming the phase makes the next occurrence
24
+ * diagnosable from the failure alone. `onlineMs` is undefined when the worker
25
+ * thread never began executing JavaScript.
26
+ */
27
+ export function startupPhase(onlineMs, elapsedMs) {
28
+ return onlineMs === undefined
29
+ ? `worker thread did not begin executing within ${elapsedMs}ms`
30
+ : `worker thread began executing after ${onlineMs}ms, then did not report readiness for a further ${elapsedMs - onlineMs}ms`;
31
+ }
32
+ /**
33
+ * Resolves once the worker reports readiness, and otherwise rejects with what it
34
+ * reached. A worker that fails or exits before reporting is rejected at once
35
+ * rather than waiting out `boundMs`, whose expiry is reported with its phase and
36
+ * timings. Separated from Worker construction so every branch is testable.
37
+ */
38
+ export function awaitStoreStartup(worker, boundMs) {
39
+ const started = performance.now(), elapsed = () => Math.round(performance.now() - started);
40
+ let onlineMs;
41
+ worker.once('online', () => { onlineMs = elapsed(); });
42
+ return new Promise((accept, reject) => {
43
+ const unavailable = (detail) => new AuthError(503, 'auth_store_unavailable', new Error(detail));
44
+ const timer = setTimeout(() => { reject(unavailable(startupPhase(onlineMs, elapsed()))); }, boundMs);
45
+ worker.once('message', (message) => {
46
+ clearTimeout(timer);
47
+ if (message.ready)
48
+ accept();
49
+ else if (startupCodes.includes(message.error ?? ''))
50
+ reject(new AuthError(503, message.error));
51
+ else
52
+ reject(unavailable(`worker reported ${message.error ?? 'no readiness'} after ${elapsed()}ms`));
53
+ });
54
+ worker.once('error', (error) => { clearTimeout(timer); reject(unavailable(`worker failed after ${elapsed()}ms: ${error instanceof Error ? error.message : String(error)}`)); });
55
+ worker.once('exit', (code) => { clearTimeout(timer); reject(unavailable(`worker exited with code ${code} after ${elapsed()}ms without reporting readiness`)); });
56
+ });
15
57
  }
16
58
  export function patched(version) { const [a = 0, b = 0, c = 0] = version.split('.').map(Number); return a > 3 || a === 3 && (b > 51 || b === 51 && c >= 3 || b === 50 && c >= 7 || b === 44 && c >= 6); }
17
59
  export async function openAuthStore(options) {
@@ -47,18 +89,7 @@ export async function openAuthStore(options) {
47
89
  worker.on('error', fail);
48
90
  worker.on('exit', fail);
49
91
  try {
50
- await new Promise((accept, reject) => {
51
- const timer = setTimeout(() => { reject(new AuthError(503, 'auth_store_unavailable')); }, 15000);
52
- worker.once('message', (message) => {
53
- clearTimeout(timer);
54
- if (message.ready)
55
- accept();
56
- else {
57
- reject(new AuthError(503, ['auth_configuration_changed', 'configuration_approval_mismatch', 'configuration_roles_invalid', 'configuration_admin_required'].includes(message.error ?? '') ? message.error : 'auth_store_unavailable'));
58
- }
59
- });
60
- worker.once('error', () => { clearTimeout(timer); reject(new AuthError(503, 'auth_store_unavailable')); });
61
- });
92
+ await awaitStoreStartup(worker, 15000);
62
93
  }
63
94
  catch (error) {
64
95
  // A rejected open must release SQLite handles before its caller can
@@ -1,4 +1,4 @@
1
- import type { ViewModel } from '@jimhoyd/urlcode-ui';
1
+ import type { ExtensionTemplates, ViewModel } from '@jimhoyd/urlcode-ui';
2
2
  export interface AuthTemplate {
3
3
  readonly source: string;
4
4
  readonly sample: ViewModel;
@@ -6,8 +6,13 @@ export interface AuthTemplate {
6
6
  /** Template sources with their view model samples, keyed by full template name. */
7
7
  export declare const authTemplates: Readonly<Record<string, AuthTemplate>>;
8
8
  export declare const authTemplateNames: readonly string[];
9
- /** What the host hands to `createUiExtension({ extensions: [authUiTemplates] })`. */
10
- export declare const authUiTemplates: {
9
+ /**
10
+ * What the host hands to `createUiExtension({ extensions: [authUiTemplates] })`. It also carries the
11
+ * catalogue the host registers in `sources` and the view model samples, so `urlcode-ui --extensions
12
+ * @jimhoyd/urlcode-auth` lists, ejects, previews and drift-checks these screens from this one export.
13
+ */
14
+ export declare const authUiTemplates: ExtensionTemplates & {
11
15
  readonly name: 'auth';
12
16
  readonly templates: Readonly<Record<string, string>>;
17
+ readonly samples: Readonly<Record<string, ViewModel>>;
13
18
  };
@@ -7,6 +7,7 @@
7
7
  * validates, what is escaped or what a page sends in headers.
8
8
  */
9
9
  import { Markup } from '@jimhoyd/urlcode-ui';
10
+ import { englishCatalogue } from "./presentation.js";
10
11
  const m = (html) => new Markup(html);
11
12
  const declare = (name, body) => `{{!-- viewModel: auth/${name}@1 --}}${body}`;
12
13
  const link = '<a href="{{href href}}">{{label}}</a>';
@@ -70,5 +71,14 @@ const screens = {
70
71
  /** Template sources with their view model samples, keyed by full template name. */
71
72
  export const authTemplates = Object.freeze(Object.fromEntries(Object.entries(screens).map(([name, screen]) => [`auth/${name}`, Object.freeze({ source: declare(name, screen.body), sample: screen.sample })])));
72
73
  export const authTemplateNames = Object.freeze(Object.keys(authTemplates));
73
- /** What the host hands to `createUiExtension({ extensions: [authUiTemplates] })`. */
74
- export const authUiTemplates = Object.freeze({ name: 'auth', templates: Object.freeze(Object.fromEntries(Object.entries(authTemplates).map(([name, template]) => [name, template.source]))) });
74
+ /**
75
+ * What the host hands to `createUiExtension({ extensions: [authUiTemplates] })`. It also carries the
76
+ * catalogue the host registers in `sources` and the view model samples, so `urlcode-ui --extensions
77
+ * @jimhoyd/urlcode-auth` lists, ejects, previews and drift-checks these screens from this one export.
78
+ */
79
+ export const authUiTemplates = Object.freeze({
80
+ name: 'auth',
81
+ templates: Object.freeze(Object.fromEntries(Object.entries(authTemplates).map(([name, template]) => [name, template.source]))),
82
+ catalogue: englishCatalogue,
83
+ samples: Object.freeze(Object.fromEntries(Object.entries(authTemplates).map(([name, template]) => [name, template.sample]))),
84
+ });
package/dist/auth-ui.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ViewModel, Kit, Presentation } from '@jimhoyd/urlcode-ui';
1
+ import type { ViewModel, Kit } from '@jimhoyd/urlcode-ui';
2
2
  export { escapeHtml } from '@jimhoyd/urlcode-ui';
3
3
  import type { TurnstileWidget } from './challenge-ui.ts';
4
4
  import type { PresentationContext } from './presentation.ts';
@@ -32,27 +32,20 @@ export interface Screen {
32
32
  export interface ScreenOptions {
33
33
  status?: number | undefined;
34
34
  headers?: [string, string][] | undefined;
35
- /** A same-origin script the screen needs (the passkey glue); it is nonce-bound on both render paths. */
35
+ /** A same-origin script the screen needs (the passkey glue); it is nonce-bound to the kit's page nonce. */
36
36
  scriptPath?: string | undefined;
37
37
  presentation?: PresentationContext | undefined;
38
38
  turnstile?: TurnstileWidget | undefined;
39
39
  layout?: 'default' | 'compact' | 'application' | undefined;
40
- /** When present and active, the screen renders through the kit; otherwise through the primitives. */
41
- ui?: UiHost | undefined;
40
+ /** The kit every account screen renders through; `authExtension` refuses activation without it. */
41
+ ui: UiHost;
42
42
  }
43
- /** Test hook: sees every screen before it renders, with the path that renders it. */
43
+ /** Test hook: sees every screen before it renders. */
44
44
  export declare const screenObserver: {
45
- current?: ((screen: Screen, path: 'primitives' | 'kit') => void) | undefined;
45
+ current?: ((screen: Screen) => void) | undefined;
46
46
  };
47
- /** Renders a screen: through `ui.kit` when the host supplied the ui extension and it is active, otherwise through the shared primitives. */
48
- export declare function screenResponse(title: string, screen: Screen, options?: ScreenOptions): AuthHttpResponse;
49
- /** The presentation auth resolves copy through: the host's, else the kit's once `ui` is active, else the bundled English catalogue. */
50
- export declare function presentationSource(presentation: Presentation | undefined, ui: UiHost | undefined, fallback: Presentation): Presentation;
51
- /** Only trusted package code constructs markup. Project/user values must pass escapeHtml. */
52
- export declare function pageResponse(title: string, markup: string, status?: number, headers?: [
53
- string,
54
- string
55
- ][], scriptPath?: string, presentation?: PresentationContext, turnstile?: TurnstileWidget, layout?: 'default' | 'compact' | 'application'): AuthHttpResponse;
47
+ /** Renders a screen through `ui.kit`, the only render path; activation already refused a missing or inactive `ui`. */
48
+ export declare function screenResponse(title: string, screen: Screen, options: ScreenOptions): AuthHttpResponse;
56
49
  export declare function formField(name: string, label: string, type?: string, autocomplete?: string, required?: boolean): string;
57
50
  export declare function csrfField(token: string): string;
58
51
  export declare function wantsJson(request: ExtensionRequest): boolean;
@@ -96,10 +89,10 @@ export declare class AuthHttp {
96
89
  string
97
90
  ][];
98
91
  }
99
- export declare function httpFailure(error: unknown, request: ExtensionRequest, presentation?: PresentationContext, recovery?: {
92
+ export declare function httpFailure(error: unknown, request: ExtensionRequest, presentation: PresentationContext | undefined, recovery: {
100
93
  href: string;
101
94
  label: string;
102
- }, ui?: UiHost): AuthHttpResponse;
95
+ } | undefined, ui: UiHost): AuthHttpResponse;
103
96
  /** Proof token stays in the submitting form and is consumed once with the primary proof. */
104
97
  export declare function secondFactorButton(base: string, text?: (source: string) => string): string;
105
98
  /** Browser glue for maintained server-side WebAuthn verification. No guest scripts. */
package/dist/auth-ui.js CHANGED
@@ -1,9 +1,8 @@
1
- import { renderDocument, field, escapeHtml, compileTemplate, Markup } from '@jimhoyd/urlcode-ui';
1
+ import { field, escapeHtml, Markup } from '@jimhoyd/urlcode-ui';
2
2
  export { escapeHtml } from '@jimhoyd/urlcode-ui';
3
3
  import { authTemplates } from "./auth-templates.js";
4
4
  import { addTurnstileWidgets, turnstileOrigin, turnstileScript } from "./challenge-ui.js";
5
5
  import { englishCatalogue } from "./presentation.js";
6
- import { createPresentation } from "./presentation.js";
7
6
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
8
7
  export class AuthHttpError extends Error {
9
8
  status;
@@ -12,33 +11,18 @@ export class AuthHttpError extends Error {
12
11
  const encoder = new TextEncoder();
13
12
  const securityHeaders = [['cache-control', 'no-store'], ['content-security-policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"], ['referrer-policy', 'strict-origin'], ['x-content-type-options', 'nosniff']];
14
13
  export function jsonResponse(status, value, headers = []) { return { status, headers: [...securityHeaders, ['content-type', 'application/json; charset=utf-8'], ...headers], body: encoder.encode(JSON.stringify(value)) }; }
15
- /** Test hook: sees every screen before it renders, with the path that renders it. */
14
+ /** Test hook: sees every screen before it renders. */
16
15
  export const screenObserver = {};
17
- let defaultContext;
18
- const localTemplates = new Map();
19
- function localTemplate(name) {
20
- let template = localTemplates.get(name);
21
- if (!template && Object.hasOwn(authTemplates, name)) {
22
- template = compileTemplate(name, authTemplates[name].source);
23
- localTemplates.set(name, template);
24
- }
25
- return template;
26
- }
27
16
  function pageTitle(title, presentation) {
28
17
  const titleKey = Object.entries(englishCatalogue).find(([key, value]) => key.startsWith('page.') && value === title)?.[0];
29
18
  return presentation ? (titleKey ? presentation.text(titleKey) : presentation.textSource(title)) : title;
30
19
  }
31
- /** Renders a screen: through `ui.kit` when the host supplied the ui extension and it is active, otherwise through the shared primitives. */
32
- export function screenResponse(title, screen, options = {}) {
20
+ /** Renders a screen through `ui.kit`, the only render path; activation already refused a missing or inactive `ui`. */
21
+ export function screenResponse(title, screen, options) {
33
22
  if (!Object.hasOwn(authTemplates, screen.name))
34
23
  throw new Error(`Unknown auth screen: ${screen.name.slice(0, 64)}`);
35
- const kit = options.ui?.active ? options.ui.kit : undefined;
36
- screenObserver.current?.(screen, kit ? 'kit' : 'primitives');
37
- if (!kit) {
38
- const context = options.presentation ?? (defaultContext ??= createPresentation().resolve());
39
- const markup = localTemplate(screen.name).render(screen.view, context, localTemplate).html;
40
- return pageResponse(title, markup, options.status, options.headers, options.scriptPath, options.presentation, options.turnstile, options.layout);
41
- }
24
+ const kit = options.ui.kit;
25
+ screenObserver.current?.(screen);
42
26
  const context = options.presentation ?? kit.resolveContext();
43
27
  const challenge = addTurnstileWidgets(kit.render(screen.name, screen.view, context).html, options.turnstile);
44
28
  const page = kit.wrap(new Markup(challenge.markup), { title: pageTitle(title, context), context, ...(options.layout ? { layout: options.layout } : {}), ...(options.status !== undefined ? { status: options.status } : {}), ...(options.headers ? { headers: options.headers } : {}), ...(challenge.enabled ? { csp: { script: [turnstileOrigin], frame: [turnstileOrigin], connect: [turnstileOrigin] } } : {}) });
@@ -53,20 +37,6 @@ export function screenResponse(title, screen, options = {}) {
53
37
  html = html.slice(0, -'</body></html>'.length) + scripts.map(script => `<script nonce="${nonce}" src="${escapeHtml(script.src)}"${script.async ? ' async' : ' defer'}></script>`).join('') + '</body></html>';
54
38
  return { status: page.status, headers: page.headers, body: encoder.encode(html) };
55
39
  }
56
- /** The presentation auth resolves copy through: the host's, else the kit's once `ui` is active, else the bundled English catalogue. */
57
- export function presentationSource(presentation, ui, fallback) {
58
- return presentation ?? (ui?.active ? ui.kit.presentation : fallback);
59
- }
60
- /** Only trusted package code constructs markup. Project/user values must pass escapeHtml. */
61
- export function pageResponse(title, markup, status = 200, headers = [], scriptPath, presentation, turnstile, layout = 'default') {
62
- title = pageTitle(title, presentation);
63
- const challenge = addTurnstileWidgets(markup, turnstile);
64
- markup = challenge.markup;
65
- const nonce = randomBytes(18).toString('base64');
66
- const scripts = [...(scriptPath ? [{ src: scriptPath, nonce: nonce }] : []), ...(challenge.enabled ? [{ src: turnstileScript, nonce: nonce, async: true }] : [])];
67
- const html = renderDocument({ title, trustedContent: markup, layout, theme: { nonce }, ...(presentation ? { presentation } : {}), scripts });
68
- return { status, headers: [...securityHeaders.map(([name, value]) => [name, name === 'content-security-policy' ? value + (presentation?.logo || presentation?.favicon ? "; img-src 'self'" : '') + (nonce ? `; script-src 'nonce-${nonce}'${challenge.enabled ? ' ' + turnstileOrigin : ''}` : '') + (challenge.enabled ? `; frame-src ${turnstileOrigin}; connect-src 'self' ${turnstileOrigin}` : '') : value]), ['content-type', 'text/html; charset=utf-8'], ...headers], body: encoder.encode(html) };
69
- }
70
40
  export function formField(name, label, type = 'text', autocomplete = 'off', required = true) { return field({ name, label, type, autocomplete, required }); }
71
41
  export function csrfField(token) { return `<input type="hidden" name="csrf" value="${escapeHtml(token)}">`; }
72
42
  export function wantsJson(request) { return (request.headers.get('accept') || '').split(',').some(value => value.trim().split(';')[0] === 'application/json') || request.headers.get('content-type')?.split(';')[0]?.trim() === 'application/json'; }
package/dist/auth.d.ts CHANGED
@@ -10,8 +10,8 @@ export interface AuthExtensionOptions {
10
10
  challenge?: AuthChallenge;
11
11
  sendFactorRecovery?: (message: FactorRecoveryMessage) => Promise<void>;
12
12
  presentation?: Presentation;
13
- /** The `ui` extension from `createUiExtension`, declared before auth in the host file. Screens then render through its kit. */
14
- ui?: UiHost;
13
+ /** The `ui` extension from `createUiExtension`, declared before auth in the host file. Every account screen renders through its kit; activation refuses without it. */
14
+ ui: UiHost;
15
15
  service: AuthService;
16
16
  csrfKey: Uint8Array;
17
17
  projectSha256: string;
package/dist/auth.js CHANGED
@@ -2,14 +2,12 @@ import { icon, hiddenField, postForm, Markup } from '@jimhoyd/urlcode-ui';
2
2
  import { createAbuseGuard } from "./abuse-http.js";
3
3
  import { createManualRecoveryFlows } from "./manual-recovery.js";
4
4
  import { createFactorRecoveryFlows } from "./factor-recovery.js";
5
- import { createPresentation } from "./presentation.js";
6
5
  import { isHoneypotFilled } from "./registration.js";
7
6
  import { createSecondFactorFlows } from "./second-factor-flows.js";
8
7
  import { createSignup } from "./auth-signup.js";
9
8
  import { createAuthFlows } from "./auth-flows.js";
10
- import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField as baseField, httpFailure, jsonResponse, presentationSource, readFields, screenResponse, wantsJson, passkeyScript, secondFactorButton } from "./auth-ui.js";
9
+ import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField as baseField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson, passkeyScript, secondFactorButton } from "./auth-ui.js";
11
10
  import { hooksConfigSchema, loadLifecycleHooks } from "./lifecycle-hooks.js";
12
- const defaultPresentation = createPresentation();
13
11
  function enrollmentRequired(principal) { return Boolean(principal.restrictions?.length); }
14
12
  export function hasPermission(principal, permission) { return !enrollmentRequired(principal) && (principal.permissions.includes('*') || principal.permissions.includes(permission)); }
15
13
  const schema = { type: 'object', additionalProperties: false, properties: { registration: { enum: ['open', 'invite-only', 'waitlist', 'off'] }, hooks: hooksConfigSchema } };
@@ -22,14 +20,20 @@ export function authExtension(options) {
22
20
  async activate(config, context) {
23
21
  if (context.mounts.length !== 1)
24
22
  throw new Error('Auth requires exactly one mount');
23
+ // Account screens render only through the kit, so a missing or unactivated `ui`
24
+ // is refused here rather than per request in production.
25
+ if (!options.ui)
26
+ throw new Error('Auth requires the ui extension: pass the object createUiExtension() returns as authExtension({ui, ...}). Every account screen renders through its kit; there is no shared-primitive fallback.');
27
+ if (!options.ui.active)
28
+ throw new Error('Auth requires an activated ui extension: declare `ui` in urlcode.yaml before `auth`, with its asset route (for example /assets/ui/*), and list ui.registration before authExtension in the host. The runtime activates extensions in the order urlcode.yaml declares them.');
25
29
  // Fail-fast: a configured hook whose module fails to load or whose
26
30
  // named export is missing fails activation here, never the first
27
31
  // request that happens to reach it. `sandbox: true` is rejected
28
32
  // inside loadLifecycleHooks, explicitly, not silently ignored.
29
33
  const hooks = await loadLifecycleHooks(config.hooks, context.root);
30
34
  const mount = context.mounts[0], http = new AuthHttp({ origin: context.origin, csrfKey: options.csrfKey }), service = options.service, registrationMode = String(config.registration || 'off'), registration = registrationMode === 'open';
31
- // The runtime activates `ui` before auth, but its kit is read per request, never captured at activation.
32
- const source = () => presentationSource(options.presentation, options.ui, defaultPresentation), localized = Boolean(options.presentation || options.ui);
35
+ // The runtime activated `ui` before auth, but its kit is read per request, never captured at activation.
36
+ const source = () => options.presentation ?? options.ui.kit.presentation;
33
37
  const lazyPresentation = { get locales() { return source().locales; }, get defaultLocale() { return source().defaultLocale; }, get english() { return source().english; }, resolve: preferences => source().resolve(preferences), coverage: locale => source().coverage(locale) };
34
38
  if (registrationMode !== service.getRegistrationMode())
35
39
  throw new Error('Project registration mode must match operator auth service mode');
@@ -136,7 +140,7 @@ export function authExtension(options) {
136
140
  let presentation = source().resolve({ ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
137
141
  try {
138
142
  const token = http.session(request), user = token ? await service.authenticate(token) : null;
139
- const locale = user && localized ? (await service.getUser(user.id))?.profile?.locale : undefined;
143
+ const locale = user ? (await service.getUser(user.id))?.profile?.locale : undefined;
140
144
  if (locale)
141
145
  presentation = source().resolve({ accountLocale: locale });
142
146
  const allowed = user && !enrollmentRequired(user) && (!requirement.role || user.roles.includes(String(requirement.role))) && (!requirement.permission || hasPermission(user, String(requirement.permission))) && (!requirement.verified || user.emailVerified) && (!requirement.freshWithinSeconds || Date.now() - user.authenticatedAt <= Number(requirement.freshWithinSeconds) * 1000);
@@ -155,13 +159,11 @@ export function authExtension(options) {
155
159
  },
156
160
  async handle(request) {
157
161
  let accountLocale;
158
- if (localized) {
159
- try {
160
- const session = http.session(request), actor = session ? await service.authenticate(session) : null;
161
- accountLocale = actor ? (await service.getUser(actor.id))?.profile?.locale : undefined;
162
- }
163
- catch { /* An unreadable session only means no account locale to prefer; fall back to the request locale. */ }
162
+ try {
163
+ const session = http.session(request), actor = session ? await service.authenticate(session) : null;
164
+ accountLocale = actor ? (await service.getUser(actor.id))?.profile?.locale : undefined;
164
165
  }
166
+ catch { /* An unreadable session only means no account locale to prefer; fall back to the request locale. */ }
165
167
  const presentation = source().resolve({ ...(accountLocale ? { accountLocale } : {}), ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
166
168
  const tr = (key, values) => escapeHtml(presentation.text(key, values));
167
169
  const text = (value) => presentation?.textSource(value) ?? value;
@@ -37,7 +37,7 @@ export interface FactorRecoveryOptions {
37
37
  challenge?: AuthChallenge;
38
38
  service: FactorRecoveryService;
39
39
  sendFactorRecovery?: (message: FactorRecoveryMessage) => Promise<void>;
40
- ui?: UiHost;
40
+ ui: UiHost;
41
41
  }
42
42
  /** Opt-in email fallback lowers factor assurance; it never creates an unrestricted session. */
43
43
  export declare function createFactorRecoveryFlows(options: FactorRecoveryOptions, http: AuthHttp, mount: string): {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { AuthService, AuthPrincipal, AuthUser } from './auth-core.ts';
3
3
  export { authExtension, hasPermission } from './auth.ts';
4
4
  export type { AuthExtensionOptions } from './auth.ts';
5
5
  export type { AuthHttpOptions, AuthHttpResponse } from './auth-ui.ts';
6
- export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, pageResponse, readFields, screenResponse, wantsJson } from './auth-ui.ts';
6
+ export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson } from './auth-ui.ts';
7
7
  export type { Screen, ScreenOptions, UiHost } from './auth-ui.ts';
8
8
  export { authTemplates, authTemplateNames, authUiTemplates } from './auth-templates.ts';
9
9
  export type { AuthTemplate } from './auth-templates.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createAuthService, AuthError } from "./auth-core.js";
2
2
  export { authExtension, hasPermission } from "./auth.js";
3
- export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, pageResponse, readFields, screenResponse, wantsJson } from "./auth-ui.js";
3
+ export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson } from "./auth-ui.js";
4
4
  export { authTemplates, authTemplateNames, authUiTemplates } from "./auth-templates.js";
5
5
  export { createOidcProvider } from "./oidc.js";
6
6
  export { createPasskeyProvider } from "./passkeys.js";
@@ -15,11 +15,24 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
15
15
  // lifecycle hooks"): no special case, no hardwired sandbox.
16
16
  //
17
17
  // `sandbox: true` is explicitly rejected at activation, never silently
18
- // ignored: core's trusted/sandboxed dispatch (TrustedFunctions/FunctionPool)
19
- // is wired to route dispatch, not exposed to extensions, so this package has
20
- // no way to actually isolate a hook call yet (tracked in
21
- // jimhoyd-com/urlcode#151). Accepting `sandbox: true` and running it trusted
22
- // anyway would misrepresent the isolation the project believes it configured.
18
+ // ignored. Core now exports the isolate itself -- `SandboxPool` from
19
+ // `@jimhoyd/urlcode/sandbox`, the same QuickJS/worker engine a sandboxed
20
+ // route uses -- so the missing piece is no longer a core primitive but this
21
+ // package's own wiring: a hook invocation is a plain in-process call and
22
+ // nothing here routes it through a pool. Until that exists, accepting
23
+ // `sandbox: true` and running it trusted anyway would misrepresent the
24
+ // isolation the project believes it configured, so it is refused instead.
25
+ //
26
+ // Each activation re-imports the hook's ENTRY module under a fresh
27
+ // cache-busting query, mirroring core's trusted route activation
28
+ // (src/trusted-functions.ts, `urlcode-trusted-epoch`): Node's ESM loader
29
+ // caches a resolved module forever by URL, so without this an edited hook
30
+ // file kept returning its previous decision for the life of the process
31
+ // (jimhoyd-com/urlcode#198). Only the entry module is refreshed — modules the
32
+ // hook itself imports stay on Node's module cache, the same already-documented
33
+ // core limitation the trusted route path has; a change to a hook's own
34
+ // dependency still needs a process restart.
35
+ import { randomUUID } from 'node:crypto';
23
36
  import { isAbsolute, relative, resolve } from 'node:path';
24
37
  import { realpath, stat } from 'node:fs/promises';
25
38
  import { pathToFileURL } from 'node:url';
@@ -82,17 +95,21 @@ export async function loadLifecycleHooks(config, root) {
82
95
  const hooks = {};
83
96
  if (!config)
84
97
  return hooks;
98
+ // One epoch per activation, not per hook: two hooks naming the same entry
99
+ // module still share a single instance within this activation, exactly as
100
+ // core's per-runtime-instance epoch does.
101
+ const epoch = randomUUID();
85
102
  for (const name of hookNames) {
86
103
  const ref = config[name];
87
104
  if (ref === undefined)
88
105
  continue;
89
106
  const definition = normalize(ref);
90
107
  if (definition.sandbox)
91
- throw new Error(`hook ${name}: sandbox: true is not yet supported for project-level hooks, see jimhoyd-com/urlcode-auth#35`);
108
+ throw new Error(`hook ${name}: sandbox: true is not yet supported for project-level hooks; this extension does not route a hook invocation through core's SandboxPool yet. See docs/EXTENSIONS.md "Project-level lifecycle hooks".`);
92
109
  const file = await projectFile(root, definition.source, name);
93
110
  let mod;
94
111
  try {
95
- mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
112
+ mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + '?urlcode-hook-epoch=' + epoch)));
96
113
  }
97
114
  catch {
98
115
  throw new Error(`hook ${name}: failed to load module "${definition.source}"`);
@@ -82,6 +82,6 @@ export interface ManualRecoveryService {
82
82
  }
83
83
  export declare function validateRecoveryEvidence(input: ManualRecoveryEvidence): ManualRecoveryEvidence;
84
84
  /** Redemption is POST-only and creates an enrollment session, never normal access. */
85
- export declare function createManualRecoveryFlows(service: ManualRecoveryService, http: AuthHttp, mount: string, ui?: UiHost): {
85
+ export declare function createManualRecoveryFlows(service: ManualRecoveryService, http: AuthHttp, mount: string, ui: UiHost): {
86
86
  handle(request: ExtensionRequest, presentation?: PresentationContext): Promise<AuthHttpResponse | undefined>;
87
87
  };
@@ -3,6 +3,10 @@ export interface AuthenticationScaffold {
3
3
  project: string;
4
4
  hostFile: string;
5
5
  operatorFile: string;
6
+ /** Exact versions recorded in the generated package.json. */
7
+ dependencies: Record<string, string>;
8
+ /** Peers whose installed version could not be read, recorded as their declared range instead of a pin. */
9
+ unpinnedDependencies: string[];
6
10
  }
7
11
  /** Shared scaffold contract (core `urlcode init --with`): what the caller is assembling. */
8
12
  export interface ScaffoldRequest {
@@ -40,5 +44,17 @@ export interface ScaffoldResult {
40
44
  export declare function scaffold(request: ScaffoldRequest): Promise<ScaffoldResult>;
41
45
  /** Deterministic YAML for scaffold fragments: block mappings, flow lists of scalars, {} for empty maps. */
42
46
  export declare function renderYaml(value: Record<string, unknown>, indent?: string): string;
47
+ export interface DependencySpecifiers {
48
+ dependencies: Record<string, string>;
49
+ unpinned: string[];
50
+ }
51
+ /**
52
+ * What a generated auth site depends on: this package at its exact version, plus each declared peer at the exact
53
+ * version installed beside it. A peer whose installation cannot be found is recorded as its declared range and
54
+ * reported, because a manifest with no dependencies at all -- what this initializer wrote before #212 -- tells the
55
+ * generated site nothing about the versions it was generated against. Nothing is installed here; running a package
56
+ * manager to produce a lockfile stays the operator's own explicit step.
57
+ */
58
+ export declare function dependencySpecifiers(): Promise<DependencySpecifiers>;
43
59
  /** Creates a new private directory only; never merges or overwrites an existing project. */
44
60
  export declare function initAuthentication(directory: string): Promise<AuthenticationScaffold>;
package/dist/scaffold.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { mkdir, open, realpath, rm } from 'node:fs/promises';
2
+ import { scaffold as uiScaffold } from '@jimhoyd/urlcode-ui/host';
3
+ import { mkdir, open, readFile, realpath, rm } from 'node:fs/promises';
3
4
  import { resolve, dirname, basename, join, relative, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
4
6
  const OPERATOR_FILE = 'operator-service.mjs', ENCRYPTION_KEY = 'data/encryption.key', CSRF_KEY = 'data/csrf.key';
5
7
  function moduleReference(from, to) {
6
8
  const path = relative(from, to).split(sep).join('/');
@@ -35,7 +37,9 @@ function readmeSection(request, admin) {
35
37
 
36
38
  ## Install
37
39
 
38
- Use a supported patched Node release. From this directory, install the separately built repositories until packages are released:
40
+ Use a supported patched Node release. The generated \`package.json\` pins the runtime and extensions to the exact versions that were installed when this directory was created; review it, then run \`npm install\` here to install exactly those and write \`package-lock.json\`. Nothing installs them for you, and there is no upgrade command: changing a pinned version today means editing \`package.json\` and re-running the install yourself.
41
+
42
+ To develop against separately built source repositories instead, replace those pins with the local paths (an install from a path is not reproducible anywhere that path does not exist):
39
43
 
40
44
  \`\`\`sh
41
45
  # First run npm ci && npm run build in each source repository.
@@ -79,6 +83,12 @@ export async function scaffold(request) {
79
83
  }
80
84
  if (!Array.isArray(request.names) || request.names.some(name => typeof name !== 'string'))
81
85
  throw new Error('Scaffold names must be strings');
86
+ // Every account screen renders through the kit, and the runtime activates extensions in urlcode.yaml order,
87
+ // which core writes in --with order. So ui must be named, and named first.
88
+ if (!request.names.includes('ui'))
89
+ throw new Error('Auth scaffold requires the ui extension: urlcode init --with ui,auth');
90
+ if (request.names.indexOf('ui') > request.names.indexOf('auth'))
91
+ throw new Error('Auth scaffold requires ui before auth so the kit activates first: urlcode init --with ui,auth');
82
92
  const directory = resolve(request.directory), project = resolve(directory, request.project), hostFile = resolve(directory, request.hostFile);
83
93
  const normalized = { directory, project, hostFile, names: request.names };
84
94
  const admin = request.names.includes('admin'), hostDirectory = dirname(hostFile);
@@ -104,7 +114,7 @@ export async function scaffold(request) {
104
114
  " if (csrfKey.length !== 32) throw new Error('Invalid CSRF key');",
105
115
  '} catch (error) { await service.close(); throw error; }',
106
116
  ],
107
- hostEntries: ['authExtension({service, csrfKey, projectSha256})'],
117
+ hostEntries: ['authExtension({service, csrfKey, projectSha256, ui})'],
108
118
  hostClose: ['csrfKey.fill(0);', 'await service.close();'],
109
119
  files: [
110
120
  { path: OPERATOR_FILE, content: serviceModule(directory), mode: 0o600 },
@@ -159,13 +169,78 @@ function hostModule(result) {
159
169
  '};',
160
170
  ].join('\n') + '\n';
161
171
  }
172
+ async function readManifest(file) {
173
+ try {
174
+ return JSON.parse(await readFile(file, 'utf8'));
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ /** Node's own `node_modules` lookup, reading manifests rather than loading anything. */
181
+ async function installedVersion(name, from) {
182
+ let directory = resolve(from);
183
+ for (;;) {
184
+ const manifest = await readManifest(join(directory, 'node_modules', ...name.split('/'), 'package.json'));
185
+ if (manifest && manifest.name === name && typeof manifest.version === 'string')
186
+ return manifest.version;
187
+ const parent = dirname(directory);
188
+ if (parent === directory)
189
+ return null;
190
+ directory = parent;
191
+ }
192
+ }
193
+ /**
194
+ * What a generated auth site depends on: this package at its exact version, plus each declared peer at the exact
195
+ * version installed beside it. A peer whose installation cannot be found is recorded as its declared range and
196
+ * reported, because a manifest with no dependencies at all -- what this initializer wrote before #212 -- tells the
197
+ * generated site nothing about the versions it was generated against. Nothing is installed here; running a package
198
+ * manager to produce a lockfile stays the operator's own explicit step.
199
+ */
200
+ export async function dependencySpecifiers() {
201
+ const own = fileURLToPath(new URL('../package.json', import.meta.url));
202
+ const manifest = await readManifest(own);
203
+ if (!manifest || typeof manifest.name !== 'string' || typeof manifest.version !== 'string')
204
+ throw new Error(`Could not read this package's manifest at ${own}`);
205
+ const dependencies = { [manifest.name]: manifest.version };
206
+ const unpinned = [];
207
+ const peers = manifest.peerDependencies && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {};
208
+ for (const [name, range] of Object.entries(peers)) {
209
+ if (typeof range !== 'string')
210
+ continue;
211
+ const version = await installedVersion(name, dirname(own));
212
+ if (version)
213
+ dependencies[name] = version;
214
+ else {
215
+ dependencies[name] = range;
216
+ unpinned.push(name);
217
+ }
218
+ }
219
+ return { dependencies: Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)), unpinned };
220
+ }
162
221
  /** Creates a new private directory only; never merges or overwrites an existing project. */
163
222
  export async function initAuthentication(directory) {
164
223
  if (typeof directory !== 'string' || !directory || directory.includes('\0'))
165
224
  throw new Error('An output directory is required');
166
225
  const requested = resolve(directory), parent = await realpath(dirname(requested)), root = join(parent, basename(requested));
167
226
  const project = join(root, 'app'), hostFile = join(root, 'host.mjs');
168
- const result = await scaffold({ directory: root, project, hostFile, names: ['auth'] });
227
+ const names = ['ui', 'auth'];
228
+ const kit = await uiScaffold({ directory: root, project, hostFile, names });
229
+ const auth = await scaffold({ directory: root, project, hostFile, names });
230
+ // ui first in both the YAML and the host: auth refuses to activate before the kit is active.
231
+ const result = {
232
+ ...auth,
233
+ extensions: { ...kit.extensions, ...auth.extensions },
234
+ routes: { ...kit.routes, ...auth.routes },
235
+ hostImports: [...kit.hostImports, ...auth.hostImports],
236
+ hostSetup: [...kit.hostSetup, ...auth.hostSetup],
237
+ hostEntries: [...kit.hostEntries, ...auth.hostEntries],
238
+ files: [...kit.files, ...auth.files],
239
+ };
240
+ // Resolved before the directory exists, so a manifest failure leaves nothing
241
+ // behind. `@jimhoyd/urlcode-ui` is a declared peer, so the kit this scaffold
242
+ // now emits is pinned by the same pass that pins auth itself.
243
+ const pins = await dependencySpecifiers();
169
244
  try {
170
245
  await mkdir(root, { mode: 0o700 });
171
246
  }
@@ -191,14 +266,16 @@ export async function initAuthentication(directory) {
191
266
  await write('app/urlcode.yaml', renderYaml({ version: '1', extensions: result.extensions, routes: result.routes }));
192
267
  await write('host.mjs', hostModule(result));
193
268
  await write('README.md', `# Auth project and operator host\n\n${result.readme}`);
194
- await write('package.json', JSON.stringify({ name: 'urlcode-auth-site', private: true, type: 'module' }, null, 2) + '\n');
269
+ await write('package.json', JSON.stringify({ name: 'urlcode-auth-site', private: true, version: '0.0.0', type: 'module', dependencies: pins.dependencies }, null, 2) + '\n');
195
270
  await write('.gitignore', 'node_modules/\ndata/\n.env\n.env.*\n');
196
271
  for (const file of result.files) {
197
272
  if (file.path.includes('\0') || resolve(root, file.path) !== join(root, file.path) || relative(root, resolve(root, file.path)).startsWith('..'))
198
273
  throw new Error('Invalid scaffold file path');
274
+ // Composed scaffolds contribute nested paths of their own (the kit's ui/copy and ui/templates).
275
+ await mkdir(dirname(join(root, file.path)), { recursive: true, mode: 0o700 });
199
276
  await write(file.path, file.content, file.mode);
200
277
  }
201
- return { directory: root, project, hostFile, operatorFile: join(root, OPERATOR_FILE) };
278
+ return { directory: root, project, hostFile, operatorFile: join(root, OPERATOR_FILE), dependencies: pins.dependencies, unpinnedDependencies: pins.unpinned };
202
279
  }
203
280
  catch (error) {
204
281
  await rm(root, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jimhoyd/urlcode-auth",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Operator-installed authentication extension for URLCode: accounts, sessions, passkeys, OIDC, TOTP and trusted account pages",
6
6
  "license": "Apache-2.0",
@@ -52,8 +52,8 @@
52
52
  "otpauth": "9.5.2"
53
53
  },
54
54
  "peerDependencies": {
55
- "@jimhoyd/urlcode": ">=0.4.0-alpha.2 <0.5.0",
56
- "@jimhoyd/urlcode-ui": ">=0.1.0-alpha.1 <0.2.0"
55
+ "@jimhoyd/urlcode": ">=0.4.1 <0.5.0",
56
+ "@jimhoyd/urlcode-ui": ">=0.4.1 <0.5.0"
57
57
  },
58
58
  "bin": {
59
59
  "urlcode-auth": "./dist/cli.js"