@jimhoyd/urlcode-auth 0.4.0-alpha.3 → 0.4.2

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. Core's extension-hook primitive resolves and imports them eagerly, exposes their typed contracts through extension inspection, and rejects `sandbox: true` under the trusted-only v1 hook contract. `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
@@ -137,14 +137,10 @@ the process. Only the entry module is refreshed: modules the hook itself
137
137
  imports stay on Node's module cache for the life of the process, so a change
138
138
  to a hook's own dependency still needs a restart.
139
139
 
140
- **`sandbox: true` is not implemented for these hooks and is refused
141
- explicitly at activation**, naming the hook: `hook <name>: sandbox: true is
142
- not yet supported for project-level hooks, see jimhoyd-com/urlcode-auth#35`.
143
- Core's trusted/sandboxed dispatch is wired to route dispatch, not exposed to
144
- extensions (jimhoyd-com/urlcode#151), so this package has no way to actually
145
- isolate a hook call yet; accepting the field and running it trusted anyway
146
- would misrepresent the isolation a project believes it configured. Declare a
147
- hook without `sandbox` (or with `sandbox: false`) to use it today.
140
+ Core's extension-hook primitive loads these hooks and publishes their contracts
141
+ through `get_extensions`. Contract v1 is trusted-only: `sandbox: true` is
142
+ refused explicitly at activation. Declare a hook without `sandbox` (or with
143
+ `sandbox: false`) to use it.
148
144
 
149
145
  ## Authentication and presentation
150
146
 
@@ -214,9 +210,9 @@ Migration preserves accounts, enrolled credentials and history, while revoking s
214
210
 
215
211
  ## Presentation
216
212
 
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 shared primitives.
213
+ 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`).
218
214
 
219
- `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.
215
+ `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.
220
216
 
221
217
  ```js
222
218
  import { createUiExtension } from '@jimhoyd/urlcode-ui/host';
@@ -228,13 +224,15 @@ export default { extensions: [ui.registration, authExtension({ service, csrfKey,
228
224
  ```yaml
229
225
  extensions:
230
226
  ui: { version: "1", config: { theme: { name: Acme }, templates: ui/templates } }
227
+ auth: { version: "1", config: { registration: "off" } }
231
228
  routes:
232
229
  /assets/ui/*: { extension: ui, methods: [GET, HEAD] }
230
+ /account/*: { extension: auth, methods: [GET, HEAD, POST] }
233
231
  ```
234
232
 
235
- 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.
233
+ 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.
236
234
 
237
- 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).
235
+ 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).
238
236
 
239
237
  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.
240
238
 
@@ -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";
11
- import { hooksConfigSchema, loadLifecycleHooks } from "./lifecycle-hooks.js";
12
- const defaultPresentation = createPresentation();
9
+ import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField as baseField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson, passkeyScript, secondFactorButton } from "./auth-ui.js";
10
+ import { authHookContracts, hooksConfigSchema, loadLifecycleHooks } from "./lifecycle-hooks.js";
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 } };
@@ -18,18 +16,24 @@ const actionIcons = { identify: 'arrow-right', login: 'arrow-right', 'step-up':
18
16
  const hidden = hiddenField;
19
17
  const m = (html) => new Markup(html);
20
18
  export function authExtension(options) {
21
- return { name: 'auth', version: '1', projectSha256: options.projectSha256, targets: ['node'], schema, policySchema, credentialHeaders: ['cookie', 'authorization', 'x-csrf-token'],
19
+ return { name: 'auth', version: '1', projectSha256: options.projectSha256, targets: ['node'], schema, policySchema, hooks: authHookContracts, credentialHeaders: ['cookie', 'authorization', 'x-csrf-token'],
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";
@@ -1,9 +1,5 @@
1
- export interface HookReference {
2
- source: string;
3
- export?: string;
4
- sandbox?: boolean;
5
- }
6
- export type HookConfig = string | HookReference;
1
+ import type { ExtensionHookConfig } from '@jimhoyd/urlcode/extensions';
2
+ export type HookConfig = ExtensionHookConfig;
7
3
  export interface LifecycleHooksConfig {
8
4
  beforeRegister?: HookConfig;
9
5
  onSignUp?: HookConfig;
@@ -30,42 +26,72 @@ export interface LifecycleHooks {
30
26
  onSignUp?(input: OnSignUpInput): void | Promise<void>;
31
27
  onDelete?(input: OnDeleteInput): void | Promise<void>;
32
28
  }
33
- export declare const hooksConfigSchema: {
34
- type: string;
35
- additionalProperties: boolean;
36
- properties: {
37
- [k: string]: {
38
- oneOf: ({
39
- type: string;
40
- minLength: number;
41
- maxLength: number;
42
- additionalProperties?: never;
43
- required?: never;
44
- properties?: never;
45
- } | {
46
- type: string;
47
- additionalProperties: boolean;
48
- required: string[];
49
- properties: {
50
- source: {
51
- type: string;
52
- minLength: number;
53
- maxLength: number;
54
- };
55
- export: {
56
- type: string;
57
- pattern: string;
58
- };
59
- sandbox: {
60
- type: string;
61
- };
62
- };
63
- minLength?: never;
64
- maxLength?: never;
65
- })[];
29
+ export declare const authHookContracts: readonly [{
30
+ readonly name: "beforeRegister";
31
+ readonly kind: "filter";
32
+ readonly description: "Runs before account creation and may return an allow/deny verdict.";
33
+ readonly inputSchema: {
34
+ readonly type: "object";
35
+ readonly additionalProperties: false;
36
+ readonly required: readonly ["email"];
37
+ readonly properties: {
38
+ readonly email: {
39
+ readonly type: "string";
40
+ };
41
+ readonly profile: {
42
+ readonly type: "object";
43
+ };
44
+ };
45
+ };
46
+ readonly outputSchema: {
47
+ readonly type: "object";
48
+ readonly additionalProperties: false;
49
+ readonly required: readonly ["allow"];
50
+ readonly properties: {
51
+ readonly allow: {
52
+ readonly type: "boolean";
53
+ };
54
+ readonly reason: {
55
+ readonly type: "string";
56
+ };
57
+ };
58
+ };
59
+ }, {
60
+ readonly name: "onSignUp";
61
+ readonly kind: "action";
62
+ readonly description: "Runs after a new account is created.";
63
+ readonly inputSchema: {
64
+ readonly type: "object";
65
+ readonly additionalProperties: false;
66
+ readonly required: readonly ["accountId", "email"];
67
+ readonly properties: {
68
+ readonly accountId: {
69
+ readonly type: "string";
70
+ };
71
+ readonly email: {
72
+ readonly type: "string";
73
+ };
74
+ };
75
+ };
76
+ }, {
77
+ readonly name: "onDelete";
78
+ readonly kind: "action";
79
+ readonly description: "Runs after an account owner schedules deletion.";
80
+ readonly inputSchema: {
81
+ readonly type: "object";
82
+ readonly additionalProperties: false;
83
+ readonly required: readonly ["accountId", "email"];
84
+ readonly properties: {
85
+ readonly accountId: {
86
+ readonly type: "string";
87
+ };
88
+ readonly email: {
89
+ readonly type: "string";
90
+ };
66
91
  };
67
92
  };
68
- };
93
+ }];
94
+ export declare const hooksConfigSchema: object;
69
95
  /**
70
96
  * Resolves and eagerly imports every declared hook, so a missing module, a
71
97
  * syntax error or a missing export fails activation (fail-fast), never the
@@ -1,11 +1,3 @@
1
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
- if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
- return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
- return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
- });
6
- }
7
- return path;
8
- };
9
1
  // Project-level lifecycle hooks (docs/SPIKE-AUTH.md, urlcode-auth#35). A
10
2
  // project names its own function per lifecycle point in `extensions.auth.config.hooks`,
11
3
  // using the same `{source, export}` (or bare string) shape `function`/`middleware`
@@ -14,12 +6,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
14
6
  // (docs/SPIKE-DEFAULT-TRUST-MODEL.md, urlcode's docs/EXTENSIONS.md "Project-level
15
7
  // lifecycle hooks"): no special case, no hardwired sandbox.
16
8
  //
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.
9
+ // Core's shared extension-hook primitive owns resolution, import and
10
+ // discovery. Contract v1 is trusted-only; `sandbox: true` is rejected at
11
+ // activation and never silently run trusted.
23
12
  //
24
13
  // Each activation re-imports the hook's ENTRY module under a fresh
25
14
  // cache-busting query, mirroring core's trusted route activation
@@ -30,58 +19,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
30
19
  // hook itself imports stay on Node's module cache, the same already-documented
31
20
  // core limitation the trusted route path has; a change to a hook's own
32
21
  // dependency still needs a process restart.
33
- import { randomUUID } from 'node:crypto';
34
- import { isAbsolute, relative, resolve } from 'node:path';
35
- import { realpath, stat } from 'node:fs/promises';
36
- import { pathToFileURL } from 'node:url';
37
- const hookNames = ['beforeRegister', 'onSignUp', 'onDelete'];
38
- export const hooksConfigSchema = {
39
- type: 'object',
40
- additionalProperties: false,
41
- properties: Object.fromEntries(hookNames.map(name => [name, {
42
- oneOf: [
43
- { type: 'string', minLength: 1, maxLength: 1024 },
44
- {
45
- type: 'object',
46
- additionalProperties: false,
47
- required: ['source'],
48
- properties: {
49
- source: { type: 'string', minLength: 1, maxLength: 1024 },
50
- export: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
51
- sandbox: { type: 'boolean' },
52
- },
53
- },
54
- ],
55
- }])),
56
- };
57
- function normalize(ref) {
58
- return typeof ref === 'string'
59
- ? { source: ref, export: 'default', sandbox: false }
60
- : { source: ref.source, export: ref.export ?? 'default', sandbox: ref.sandbox === true };
61
- }
62
- // Same project-relative-file discipline core's own `safeFile` applies to a
63
- // route's `function.source`: resolved against the project root, refused if
64
- // it escapes it. Not a security boundary against the module's own code
65
- // (trusted hooks get full Node access like any other project code), just the
66
- // same "the YAML cannot point outside the project" hygiene.
67
- async function projectFile(root, file, hookName) {
68
- if (isAbsolute(file))
69
- throw new Error(`hook ${hookName}: source must be a project-relative path`);
70
- const base = await realpath(root);
71
- let actual;
72
- try {
73
- actual = await realpath(resolve(base, file));
74
- }
75
- catch {
76
- throw new Error(`hook ${hookName}: source module "${file}" was not found`);
77
- }
78
- const rel = relative(base, actual);
79
- if (!rel || rel === '..' || rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')) || isAbsolute(rel))
80
- throw new Error(`hook ${hookName}: source escapes the project`);
81
- if (!(await stat(actual)).isFile())
82
- throw new Error(`hook ${hookName}: source must be a file`);
83
- return actual;
84
- }
22
+ import { extensionHooksSchema, loadExtensionHooks } from '@jimhoyd/urlcode/extensions';
23
+ export const authHookContracts = [
24
+ { name: 'beforeRegister', kind: 'filter', description: 'Runs before account creation and may return an allow/deny verdict.', inputSchema: { type: 'object', additionalProperties: false, required: ['email'], properties: { email: { type: 'string' }, profile: { type: 'object' } } }, outputSchema: { type: 'object', additionalProperties: false, required: ['allow'], properties: { allow: { type: 'boolean' }, reason: { type: 'string' } } } },
25
+ { name: 'onSignUp', kind: 'action', description: 'Runs after a new account is created.', inputSchema: { type: 'object', additionalProperties: false, required: ['accountId', 'email'], properties: { accountId: { type: 'string' }, email: { type: 'string' } } } },
26
+ { name: 'onDelete', kind: 'action', description: 'Runs after an account owner schedules deletion.', inputSchema: { type: 'object', additionalProperties: false, required: ['accountId', 'email'], properties: { accountId: { type: 'string' }, email: { type: 'string' } } } },
27
+ ];
28
+ export const hooksConfigSchema = extensionHooksSchema(authHookContracts);
85
29
  /**
86
30
  * Resolves and eagerly imports every declared hook, so a missing module, a
87
31
  * syntax error or a missing export fails activation (fail-fast), never the
@@ -90,32 +34,5 @@ async function projectFile(root, file, hookName) {
90
34
  * trusted.
91
35
  */
92
36
  export async function loadLifecycleHooks(config, root) {
93
- const hooks = {};
94
- if (!config)
95
- return hooks;
96
- // One epoch per activation, not per hook: two hooks naming the same entry
97
- // module still share a single instance within this activation, exactly as
98
- // core's per-runtime-instance epoch does.
99
- const epoch = randomUUID();
100
- for (const name of hookNames) {
101
- const ref = config[name];
102
- if (ref === undefined)
103
- continue;
104
- const definition = normalize(ref);
105
- if (definition.sandbox)
106
- throw new Error(`hook ${name}: sandbox: true is not yet supported for project-level hooks, see jimhoyd-com/urlcode-auth#35`);
107
- const file = await projectFile(root, definition.source, name);
108
- let mod;
109
- try {
110
- mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + '?urlcode-hook-epoch=' + epoch)));
111
- }
112
- catch {
113
- throw new Error(`hook ${name}: failed to load module "${definition.source}"`);
114
- }
115
- const fn = mod[definition.export];
116
- if (typeof fn !== 'function')
117
- throw new Error(`hook ${name}: export "${definition.export}" in "${definition.source}" is not a function`);
118
- hooks[name] = fn;
119
- }
120
- return hooks;
37
+ return await loadExtensionHooks(config, authHookContracts, { root });
121
38
  }
@@ -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.4.0-alpha.3",
3
+ "version": "0.4.2",
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.3 <0.5.0",
56
- "@jimhoyd/urlcode-ui": ">=0.4.0-alpha.3 <0.5.0"
55
+ "@jimhoyd/urlcode": ">=0.4.2 <0.5.0",
56
+ "@jimhoyd/urlcode-ui": ">=0.4.2 <0.5.0"
57
57
  },
58
58
  "bin": {
59
59
  "urlcode-auth": "./dist/cli.js"