@jimhoyd/urlcode-auth 0.4.0-alpha.3 → 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 +17 -5
- package/README.md +16 -18
- package/dist/abuse-http.d.ts +1 -1
- package/dist/auth-baseline.js +6 -2
- package/dist/auth-flows.d.ts +1 -1
- package/dist/auth-store.d.ts +17 -1
- package/dist/auth-store.js +44 -13
- package/dist/auth-templates.d.ts +8 -3
- package/dist/auth-templates.js +12 -2
- package/dist/auth-ui.d.ts +10 -17
- package/dist/auth-ui.js +6 -36
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +16 -14
- package/dist/factor-recovery.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lifecycle-hooks.d.ts +66 -40
- package/dist/lifecycle-hooks.js +11 -94
- package/dist/manual-recovery.d.ts +1 -1
- package/dist/scaffold.d.ts +16 -0
- package/dist/scaffold.js +83 -6
- package/package.json +3 -3
package/IMPLEMENTATION-STATUS.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Auth implementation status
|
|
2
2
|
|
|
3
|
-
Status: `@jimhoyd/urlcode-auth`
|
|
3
|
+
Status: `@jimhoyd/urlcode-auth` is published to npm as an alpha. The current
|
|
4
|
+
version and the peer ranges it supports are in `package.json`; read them there
|
|
5
|
+
rather than from this page, and see [package and channel
|
|
6
|
+
alignment](../../docs/VERSION-ALIGNMENT.md) for how versions, channels and tags
|
|
7
|
+
relate. Core, ui, auth and admin are workspace packages in one repository, so a
|
|
8
|
+
single commit identifies all of them and development resolves peers through the
|
|
9
|
+
workspace links rather than published versions. `src/auth.ts` resolves project
|
|
10
|
+
lifecycle hooks through `ExtensionActivation.root`, which is the oldest core API
|
|
11
|
+
this package needs. The implemented auth and shared-presentation work is merged
|
|
12
|
+
to main. The source plan is URLCode PR #54; release acceptance is tracked in
|
|
13
|
+
[issue 58](https://github.com/jimhoyd-com/urlcode/issues/58), and the generic
|
|
14
|
+
core extension contract from PR #59 is merged. Implementation and synthetic
|
|
15
|
+
acceptance do not establish production readiness.
|
|
4
16
|
|
|
5
17
|
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
18
|
|
|
@@ -8,9 +20,9 @@ Resumable verification-first password/passkey signup (including waitlist approva
|
|
|
8
20
|
|
|
9
21
|
Mandatory verification/TOTP enrollment, operator standard/hardened presets and explicit pinned configuration migration are implemented; hardened requires email and breach-screening adapters.
|
|
10
22
|
|
|
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
|
|
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.
|
|
12
24
|
|
|
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.
|
|
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.
|
|
14
26
|
|
|
15
27
|
## Additional implemented acceptance
|
|
16
28
|
|
|
@@ -31,9 +43,9 @@ Live Google/Apple/SES testing is explicitly deferred by the project owner and is
|
|
|
31
43
|
|
|
32
44
|
## Agreed architecture corrections
|
|
33
45
|
|
|
34
|
-
Auth
|
|
46
|
+
Auth and admin are separate packages with their own contracts; core owns the generic extension contract and never depends on auth. SQLite and privileged transactions belong to the trusted operator service. Project YAML cannot select host modules or credentials. Safe package renderers replace arbitrary project templates. The initial auth target is Node with operator-owned durable storage; runtime adapter availability does not make this SQLite service portable to every deployment target.
|
|
35
47
|
|
|
36
48
|
## Recorded acceptance
|
|
37
49
|
|
|
38
|
-
See [ACCEPTANCE.md](
|
|
50
|
+
See [ACCEPTANCE.md](ACCEPTANCE.md) for exact merged revisions, automated coverage,
|
|
39
51
|
clean-install evidence and the remaining operational validation boundary.
|
package/README.md
CHANGED
|
@@ -8,20 +8,20 @@ The implementation is under active review. Local tests and builds are evidence o
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
`@jimhoyd/urlcode-auth` is published to npm as an alpha alongside its peers. Install the three packages together; the peer ranges
|
|
11
|
+
`@jimhoyd/urlcode-auth` is published to npm as an alpha alongside its peers. Install the three packages together; the peer ranges for `@jimhoyd/urlcode` and `@jimhoyd/urlcode-ui` are declared in `package.json` — read them there rather than from this page — and the release workflow builds and tests against exactly those registry versions.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
14
|
npm install @jimhoyd/urlcode @jimhoyd/urlcode-ui @jimhoyd/urlcode-auth
|
|
15
|
-
npx urlcode init my-site --with auth
|
|
15
|
+
npx urlcode init my-site --with ui,auth
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
`urlcode init --with auth` is core's layered scaffold; `npx urlcode-auth init --directory /absolute/new-account-site` scaffolds an auth-only project. Either writes `app/urlcode.yaml`, external `host.mjs` and `operator-service.mjs`, a private `data/` directory and independent encryption/CSRF keys, and refuses an existing destination. Its README gives the exact next steps.
|
|
18
|
+
`urlcode init --with ui,auth` is core's layered scaffold (auth renders through the ui kit, so `ui` must be named first: the runtime activates extensions in the order the project declares them, and auth's scaffold refuses any other order); `npx urlcode-auth init --directory /absolute/new-account-site` scaffolds an auth-only project. Either writes `app/urlcode.yaml`, external `host.mjs` and `operator-service.mjs`, a private `data/` directory and independent encryption/CSRF keys, and refuses an existing destination. Its README gives the exact next steps.
|
|
19
19
|
|
|
20
20
|
Alpha caveat: the source is complete for the first release and its automated checks pass, but independent security review, accessibility assessment, browser/device WebAuthn coverage and deployment/soak/recovery exercises are still pending (see [IMPLEMENTATION-STATUS.md](IMPLEMENTATION-STATUS.md)). Alpha versions may change public exports, configuration keys and the SQLite schema between releases without a migration path. Do not run an alpha on production accounts.
|
|
21
21
|
|
|
22
22
|
Use a current supported Node release with a patched SQLite build. The actual runtime requirement is a Node build whose bundled SQLite (`process.versions.sqlite`) is 3.51.3 or newer, or a patched 3.50.7+ / 3.44.6+ branch release; `engines.node` alone does not encode this, and the service (`src/auth-store.ts`) refuses other builds with `patched_sqlite_required` even when the package's minimum Node version is satisfied.
|
|
23
23
|
|
|
24
|
-
Every release tarball is attested from the tagged commit: `gh attestation verify jimhoyd-urlcode-auth-<version>.tgz --repo jimhoyd-com/urlcode
|
|
24
|
+
Every release tarball is attested from the tagged commit: `gh attestation verify jimhoyd-urlcode-auth-<version>.tgz --repo jimhoyd-com/urlcode`. `npm view @jimhoyd/urlcode-auth` shows the published provenance.
|
|
25
25
|
|
|
26
26
|
## Install from reviewed source
|
|
27
27
|
|
|
@@ -42,7 +42,7 @@ the same reviewed revision of this repository. The script refuses to run if the
|
|
|
42
42
|
checkout is not at that exact commit or has uncommitted changes, and re-checks
|
|
43
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`
|
|
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` for each workspace package; source packaging runs typecheck/build, not the HTTP suite.
|
|
46
46
|
|
|
47
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:
|
|
48
48
|
|
|
@@ -55,7 +55,7 @@ Tarball names and versions must match the generated manifest. Install the same r
|
|
|
55
55
|
|
|
56
56
|
## Programmatic scaffold
|
|
57
57
|
|
|
58
|
-
`scaffold({directory, project, hostFile, names})` returns the auth pieces of a layered project (YAML fragments, host imports and entries, private files with in-memory key material, a README section and next steps) without writing anything; `initAuthentication` is assembled from it. Core's `urlcode init --with auth` calls this export and merges it with other extensions
|
|
58
|
+
`scaffold({directory, project, hostFile, names})` returns the auth pieces of a layered project (YAML fragments, host imports and entries, private files with in-memory key material, a README section and next steps) without writing anything; `initAuthentication` is assembled from it. Core's `urlcode init --with ui,auth` calls this export and merges it with other extensions; it refuses a request whose `names` omit `ui` or place it after `auth`.
|
|
59
59
|
Exported types: `ScaffoldRequest`, `ScaffoldResult`, `ScaffoldFile`.
|
|
60
60
|
|
|
61
61
|
## Operator activation
|
|
@@ -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
|
-
extensions (jimhoyd-com/urlcode#151), so this package has no way to actually
|
|
145
|
-
isolate a hook call yet; accepting the field and running it trusted anyway
|
|
146
|
-
would misrepresent the isolation a project believes it configured. Declare a
|
|
147
|
-
hook without `sandbox` (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
|
|
|
@@ -214,9 +210,9 @@ Migration preserves accounts, enrolled credentials and history, while revoking s
|
|
|
214
210
|
|
|
215
211
|
## Presentation
|
|
216
212
|
|
|
217
|
-
Every account screen is an `auth/*` template in the urlcode-ui kit language with a declared view model (`authTemplates`, each with a sample view; `authUiTemplates` is the block the `ui` extension takes). The extension computes the view and the template only places it: a template cannot change which steps a flow has, what a form validates, what is escaped, or the CSRF field and headers a page sends. Forms, fields and buttons arrive in the view as renderer-produced markup built by the shared primitives.
|
|
213
|
+
Every account screen is an `auth/*` template in the urlcode-ui kit language with a declared view model (`authTemplates`, each with a sample view; `authUiTemplates` is the block the `ui` extension takes). The extension computes the view and the template only places it: a template cannot change which steps a flow has, what a form validates, what is escaped, or the CSRF field and headers a page sends. Forms, fields and buttons arrive in the view as renderer-produced markup built by the kit's shared form primitives (`field`, `postForm` and friends from `@jimhoyd/urlcode-ui`).
|
|
218
214
|
|
|
219
|
-
`authExtension`
|
|
215
|
+
`authExtension` requires `ui`, the object `createUiExtension` returns: the kit is the only render path. Declare `ui` before `auth` in `urlcode.yaml` and list `ui.registration` before `authExtension` in the host — the runtime activates extensions in the order `urlcode.yaml` declares them, and auth refuses activation when `ui` is missing or not yet activated. Auth reads `ui.kit` per request and never captures it at activation. `@jimhoyd/urlcode-ui` is already a required peer dependency, so this adds nothing to install.
|
|
220
216
|
|
|
221
217
|
```js
|
|
222
218
|
import { createUiExtension } from '@jimhoyd/urlcode-ui/host';
|
|
@@ -228,13 +224,15 @@ export default { extensions: [ui.registration, authExtension({ service, csrfKey,
|
|
|
228
224
|
```yaml
|
|
229
225
|
extensions:
|
|
230
226
|
ui: { version: "1", config: { theme: { name: Acme }, templates: ui/templates } }
|
|
227
|
+
auth: { version: "1", config: { registration: "off" } }
|
|
231
228
|
routes:
|
|
232
229
|
/assets/ui/*: { extension: ui, methods: [GET, HEAD] }
|
|
230
|
+
/account/*: { extension: auth, methods: [GET, HEAD, POST] }
|
|
233
231
|
```
|
|
234
232
|
|
|
235
|
-
|
|
233
|
+
Screens render through `ui.kit.page`: the project's theme, layout, hashed stylesheet and copy apply, a project file `ui/templates/auth/<screen>.html` shadows the shipped template, and `urlcode-ui doctor --extensions @jimhoyd/urlcode-auth` reports every `auth/*` template behind its view model (the CLI loads the namespace, copy and samples from this package's `authUiTemplates` export; without the flag it sees the kit alone). Copy then resolves through the kit's presentation, which carries the kit catalogue, the auth catalogue and the project's `extensions.ui` copy; omit `presentation` in that case. If both are given, `presentation` wins and must register the kit catalogue for the layout's own keys.
|
|
236
234
|
|
|
237
|
-
|
|
235
|
+
There is no fallback render path: earlier releases rendered the same templates through the shared primitives when `ui` was absent, and that branch has been removed. The auth passkey script and the optional challenge widget are nonce-bound to the kit's page nonce and the page CSP admits only that nonce (plus the challenge origin when configured).
|
|
238
236
|
|
|
239
237
|
Changing `configurationTag` deliberately advances the approved configuration revision for provider/callback/profile-policy deployments that cannot be fingerprinted as simple data. The service does not automatically fingerprint executable callbacks. Session idle and absolute limits do participate in the declared configuration fingerprint.
|
|
240
238
|
|
package/dist/abuse-http.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ import { AuthHttp } from './auth-ui.ts';
|
|
|
5
5
|
import type { UiHost } from './auth-ui.ts';
|
|
6
6
|
import type { PresentationContext } from './presentation.ts';
|
|
7
7
|
/** Entry requests only: callback and code/token redemption keep their own bound proofs. */
|
|
8
|
-
export declare function createAbuseGuard(service: AuthService, http: AuthHttp, mount: string, challenge
|
|
8
|
+
export declare function createAbuseGuard(service: AuthService, http: AuthHttp, mount: string, challenge: AuthChallenge | undefined, ui: UiHost): (request: ExtensionRequest, presentation?: PresentationContext) => Promise<import("./auth-ui.ts").AuthHttpResponse | undefined>;
|
package/dist/auth-baseline.js
CHANGED
|
@@ -66,11 +66,14 @@ async function probe(root) {
|
|
|
66
66
|
let runtime, service;
|
|
67
67
|
try {
|
|
68
68
|
const { createRuntime } = await import('@jimhoyd/urlcode'), { inspectExtensionRevision } = await import('@jimhoyd/urlcode/extensions'), { createAuthService } = await import("./auth-core.js"), { authExtension } = await import("./auth.js");
|
|
69
|
+
// Account screens render only through the kit, so the synthetic project declares and registers `ui` exactly as a real host must.
|
|
70
|
+
const { createUiExtension } = await import('@jimhoyd/urlcode-ui/host'), { authUiTemplates } = await import("./auth-templates.js"), { englishCatalogue } = await import("./presentation.js");
|
|
69
71
|
const project = join(root, 'project'), operator = join(root, 'operator'), origin = 'https://baseline.invalid';
|
|
70
72
|
await mkdir(project, { mode: 0o700 });
|
|
71
73
|
await mkdir(operator, { mode: 0o700 });
|
|
72
|
-
await writeFile(join(project, 'urlcode.yaml'), JSON.stringify({ version: '1', extensions: { auth: { version: '1', config: { registration: 'open' } } }, routes: {
|
|
74
|
+
await writeFile(join(project, 'urlcode.yaml'), JSON.stringify({ version: '1', extensions: { ui: { version: '1', config: {} }, auth: { version: '1', config: { registration: 'open' } } }, routes: {
|
|
73
75
|
'/account/*': { extension: 'auth', methods: ['GET', 'HEAD', 'POST'] },
|
|
76
|
+
'/assets/ui/*': { extension: 'ui', methods: ['GET', 'HEAD'] },
|
|
74
77
|
'/protected': { respond: { json: { authorized: true } }, methods: ['GET', 'POST'], policies: { extensions: { auth: { permission: 'baseline.read' } } } },
|
|
75
78
|
'/public-guest': { parameters: [{ name: 'cookie', in: 'header', schema: { type: 'string', default: 'untrusted-default' } }, { name: 'authorization', in: 'header', schema: { type: 'string' } }], function: { source: 'guest.mjs' } },
|
|
76
79
|
} }), { mode: 0o600 });
|
|
@@ -78,7 +81,8 @@ async function probe(root) {
|
|
|
78
81
|
const revision = await inspectExtensionRevision(project), csrfKey = randomBytes(32), encryptionKey = randomBytes(32), password = 'synthetic-baseline-' + randomBytes(16).toString('hex');
|
|
79
82
|
service = await createAuthService({ database: join(operator, 'ordinary.sqlite'), encryptionKey, roles: { member: ['baseline.read'], admin: ['*'] }, defaultRole: 'member' });
|
|
80
83
|
const account = await service.register({ email: 'synthetic-baseline@example.test', password });
|
|
81
|
-
const
|
|
84
|
+
const ui = createUiExtension({ projectSha256: revision, projectRoot: project, sources: [englishCatalogue], extensions: [authUiTemplates] });
|
|
85
|
+
const startRuntime = () => createRuntime(project, { origin, environment: {}, workers: 1, timeoutMs: 1000, extensions: [ui.registration, authExtension({ service: service, csrfKey, projectSha256: revision, ui })], log: () => { } });
|
|
82
86
|
runtime = await startRuntime();
|
|
83
87
|
const text = (response) => typeof response.body === 'string' ? response.body : response.body ? Buffer.from(response.body).toString('utf8') : '';
|
|
84
88
|
const cookies = new Map();
|
package/dist/auth-flows.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { AuthHttpResponse, UiHost } from './auth-ui.ts';
|
|
|
10
10
|
export interface AuthFlowOptions {
|
|
11
11
|
service: AuthService;
|
|
12
12
|
presentation?: Presentation;
|
|
13
|
-
ui
|
|
13
|
+
ui: UiHost;
|
|
14
14
|
onSession?: (request: ExtensionRequest, result: AuthSessionResult) => Promise<[
|
|
15
15
|
string,
|
|
16
16
|
string
|
package/dist/auth-store.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { AuthAbusePolicy } from './abuse.ts';
|
|
2
2
|
import type { RegistrationProfile } from './registration.ts';
|
|
3
|
+
import type { EventEmitter } from 'node:events';
|
|
3
4
|
export declare class AuthError extends Error {
|
|
4
5
|
readonly status: number;
|
|
5
6
|
readonly code: string;
|
|
6
|
-
constructor(status: number, code: string);
|
|
7
|
+
constructor(status: number, code: string, cause?: unknown);
|
|
7
8
|
}
|
|
8
9
|
export interface AuthRecord {
|
|
9
10
|
mfaPasskeys?: string[];
|
|
@@ -77,5 +78,20 @@ export interface AuthStore {
|
|
|
77
78
|
call<T = unknown>(operation: string, args?: Record<string, unknown>): Promise<T>;
|
|
78
79
|
close(): Promise<void>;
|
|
79
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Describes the phase a worker was still in when its startup bound elapsed.
|
|
83
|
+
* Thread scheduling and database initialization fail for unrelated reasons, and a
|
|
84
|
+
* bare timeout cannot tell them apart; naming the phase makes the next occurrence
|
|
85
|
+
* diagnosable from the failure alone. `onlineMs` is undefined when the worker
|
|
86
|
+
* thread never began executing JavaScript.
|
|
87
|
+
*/
|
|
88
|
+
export declare function startupPhase(onlineMs: number | undefined, elapsedMs: number): string;
|
|
89
|
+
/**
|
|
90
|
+
* Resolves once the worker reports readiness, and otherwise rejects with what it
|
|
91
|
+
* reached. A worker that fails or exits before reporting is rejected at once
|
|
92
|
+
* rather than waiting out `boundMs`, whose expiry is reported with its phase and
|
|
93
|
+
* timings. Separated from Worker construction so every branch is testable.
|
|
94
|
+
*/
|
|
95
|
+
export declare function awaitStoreStartup(worker: EventEmitter, boundMs: number): Promise<void>;
|
|
80
96
|
export declare function patched(version: string): boolean;
|
|
81
97
|
export declare function openAuthStore(options: StoreOptions): Promise<AuthStore>;
|
package/dist/auth-store.js
CHANGED
|
@@ -11,7 +11,49 @@ import { resolve, dirname, basename, join } from 'node:path';
|
|
|
11
11
|
export class AuthError extends Error {
|
|
12
12
|
status;
|
|
13
13
|
code;
|
|
14
|
-
|
|
14
|
+
// `cause` carries operator diagnostics only. Responses are built from `status`
|
|
15
|
+
// and `code`, so nothing recorded here reaches a client.
|
|
16
|
+
constructor(status, code, cause) { super(code, cause === undefined ? undefined : { cause }); this.status = status; this.code = code; }
|
|
17
|
+
}
|
|
18
|
+
/** Startup codes the worker reports for itself; anything else is an unavailable store. */
|
|
19
|
+
const startupCodes = ['auth_configuration_changed', 'configuration_approval_mismatch', 'configuration_roles_invalid', 'configuration_admin_required'];
|
|
20
|
+
/**
|
|
21
|
+
* Describes the phase a worker was still in when its startup bound elapsed.
|
|
22
|
+
* Thread scheduling and database initialization fail for unrelated reasons, and a
|
|
23
|
+
* bare timeout cannot tell them apart; naming the phase makes the next occurrence
|
|
24
|
+
* diagnosable from the failure alone. `onlineMs` is undefined when the worker
|
|
25
|
+
* thread never began executing JavaScript.
|
|
26
|
+
*/
|
|
27
|
+
export function startupPhase(onlineMs, elapsedMs) {
|
|
28
|
+
return onlineMs === undefined
|
|
29
|
+
? `worker thread did not begin executing within ${elapsedMs}ms`
|
|
30
|
+
: `worker thread began executing after ${onlineMs}ms, then did not report readiness for a further ${elapsedMs - onlineMs}ms`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolves once the worker reports readiness, and otherwise rejects with what it
|
|
34
|
+
* reached. A worker that fails or exits before reporting is rejected at once
|
|
35
|
+
* rather than waiting out `boundMs`, whose expiry is reported with its phase and
|
|
36
|
+
* timings. Separated from Worker construction so every branch is testable.
|
|
37
|
+
*/
|
|
38
|
+
export function awaitStoreStartup(worker, boundMs) {
|
|
39
|
+
const started = performance.now(), elapsed = () => Math.round(performance.now() - started);
|
|
40
|
+
let onlineMs;
|
|
41
|
+
worker.once('online', () => { onlineMs = elapsed(); });
|
|
42
|
+
return new Promise((accept, reject) => {
|
|
43
|
+
const unavailable = (detail) => new AuthError(503, 'auth_store_unavailable', new Error(detail));
|
|
44
|
+
const timer = setTimeout(() => { reject(unavailable(startupPhase(onlineMs, elapsed()))); }, boundMs);
|
|
45
|
+
worker.once('message', (message) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (message.ready)
|
|
48
|
+
accept();
|
|
49
|
+
else if (startupCodes.includes(message.error ?? ''))
|
|
50
|
+
reject(new AuthError(503, message.error));
|
|
51
|
+
else
|
|
52
|
+
reject(unavailable(`worker reported ${message.error ?? 'no readiness'} after ${elapsed()}ms`));
|
|
53
|
+
});
|
|
54
|
+
worker.once('error', (error) => { clearTimeout(timer); reject(unavailable(`worker failed after ${elapsed()}ms: ${error instanceof Error ? error.message : String(error)}`)); });
|
|
55
|
+
worker.once('exit', (code) => { clearTimeout(timer); reject(unavailable(`worker exited with code ${code} after ${elapsed()}ms without reporting readiness`)); });
|
|
56
|
+
});
|
|
15
57
|
}
|
|
16
58
|
export function patched(version) { const [a = 0, b = 0, c = 0] = version.split('.').map(Number); return a > 3 || a === 3 && (b > 51 || b === 51 && c >= 3 || b === 50 && c >= 7 || b === 44 && c >= 6); }
|
|
17
59
|
export async function openAuthStore(options) {
|
|
@@ -47,18 +89,7 @@ export async function openAuthStore(options) {
|
|
|
47
89
|
worker.on('error', fail);
|
|
48
90
|
worker.on('exit', fail);
|
|
49
91
|
try {
|
|
50
|
-
await
|
|
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')); });
|
|
61
|
-
});
|
|
92
|
+
await awaitStoreStartup(worker, 15000);
|
|
62
93
|
}
|
|
63
94
|
catch (error) {
|
|
64
95
|
// A rejected open must release SQLite handles before its caller can
|
package/dist/auth-templates.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ViewModel } from '@jimhoyd/urlcode-ui';
|
|
1
|
+
import type { ExtensionTemplates, ViewModel } from '@jimhoyd/urlcode-ui';
|
|
2
2
|
export interface AuthTemplate {
|
|
3
3
|
readonly source: string;
|
|
4
4
|
readonly sample: ViewModel;
|
|
@@ -6,8 +6,13 @@ export interface AuthTemplate {
|
|
|
6
6
|
/** Template sources with their view model samples, keyed by full template name. */
|
|
7
7
|
export declare const authTemplates: Readonly<Record<string, AuthTemplate>>;
|
|
8
8
|
export declare const authTemplateNames: readonly string[];
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* What the host hands to `createUiExtension({ extensions: [authUiTemplates] })`. It also carries the
|
|
11
|
+
* catalogue the host registers in `sources` and the view model samples, so `urlcode-ui --extensions
|
|
12
|
+
* @jimhoyd/urlcode-auth` lists, ejects, previews and drift-checks these screens from this one export.
|
|
13
|
+
*/
|
|
14
|
+
export declare const authUiTemplates: ExtensionTemplates & {
|
|
11
15
|
readonly name: 'auth';
|
|
12
16
|
readonly templates: Readonly<Record<string, string>>;
|
|
17
|
+
readonly samples: Readonly<Record<string, ViewModel>>;
|
|
13
18
|
};
|
package/dist/auth-templates.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* validates, what is escaped or what a page sends in headers.
|
|
8
8
|
*/
|
|
9
9
|
import { Markup } from '@jimhoyd/urlcode-ui';
|
|
10
|
+
import { englishCatalogue } from "./presentation.js";
|
|
10
11
|
const m = (html) => new Markup(html);
|
|
11
12
|
const declare = (name, body) => `{{!-- viewModel: auth/${name}@1 --}}${body}`;
|
|
12
13
|
const link = '<a href="{{href href}}">{{label}}</a>';
|
|
@@ -70,5 +71,14 @@ const screens = {
|
|
|
70
71
|
/** Template sources with their view model samples, keyed by full template name. */
|
|
71
72
|
export const authTemplates = Object.freeze(Object.fromEntries(Object.entries(screens).map(([name, screen]) => [`auth/${name}`, Object.freeze({ source: declare(name, screen.body), sample: screen.sample })])));
|
|
72
73
|
export const authTemplateNames = Object.freeze(Object.keys(authTemplates));
|
|
73
|
-
/**
|
|
74
|
-
|
|
74
|
+
/**
|
|
75
|
+
* What the host hands to `createUiExtension({ extensions: [authUiTemplates] })`. It also carries the
|
|
76
|
+
* catalogue the host registers in `sources` and the view model samples, so `urlcode-ui --extensions
|
|
77
|
+
* @jimhoyd/urlcode-auth` lists, ejects, previews and drift-checks these screens from this one export.
|
|
78
|
+
*/
|
|
79
|
+
export const authUiTemplates = Object.freeze({
|
|
80
|
+
name: 'auth',
|
|
81
|
+
templates: Object.freeze(Object.fromEntries(Object.entries(authTemplates).map(([name, template]) => [name, template.source]))),
|
|
82
|
+
catalogue: englishCatalogue,
|
|
83
|
+
samples: Object.freeze(Object.fromEntries(Object.entries(authTemplates).map(([name, template]) => [name, template.sample]))),
|
|
84
|
+
});
|
package/dist/auth-ui.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ViewModel, Kit
|
|
1
|
+
import type { ViewModel, Kit } from '@jimhoyd/urlcode-ui';
|
|
2
2
|
export { escapeHtml } from '@jimhoyd/urlcode-ui';
|
|
3
3
|
import type { TurnstileWidget } from './challenge-ui.ts';
|
|
4
4
|
import type { PresentationContext } from './presentation.ts';
|
|
@@ -32,27 +32,20 @@ export interface Screen {
|
|
|
32
32
|
export interface ScreenOptions {
|
|
33
33
|
status?: number | undefined;
|
|
34
34
|
headers?: [string, string][] | undefined;
|
|
35
|
-
/** A same-origin script the screen needs (the passkey glue); it is nonce-bound
|
|
35
|
+
/** A same-origin script the screen needs (the passkey glue); it is nonce-bound to the kit's page nonce. */
|
|
36
36
|
scriptPath?: string | undefined;
|
|
37
37
|
presentation?: PresentationContext | undefined;
|
|
38
38
|
turnstile?: TurnstileWidget | undefined;
|
|
39
39
|
layout?: 'default' | 'compact' | 'application' | undefined;
|
|
40
|
-
/**
|
|
41
|
-
ui
|
|
40
|
+
/** The kit every account screen renders through; `authExtension` refuses activation without it. */
|
|
41
|
+
ui: UiHost;
|
|
42
42
|
}
|
|
43
|
-
/** Test hook: sees every screen before it renders
|
|
43
|
+
/** Test hook: sees every screen before it renders. */
|
|
44
44
|
export declare const screenObserver: {
|
|
45
|
-
current?: ((screen: Screen
|
|
45
|
+
current?: ((screen: Screen) => void) | undefined;
|
|
46
46
|
};
|
|
47
|
-
/** Renders a screen
|
|
48
|
-
export declare function screenResponse(title: string, screen: Screen, options
|
|
49
|
-
/** The presentation auth resolves copy through: the host's, else the kit's once `ui` is active, else the bundled English catalogue. */
|
|
50
|
-
export declare function presentationSource(presentation: Presentation | undefined, ui: UiHost | undefined, fallback: Presentation): Presentation;
|
|
51
|
-
/** Only trusted package code constructs markup. Project/user values must pass escapeHtml. */
|
|
52
|
-
export declare function pageResponse(title: string, markup: string, status?: number, headers?: [
|
|
53
|
-
string,
|
|
54
|
-
string
|
|
55
|
-
][], scriptPath?: string, presentation?: PresentationContext, turnstile?: TurnstileWidget, layout?: 'default' | 'compact' | 'application'): AuthHttpResponse;
|
|
47
|
+
/** Renders a screen through `ui.kit`, the only render path; activation already refused a missing or inactive `ui`. */
|
|
48
|
+
export declare function screenResponse(title: string, screen: Screen, options: ScreenOptions): AuthHttpResponse;
|
|
56
49
|
export declare function formField(name: string, label: string, type?: string, autocomplete?: string, required?: boolean): string;
|
|
57
50
|
export declare function csrfField(token: string): string;
|
|
58
51
|
export declare function wantsJson(request: ExtensionRequest): boolean;
|
|
@@ -96,10 +89,10 @@ export declare class AuthHttp {
|
|
|
96
89
|
string
|
|
97
90
|
][];
|
|
98
91
|
}
|
|
99
|
-
export declare function httpFailure(error: unknown, request: ExtensionRequest, presentation
|
|
92
|
+
export declare function httpFailure(error: unknown, request: ExtensionRequest, presentation: PresentationContext | undefined, recovery: {
|
|
100
93
|
href: string;
|
|
101
94
|
label: string;
|
|
102
|
-
}, ui
|
|
95
|
+
} | undefined, ui: UiHost): AuthHttpResponse;
|
|
103
96
|
/** Proof token stays in the submitting form and is consumed once with the primary proof. */
|
|
104
97
|
export declare function secondFactorButton(base: string, text?: (source: string) => string): string;
|
|
105
98
|
/** Browser glue for maintained server-side WebAuthn verification. No guest scripts. */
|
package/dist/auth-ui.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { field, escapeHtml, Markup } from '@jimhoyd/urlcode-ui';
|
|
2
2
|
export { escapeHtml } from '@jimhoyd/urlcode-ui';
|
|
3
3
|
import { authTemplates } from "./auth-templates.js";
|
|
4
4
|
import { addTurnstileWidgets, turnstileOrigin, turnstileScript } from "./challenge-ui.js";
|
|
5
5
|
import { englishCatalogue } from "./presentation.js";
|
|
6
|
-
import { createPresentation } from "./presentation.js";
|
|
7
6
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
8
7
|
export class AuthHttpError extends Error {
|
|
9
8
|
status;
|
|
@@ -12,33 +11,18 @@ export class AuthHttpError extends Error {
|
|
|
12
11
|
const encoder = new TextEncoder();
|
|
13
12
|
const securityHeaders = [['cache-control', 'no-store'], ['content-security-policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"], ['referrer-policy', 'strict-origin'], ['x-content-type-options', 'nosniff']];
|
|
14
13
|
export function jsonResponse(status, value, headers = []) { return { status, headers: [...securityHeaders, ['content-type', 'application/json; charset=utf-8'], ...headers], body: encoder.encode(JSON.stringify(value)) }; }
|
|
15
|
-
/** Test hook: sees every screen before it renders
|
|
14
|
+
/** Test hook: sees every screen before it renders. */
|
|
16
15
|
export const screenObserver = {};
|
|
17
|
-
let defaultContext;
|
|
18
|
-
const localTemplates = new Map();
|
|
19
|
-
function localTemplate(name) {
|
|
20
|
-
let template = localTemplates.get(name);
|
|
21
|
-
if (!template && Object.hasOwn(authTemplates, name)) {
|
|
22
|
-
template = compileTemplate(name, authTemplates[name].source);
|
|
23
|
-
localTemplates.set(name, template);
|
|
24
|
-
}
|
|
25
|
-
return template;
|
|
26
|
-
}
|
|
27
16
|
function pageTitle(title, presentation) {
|
|
28
17
|
const titleKey = Object.entries(englishCatalogue).find(([key, value]) => key.startsWith('page.') && value === title)?.[0];
|
|
29
18
|
return presentation ? (titleKey ? presentation.text(titleKey) : presentation.textSource(title)) : title;
|
|
30
19
|
}
|
|
31
|
-
/** Renders a screen
|
|
32
|
-
export function screenResponse(title, screen, options
|
|
20
|
+
/** Renders a screen through `ui.kit`, the only render path; activation already refused a missing or inactive `ui`. */
|
|
21
|
+
export function screenResponse(title, screen, options) {
|
|
33
22
|
if (!Object.hasOwn(authTemplates, screen.name))
|
|
34
23
|
throw new Error(`Unknown auth screen: ${screen.name.slice(0, 64)}`);
|
|
35
|
-
const kit = options.ui
|
|
36
|
-
screenObserver.current?.(screen
|
|
37
|
-
if (!kit) {
|
|
38
|
-
const context = options.presentation ?? (defaultContext ??= createPresentation().resolve());
|
|
39
|
-
const markup = localTemplate(screen.name).render(screen.view, context, localTemplate).html;
|
|
40
|
-
return pageResponse(title, markup, options.status, options.headers, options.scriptPath, options.presentation, options.turnstile, options.layout);
|
|
41
|
-
}
|
|
24
|
+
const kit = options.ui.kit;
|
|
25
|
+
screenObserver.current?.(screen);
|
|
42
26
|
const context = options.presentation ?? kit.resolveContext();
|
|
43
27
|
const challenge = addTurnstileWidgets(kit.render(screen.name, screen.view, context).html, options.turnstile);
|
|
44
28
|
const page = kit.wrap(new Markup(challenge.markup), { title: pageTitle(title, context), context, ...(options.layout ? { layout: options.layout } : {}), ...(options.status !== undefined ? { status: options.status } : {}), ...(options.headers ? { headers: options.headers } : {}), ...(challenge.enabled ? { csp: { script: [turnstileOrigin], frame: [turnstileOrigin], connect: [turnstileOrigin] } } : {}) });
|
|
@@ -53,20 +37,6 @@ export function screenResponse(title, screen, options = {}) {
|
|
|
53
37
|
html = html.slice(0, -'</body></html>'.length) + scripts.map(script => `<script nonce="${nonce}" src="${escapeHtml(script.src)}"${script.async ? ' async' : ' defer'}></script>`).join('') + '</body></html>';
|
|
54
38
|
return { status: page.status, headers: page.headers, body: encoder.encode(html) };
|
|
55
39
|
}
|
|
56
|
-
/** The presentation auth resolves copy through: the host's, else the kit's once `ui` is active, else the bundled English catalogue. */
|
|
57
|
-
export function presentationSource(presentation, ui, fallback) {
|
|
58
|
-
return presentation ?? (ui?.active ? ui.kit.presentation : fallback);
|
|
59
|
-
}
|
|
60
|
-
/** Only trusted package code constructs markup. Project/user values must pass escapeHtml. */
|
|
61
|
-
export function pageResponse(title, markup, status = 200, headers = [], scriptPath, presentation, turnstile, layout = 'default') {
|
|
62
|
-
title = pageTitle(title, presentation);
|
|
63
|
-
const challenge = addTurnstileWidgets(markup, turnstile);
|
|
64
|
-
markup = challenge.markup;
|
|
65
|
-
const nonce = randomBytes(18).toString('base64');
|
|
66
|
-
const scripts = [...(scriptPath ? [{ src: scriptPath, nonce: nonce }] : []), ...(challenge.enabled ? [{ src: turnstileScript, nonce: nonce, async: true }] : [])];
|
|
67
|
-
const html = renderDocument({ title, trustedContent: markup, layout, theme: { nonce }, ...(presentation ? { presentation } : {}), scripts });
|
|
68
|
-
return { status, headers: [...securityHeaders.map(([name, value]) => [name, name === 'content-security-policy' ? value + (presentation?.logo || presentation?.favicon ? "; img-src 'self'" : '') + (nonce ? `; script-src 'nonce-${nonce}'${challenge.enabled ? ' ' + turnstileOrigin : ''}` : '') + (challenge.enabled ? `; frame-src ${turnstileOrigin}; connect-src 'self' ${turnstileOrigin}` : '') : value]), ['content-type', 'text/html; charset=utf-8'], ...headers], body: encoder.encode(html) };
|
|
69
|
-
}
|
|
70
40
|
export function formField(name, label, type = 'text', autocomplete = 'off', required = true) { return field({ name, label, type, autocomplete, required }); }
|
|
71
41
|
export function csrfField(token) { return `<input type="hidden" name="csrf" value="${escapeHtml(token)}">`; }
|
|
72
42
|
export function wantsJson(request) { return (request.headers.get('accept') || '').split(',').some(value => value.trim().split(';')[0] === 'application/json') || request.headers.get('content-type')?.split(';')[0]?.trim() === 'application/json'; }
|
package/dist/auth.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ export interface AuthExtensionOptions {
|
|
|
10
10
|
challenge?: AuthChallenge;
|
|
11
11
|
sendFactorRecovery?: (message: FactorRecoveryMessage) => Promise<void>;
|
|
12
12
|
presentation?: Presentation;
|
|
13
|
-
/** The `ui` extension from `createUiExtension`, declared before auth in the host file.
|
|
14
|
-
ui
|
|
13
|
+
/** The `ui` extension from `createUiExtension`, declared before auth in the host file. Every account screen renders through its kit; activation refuses without it. */
|
|
14
|
+
ui: UiHost;
|
|
15
15
|
service: AuthService;
|
|
16
16
|
csrfKey: Uint8Array;
|
|
17
17
|
projectSha256: string;
|
package/dist/auth.js
CHANGED
|
@@ -2,14 +2,12 @@ import { icon, hiddenField, postForm, Markup } from '@jimhoyd/urlcode-ui';
|
|
|
2
2
|
import { createAbuseGuard } from "./abuse-http.js";
|
|
3
3
|
import { createManualRecoveryFlows } from "./manual-recovery.js";
|
|
4
4
|
import { createFactorRecoveryFlows } from "./factor-recovery.js";
|
|
5
|
-
import { createPresentation } from "./presentation.js";
|
|
6
5
|
import { isHoneypotFilled } from "./registration.js";
|
|
7
6
|
import { createSecondFactorFlows } from "./second-factor-flows.js";
|
|
8
7
|
import { createSignup } from "./auth-signup.js";
|
|
9
8
|
import { createAuthFlows } from "./auth-flows.js";
|
|
10
|
-
import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField as baseField, httpFailure, jsonResponse,
|
|
11
|
-
import { hooksConfigSchema, loadLifecycleHooks } from "./lifecycle-hooks.js";
|
|
12
|
-
const defaultPresentation = createPresentation();
|
|
9
|
+
import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField as baseField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson, passkeyScript, secondFactorButton } from "./auth-ui.js";
|
|
10
|
+
import { authHookContracts, hooksConfigSchema, loadLifecycleHooks } from "./lifecycle-hooks.js";
|
|
13
11
|
function enrollmentRequired(principal) { return Boolean(principal.restrictions?.length); }
|
|
14
12
|
export function hasPermission(principal, permission) { return !enrollmentRequired(principal) && (principal.permissions.includes('*') || principal.permissions.includes(permission)); }
|
|
15
13
|
const schema = { type: 'object', additionalProperties: false, properties: { registration: { enum: ['open', 'invite-only', 'waitlist', 'off'] }, hooks: hooksConfigSchema } };
|
|
@@ -18,18 +16,24 @@ const actionIcons = { identify: 'arrow-right', login: 'arrow-right', 'step-up':
|
|
|
18
16
|
const hidden = hiddenField;
|
|
19
17
|
const m = (html) => new Markup(html);
|
|
20
18
|
export function authExtension(options) {
|
|
21
|
-
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'],
|
|
22
20
|
async activate(config, context) {
|
|
23
21
|
if (context.mounts.length !== 1)
|
|
24
22
|
throw new Error('Auth requires exactly one mount');
|
|
23
|
+
// Account screens render only through the kit, so a missing or unactivated `ui`
|
|
24
|
+
// is refused here rather than per request in production.
|
|
25
|
+
if (!options.ui)
|
|
26
|
+
throw new Error('Auth requires the ui extension: pass the object createUiExtension() returns as authExtension({ui, ...}). Every account screen renders through its kit; there is no shared-primitive fallback.');
|
|
27
|
+
if (!options.ui.active)
|
|
28
|
+
throw new Error('Auth requires an activated ui extension: declare `ui` in urlcode.yaml before `auth`, with its asset route (for example /assets/ui/*), and list ui.registration before authExtension in the host. The runtime activates extensions in the order urlcode.yaml declares them.');
|
|
25
29
|
// Fail-fast: a configured hook whose module fails to load or whose
|
|
26
30
|
// named export is missing fails activation here, never the first
|
|
27
31
|
// request that happens to reach it. `sandbox: true` is rejected
|
|
28
32
|
// inside loadLifecycleHooks, explicitly, not silently ignored.
|
|
29
33
|
const hooks = await loadLifecycleHooks(config.hooks, context.root);
|
|
30
34
|
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';
|
|
31
|
-
// The runtime
|
|
32
|
-
const source = () =>
|
|
35
|
+
// The runtime activated `ui` before auth, but its kit is read per request, never captured at activation.
|
|
36
|
+
const source = () => options.presentation ?? options.ui.kit.presentation;
|
|
33
37
|
const lazyPresentation = { get locales() { return source().locales; }, get defaultLocale() { return source().defaultLocale; }, get english() { return source().english; }, resolve: preferences => source().resolve(preferences), coverage: locale => source().coverage(locale) };
|
|
34
38
|
if (registrationMode !== service.getRegistrationMode())
|
|
35
39
|
throw new Error('Project registration mode must match operator auth service mode');
|
|
@@ -136,7 +140,7 @@ export function authExtension(options) {
|
|
|
136
140
|
let presentation = source().resolve({ ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
|
|
137
141
|
try {
|
|
138
142
|
const token = http.session(request), user = token ? await service.authenticate(token) : null;
|
|
139
|
-
const locale = user
|
|
143
|
+
const locale = user ? (await service.getUser(user.id))?.profile?.locale : undefined;
|
|
140
144
|
if (locale)
|
|
141
145
|
presentation = source().resolve({ accountLocale: locale });
|
|
142
146
|
const allowed = user && !enrollmentRequired(user) && (!requirement.role || user.roles.includes(String(requirement.role))) && (!requirement.permission || hasPermission(user, String(requirement.permission))) && (!requirement.verified || user.emailVerified) && (!requirement.freshWithinSeconds || Date.now() - user.authenticatedAt <= Number(requirement.freshWithinSeconds) * 1000);
|
|
@@ -155,13 +159,11 @@ export function authExtension(options) {
|
|
|
155
159
|
},
|
|
156
160
|
async handle(request) {
|
|
157
161
|
let accountLocale;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
accountLocale = actor ? (await service.getUser(actor.id))?.profile?.locale : undefined;
|
|
162
|
-
}
|
|
163
|
-
catch { /* An unreadable session only means no account locale to prefer; fall back to the request locale. */ }
|
|
162
|
+
try {
|
|
163
|
+
const session = http.session(request), actor = session ? await service.authenticate(session) : null;
|
|
164
|
+
accountLocale = actor ? (await service.getUser(actor.id))?.profile?.locale : undefined;
|
|
164
165
|
}
|
|
166
|
+
catch { /* An unreadable session only means no account locale to prefer; fall back to the request locale. */ }
|
|
165
167
|
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') } : {}) });
|
|
166
168
|
const tr = (key, values) => escapeHtml(presentation.text(key, values));
|
|
167
169
|
const text = (value) => presentation?.textSource(value) ?? value;
|
|
@@ -37,7 +37,7 @@ export interface FactorRecoveryOptions {
|
|
|
37
37
|
challenge?: AuthChallenge;
|
|
38
38
|
service: FactorRecoveryService;
|
|
39
39
|
sendFactorRecovery?: (message: FactorRecoveryMessage) => Promise<void>;
|
|
40
|
-
ui
|
|
40
|
+
ui: UiHost;
|
|
41
41
|
}
|
|
42
42
|
/** Opt-in email fallback lowers factor assurance; it never creates an unrestricted session. */
|
|
43
43
|
export declare function createFactorRecoveryFlows(options: FactorRecoveryOptions, http: AuthHttp, mount: string): {
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type { AuthService, AuthPrincipal, AuthUser } from './auth-core.ts';
|
|
|
3
3
|
export { authExtension, hasPermission } from './auth.ts';
|
|
4
4
|
export type { AuthExtensionOptions } from './auth.ts';
|
|
5
5
|
export type { AuthHttpOptions, AuthHttpResponse } from './auth-ui.ts';
|
|
6
|
-
export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse,
|
|
6
|
+
export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson } from './auth-ui.ts';
|
|
7
7
|
export type { Screen, ScreenOptions, UiHost } from './auth-ui.ts';
|
|
8
8
|
export { authTemplates, authTemplateNames, authUiTemplates } from './auth-templates.ts';
|
|
9
9
|
export type { AuthTemplate } from './auth-templates.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createAuthService, AuthError } from "./auth-core.js";
|
|
2
2
|
export { authExtension, hasPermission } from "./auth.js";
|
|
3
|
-
export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse,
|
|
3
|
+
export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson } from "./auth-ui.js";
|
|
4
4
|
export { authTemplates, authTemplateNames, authUiTemplates } from "./auth-templates.js";
|
|
5
5
|
export { createOidcProvider } from "./oidc.js";
|
|
6
6
|
export { createPasskeyProvider } from "./passkeys.js";
|
|
@@ -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,12 +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
|
-
// 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.
|
|
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.
|
|
23
12
|
//
|
|
24
13
|
// Each activation re-imports the hook's ENTRY module under a fresh
|
|
25
14
|
// cache-busting query, mirroring core's trusted route activation
|
|
@@ -30,58 +19,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
30
19
|
// hook itself imports stay on Node's module cache, the same already-documented
|
|
31
20
|
// core limitation the trusted route path has; a change to a hook's own
|
|
32
21
|
// dependency still needs a process restart.
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
additionalProperties: false,
|
|
41
|
-
properties: Object.fromEntries(hookNames.map(name => [name, {
|
|
42
|
-
oneOf: [
|
|
43
|
-
{ type: 'string', minLength: 1, maxLength: 1024 },
|
|
44
|
-
{
|
|
45
|
-
type: 'object',
|
|
46
|
-
additionalProperties: false,
|
|
47
|
-
required: ['source'],
|
|
48
|
-
properties: {
|
|
49
|
-
source: { type: 'string', minLength: 1, maxLength: 1024 },
|
|
50
|
-
export: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
|
|
51
|
-
sandbox: { type: 'boolean' },
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
],
|
|
55
|
-
}])),
|
|
56
|
-
};
|
|
57
|
-
function normalize(ref) {
|
|
58
|
-
return typeof ref === 'string'
|
|
59
|
-
? { source: ref, export: 'default', sandbox: false }
|
|
60
|
-
: { source: ref.source, export: ref.export ?? 'default', sandbox: ref.sandbox === true };
|
|
61
|
-
}
|
|
62
|
-
// Same project-relative-file discipline core's own `safeFile` applies to a
|
|
63
|
-
// route's `function.source`: resolved against the project root, refused if
|
|
64
|
-
// it escapes it. Not a security boundary against the module's own code
|
|
65
|
-
// (trusted hooks get full Node access like any other project code), just the
|
|
66
|
-
// same "the YAML cannot point outside the project" hygiene.
|
|
67
|
-
async function projectFile(root, file, hookName) {
|
|
68
|
-
if (isAbsolute(file))
|
|
69
|
-
throw new Error(`hook ${hookName}: source must be a project-relative path`);
|
|
70
|
-
const base = await realpath(root);
|
|
71
|
-
let actual;
|
|
72
|
-
try {
|
|
73
|
-
actual = await realpath(resolve(base, file));
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
throw new Error(`hook ${hookName}: source module "${file}" was not found`);
|
|
77
|
-
}
|
|
78
|
-
const rel = relative(base, actual);
|
|
79
|
-
if (!rel || rel === '..' || rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')) || isAbsolute(rel))
|
|
80
|
-
throw new Error(`hook ${hookName}: source escapes the project`);
|
|
81
|
-
if (!(await stat(actual)).isFile())
|
|
82
|
-
throw new Error(`hook ${hookName}: source must be a file`);
|
|
83
|
-
return actual;
|
|
84
|
-
}
|
|
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);
|
|
85
29
|
/**
|
|
86
30
|
* Resolves and eagerly imports every declared hook, so a missing module, a
|
|
87
31
|
* syntax error or a missing export fails activation (fail-fast), never the
|
|
@@ -90,32 +34,5 @@ async function projectFile(root, file, hookName) {
|
|
|
90
34
|
* trusted.
|
|
91
35
|
*/
|
|
92
36
|
export async function loadLifecycleHooks(config, root) {
|
|
93
|
-
|
|
94
|
-
if (!config)
|
|
95
|
-
return hooks;
|
|
96
|
-
// One epoch per activation, not per hook: two hooks naming the same entry
|
|
97
|
-
// module still share a single instance within this activation, exactly as
|
|
98
|
-
// core's per-runtime-instance epoch does.
|
|
99
|
-
const epoch = randomUUID();
|
|
100
|
-
for (const name of hookNames) {
|
|
101
|
-
const ref = config[name];
|
|
102
|
-
if (ref === undefined)
|
|
103
|
-
continue;
|
|
104
|
-
const definition = normalize(ref);
|
|
105
|
-
if (definition.sandbox)
|
|
106
|
-
throw new Error(`hook ${name}: sandbox: true is not yet supported for project-level hooks, see jimhoyd-com/urlcode-auth#35`);
|
|
107
|
-
const file = await projectFile(root, definition.source, name);
|
|
108
|
-
let mod;
|
|
109
|
-
try {
|
|
110
|
-
mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + '?urlcode-hook-epoch=' + epoch)));
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
throw new Error(`hook ${name}: failed to load module "${definition.source}"`);
|
|
114
|
-
}
|
|
115
|
-
const fn = mod[definition.export];
|
|
116
|
-
if (typeof fn !== 'function')
|
|
117
|
-
throw new Error(`hook ${name}: export "${definition.export}" in "${definition.source}" is not a function`);
|
|
118
|
-
hooks[name] = fn;
|
|
119
|
-
}
|
|
120
|
-
return hooks;
|
|
37
|
+
return await loadExtensionHooks(config, authHookContracts, { root });
|
|
121
38
|
}
|
|
@@ -82,6 +82,6 @@ export interface ManualRecoveryService {
|
|
|
82
82
|
}
|
|
83
83
|
export declare function validateRecoveryEvidence(input: ManualRecoveryEvidence): ManualRecoveryEvidence;
|
|
84
84
|
/** Redemption is POST-only and creates an enrollment session, never normal access. */
|
|
85
|
-
export declare function createManualRecoveryFlows(service: ManualRecoveryService, http: AuthHttp, mount: string, ui
|
|
85
|
+
export declare function createManualRecoveryFlows(service: ManualRecoveryService, http: AuthHttp, mount: string, ui: UiHost): {
|
|
86
86
|
handle(request: ExtensionRequest, presentation?: PresentationContext): Promise<AuthHttpResponse | undefined>;
|
|
87
87
|
};
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export interface AuthenticationScaffold {
|
|
|
3
3
|
project: string;
|
|
4
4
|
hostFile: string;
|
|
5
5
|
operatorFile: string;
|
|
6
|
+
/** Exact versions recorded in the generated package.json. */
|
|
7
|
+
dependencies: Record<string, string>;
|
|
8
|
+
/** Peers whose installed version could not be read, recorded as their declared range instead of a pin. */
|
|
9
|
+
unpinnedDependencies: string[];
|
|
6
10
|
}
|
|
7
11
|
/** Shared scaffold contract (core `urlcode init --with`): what the caller is assembling. */
|
|
8
12
|
export interface ScaffoldRequest {
|
|
@@ -40,5 +44,17 @@ export interface ScaffoldResult {
|
|
|
40
44
|
export declare function scaffold(request: ScaffoldRequest): Promise<ScaffoldResult>;
|
|
41
45
|
/** Deterministic YAML for scaffold fragments: block mappings, flow lists of scalars, {} for empty maps. */
|
|
42
46
|
export declare function renderYaml(value: Record<string, unknown>, indent?: string): string;
|
|
47
|
+
export interface DependencySpecifiers {
|
|
48
|
+
dependencies: Record<string, string>;
|
|
49
|
+
unpinned: string[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* What a generated auth site depends on: this package at its exact version, plus each declared peer at the exact
|
|
53
|
+
* version installed beside it. A peer whose installation cannot be found is recorded as its declared range and
|
|
54
|
+
* reported, because a manifest with no dependencies at all -- what this initializer wrote before #212 -- tells the
|
|
55
|
+
* generated site nothing about the versions it was generated against. Nothing is installed here; running a package
|
|
56
|
+
* manager to produce a lockfile stays the operator's own explicit step.
|
|
57
|
+
*/
|
|
58
|
+
export declare function dependencySpecifiers(): Promise<DependencySpecifiers>;
|
|
43
59
|
/** Creates a new private directory only; never merges or overwrites an existing project. */
|
|
44
60
|
export declare function initAuthentication(directory: string): Promise<AuthenticationScaffold>;
|
package/dist/scaffold.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { scaffold as uiScaffold } from '@jimhoyd/urlcode-ui/host';
|
|
3
|
+
import { mkdir, open, readFile, realpath, rm } from 'node:fs/promises';
|
|
3
4
|
import { resolve, dirname, basename, join, relative, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
const OPERATOR_FILE = 'operator-service.mjs', ENCRYPTION_KEY = 'data/encryption.key', CSRF_KEY = 'data/csrf.key';
|
|
5
7
|
function moduleReference(from, to) {
|
|
6
8
|
const path = relative(from, to).split(sep).join('/');
|
|
@@ -35,7 +37,9 @@ function readmeSection(request, admin) {
|
|
|
35
37
|
|
|
36
38
|
## Install
|
|
37
39
|
|
|
38
|
-
Use a supported patched Node release.
|
|
40
|
+
Use a supported patched Node release. The generated \`package.json\` pins the runtime and extensions to the exact versions that were installed when this directory was created; review it, then run \`npm install\` here to install exactly those and write \`package-lock.json\`. Nothing installs them for you, and there is no upgrade command: changing a pinned version today means editing \`package.json\` and re-running the install yourself.
|
|
41
|
+
|
|
42
|
+
To develop against separately built source repositories instead, replace those pins with the local paths (an install from a path is not reproducible anywhere that path does not exist):
|
|
39
43
|
|
|
40
44
|
\`\`\`sh
|
|
41
45
|
# First run npm ci && npm run build in each source repository.
|
|
@@ -79,6 +83,12 @@ export async function scaffold(request) {
|
|
|
79
83
|
}
|
|
80
84
|
if (!Array.isArray(request.names) || request.names.some(name => typeof name !== 'string'))
|
|
81
85
|
throw new Error('Scaffold names must be strings');
|
|
86
|
+
// Every account screen renders through the kit, and the runtime activates extensions in urlcode.yaml order,
|
|
87
|
+
// which core writes in --with order. So ui must be named, and named first.
|
|
88
|
+
if (!request.names.includes('ui'))
|
|
89
|
+
throw new Error('Auth scaffold requires the ui extension: urlcode init --with ui,auth');
|
|
90
|
+
if (request.names.indexOf('ui') > request.names.indexOf('auth'))
|
|
91
|
+
throw new Error('Auth scaffold requires ui before auth so the kit activates first: urlcode init --with ui,auth');
|
|
82
92
|
const directory = resolve(request.directory), project = resolve(directory, request.project), hostFile = resolve(directory, request.hostFile);
|
|
83
93
|
const normalized = { directory, project, hostFile, names: request.names };
|
|
84
94
|
const admin = request.names.includes('admin'), hostDirectory = dirname(hostFile);
|
|
@@ -104,7 +114,7 @@ export async function scaffold(request) {
|
|
|
104
114
|
" if (csrfKey.length !== 32) throw new Error('Invalid CSRF key');",
|
|
105
115
|
'} catch (error) { await service.close(); throw error; }',
|
|
106
116
|
],
|
|
107
|
-
hostEntries: ['authExtension({service, csrfKey, projectSha256})'],
|
|
117
|
+
hostEntries: ['authExtension({service, csrfKey, projectSha256, ui})'],
|
|
108
118
|
hostClose: ['csrfKey.fill(0);', 'await service.close();'],
|
|
109
119
|
files: [
|
|
110
120
|
{ path: OPERATOR_FILE, content: serviceModule(directory), mode: 0o600 },
|
|
@@ -159,13 +169,78 @@ function hostModule(result) {
|
|
|
159
169
|
'};',
|
|
160
170
|
].join('\n') + '\n';
|
|
161
171
|
}
|
|
172
|
+
async function readManifest(file) {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Node's own `node_modules` lookup, reading manifests rather than loading anything. */
|
|
181
|
+
async function installedVersion(name, from) {
|
|
182
|
+
let directory = resolve(from);
|
|
183
|
+
for (;;) {
|
|
184
|
+
const manifest = await readManifest(join(directory, 'node_modules', ...name.split('/'), 'package.json'));
|
|
185
|
+
if (manifest && manifest.name === name && typeof manifest.version === 'string')
|
|
186
|
+
return manifest.version;
|
|
187
|
+
const parent = dirname(directory);
|
|
188
|
+
if (parent === directory)
|
|
189
|
+
return null;
|
|
190
|
+
directory = parent;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* What a generated auth site depends on: this package at its exact version, plus each declared peer at the exact
|
|
195
|
+
* version installed beside it. A peer whose installation cannot be found is recorded as its declared range and
|
|
196
|
+
* reported, because a manifest with no dependencies at all -- what this initializer wrote before #212 -- tells the
|
|
197
|
+
* generated site nothing about the versions it was generated against. Nothing is installed here; running a package
|
|
198
|
+
* manager to produce a lockfile stays the operator's own explicit step.
|
|
199
|
+
*/
|
|
200
|
+
export async function dependencySpecifiers() {
|
|
201
|
+
const own = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
202
|
+
const manifest = await readManifest(own);
|
|
203
|
+
if (!manifest || typeof manifest.name !== 'string' || typeof manifest.version !== 'string')
|
|
204
|
+
throw new Error(`Could not read this package's manifest at ${own}`);
|
|
205
|
+
const dependencies = { [manifest.name]: manifest.version };
|
|
206
|
+
const unpinned = [];
|
|
207
|
+
const peers = manifest.peerDependencies && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {};
|
|
208
|
+
for (const [name, range] of Object.entries(peers)) {
|
|
209
|
+
if (typeof range !== 'string')
|
|
210
|
+
continue;
|
|
211
|
+
const version = await installedVersion(name, dirname(own));
|
|
212
|
+
if (version)
|
|
213
|
+
dependencies[name] = version;
|
|
214
|
+
else {
|
|
215
|
+
dependencies[name] = range;
|
|
216
|
+
unpinned.push(name);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { dependencies: Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)), unpinned };
|
|
220
|
+
}
|
|
162
221
|
/** Creates a new private directory only; never merges or overwrites an existing project. */
|
|
163
222
|
export async function initAuthentication(directory) {
|
|
164
223
|
if (typeof directory !== 'string' || !directory || directory.includes('\0'))
|
|
165
224
|
throw new Error('An output directory is required');
|
|
166
225
|
const requested = resolve(directory), parent = await realpath(dirname(requested)), root = join(parent, basename(requested));
|
|
167
226
|
const project = join(root, 'app'), hostFile = join(root, 'host.mjs');
|
|
168
|
-
const
|
|
227
|
+
const names = ['ui', 'auth'];
|
|
228
|
+
const kit = await uiScaffold({ directory: root, project, hostFile, names });
|
|
229
|
+
const auth = await scaffold({ directory: root, project, hostFile, names });
|
|
230
|
+
// ui first in both the YAML and the host: auth refuses to activate before the kit is active.
|
|
231
|
+
const result = {
|
|
232
|
+
...auth,
|
|
233
|
+
extensions: { ...kit.extensions, ...auth.extensions },
|
|
234
|
+
routes: { ...kit.routes, ...auth.routes },
|
|
235
|
+
hostImports: [...kit.hostImports, ...auth.hostImports],
|
|
236
|
+
hostSetup: [...kit.hostSetup, ...auth.hostSetup],
|
|
237
|
+
hostEntries: [...kit.hostEntries, ...auth.hostEntries],
|
|
238
|
+
files: [...kit.files, ...auth.files],
|
|
239
|
+
};
|
|
240
|
+
// Resolved before the directory exists, so a manifest failure leaves nothing
|
|
241
|
+
// behind. `@jimhoyd/urlcode-ui` is a declared peer, so the kit this scaffold
|
|
242
|
+
// now emits is pinned by the same pass that pins auth itself.
|
|
243
|
+
const pins = await dependencySpecifiers();
|
|
169
244
|
try {
|
|
170
245
|
await mkdir(root, { mode: 0o700 });
|
|
171
246
|
}
|
|
@@ -191,14 +266,16 @@ export async function initAuthentication(directory) {
|
|
|
191
266
|
await write('app/urlcode.yaml', renderYaml({ version: '1', extensions: result.extensions, routes: result.routes }));
|
|
192
267
|
await write('host.mjs', hostModule(result));
|
|
193
268
|
await write('README.md', `# Auth project and operator host\n\n${result.readme}`);
|
|
194
|
-
await write('package.json', JSON.stringify({ name: 'urlcode-auth-site', private: true, type: 'module' }, null, 2) + '\n');
|
|
269
|
+
await write('package.json', JSON.stringify({ name: 'urlcode-auth-site', private: true, version: '0.0.0', type: 'module', dependencies: pins.dependencies }, null, 2) + '\n');
|
|
195
270
|
await write('.gitignore', 'node_modules/\ndata/\n.env\n.env.*\n');
|
|
196
271
|
for (const file of result.files) {
|
|
197
272
|
if (file.path.includes('\0') || resolve(root, file.path) !== join(root, file.path) || relative(root, resolve(root, file.path)).startsWith('..'))
|
|
198
273
|
throw new Error('Invalid scaffold file path');
|
|
274
|
+
// Composed scaffolds contribute nested paths of their own (the kit's ui/copy and ui/templates).
|
|
275
|
+
await mkdir(dirname(join(root, file.path)), { recursive: true, mode: 0o700 });
|
|
199
276
|
await write(file.path, file.content, file.mode);
|
|
200
277
|
}
|
|
201
|
-
return { directory: root, project, hostFile, operatorFile: join(root, OPERATOR_FILE) };
|
|
278
|
+
return { directory: root, project, hostFile, operatorFile: join(root, OPERATOR_FILE), dependencies: pins.dependencies, unpinnedDependencies: pins.unpinned };
|
|
202
279
|
}
|
|
203
280
|
catch (error) {
|
|
204
281
|
await rm(root, { recursive: true, force: true });
|
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"
|