@jimhoyd/urlcode-auth 0.4.1 → 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.
@@ -22,7 +22,7 @@ Mandatory verification/TOTP enrollment, operator standard/hardened presets and e
22
22
 
23
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.
24
24
 
25
- Project-level lifecycle hooks (urlcode-auth#35) are implemented: `beforeRegister`, `onSignUp` and `onDelete` in `extensions.auth.config.hooks` (README.md), run trusted and in-process — the same default as any `function`/`middleware` route, no special case. A configured hook's module is resolved and imported eagerly at activation, so a missing module or a broken/missing export fails activation rather than the first request; `sandbox: true` on a hook is refused explicitly at activation rather than silently ignored — core exports the `SandboxPool` primitive (`@jimhoyd/urlcode/sandbox`), but this package does not route a hook invocation through it yet, so the opt-in it would imply does not exist here. `beforeRegister` covers the immediate `/register` endpoint and the resumable `/signup/begin` step; `onSignUp` fires after a genuinely new account is created (not an existing-account signup attempt that resolves to sign-in); `onDelete` fires when the account owner schedules their own deletion, not yet from an administrator-initiated deletion or the background purge.
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.
26
26
 
27
27
  ## Additional implemented acceptance
28
28
 
package/README.md CHANGED
@@ -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. Core exports the isolate itself —
142
- `SandboxPool` from `@jimhoyd/urlcode/sandbox`, the same engine a sandboxed
143
- route uses ([extensions](../../docs/EXTENSIONS.md)) but this package does not
144
- route a hook invocation through it, so the opt-in does not exist here yet.
145
- Accepting the field and running it trusted anyway would misrepresent the
146
- isolation a project believes it configured. Declare a hook without `sandbox`
147
- (or with `sandbox: false`) to use it today.
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
 
package/dist/auth.js CHANGED
@@ -7,7 +7,7 @@ import { createSecondFactorFlows } from "./second-factor-flows.js";
7
7
  import { createSignup } from "./auth-signup.js";
8
8
  import { createAuthFlows } from "./auth-flows.js";
9
9
  import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField as baseField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson, passkeyScript, secondFactorButton } from "./auth-ui.js";
10
- import { hooksConfigSchema, loadLifecycleHooks } from "./lifecycle-hooks.js";
10
+ import { authHookContracts, hooksConfigSchema, loadLifecycleHooks } from "./lifecycle-hooks.js";
11
11
  function enrollmentRequired(principal) { return Boolean(principal.restrictions?.length); }
12
12
  export function hasPermission(principal, permission) { return !enrollmentRequired(principal) && (principal.permissions.includes('*') || principal.permissions.includes(permission)); }
13
13
  const schema = { type: 'object', additionalProperties: false, properties: { registration: { enum: ['open', 'invite-only', 'waitlist', 'off'] }, hooks: hooksConfigSchema } };
@@ -16,7 +16,7 @@ const actionIcons = { identify: 'arrow-right', login: 'arrow-right', 'step-up':
16
16
  const hidden = hiddenField;
17
17
  const m = (html) => new Markup(html);
18
18
  export function authExtension(options) {
19
- 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'],
20
20
  async activate(config, context) {
21
21
  if (context.mounts.length !== 1)
22
22
  throw new Error('Auth requires exactly one mount');
@@ -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,14 +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 now exports the isolate itself -- `SandboxPool` from
19
- // `@jimhoyd/urlcode/sandbox`, the same QuickJS/worker engine a sandboxed
20
- // route uses -- so the missing piece is no longer a core primitive but this
21
- // package's own wiring: a hook invocation is a plain in-process call and
22
- // nothing here routes it through a pool. Until that exists, accepting
23
- // `sandbox: true` and running it trusted anyway would misrepresent the
24
- // isolation the project believes it configured, so it is refused instead.
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.
25
12
  //
26
13
  // Each activation re-imports the hook's ENTRY module under a fresh
27
14
  // cache-busting query, mirroring core's trusted route activation
@@ -32,58 +19,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
32
19
  // hook itself imports stay on Node's module cache, the same already-documented
33
20
  // core limitation the trusted route path has; a change to a hook's own
34
21
  // dependency still needs a process restart.
35
- import { randomUUID } from 'node:crypto';
36
- import { isAbsolute, relative, resolve } from 'node:path';
37
- import { realpath, stat } from 'node:fs/promises';
38
- import { pathToFileURL } from 'node:url';
39
- const hookNames = ['beforeRegister', 'onSignUp', 'onDelete'];
40
- export const hooksConfigSchema = {
41
- type: 'object',
42
- additionalProperties: false,
43
- properties: Object.fromEntries(hookNames.map(name => [name, {
44
- oneOf: [
45
- { type: 'string', minLength: 1, maxLength: 1024 },
46
- {
47
- type: 'object',
48
- additionalProperties: false,
49
- required: ['source'],
50
- properties: {
51
- source: { type: 'string', minLength: 1, maxLength: 1024 },
52
- export: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
53
- sandbox: { type: 'boolean' },
54
- },
55
- },
56
- ],
57
- }])),
58
- };
59
- function normalize(ref) {
60
- return typeof ref === 'string'
61
- ? { source: ref, export: 'default', sandbox: false }
62
- : { source: ref.source, export: ref.export ?? 'default', sandbox: ref.sandbox === true };
63
- }
64
- // Same project-relative-file discipline core's own `safeFile` applies to a
65
- // route's `function.source`: resolved against the project root, refused if
66
- // it escapes it. Not a security boundary against the module's own code
67
- // (trusted hooks get full Node access like any other project code), just the
68
- // same "the YAML cannot point outside the project" hygiene.
69
- async function projectFile(root, file, hookName) {
70
- if (isAbsolute(file))
71
- throw new Error(`hook ${hookName}: source must be a project-relative path`);
72
- const base = await realpath(root);
73
- let actual;
74
- try {
75
- actual = await realpath(resolve(base, file));
76
- }
77
- catch {
78
- throw new Error(`hook ${hookName}: source module "${file}" was not found`);
79
- }
80
- const rel = relative(base, actual);
81
- if (!rel || rel === '..' || rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')) || isAbsolute(rel))
82
- throw new Error(`hook ${hookName}: source escapes the project`);
83
- if (!(await stat(actual)).isFile())
84
- throw new Error(`hook ${hookName}: source must be a file`);
85
- return actual;
86
- }
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);
87
29
  /**
88
30
  * Resolves and eagerly imports every declared hook, so a missing module, a
89
31
  * syntax error or a missing export fails activation (fail-fast), never the
@@ -92,32 +34,5 @@ async function projectFile(root, file, hookName) {
92
34
  * trusted.
93
35
  */
94
36
  export async function loadLifecycleHooks(config, root) {
95
- const hooks = {};
96
- if (!config)
97
- return hooks;
98
- // One epoch per activation, not per hook: two hooks naming the same entry
99
- // module still share a single instance within this activation, exactly as
100
- // core's per-runtime-instance epoch does.
101
- const epoch = randomUUID();
102
- for (const name of hookNames) {
103
- const ref = config[name];
104
- if (ref === undefined)
105
- continue;
106
- const definition = normalize(ref);
107
- if (definition.sandbox)
108
- throw new Error(`hook ${name}: sandbox: true is not yet supported for project-level hooks; this extension does not route a hook invocation through core's SandboxPool yet. See docs/EXTENSIONS.md "Project-level lifecycle hooks".`);
109
- const file = await projectFile(root, definition.source, name);
110
- let mod;
111
- try {
112
- mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + '?urlcode-hook-epoch=' + epoch)));
113
- }
114
- catch {
115
- throw new Error(`hook ${name}: failed to load module "${definition.source}"`);
116
- }
117
- const fn = mod[definition.export];
118
- if (typeof fn !== 'function')
119
- throw new Error(`hook ${name}: export "${definition.export}" in "${definition.source}" is not a function`);
120
- hooks[name] = fn;
121
- }
122
- return hooks;
37
+ return await loadExtensionHooks(config, authHookContracts, { root });
123
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jimhoyd/urlcode-auth",
3
- "version": "0.4.1",
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.1 <0.5.0",
56
- "@jimhoyd/urlcode-ui": ">=0.4.1 <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"