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

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
@@ -83,6 +83,62 @@ The external host creates an AuthService and supplies `authExtension({service, c
83
83
 
84
84
  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
85
 
86
+ ## Project-level lifecycle hooks
87
+
88
+ 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"):
89
+
90
+ ```yaml
91
+ extensions:
92
+ auth:
93
+ version: '1'
94
+ config:
95
+ registration: open
96
+ hooks:
97
+ beforeRegister: ./hooks/registration-rule.mjs # bare string: default export
98
+ onSignUp:
99
+ source: ./hooks/on-signup.mjs
100
+ export: provisionWorkspace
101
+ onDelete: ./hooks/on-delete.mjs
102
+ ```
103
+
104
+ Three lifecycle points are implemented:
105
+
106
+ - **`beforeRegister(input: {email, profile?})`** runs before an account is
107
+ created, from the immediate `/register` endpoint and from the resumable
108
+ `/signup/begin` step, and returns a typed verdict: `{allow: true}` lets the
109
+ attempt continue, `{allow: false, reason}` rejects it and the `reason` is
110
+ surfaced to the caller the same way any other registration rejection is (a
111
+ `403` with that message). This is how "only `@acme.com` may register"
112
+ becomes portable project code instead of a fork.
113
+ - **`onSignUp(input: {accountId, email})`** is a side-effect hook (no
114
+ verdict) that fires once, after a *new* account is actually created — from
115
+ the immediate `/register` endpoint and from `/signup/complete` (an
116
+ existing-account signup attempt that resolves to sign-in, not a new
117
+ account, never fires it). Use it for something like provisioning a
118
+ workspace after sign-up.
119
+ - **`onDelete(input: {accountId, email})`** fires when the account owner
120
+ schedules their own deletion through the account page's `/delete` endpoint
121
+ (the deletion grace period still applies and can still be cancelled). It
122
+ does not yet fire from an administrator-initiated deletion or from the
123
+ background purge once the grace period elapses.
124
+
125
+ These hooks are first-party project code, the same trust category as any
126
+ `function`/`middleware` route: **trusted, in-process execution by default**,
127
+ following the runtime's trust model with no special case (urlcode's
128
+ docs/SPIKE-DEFAULT-TRUST-MODEL.md). A missing module, a module that fails to
129
+ import, or a named export that is not a function fails **activation** —
130
+ before this extension serves a single request — never the first request
131
+ that happens to reach the hook.
132
+
133
+ **`sandbox: true` is not implemented for these hooks and is refused
134
+ explicitly at activation**, naming the hook: `hook <name>: sandbox: true is
135
+ not yet supported for project-level hooks, see jimhoyd-com/urlcode-auth#35`.
136
+ Core's trusted/sandboxed dispatch is wired to route dispatch, not exposed to
137
+ extensions (jimhoyd-com/urlcode#151), so this package has no way to actually
138
+ isolate a hook call yet; accepting the field and running it trusted anyway
139
+ would misrepresent the isolation a project believes it configured. Declare a
140
+ hook without `sandbox` (or with `sandbox: false`) to use it today.
141
+
86
142
  ## Authentication and presentation
87
143
 
88
144
  `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.
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. Neither trusted nor sandboxed project routes gain authority to load auth's own host modules — that boundary is enforced by the host-file/operator-registration mechanism below, independent of a route's own `sandbox` setting. 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
@@ -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]);
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);
@@ -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') {
@@ -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
  }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@jimhoyd/urlcode-auth",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
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",
@@ -50,7 +50,7 @@
50
50
  "otpauth": "9.5.2"
51
51
  },
52
52
  "peerDependencies": {
53
- "@jimhoyd/urlcode": ">=0.4.0-alpha.1 <0.5.0",
53
+ "@jimhoyd/urlcode": ">=0.4.0-alpha.2 <0.5.0",
54
54
  "@jimhoyd/urlcode-ui": ">=0.1.0-alpha.1 <0.2.0"
55
55
  },
56
56
  "bin": {