@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.
- package/IMPLEMENTATION-STATUS.md +3 -1
- package/README.md +66 -9
- package/SECURITY.md +1 -1
- package/THREAT-MODEL.md +12 -4
- package/dist/admin-account-store.js +1 -1
- package/dist/auth-core.js +1 -1
- package/dist/auth-flows.d.ts +1 -1
- package/dist/auth-flows.js +1 -1
- package/dist/auth-signup.d.ts +2 -1
- package/dist/auth-signup.js +10 -1
- package/dist/auth-store.js +19 -12
- package/dist/auth.js +35 -6
- package/dist/backup.js +12 -7
- package/dist/challenge.js +1 -1
- package/dist/lifecycle-hooks.d.ts +76 -0
- package/dist/lifecycle-hooks.js +106 -0
- package/package.json +8 -6
package/IMPLEMENTATION-STATUS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Auth implementation status
|
|
2
2
|
|
|
3
|
-
Status: `@jimhoyd/urlcode-auth` 0.1.0-alpha.
|
|
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
|
-
[](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.
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
|
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.
|
|
6
|
-
|
|
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
|
|
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
|
-
-
|
|
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 = (
|
|
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"()
|
|
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
|
}
|
package/dist/auth-flows.d.ts
CHANGED
|
@@ -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,
|
|
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
|
};
|
package/dist/auth-flows.js
CHANGED
|
@@ -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,
|
|
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));
|
package/dist/auth-signup.d.ts
CHANGED
|
@@ -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>;
|
package/dist/auth-signup.js
CHANGED
|
@@ -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-store.js
CHANGED
|
@@ -46,19 +46,26 @@ export async function openAuthStore(options) {
|
|
|
46
46
|
};
|
|
47
47
|
worker.on('error', fail);
|
|
48
48
|
worker.on('exit', fail);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 || /[
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
61
|
+
"homepage": "https://github.com/jimhoyd-com/urlcode/tree/main/packages/auth#readme",
|
|
60
62
|
"bugs": {
|
|
61
|
-
"url": "https://github.com/jimhoyd-com/urlcode
|
|
63
|
+
"url": "https://github.com/jimhoyd-com/urlcode/issues"
|
|
62
64
|
}
|
|
63
65
|
}
|