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

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,6 @@
1
1
  # Auth implementation status
2
2
 
3
- Status: `@jimhoyd/urlcode-auth` 0.1.0-alpha.1 is the first npm release (core issue #78); it requires the published `@jimhoyd/urlcode` 0.4.x 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` 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.
4
4
 
5
5
  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
6
 
@@ -10,6 +10,8 @@ Mandatory verification/TOTP enrollment, operator standard/hardened presets and e
10
10
 
11
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.
12
12
 
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.
14
+
13
15
  ## Additional implemented acceptance
14
16
 
15
17
  - Bounded localized email copy, durable progressive password backoff, trusted-client and signup-domain velocity budgets, optional fixed-origin Turnstile verification/widget, and pinned disposable-domain data.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  An optional, operator-installed authentication extension for URLCode. This repository contains the Node/SQLite implementation: password and passkey authentication, OpenID Connect, email codes, TOTP, recovery codes, versioned registration profiles, account lifecycle operations, administrative service operations and trusted HTML pages.
4
4
 
5
- [![Verify](https://github.com/jimhoyd-com/urlcode-auth/actions/workflows/verify.yml/badge.svg)](https://github.com/jimhoyd-com/urlcode-auth/actions/workflows/verify.yml)
5
+ [![CI](https://github.com/jimhoyd-com/urlcode/actions/workflows/ci.yml/badge.svg)](https://github.com/jimhoyd-com/urlcode/actions/workflows/ci.yml)
6
6
 
7
7
  The implementation is under active review. Local tests and builds are evidence of those checks, not an independent security assessment, production deployment, provider certification or recovery/soak result. See [SECURITY.md](SECURITY.md) for the trust boundary and [the first-release coverage review](docs/SPIKE-AUTH.md) for the proposal; the proposal is not a list of completed features.
8
8
 
@@ -27,20 +27,21 @@ Every release tarball is attested from the tagged commit: `gh attestation verify
27
27
 
28
28
  Operators who pin exact reviewed commits rather than registry versions can build the same packages locally. A registry version alone does not establish that a revision was reviewed: this implementation requires the core extension contract introduced by [core PR #59](https://github.com/jimhoyd-com/urlcode/pull/59). Use its reviewed implementation or a reviewed successor containing it, pinned to an exact commit. Do not infer approval from the current branch name.
29
29
 
30
- This package also depends on the shared `@jimhoyd/urlcode-ui` peer, which owns document layout, semantic fields, escaping, themes and the locale engine; authentication/administration behavior remains here. Core can use UI without auth/admin. Source CI (`verify.yml`) checks the peers out at the commits in [`peers.json`](peers.json) and needs the narrow `URLCODE_UI_READ_TOKEN`; the release workflow resolves them from the registry instead.
30
+ This package also depends on the shared `@jimhoyd/urlcode-ui` peer, which owns document layout, semantic fields, escaping, themes and the locale engine; authentication/administration behavior remains here. Core can use UI without auth/admin. Both peers are siblings in this repository, so CI builds them from the same commit there is no peer checkout, no `peers.json` and no read token; the release workflow resolves the published versions from the registry instead, to prove the declared ranges are satisfiable.
31
31
 
32
- Each repository has a lockfile. The source packaging helper installs dependencies with lifecycle scripts disabled, builds the reviewed packages (core, then UI, then their consumers), installs local peer tarballs in dependency order and writes package integrity/revision metadata. It does not publish. All source trees must be committed and clean. `--core`, `--auth`, `--ui`, `--core-revision` and `--out` are required. Replace these illustrative paths with your reviewed locations. `--core-revision` defaults to the `urlcode` entry in [`peers.json`](peers.json), the single source of verified peer revisions; pass it explicitly only to override:
32
+ One lockfile governs the workspace. The source packaging helper installs dependencies with lifecycle scripts disabled, builds the reviewed packages in dependency order (core, then UI, then their consumers) and writes package integrity metadata. It does not publish. The tree must be committed and clean, and it re-checks that after every build and pack. `--revision` and `--out` are required; `--revision` is exact and has no default, because the reviewed commit is the thing being asserted:
33
33
 
34
34
  ```sh
35
35
  node scripts/pack-sources.mjs \
36
- --core /absolute/source/urlcode \
37
- --ui /absolute/source/urlcode-ui \
38
- --auth /absolute/source/urlcode-auth \
39
- --admin /absolute/source/urlcode-admin \
40
- --core-revision REVIEWED_40_CHARACTER_COMMIT_SHA \
36
+ --revision REVIEWED_40_CHARACTER_COMMIT_SHA \
41
37
  --out /absolute/new-private-package-directory
42
38
  ```
43
39
 
40
+ One commit identifies every package: core, ui, auth and admin are built from
41
+ the same reviewed revision of this repository. The script refuses to run if the
42
+ checkout is not at that exact commit or has uncommitted changes, and re-checks
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
46
 
46
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:
@@ -83,6 +84,62 @@ The external host creates an AuthService and supplies `authExtension({service, c
83
84
 
84
85
  Registration starts off. Bootstrap the first administrator through `urlcode-auth bootstrap --operator-file /absolute/operator-service.mjs`, supplying `{email,password}` as bounded JSON on stdin. Never place passwords in command arguments or source files. The command returns account metadata, not the session token. A role/default-role configuration change is a reviewed operator change, not an administration-page edit.
85
86
 
87
+ ## Project-level lifecycle hooks
88
+
89
+ A project can name its own function per lifecycle point in `extensions.auth.config.hooks`, using the same `{source, export}` shape (or a bare string, defaulting to the module's default export) `function`/`middleware` routes already use — the behavior-layer counterpart to `urlcode-ui`'s presentation layering (urlcode-auth#35, urlcode's docs/EXTENSIONS.md "Project-level lifecycle hooks"):
90
+
91
+ ```yaml
92
+ extensions:
93
+ auth:
94
+ version: '1'
95
+ config:
96
+ registration: open
97
+ hooks:
98
+ beforeRegister: ./hooks/registration-rule.mjs # bare string: default export
99
+ onSignUp:
100
+ source: ./hooks/on-signup.mjs
101
+ export: provisionWorkspace
102
+ onDelete: ./hooks/on-delete.mjs
103
+ ```
104
+
105
+ Three lifecycle points are implemented:
106
+
107
+ - **`beforeRegister(input: {email, profile?})`** runs before an account is
108
+ created, from the immediate `/register` endpoint and from the resumable
109
+ `/signup/begin` step, and returns a typed verdict: `{allow: true}` lets the
110
+ attempt continue, `{allow: false, reason}` rejects it and the `reason` is
111
+ surfaced to the caller the same way any other registration rejection is (a
112
+ `403` with that message). This is how "only `@acme.com` may register"
113
+ becomes portable project code instead of a fork.
114
+ - **`onSignUp(input: {accountId, email})`** is a side-effect hook (no
115
+ verdict) that fires once, after a *new* account is actually created — from
116
+ the immediate `/register` endpoint and from `/signup/complete` (an
117
+ existing-account signup attempt that resolves to sign-in, not a new
118
+ account, never fires it). Use it for something like provisioning a
119
+ workspace after sign-up.
120
+ - **`onDelete(input: {accountId, email})`** fires when the account owner
121
+ schedules their own deletion through the account page's `/delete` endpoint
122
+ (the deletion grace period still applies and can still be cancelled). It
123
+ does not yet fire from an administrator-initiated deletion or from the
124
+ background purge once the grace period elapses.
125
+
126
+ These hooks are first-party project code, the same trust category as any
127
+ `function`/`middleware` route: **trusted, in-process execution by default**,
128
+ following the runtime's trust model with no special case (urlcode's
129
+ docs/SPIKE-DEFAULT-TRUST-MODEL.md). A missing module, a module that fails to
130
+ import, or a named export that is not a function fails **activation** —
131
+ before this extension serves a single request — never the first request
132
+ that happens to reach the hook.
133
+
134
+ **`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.
142
+
86
143
  ## Authentication and presentation
87
144
 
88
145
  `createAuthService` owns a private SQLite database outside the application directory. Its operations enforce authority, fresh authentication, delegation ceilings, replay protection and transaction boundaries. Callers must preserve the distinction between unrestricted operator APIs and actor-token administrative APIs. `authExtension` adds HTTP cookies, same-origin CSRF checks, bounded bodies and trusted pages.
@@ -127,7 +184,7 @@ Run `urlcode-auth --help` for the current CLI. Operator commands have full datab
127
184
 
128
185
  `--operator-file` is an absolute path to a module that default-exports an `AuthService`.
129
186
 
130
- Backup/restore accepts JSON paths on stdin. `createBackup({database,destination,projectRoot})` uses SQLite's online backup API, including committed WAL pages, with a bounded worker and integrity checks. `restoreBackup({backup,destination,projectRoot})` restores to a **new** path. Both require private operator paths outside the project and refuse overwrite. Never copy only a live `.sqlite` file and assume its WAL is included.
187
+ Backup/restore accepts JSON paths on stdin. `createBackup({database,destination,projectRoot})` uses SQLite's online backup API, including committed WAL pages, with a bounded worker and integrity checks. `restoreBackup({backup,destination,projectRoot})` restores to a **new** path. Both require private operator paths outside the project and refuse overwrite. Never copy only a live `.sqlite` file and assume its WAL is included. See [backup and restore platform guarantees](../../docs/AUTH-BACKUP.md), including Windows ACL and directory durability limits.
131
188
 
132
189
  Back up encryption keys, CSRF keys and reviewed static configuration separately. Database snapshots contain sensitive account/audit data and password hashes, but do not export key files. Restoring historical data also restores historical sessions/tokens and revocation state: plan revocation and recovery before reopening traffic. Rotate keys by adding a new active key, retaining decryption keys while bounded migration reports remaining records, then remove old keys only after completion and backup verification. Old writers fail closed after activation switches. Keep a tested isolated restore procedure.
133
190
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ This repository is an actively reviewed implementation, not an independent secur
4
4
 
5
5
  ## Trusted and untrusted components
6
6
 
7
- Operator modules, their dependencies, configuration, database directory, encryption/CSRF keys, identity providers and mail transport are trusted. Project routes and sandboxed guest code do not gain authority to load host modules. Core extension activation requires an explicitly supplied registry and a reviewed exact project revision pin. Never turn revision inspection into automatic approval.
7
+ Operator modules, their dependencies, configuration, database directory, encryption/CSRF keys, identity providers and mail transport are trusted. Project routes are trusted and run in-process with full Node access by default; `sandbox: true` opts a route into the isolated QuickJS/WASM worker pool instead. Project YAML cannot register auth's host modules: activation requires the explicit host-file/operator-registration mechanism. This is not a confinement boundary for trusted Node code, which can independently import accessible modules and read process environment/files with the host user's authority. Only an explicitly sandboxed route has the guest isolation boundary. Core extension activation requires an explicitly supplied registry and a reviewed exact project revision pin. Never turn revision inspection into automatic approval.
8
8
 
9
9
  Auth is Node/SQLite only. It refuses unpatched SQLite versions and requires private database files. Keep the database, WAL/SHM, backups, operator modules and key files outside the application project and inaccessible to guest filesystem access. Do not run the host as a shared hostile operating-system user. Filesystem permission and symlink checks do not defend against an attacker who already controls the operator account or its parent directories.
10
10
 
package/THREAT-MODEL.md CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  ## Scope and assets
4
4
 
5
- This package is a trusted Node host service. Application server code remains
6
- untrusted WASM under the core runtime's capability model. The auth service protects
5
+ This package is a trusted Node host service. Under the core runtime's current
6
+ capability model, application `function`/`middleware` routes run trusted and
7
+ in-process with full Node access by default; a route opts into isolated
8
+ QuickJS/WASM execution only by declaring `sandbox: true`. The auth service protects
7
9
  account ownership, verified identifiers, credential material, sessions, factor and
8
10
  recovery proofs, operator role grants, audit history and private profile data.
9
11
  SQLite files and backups contain sensitive account data: file permissions are a
@@ -25,12 +27,18 @@ passwords are hashed, and reusable browser capabilities are stored as hashes.
25
27
  A same-origin frontend can exercise browser authority even without reading an
26
28
  HttpOnly cookie. Do not host adversarial frontend scripts on this origin. A hostile
27
29
  host process, operator module, dependency or OS user able to read the key material
28
- is outside the WASM boundary. This release does not claim hostile multi-tenant
30
+ is outside the boundary this extension protects: that boundary is the
31
+ operator/host-file boundary, not a sandbox. The QuickJS/WASM sandbox is not an
32
+ ambient boundary — it exists only for routes that declare `sandbox: true`. This release does not claim hostile multi-tenant
29
33
  readiness or independent assessment.
30
34
 
31
35
  ## Required invariants
32
36
 
33
- - Guests cannot select host modules or receive session, provider or recovery secrets.
37
+ - Project declarations cannot register host modules, and the runtime does not
38
+ inject session, provider or recovery secrets into application handlers.
39
+ Trusted Node code still has the host process's ambient authority; host-file
40
+ registration and header filtering do not confine it. Keep hostile code out of
41
+ that process. `sandbox: true` retains the guest isolation boundary and grants.
34
42
  - Required signup verification precedes stored credentials. Existing accounts are
35
43
  never overwritten by a duplicate signup, linked by email alone, or upgraded from
36
44
  untrusted metadata.
@@ -68,7 +68,7 @@ export function adminAccountOperation(operation, args, context) {
68
68
  }
69
69
  return user;
70
70
  });
71
- const disabling = (user) => ['schedule-deletion', 'force-password-reset'].includes(plan.action) || plan.action === 'assign-roles' && !permissions(plan.parameters.roles).includes('*');
71
+ const disabling = (_user) => ['schedule-deletion', 'force-password-reset'].includes(plan.action) || plan.action === 'assign-roles' && !permissions(plan.parameters.roles).includes('*');
72
72
  if (targets.some(user => administrator(user) && user.status === 'active' && !context.isRestricted(user) && disabling(user))) {
73
73
  const disabled = new Set(targets.filter(disabling).map(user => user.id));
74
74
  const remaining = db.prepare("SELECT data FROM auth_accounts WHERE administrator=1 AND status='active'").all().some(row => { const user = JSON.parse(String(row.data)); return !disabled.has(user.id) && !context.isRestricted(user); });
package/dist/auth-core.js CHANGED
@@ -27,7 +27,7 @@ export function normalizeEmail(value) {
27
27
  if (parts.length !== 2)
28
28
  fail(400, 'invalid_email');
29
29
  const local = parts[0].toLowerCase(), domain = domainToASCII(parts[1]).toLowerCase();
30
- if (!local || local.length > 64 || /[\s\x00-\x1f\x7f"(),:;<>\[\]\\]/.test(local) || !domain || domain.length > 253 || domain.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) || local.length + domain.length + 1 > 254)
30
+ if (!local || local.length > 64 || /[\s\x00-\x1f\x7f"(),:;<>[\]\\]/.test(local) || !domain || domain.length > 253 || domain.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) || local.length + domain.length + 1 > 254)
31
31
  fail(400, 'invalid_email');
32
32
  return local + '@' + domain;
33
33
  }
@@ -25,6 +25,6 @@ export interface AuthFlowOptions {
25
25
  };
26
26
  }
27
27
  export declare function createAuthFlows(options: AuthFlowOptions, http: AuthHttp, mount: string, registration: boolean): {
28
- buttons(csrf: string, link?: boolean, text?: (value: string) => string, locale?: string, presentation?: PresentationContext): string;
28
+ buttons(csrf: string, link?: boolean, _text?: (value: string) => string, locale?: string, presentation?: PresentationContext): string;
29
29
  handle(request: ExtensionRequest): Promise<AuthHttpResponse | undefined>;
30
30
  };
@@ -42,7 +42,7 @@ export function createAuthFlows(options, http, mount, registration) {
42
42
  const cookie = (value, maxAge = 600) => ['set-cookie', `${flowCookie}=${value}; Path=/; Secure; HttpOnly; SameSite=None; Max-Age=${maxAge}`];
43
43
  const finish = async (request, result) => { const headers = options.onSession ? await options.onSession(request, result) : []; return wantsJson(request) ? jsonResponse(200, { user: result.user, csrf: http.token(result.token), ...(result.principal.restrictions ? { restrictions: result.principal.restrictions } : {}) }, [...http.sessionHeaders(result.token), cookie('', 0), ...headers]) : jsonResponse(303, { redirect: mount + '/account' }, [['location', mount + '/account'], ...http.sessionHeaders(result.token), cookie('', 0), ...headers]); };
44
44
  return {
45
- buttons(csrf, link = false, text = value => value, locale, presentation) { return Object.keys(providers).map(name => `<form method="post" action="${escapeHtml(mount + '/providers/' + name + (link ? '/link' : '/start') + (locale ? '?lang=' + encodeURIComponent(locale) : ''))}">${csrfField(csrf)}<button type="submit">${escapeHtml((presentation ?? defaultPresentation.resolve()).text(link ? 'provider.link' : 'provider.signIn', { provider: name }))}</button></form>`).join(''); },
45
+ buttons(csrf, link = false, _text = value => value, locale, presentation) { return Object.keys(providers).map(name => `<form method="post" action="${escapeHtml(mount + '/providers/' + name + (link ? '/link' : '/start') + (locale ? '?lang=' + encodeURIComponent(locale) : ''))}">${csrfField(csrf)}<button type="submit">${escapeHtml((presentation ?? defaultPresentation.resolve()).text(link ? 'provider.link' : 'provider.signIn', { provider: name }))}</button></form>`).join(''); },
46
46
  async handle(request) {
47
47
  let presentation = (options.presentation ?? defaultPresentation).resolve({ ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
48
48
  const tr = (key, values) => escapeHtml(presentation.text(key, values));
@@ -3,9 +3,10 @@ import type { AuthExtensionOptions } from './auth.ts';
3
3
  import type { PresentationContext } from './presentation.ts';
4
4
  import type { RegistrationInput } from './registration.ts';
5
5
  import { AuthHttp } from './auth-ui.ts';
6
+ import type { LifecycleHooks } from './lifecycle-hooks.ts';
6
7
  /** Operator-owned signup orchestration. Only opaque browser-bound state is held in cookies. */
7
8
  export declare function createSignup(options: AuthExtensionOptions, http: AuthHttp, mount: string, profile: {
8
9
  fields(p: PresentationContext): string;
9
10
  read(fields: Record<string, string>): RegistrationInput;
10
11
  names: string[];
11
- }): (request: ExtensionRequest, presentation: PresentationContext) => Promise<import("./auth-ui.ts").AuthHttpResponse | undefined>;
12
+ }, hooks?: LifecycleHooks): (request: ExtensionRequest, presentation: PresentationContext) => Promise<import("./auth-ui.ts").AuthHttpResponse | undefined>;
@@ -3,7 +3,7 @@ import { createHash, randomBytes } from 'node:crypto';
3
3
  import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, jsonResponse, readFields, screenResponse, wantsJson } from "./auth-ui.js";
4
4
  import { Markup } from '@jimhoyd/urlcode-ui';
5
5
  /** Operator-owned signup orchestration. Only opaque browser-bound state is held in cookies. */
6
- export function createSignup(options, http, mount, profile) {
6
+ export function createSignup(options, http, mount, profile, hooks = {}) {
7
7
  const service = options.service, browserCookie = '__Host-urlcode-signup-browser', flowCookie = '__Host-urlcode-signup';
8
8
  const clear = () => [['set-cookie', http.setCookie(flowCookie, '', 0)]];
9
9
  async function delivery(message, locale) {
@@ -107,6 +107,11 @@ export function createSignup(options, http, mount, profile) {
107
107
  if (path === '/signup/begin') {
108
108
  if (service.getSecurityPolicy().requireEmailVerification && !options.sendSignupCode)
109
109
  throw new AuthHttpError(503, 'Email delivery is not configured');
110
+ if (hooks.beforeRegister) {
111
+ const verdict = await hooks.beforeRegister({ email: fields.email || '' });
112
+ if (!verdict || verdict.allow !== true)
113
+ throw new AuthHttpError(403, verdict?.reason || 'Registration not permitted');
114
+ }
110
115
  const started = await service.beginSignup({ email: fields.email || '', browserHash, ...(fields.invitationToken ? { invitationToken: fields.invitationToken } : {}) });
111
116
  if (started.delivery)
112
117
  await delivery(started.delivery, presentation.locale);
@@ -134,6 +139,10 @@ export function createSignup(options, http, mount, profile) {
134
139
  const resultHeaders = [...headers, ...clear(), ...(result ? http.sessionHeaders(result.token) : [])];
135
140
  if (result?.newDevice)
136
141
  await delivery({ kind: 'new-device', email: result.user.email }, options.presentation?.resolve({ ...(result.user.profile?.locale ? { accountLocale: result.user.profile.locale } : {}), queryLocale: presentation.locale }).locale ?? presentation.locale);
142
+ // Existing-account attempts complete at sign-in (`result` is null), never a
143
+ // fresh account, so onSignUp fires only for a genuinely new account.
144
+ if (result && hooks.onSignUp)
145
+ await hooks.onSignUp({ accountId: result.user.id, email: result.user.email });
137
146
  // Existing-account attempts finish at sign-in; no existing credentials are replaced.
138
147
  const target = mount + (result ? '/account' : service.getRegistrationMode() === 'waitlist' ? '/signup/pending' : '/login');
139
148
  return wantsJson(request) ? jsonResponse(200, { complete: true, redirect: target, ...(result ? { csrf: http.token(result.token) } : {}) }, resultHeaders) : jsonResponse(303, { redirect: target }, [['location', target], ...resultHeaders]);
@@ -46,19 +46,26 @@ export async function openAuthStore(options) {
46
46
  };
47
47
  worker.on('error', fail);
48
48
  worker.on('exit', fail);
49
- await new Promise((accept, reject) => {
50
- const timer = setTimeout(() => { void worker.terminate(); reject(new AuthError(503, 'auth_store_unavailable')); }, 15000);
51
- worker.once('message', (message) => {
52
- clearTimeout(timer);
53
- if (message.ready)
54
- accept();
55
- else {
56
- void worker.terminate();
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
- }
49
+ 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')); });
59
61
  });
60
- worker.once('error', () => { clearTimeout(timer); reject(new AuthError(503, 'auth_store_unavailable')); });
61
- });
62
+ }
63
+ catch (error) {
64
+ // A rejected open must release SQLite handles before its caller can
65
+ // retry, restore or remove the database (Windows cannot unlink them).
66
+ await worker.terminate();
67
+ throw error;
68
+ }
62
69
  worker.on('message', (message) => {
63
70
  const p = pending.get(message.id);
64
71
  if (!p)
package/dist/auth.js CHANGED
@@ -8,19 +8,25 @@ import { createSecondFactorFlows } from "./second-factor-flows.js";
8
8
  import { createSignup } from "./auth-signup.js";
9
9
  import { createAuthFlows } from "./auth-flows.js";
10
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";
11
12
  const defaultPresentation = createPresentation();
12
13
  function enrollmentRequired(principal) { return Boolean(principal.restrictions?.length); }
13
14
  export function hasPermission(principal, permission) { return !enrollmentRequired(principal) && (principal.permissions.includes('*') || principal.permissions.includes(permission)); }
14
- const schema = { type: 'object', additionalProperties: false, properties: { registration: { enum: ['open', 'invite-only', 'waitlist', 'off'] } } };
15
+ const schema = { type: 'object', additionalProperties: false, properties: { registration: { enum: ['open', 'invite-only', 'waitlist', 'off'] }, hooks: hooksConfigSchema } };
15
16
  const policySchema = { type: 'object', additionalProperties: false, properties: { role: { type: 'string', minLength: 1, maxLength: 64 }, permission: { type: 'string', minLength: 1, maxLength: 128 }, verified: { type: 'boolean' }, freshWithinSeconds: { type: 'integer', minimum: 1, maximum: 3600 }, onDeny: { enum: [401, 403, 404, 'sign-in'] } }, minProperties: 0 };
16
17
  const actionIcons = { identify: 'arrow-right', login: 'arrow-right', 'step-up': 'shield', logout: 'log-out', export: 'download' };
17
18
  const hidden = hiddenField;
18
19
  const m = (html) => new Markup(html);
19
20
  export function authExtension(options) {
20
21
  return { name: 'auth', version: '1', projectSha256: options.projectSha256, targets: ['node'], schema, policySchema, credentialHeaders: ['cookie', 'authorization', 'x-csrf-token'],
21
- activate(config, context) {
22
+ async activate(config, context) {
22
23
  if (context.mounts.length !== 1)
23
24
  throw new Error('Auth requires exactly one mount');
25
+ // Fail-fast: a configured hook whose module fails to load or whose
26
+ // named export is missing fails activation here, never the first
27
+ // request that happens to reach it. `sandbox: true` is rejected
28
+ // inside loadLifecycleHooks, explicitly, not silently ignored.
29
+ const hooks = await loadLifecycleHooks(config.hooks, context.root);
24
30
  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';
25
31
  // The runtime activates `ui` before auth, but its kit is read per request, never captured at activation.
26
32
  const source = () => presentationSource(options.presentation, options.ui, defaultPresentation), localized = Boolean(options.presentation || options.ui);
@@ -63,7 +69,18 @@ export function authExtension(options) {
63
69
  }, enrollment: { required: !!registrationSchema.termsVersion || metadataFields.some(([, field]) => field.required), fields: (presentation) => profileMarkup((name, label, ...rest) => baseField(name, presentation?.textSource(label) ?? label, ...rest), presentation), read: profileInput, names: ['displayName', 'locale', 'termsAccepted', ...metadataFields.map(([name]) => 'meta.' + name)] } }, http, mount, registration);
64
70
  const factorRecovery = createFactorRecoveryFlows(options, http, mount);
65
71
  const manualRecovery = createManualRecoveryFlows(service, http, mount, options.ui);
66
- const signup = createSignup({ ...options, presentation: lazyPresentation }, http, mount, { fields: p => profileMarkup((name, label, ...rest) => baseField(name, p.textSource(label), ...rest), p), read: profileInput, names: ['displayName', 'locale', 'termsAccepted', ...metadataFields.map(([name]) => 'meta.' + name)] });
72
+ const signup = createSignup({ ...options, presentation: lazyPresentation }, http, mount, { fields: p => profileMarkup((name, label, ...rest) => baseField(name, p.textSource(label), ...rest), p), read: profileInput, names: ['displayName', 'locale', 'termsAccepted', ...metadataFields.map(([name]) => 'meta.' + name)] }, hooks);
73
+ // `beforeRegister` is project governance over the project's own signup flow
74
+ // (docs/SPIKE-AUTH.md): a missing verdict or `allow: false` rejects the
75
+ // attempt with the hook's own reason, surfaced the same way any other
76
+ // registration rejection is (AuthHttpError -> httpFailure).
77
+ async function checkBeforeRegister(email, profile) {
78
+ if (!hooks.beforeRegister)
79
+ return;
80
+ const verdict = await hooks.beforeRegister({ email, ...(profile ? { profile } : {}) });
81
+ if (!verdict || verdict.allow !== true)
82
+ throw new AuthHttpError(403, verdict?.reason || 'Registration not permitted');
83
+ }
67
84
  const passkeyButton = (kind, text = value => value) => options.passkeys ? `<button type="button" data-passkey="${kind}" data-base="${escapeHtml(mount)}" data-unavailable="${escapeHtml(text('Passkeys are unavailable in this browser. Use another sign-in method.'))}" data-failed="${escapeHtml(text('Passkey request failed'))}" data-cancelled="${escapeHtml(text('Passkey ceremony cancelled'))}">${escapeHtml(text(kind === 'register' ? 'Add a passkey' : kind === 'step-up' ? 'Confirm identity with a passkey' : 'Sign in with a passkey'))}</button><p role="status" aria-live="polite" data-passkey-status></p>` : '';
68
85
  async function principal(request) {
69
86
  const token = http.session(request);
@@ -108,7 +125,7 @@ export function authExtension(options) {
108
125
  try {
109
126
  await Promise.race([options.sendNotice({ email, event, ...(locale ? { locale } : {}), signal: controller.signal }), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, 5000); })]);
110
127
  }
111
- catch { }
128
+ catch { /* Notification delivery is best-effort: a failed or timed-out notice must not fail the operation that triggered it. */ }
112
129
  finally {
113
130
  if (timer)
114
131
  clearTimeout(timer);
@@ -143,7 +160,7 @@ export function authExtension(options) {
143
160
  const session = http.session(request), actor = session ? await service.authenticate(session) : null;
144
161
  accountLocale = actor ? (await service.getUser(actor.id))?.profile?.locale : undefined;
145
162
  }
146
- catch { }
163
+ catch { /* An unreadable session only means no account locale to prefer; fall back to the request locale. */ }
147
164
  }
148
165
  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') } : {}) });
149
166
  const tr = (key, values) => escapeHtml(presentation.text(key, values));
@@ -327,6 +344,10 @@ export function authExtension(options) {
327
344
  return jsonResponse(202, { message: presentation.textSource('Registration request received.') });
328
345
  if (path === '/register' && registrationMode === 'off')
329
346
  throw new AuthHttpError(404, 'Not found');
347
+ if (path === '/register') {
348
+ const registerProfile = profileInput(fields);
349
+ await checkBeforeRegister(fields.email || '', registerProfile);
350
+ }
330
351
  if (path === '/register' && registrationMode === 'waitlist') {
331
352
  await service.requestRegistration({ email: fields.email || '', password: fields.password || '', profile: profileInput(fields) });
332
353
  return jsonResponse(202, { message: presentation.textSource('Registration request received.') });
@@ -335,6 +356,8 @@ export function authExtension(options) {
335
356
  const result = path === '/register' ? await service.register({ email: fields.email || '', password: fields.password || '', device: { id: device.id, label: device.label }, profile: profileInput(fields), ...(fields.invitationToken ? { invitationToken: fields.invitationToken } : {}) }) : await service.login({ ...trusted(request), email: fields.email || '', password: fields.password || '', device: { id: device.id, label: device.label }, ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...secondFactor });
336
357
  if (result.newDevice)
337
358
  await notice(result.user.email, 'new-device', noticeLocale(request, result.user));
359
+ if (path === '/register' && hooks.onSignUp)
360
+ await hooks.onSignUp({ accountId: result.user.id, email: result.user.email });
338
361
  return wantsJson(request) ? jsonResponse(path === '/register' ? 201 : 200, { user: result.user, csrf: http.token(result.token), ...(result.principal.restrictions ? { restrictions: result.principal.restrictions } : {}) }, [...http.sessionHeaders(result.token), ...device.headers]) : redirect(mount + '/account', [...http.sessionHeaders(result.token), ...device.headers]);
339
362
  }
340
363
  if (path === '/send-email-code') {
@@ -347,7 +370,7 @@ export function authExtension(options) {
347
370
  try {
348
371
  await Promise.race([options.sendEmailCode({ email, flowId: issued.flowId, code: issued.code, locale: presentation.locale, signal: controller.signal }), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, 5000); })]);
349
372
  }
350
- catch { }
373
+ catch { /* Delivery is best-effort here too: the code is already issued, and the flow continues on the verification step. */ }
351
374
  finally {
352
375
  if (timer)
353
376
  clearTimeout(timer);
@@ -454,6 +477,12 @@ export function authExtension(options) {
454
477
  if (fields.confirmation !== 'DELETE')
455
478
  throw new AuthHttpError(400, 'Deletion confirmation required');
456
479
  const result = await service.deleteAccount({ token: current.token, ...(fields.password ? { password: fields.password } : {}), ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...secondFactor });
480
+ // Fires when the account owner schedules their own deletion (the
481
+ // grace period still applies and can be cancelled); it does not
482
+ // yet fire from an administrator-initiated deletion or from the
483
+ // background purge once the grace period elapses.
484
+ if (hooks.onDelete)
485
+ await hooks.onDelete({ accountId: current.principal.id, email: current.principal.email });
457
486
  await deliver(current.principal.email, result.cancelToken, 'cancel-deletion', false, presentation.locale);
458
487
  return completed({ deletionScheduled: true, deleteAfter: result.deleteAfter, cancellationDays: service.getSecurityPolicy().deletionGraceMs / 86400000 }, 'Account deletion scheduled', 'Your account deletion is scheduled. Check your email for cancellation instructions if you change your mind.', http.clearSession(), '/login');
459
488
  }
package/dist/backup.js CHANGED
@@ -61,7 +61,8 @@ async function snapshot(sourceInput, destinationInput, projectRoot) {
61
61
  const copied = await lstat(file);
62
62
  if (!copied.isFile() || copied.size > 1073741824)
63
63
  throw new Error('Backup exceeds size limit');
64
- const handle = await open(file, 'r');
64
+ // Windows FlushFileBuffers requires a writable handle.
65
+ const handle = await open(file, 'r+');
65
66
  try {
66
67
  await handle.sync();
67
68
  }
@@ -71,12 +72,16 @@ async function snapshot(sourceInput, destinationInput, projectRoot) {
71
72
  // Linking is atomic and refuses any existing destination, including a raced-in symlink.
72
73
  await link(file, destination);
73
74
  await rm(file);
74
- const directory = await open(parent, 'r');
75
- try {
76
- await directory.sync();
77
- }
78
- finally {
79
- await directory.close();
75
+ // Node cannot open/flush directory handles this way on Windows.
76
+ // File bytes were flushed above; POSIX also flushes the new directory entry.
77
+ if (process.platform !== 'win32') {
78
+ const directory = await open(parent, 'r');
79
+ try {
80
+ await directory.sync();
81
+ }
82
+ finally {
83
+ await directory.close();
84
+ }
80
85
  }
81
86
  return { format: 'urlcode-auth-sqlite-v1', bytes: copied.size };
82
87
  }
package/dist/challenge.js CHANGED
@@ -10,7 +10,7 @@ export function createTurnstileChallenge(options) {
10
10
  throw new Error('Invalid Turnstile hostname');
11
11
  }
12
12
  const timeout = options.timeoutMs ?? 5000;
13
- if (!key(options.secret) || !key(options.siteKey) || hostname !== options.hostname || !hostname || /[\/:@?#]/.test(hostname) || !Number.isInteger(timeout) || timeout < 10 || timeout > 5000)
13
+ if (!key(options.secret) || !key(options.siteKey) || hostname !== options.hostname || !hostname || /[/:@?#]/.test(hostname) || !Number.isInteger(timeout) || timeout < 10 || timeout > 5000)
14
14
  throw new Error('Invalid Turnstile configuration');
15
15
  let active = 0;
16
16
  return Object.freeze({ widget: Object.freeze({ siteKey: options.siteKey, action: 'auth' }), async verify(input) {
@@ -0,0 +1,76 @@
1
+ export interface HookReference {
2
+ source: string;
3
+ export?: string;
4
+ sandbox?: boolean;
5
+ }
6
+ export type HookConfig = string | HookReference;
7
+ export interface LifecycleHooksConfig {
8
+ beforeRegister?: HookConfig;
9
+ onSignUp?: HookConfig;
10
+ onDelete?: HookConfig;
11
+ }
12
+ export interface BeforeRegisterInput {
13
+ email: string;
14
+ profile?: Record<string, unknown>;
15
+ }
16
+ export interface BeforeRegisterVerdict {
17
+ allow: boolean;
18
+ reason?: string;
19
+ }
20
+ export interface OnSignUpInput {
21
+ accountId: string;
22
+ email: string;
23
+ }
24
+ export interface OnDeleteInput {
25
+ accountId: string;
26
+ email: string;
27
+ }
28
+ export interface LifecycleHooks {
29
+ beforeRegister?(input: BeforeRegisterInput): BeforeRegisterVerdict | Promise<BeforeRegisterVerdict>;
30
+ onSignUp?(input: OnSignUpInput): void | Promise<void>;
31
+ onDelete?(input: OnDeleteInput): void | Promise<void>;
32
+ }
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
+ })[];
66
+ };
67
+ };
68
+ };
69
+ /**
70
+ * Resolves and eagerly imports every declared hook, so a missing module, a
71
+ * syntax error or a missing export fails activation (fail-fast), never the
72
+ * first request that happens to reach the hook. `sandbox: true` is rejected
73
+ * here, immediately and explicitly, rather than accepted and silently run
74
+ * trusted.
75
+ */
76
+ export declare function loadLifecycleHooks(config: LifecycleHooksConfig | undefined, root: string): Promise<LifecycleHooks>;
@@ -0,0 +1,106 @@
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
+ // Project-level lifecycle hooks (docs/SPIKE-AUTH.md, urlcode-auth#35). A
10
+ // project names its own function per lifecycle point in `extensions.auth.config.hooks`,
11
+ // using the same `{source, export}` (or bare string) shape `function`/`middleware`
12
+ // routes already use. These hooks are first-party project code and run
13
+ // trusted, in-process, exactly like any other route's `function`/`middleware`
14
+ // (docs/SPIKE-DEFAULT-TRUST-MODEL.md, urlcode's docs/EXTENSIONS.md "Project-level
15
+ // lifecycle hooks"): no special case, no hardwired sandbox.
16
+ //
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.
23
+ import { isAbsolute, relative, resolve } from 'node:path';
24
+ import { realpath, stat } from 'node:fs/promises';
25
+ import { pathToFileURL } from 'node:url';
26
+ const hookNames = ['beforeRegister', 'onSignUp', 'onDelete'];
27
+ export const hooksConfigSchema = {
28
+ type: 'object',
29
+ additionalProperties: false,
30
+ properties: Object.fromEntries(hookNames.map(name => [name, {
31
+ oneOf: [
32
+ { type: 'string', minLength: 1, maxLength: 1024 },
33
+ {
34
+ type: 'object',
35
+ additionalProperties: false,
36
+ required: ['source'],
37
+ properties: {
38
+ source: { type: 'string', minLength: 1, maxLength: 1024 },
39
+ export: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
40
+ sandbox: { type: 'boolean' },
41
+ },
42
+ },
43
+ ],
44
+ }])),
45
+ };
46
+ function normalize(ref) {
47
+ return typeof ref === 'string'
48
+ ? { source: ref, export: 'default', sandbox: false }
49
+ : { source: ref.source, export: ref.export ?? 'default', sandbox: ref.sandbox === true };
50
+ }
51
+ // Same project-relative-file discipline core's own `safeFile` applies to a
52
+ // route's `function.source`: resolved against the project root, refused if
53
+ // it escapes it. Not a security boundary against the module's own code
54
+ // (trusted hooks get full Node access like any other project code), just the
55
+ // same "the YAML cannot point outside the project" hygiene.
56
+ async function projectFile(root, file, hookName) {
57
+ if (isAbsolute(file))
58
+ throw new Error(`hook ${hookName}: source must be a project-relative path`);
59
+ const base = await realpath(root);
60
+ let actual;
61
+ try {
62
+ actual = await realpath(resolve(base, file));
63
+ }
64
+ catch {
65
+ throw new Error(`hook ${hookName}: source module "${file}" was not found`);
66
+ }
67
+ const rel = relative(base, actual);
68
+ if (!rel || rel === '..' || rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')) || isAbsolute(rel))
69
+ throw new Error(`hook ${hookName}: source escapes the project`);
70
+ if (!(await stat(actual)).isFile())
71
+ throw new Error(`hook ${hookName}: source must be a file`);
72
+ return actual;
73
+ }
74
+ /**
75
+ * Resolves and eagerly imports every declared hook, so a missing module, a
76
+ * syntax error or a missing export fails activation (fail-fast), never the
77
+ * first request that happens to reach the hook. `sandbox: true` is rejected
78
+ * here, immediately and explicitly, rather than accepted and silently run
79
+ * trusted.
80
+ */
81
+ export async function loadLifecycleHooks(config, root) {
82
+ const hooks = {};
83
+ if (!config)
84
+ return hooks;
85
+ for (const name of hookNames) {
86
+ const ref = config[name];
87
+ if (ref === undefined)
88
+ continue;
89
+ const definition = normalize(ref);
90
+ 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`);
92
+ const file = await projectFile(root, definition.source, name);
93
+ let mod;
94
+ try {
95
+ mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
96
+ }
97
+ catch {
98
+ throw new Error(`hook ${name}: failed to load module "${definition.source}"`);
99
+ }
100
+ const fn = mod[definition.export];
101
+ if (typeof fn !== 'function')
102
+ throw new Error(`hook ${name}: export "${definition.export}" in "${definition.source}" is not a function`);
103
+ hooks[name] = fn;
104
+ }
105
+ return hooks;
106
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@jimhoyd/urlcode-auth",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.6",
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",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/jimhoyd-com/urlcode-auth.git"
9
+ "url": "git+https://github.com/jimhoyd-com/urlcode.git",
10
+ "directory": "packages/auth"
10
11
  },
11
12
  "publishConfig": {
12
13
  "access": "public"
@@ -18,7 +19,7 @@
18
19
  "build": "tsc -p tsconfig.build.json",
19
20
  "typecheck": "tsc --noEmit",
20
21
  "pretest": "node scripts/check-sqlite.mjs",
21
- "test": "node --conditions=development --test test/*.test.ts",
22
+ "test": "node --conditions=development --test --test-timeout=300000 test/*.test.ts",
22
23
  "verify": "npm run typecheck && npm run build && npm test"
23
24
  },
24
25
  "exports": {
@@ -38,6 +39,7 @@
38
39
  "THREAT-MODEL.md"
39
40
  ],
40
41
  "devDependencies": {
42
+ "@jimhoyd/urlcode": "file:../..",
41
43
  "@types/node": "26.5.1",
42
44
  "typescript": "6.0.3"
43
45
  },
@@ -50,14 +52,14 @@
50
52
  "otpauth": "9.5.2"
51
53
  },
52
54
  "peerDependencies": {
53
- "@jimhoyd/urlcode": ">=0.4.0-alpha.1 <0.5.0",
55
+ "@jimhoyd/urlcode": ">=0.4.0-alpha.2 <0.5.0",
54
56
  "@jimhoyd/urlcode-ui": ">=0.1.0-alpha.1 <0.2.0"
55
57
  },
56
58
  "bin": {
57
59
  "urlcode-auth": "./dist/cli.js"
58
60
  },
59
- "homepage": "https://github.com/jimhoyd-com/urlcode-auth#readme",
61
+ "homepage": "https://github.com/jimhoyd-com/urlcode/tree/main/packages/auth#readme",
60
62
  "bugs": {
61
- "url": "https://github.com/jimhoyd-com/urlcode-auth/issues"
63
+ "url": "https://github.com/jimhoyd-com/urlcode/issues"
62
64
  }
63
65
  }