@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.
- package/IMPLEMENTATION-STATUS.md +1 -1
- package/README.md +4 -8
- package/dist/auth.js +2 -2
- package/dist/lifecycle-hooks.d.ts +66 -40
- package/dist/lifecycle-hooks.js +11 -96
- package/package.json +3 -3
package/IMPLEMENTATION-STATUS.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
type:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
package/dist/lifecycle-hooks.js
CHANGED
|
@@ -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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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 {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
56
|
-
"@jimhoyd/urlcode-ui": ">=0.4.
|
|
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"
|