@jimhoyd/urlcode-auth 0.4.2 → 0.4.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/README.md +36 -3
- package/dist/auth-store.d.ts +1 -1
- package/dist/auth-store.js +12 -5
- package/dist/auth-templates.js +2 -2
- package/dist/auth.d.ts +29 -0
- package/dist/auth.js +12 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/presentation.js +2 -0
- package/dist/scaffold.d.ts +5 -0
- package/dist/scaffold.js +10 -6
- package/dist/senders.d.ts +30 -4
- package/dist/senders.js +16 -3
- package/package.json +11 -8
- package/IMPLEMENTATION-STATUS.md +0 -51
- package/THREAT-MODEL.md +0 -70
package/README.md
CHANGED
|
@@ -8,7 +8,12 @@ The implementation is under active review. Local tests and builds are evidence o
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The npm `latest` tag identifies auth's stable package version; `alpha` is the
|
|
12
|
+
separate prerelease channel. Install the three packages together and pin the
|
|
13
|
+
resolved versions. The peer ranges for `@jimhoyd/urlcode` and
|
|
14
|
+
`@jimhoyd/urlcode-ui` are declared in `package.json`, while each release created
|
|
15
|
+
by the current publisher shows the exact stack tested together and attaches its
|
|
16
|
+
signed `train.json` receipt.
|
|
12
17
|
|
|
13
18
|
```sh
|
|
14
19
|
npm install @jimhoyd/urlcode @jimhoyd/urlcode-ui @jimhoyd/urlcode-auth
|
|
@@ -17,7 +22,12 @@ npx urlcode init my-site --with ui,auth
|
|
|
17
22
|
|
|
18
23
|
`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
24
|
|
|
20
|
-
|
|
25
|
+
Stable publication does not establish production readiness: independent
|
|
26
|
+
security review, accessibility assessment, broader browser/device WebAuthn
|
|
27
|
+
coverage and deployment/soak/recovery exercises remain pending (see
|
|
28
|
+
[IMPLEMENTATION-STATUS.md](IMPLEMENTATION-STATUS.md)). Prerelease versions may
|
|
29
|
+
change public exports, configuration keys and the SQLite schema without a
|
|
30
|
+
migration path; do not run the `alpha` channel on production accounts.
|
|
21
31
|
|
|
22
32
|
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
33
|
|
|
@@ -156,7 +166,22 @@ An operator can configure `checkPassword: createPasswordBreachChecker()` on `cre
|
|
|
156
166
|
|
|
157
167
|
## Email and local development
|
|
158
168
|
|
|
159
|
-
|
|
169
|
+
SES is optional so an auth installation that uses another delivery adapter does
|
|
170
|
+
not install the AWS SDK and its provider chain. Install it explicitly before
|
|
171
|
+
using the built-in sender:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
npm install --save-exact @aws-sdk/client-sesv2@3.1135.0
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`createSesSender({region, from, origin, authMount, credentials?})` then returns a
|
|
178
|
+
callable token sender with `sendEmailCode`, `notify` and `close`. Without the
|
|
179
|
+
optional SDK it fails immediately with an installation instruction. Wire
|
|
180
|
+
callbacks explicitly into the auth/admin factories. Production credentials
|
|
181
|
+
come from operator configuration or the SDK credential chain; never put them
|
|
182
|
+
in YAML. Delivery is bounded and cancellable; services cannot guarantee that
|
|
183
|
+
an email reaches an inbox. A test-only `transport` injection does not load the
|
|
184
|
+
SDK.
|
|
160
185
|
|
|
161
186
|
`createDevelopmentSender` requires `allowDevelopment: true` and either a private output directory outside the project plus its `projectRoot`, or `allowConsoleTokens: true`. File notices use exclusive `0600` files and a bounded count. Console mode deliberately exposes development tokens and must never feed shared production logs. Sender helpers do not infer safety from `NODE_ENV`.
|
|
162
187
|
|
|
@@ -210,6 +235,14 @@ Migration preserves accounts, enrolled credentials and history, while revoking s
|
|
|
210
235
|
|
|
211
236
|
## Presentation
|
|
212
237
|
|
|
238
|
+
Auth is one part of the product, while this package retains ownership of
|
|
239
|
+
identity, sessions, CSRF, validation and recovery behavior. Its extension
|
|
240
|
+
registration publishes a machine-readable `authoring` contract through
|
|
241
|
+
`urlcode extensions --host-file ... --json` and MCP `get_extensions`. Follow
|
|
242
|
+
those configuration, copy, template and lifecycle-hook surfaces before copying
|
|
243
|
+
an auth screen or flow into the project. The contract also lists focused checks
|
|
244
|
+
for the edit loop; full project tests remain the handoff evidence.
|
|
245
|
+
|
|
213
246
|
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`).
|
|
214
247
|
|
|
215
248
|
`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.
|
package/dist/auth-store.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export interface AuthStore {
|
|
|
85
85
|
* diagnosable from the failure alone. `onlineMs` is undefined when the worker
|
|
86
86
|
* thread never began executing JavaScript.
|
|
87
87
|
*/
|
|
88
|
-
export declare function startupPhase(onlineMs: number | undefined, elapsedMs: number): string;
|
|
88
|
+
export declare function startupPhase(onlineMs: number | undefined, elapsedMs: number, stage?: string): string;
|
|
89
89
|
/**
|
|
90
90
|
* Resolves once the worker reports readiness, and otherwise rejects with what it
|
|
91
91
|
* reached. A worker that fails or exits before reporting is rejected at once
|
package/dist/auth-store.js
CHANGED
|
@@ -24,10 +24,10 @@ const startupCodes = ['auth_configuration_changed', 'configuration_approval_mism
|
|
|
24
24
|
* diagnosable from the failure alone. `onlineMs` is undefined when the worker
|
|
25
25
|
* thread never began executing JavaScript.
|
|
26
26
|
*/
|
|
27
|
-
export function startupPhase(onlineMs, elapsedMs) {
|
|
27
|
+
export function startupPhase(onlineMs, elapsedMs, stage) {
|
|
28
28
|
return onlineMs === undefined
|
|
29
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`;
|
|
30
|
+
: `worker thread began executing after ${onlineMs}ms, then did not report readiness for a further ${elapsedMs - onlineMs}ms${stage ? ` (last startup stage reached: ${stage})` : ' (no startup stage reached: the database open itself had not returned)'}`;
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* Resolves once the worker reports readiness, and otherwise rejects with what it
|
|
@@ -37,12 +37,17 @@ export function startupPhase(onlineMs, elapsedMs) {
|
|
|
37
37
|
*/
|
|
38
38
|
export function awaitStoreStartup(worker, boundMs) {
|
|
39
39
|
const started = performance.now(), elapsed = () => Math.round(performance.now() - started);
|
|
40
|
-
let onlineMs;
|
|
40
|
+
let onlineMs, stage;
|
|
41
41
|
worker.once('online', () => { onlineMs = elapsed(); });
|
|
42
42
|
return new Promise((accept, reject) => {
|
|
43
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.
|
|
44
|
+
const timer = setTimeout(() => { worker.removeAllListeners('message'); reject(unavailable(startupPhase(onlineMs, elapsed(), stage))); }, boundMs);
|
|
45
|
+
worker.on('message', function report(message) {
|
|
46
|
+
if (message.stage) {
|
|
47
|
+
stage = message.stage;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
worker.off('message', report);
|
|
46
51
|
clearTimeout(timer);
|
|
47
52
|
if (message.ready)
|
|
48
53
|
accept();
|
|
@@ -288,12 +293,14 @@ if (!isMainThread && workerData?.authStore) {
|
|
|
288
293
|
};
|
|
289
294
|
try {
|
|
290
295
|
db = new DatabaseSync(options.database, { allowExtension: false });
|
|
296
|
+
port.postMessage({ stage: 'database opened' });
|
|
291
297
|
db.exec('PRAGMA busy_timeout=1000; PRAGMA foreign_keys=ON; PRAGMA trusted_schema=OFF;');
|
|
292
298
|
const version = db.prepare('PRAGMA user_version').get()?.user_version, application = db.prepare('PRAGMA application_id').get()?.application_id;
|
|
293
299
|
const empty = db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='table'").get()?.n === 0;
|
|
294
300
|
if (!(version === 1 && application === 1430345032) && !(version === 0 && application === 0 && empty))
|
|
295
301
|
error(503, 'unsupported_auth_database');
|
|
296
302
|
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
|
|
303
|
+
port.postMessage({ stage: 'journal mode set' });
|
|
297
304
|
if (empty)
|
|
298
305
|
transaction(() => {
|
|
299
306
|
db.exec(`CREATE TABLE auth_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
package/dist/auth-templates.js
CHANGED
|
@@ -13,8 +13,8 @@ const declare = (name, body) => `{{!-- viewModel: auth/${name}@1 --}}${body}`;
|
|
|
13
13
|
const link = '<a href="{{href href}}">{{label}}</a>';
|
|
14
14
|
const screens = {
|
|
15
15
|
'sign-in': {
|
|
16
|
-
body: `{{#if intro}}<p class="ui-intro">{{intro}}</p>{{/if}}{{form}}{{passkey}}{{providers}}<nav class="ui-link-list" aria-label="{{linksLabel}}">{{#each links}}${link}{{/each}}</nav>`,
|
|
17
|
-
sample: { intro: '', form: m('<form method="post"></form>'), passkey: m(''), providers: m(''), linksLabel: 'Sign-in methods', links: [{ href: '/account/register', label: 'Create account' }] },
|
|
16
|
+
body: `{{#if intro}}<p class="ui-intro">{{intro}}</p>{{/if}}<div class="ui-auth-primary">{{form}}</div>{{#if passkey}}<div class="ui-field-separator" data-slot="field-separator"><span>{{alternativeLabel}}</span></div>{{else}}{{#if providers}}<div class="ui-field-separator" data-slot="field-separator"><span>{{alternativeLabel}}</span></div>{{/if}}{{/if}}<div class="ui-auth-alternatives">{{passkey}}{{providers}}</div><nav class="ui-link-list" aria-label="{{linksLabel}}">{{#each links}}${link}{{/each}}</nav>`,
|
|
17
|
+
sample: { intro: 'Enter your email to continue to your account.', form: m('<form method="post"></form>'), alternativeLabel: 'Or continue with', passkey: m(''), providers: m('<form method="post"><button type="submit">Continue with Example</button></form>'), linksLabel: 'Sign-in methods', links: [{ href: '/account/register', label: 'Create account' }] },
|
|
18
18
|
},
|
|
19
19
|
password: {
|
|
20
20
|
body: `{{#if failed}}<p class="error" role="alert">{{failed}}</p>{{/if}}{{#if intro}}<p class="ui-intro">{{intro}}</p>{{/if}}<div class="ui-selected-identity"><span class="ui-identifier">{{email}}</span><a href="{{href changeHref}}">{{changeLabel}}</a></div>{{form}}`,
|
package/dist/auth.d.ts
CHANGED
|
@@ -45,5 +45,34 @@ export interface AuthExtensionOptions {
|
|
|
45
45
|
}) => Promise<void>;
|
|
46
46
|
}
|
|
47
47
|
export declare function hasPermission(principal: AuthPrincipal, permission: string): boolean;
|
|
48
|
+
export declare const authAuthoring: Readonly<{
|
|
49
|
+
description: "Auth is part of the application, while this package keeps ownership of identity, session, CSRF and recovery behavior. Customize its project configuration and UI surfaces before replacing package behavior.";
|
|
50
|
+
surfaces: readonly ({
|
|
51
|
+
kind: "configuration";
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
path: string;
|
|
55
|
+
command?: never;
|
|
56
|
+
} | {
|
|
57
|
+
kind: "copy";
|
|
58
|
+
name: string;
|
|
59
|
+
description: string;
|
|
60
|
+
path: string;
|
|
61
|
+
command?: never;
|
|
62
|
+
} | {
|
|
63
|
+
kind: "template";
|
|
64
|
+
name: string;
|
|
65
|
+
description: string;
|
|
66
|
+
path: string;
|
|
67
|
+
command: string;
|
|
68
|
+
} | {
|
|
69
|
+
kind: "hook";
|
|
70
|
+
name: string;
|
|
71
|
+
description: string;
|
|
72
|
+
path: string;
|
|
73
|
+
command?: never;
|
|
74
|
+
})[];
|
|
75
|
+
fastChecks: readonly string[];
|
|
76
|
+
}>;
|
|
48
77
|
export declare function authExtension(options: AuthExtensionOptions): RuntimeExtension;
|
|
49
78
|
export type { AuthUser };
|
package/dist/auth.js
CHANGED
|
@@ -13,10 +13,20 @@ export function hasPermission(principal, permission) { return !enrollmentRequire
|
|
|
13
13
|
const schema = { type: 'object', additionalProperties: false, properties: { registration: { enum: ['open', 'invite-only', 'waitlist', 'off'] }, hooks: hooksConfigSchema } };
|
|
14
14
|
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 };
|
|
15
15
|
const actionIcons = { identify: 'arrow-right', login: 'arrow-right', 'step-up': 'shield', logout: 'log-out', export: 'download' };
|
|
16
|
+
export const authAuthoring = Object.freeze({
|
|
17
|
+
description: 'Auth is part of the application, while this package keeps ownership of identity, session, CSRF and recovery behavior. Customize its project configuration and UI surfaces before replacing package behavior.',
|
|
18
|
+
surfaces: Object.freeze([
|
|
19
|
+
{ kind: 'configuration', name: 'registration', description: 'Select the supported registration mode in extensions.auth.config.registration.', path: 'urlcode.yaml#extensions.auth.config.registration' },
|
|
20
|
+
{ kind: 'copy', name: 'account copy', description: 'Change account-screen wording through the UI catalogue.', path: 'ui/copy/<locale>.json' },
|
|
21
|
+
{ kind: 'template', name: 'account screens', description: 'Override one auth/* screen when its structure must change; keep form actions and security behavior package-owned.', path: 'ui/templates/auth/<screen>.html', command: 'urlcode-ui list --project . --extensions @jimhoyd/urlcode-auth' },
|
|
22
|
+
{ kind: 'hook', name: 'registration lifecycle', description: 'Use the declared beforeRegister, onSignUp and onDelete hooks for application behavior at supported lifecycle points.', path: 'extensions.auth.config.hooks' },
|
|
23
|
+
]),
|
|
24
|
+
fastChecks: Object.freeze(['urlcode-ui doctor --project . --extensions @jimhoyd/urlcode-auth --copy ui/copy --templates ui/templates --stylesheet ui/extra.css', 'urlcode validate --local', 'urlcode test']),
|
|
25
|
+
});
|
|
16
26
|
const hidden = hiddenField;
|
|
17
27
|
const m = (html) => new Markup(html);
|
|
18
28
|
export function authExtension(options) {
|
|
19
|
-
return { name: 'auth', version: '1', projectSha256: options.projectSha256, targets: ['node'], schema, policySchema, hooks: authHookContracts, credentialHeaders: ['cookie', 'authorization', 'x-csrf-token'],
|
|
29
|
+
return { name: 'auth', version: '1', projectSha256: options.projectSha256, targets: ['node'], schema, policySchema, hooks: authHookContracts, authoring: authAuthoring, credentialHeaders: ['cookie', 'authorization', 'x-csrf-token'],
|
|
20
30
|
async activate(config, context) {
|
|
21
31
|
if (context.mounts.length !== 1)
|
|
22
32
|
throw new Error('Auth requires exactly one mount');
|
|
@@ -234,7 +244,7 @@ export function authExtension(options) {
|
|
|
234
244
|
if (path === '/csrf')
|
|
235
245
|
return jsonResponse(200, { csrf }, headers);
|
|
236
246
|
if (path === '/' || path === '/login')
|
|
237
|
-
return screen('Sign in', 'sign-in', { intro: '', form: m(form(mount + '/identify', csrf, formField('email', 'Email address', 'email', 'username'), 'Continue')), passkey: m(passkeyLogin(csrf)), providers: m(flows.buttons(csrf, false, text, presentation.locale, presentation)), linksLabel: text('Sign-in methods'), links: [...(registrationMode !== 'off' ? [{ href: mount + '/register', label: presentation.text('action.register') }] : []), ...(factorRecovery.enabled() ? [{ href: mount + '/recover-factor', label: presentation.text('recovery.lost') }] : []), ...(options.sendToken ? [{ href: mount + '/forgot-password', label: presentation.text('nav.forgotPassword') }] : []), ...(options.sendEmailCode ? [{ href: mount + '/email-code', label: presentation.text('copy.emailSignIn') }] : [])] }, 200, headers, options.passkeys ? mount + '/assets/passkeys.js' : undefined);
|
|
247
|
+
return screen('Sign in', 'sign-in', { intro: presentation.text('ux.signInIntro'), form: m(form(mount + '/identify', csrf, formField('email', 'Email address', 'email', 'username'), 'Continue')), alternativeLabel: presentation.text('ux.orContinueWith'), passkey: m(passkeyLogin(csrf)), providers: m(flows.buttons(csrf, false, text, presentation.locale, presentation)), linksLabel: text('Sign-in methods'), links: [...(registrationMode !== 'off' ? [{ href: mount + '/register', label: presentation.text('action.register') }] : []), ...(factorRecovery.enabled() ? [{ href: mount + '/recover-factor', label: presentation.text('recovery.lost') }] : []), ...(options.sendToken ? [{ href: mount + '/forgot-password', label: presentation.text('nav.forgotPassword') }] : []), ...(options.sendEmailCode ? [{ href: mount + '/email-code', label: presentation.text('copy.emailSignIn') }] : [])] }, 200, headers, options.passkeys ? mount + '/assets/passkeys.js' : undefined);
|
|
238
248
|
if (path === '/register') {
|
|
239
249
|
const invitations = request.query.getAll('token');
|
|
240
250
|
if (invitations.length > 1 || invitations.some(token => token.length > 512))
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createAuthService, AuthError } from './auth-core.ts';
|
|
2
2
|
export type { AuthService, AuthPrincipal, AuthUser } from './auth-core.ts';
|
|
3
|
-
export { authExtension, hasPermission } from './auth.ts';
|
|
3
|
+
export { authExtension, authAuthoring, hasPermission } from './auth.ts';
|
|
4
4
|
export type { AuthExtensionOptions } from './auth.ts';
|
|
5
5
|
export type { AuthHttpOptions, AuthHttpResponse } from './auth-ui.ts';
|
|
6
6
|
export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, readFields, screenResponse, wantsJson } from './auth-ui.ts';
|
|
@@ -22,7 +22,7 @@ export type { BackupOptions, BackupResult, RestoreOptions } from './backup.ts';
|
|
|
22
22
|
export type { AuthOptions, AuthSessionResult, AuthSession, AuthAuditEvent, AuthCase } from './auth-core.ts';
|
|
23
23
|
export { initAuthentication, scaffold } from './scaffold.ts';
|
|
24
24
|
export type { AuthenticationScaffold, ScaffoldRequest, ScaffoldResult, ScaffoldFile } from './scaffold.ts';
|
|
25
|
-
export type { EmailSender, TokenSender, TokenMessage, EmailCodeMessage, FactorRecoveryMessage, SignupCodeMessage, SecurityNotice, SesSenderOptions, DevelopmentSenderOptions } from './senders.ts';
|
|
25
|
+
export type { EmailSender, TokenSender, TokenMessage, EmailCodeMessage, FactorRecoveryMessage, SignupCodeMessage, SecurityNotice, SesSenderOptions, SesCredentials, SesEmailInput, SesEmailCommand, DevelopmentSenderOptions } from './senders.ts';
|
|
26
26
|
export { createPasswordBreachChecker } from './password-policy.ts';
|
|
27
27
|
export type { PasswordBreachOptions } from './password-policy.ts';
|
|
28
28
|
export type { AuthDailyMetric, AuthHookStats, AuthLifecycleEvent, AuthCredentials, AuthSecondFactor, AuthDevice, AuthPasskey, AuthProof, ExternalAuthProof, PasskeyAuthProof, SignupBinding, SignupState, SignupStart } from './auth-core.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createAuthService, AuthError } from "./auth-core.js";
|
|
2
|
-
export { authExtension, hasPermission } from "./auth.js";
|
|
2
|
+
export { authExtension, authAuthoring, hasPermission } from "./auth.js";
|
|
3
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";
|
package/dist/presentation.js
CHANGED
|
@@ -415,6 +415,8 @@ export const englishCatalogue = Object.freeze({
|
|
|
415
415
|
"ux.twoStepHelp": "If your account uses two-step verification, enter an authenticator code or a recovery code. Otherwise, leave this section closed.",
|
|
416
416
|
"ux.change": "Change",
|
|
417
417
|
"ux.passwordIntro": "Enter the password for this account.",
|
|
418
|
+
"ux.signInIntro": "Enter your email to continue to your account.",
|
|
419
|
+
"ux.orContinueWith": "Or continue with",
|
|
418
420
|
"ux.resetIntro": "Enter your account email. If it is eligible, we will send a password reset link.",
|
|
419
421
|
"ux.resetSent": "If this account is eligible, a reset message will be sent. Check your inbox and spam folder, then follow the link to choose a new password.",
|
|
420
422
|
"ux.backSignIn": "Back to sign in",
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ export interface ScaffoldFile {
|
|
|
26
26
|
}
|
|
27
27
|
export interface ScaffoldResult {
|
|
28
28
|
name: string;
|
|
29
|
+
/** Composition contract: capabilities offered, extensions or capabilities required (and ordered before), ordered-after-if-present, and refused together. */
|
|
30
|
+
provides?: string[];
|
|
31
|
+
requires?: string[];
|
|
32
|
+
after?: string[];
|
|
33
|
+
conflicts?: string[];
|
|
29
34
|
extensions: Record<string, unknown>;
|
|
30
35
|
routes: Record<string, unknown>;
|
|
31
36
|
hostImports: string[];
|
package/dist/scaffold.js
CHANGED
|
@@ -83,18 +83,18 @@ export async function scaffold(request) {
|
|
|
83
83
|
}
|
|
84
84
|
if (!Array.isArray(request.names) || request.names.some(name => typeof name !== 'string'))
|
|
85
85
|
throw new Error('Scaffold names must be strings');
|
|
86
|
-
// Every account screen renders through the kit
|
|
87
|
-
//
|
|
86
|
+
// Every account screen renders through the kit. Core orders the host and urlcode.yaml from `requires` below, so
|
|
87
|
+
// the spelling of --with does not matter, but the ui extension has to be part of the set.
|
|
88
88
|
if (!request.names.includes('ui'))
|
|
89
|
-
throw new Error('
|
|
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');
|
|
89
|
+
throw new Error('auth requires the ui extension, which is not part of this composition; add ui to --with');
|
|
92
90
|
const directory = resolve(request.directory), project = resolve(directory, request.project), hostFile = resolve(directory, request.hostFile);
|
|
93
91
|
const normalized = { directory, project, hostFile, names: request.names };
|
|
94
92
|
const admin = request.names.includes('admin'), hostDirectory = dirname(hostFile);
|
|
95
93
|
const operator = shellReference(directory, join(directory, OPERATOR_FILE)), projectPath = shellReference(directory, project), host = shellReference(directory, hostFile);
|
|
96
94
|
return {
|
|
97
95
|
name: 'auth',
|
|
96
|
+
requires: ['ui.kit'],
|
|
97
|
+
provides: ['auth.service'],
|
|
98
98
|
extensions: { auth: { version: '1', config: { registration: 'off' } } },
|
|
99
99
|
routes: {
|
|
100
100
|
'/account/*': { extension: 'auth', methods: ['GET', 'HEAD', 'POST'] },
|
|
@@ -205,9 +205,13 @@ export async function dependencySpecifiers() {
|
|
|
205
205
|
const dependencies = { [manifest.name]: manifest.version };
|
|
206
206
|
const unpinned = [];
|
|
207
207
|
const peers = manifest.peerDependencies && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {};
|
|
208
|
+
const meta = manifest.peerDependenciesMeta && typeof manifest.peerDependenciesMeta === 'object' ? manifest.peerDependenciesMeta : {};
|
|
208
209
|
for (const [name, range] of Object.entries(peers)) {
|
|
209
210
|
if (typeof range !== 'string')
|
|
210
211
|
continue;
|
|
212
|
+
const detail = meta[name];
|
|
213
|
+
if (detail && typeof detail === 'object' && detail.optional === true)
|
|
214
|
+
continue;
|
|
211
215
|
const version = await installedVersion(name, dirname(own));
|
|
212
216
|
if (version)
|
|
213
217
|
dependencies[name] = version;
|
|
@@ -227,7 +231,7 @@ export async function initAuthentication(directory) {
|
|
|
227
231
|
const names = ['ui', 'auth'];
|
|
228
232
|
const kit = await uiScaffold({ directory: root, project, hostFile, names });
|
|
229
233
|
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.
|
|
234
|
+
// ui first in both the YAML and the host (auth declares requires: ui.kit): auth refuses to activate before the kit is active.
|
|
231
235
|
const result = {
|
|
232
236
|
...auth,
|
|
233
237
|
extensions: { ...kit.extensions, ...auth.extensions },
|
package/dist/senders.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { AdminAccountDelivery } from './admin-account-operations.ts';
|
|
2
2
|
import type { EmailCopy } from './email-copy.ts';
|
|
3
3
|
import type { ManualRecoveryDelivery } from './manual-recovery.ts';
|
|
4
|
-
import { SendEmailCommand } from '@aws-sdk/client-sesv2';
|
|
5
|
-
import type { SESv2ClientConfig } from '@aws-sdk/client-sesv2';
|
|
6
4
|
export interface TokenMessage {
|
|
7
5
|
email: string;
|
|
8
6
|
token: string;
|
|
@@ -60,12 +58,40 @@ interface SenderLocation {
|
|
|
60
58
|
export interface SesSenderOptions extends SenderLocation {
|
|
61
59
|
region: string;
|
|
62
60
|
from: string;
|
|
63
|
-
credentials?:
|
|
61
|
+
credentials?: SesCredentials | (() => Promise<SesCredentials>);
|
|
64
62
|
/** Trusted injection for tests; production uses the AWS SDK with bounded retries. */
|
|
65
|
-
transport?: (command:
|
|
63
|
+
transport?: (command: SesEmailCommand, options: {
|
|
66
64
|
abortSignal: AbortSignal;
|
|
67
65
|
}) => Promise<unknown>;
|
|
68
66
|
}
|
|
67
|
+
export interface SesCredentials {
|
|
68
|
+
accessKeyId: string;
|
|
69
|
+
secretAccessKey: string;
|
|
70
|
+
sessionToken?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface SesEmailInput {
|
|
73
|
+
FromEmailAddress: string;
|
|
74
|
+
Destination: {
|
|
75
|
+
ToAddresses: string[];
|
|
76
|
+
};
|
|
77
|
+
Content: {
|
|
78
|
+
Simple: {
|
|
79
|
+
Subject: {
|
|
80
|
+
Data: string;
|
|
81
|
+
Charset: 'UTF-8';
|
|
82
|
+
};
|
|
83
|
+
Body: {
|
|
84
|
+
Text: {
|
|
85
|
+
Data: string;
|
|
86
|
+
Charset: 'UTF-8';
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export interface SesEmailCommand {
|
|
93
|
+
input: SesEmailInput;
|
|
94
|
+
}
|
|
69
95
|
export declare function createSesSender(options: SesSenderOptions): EmailSender;
|
|
70
96
|
export type DevelopmentSenderOptions = SenderLocation & {
|
|
71
97
|
allowDevelopment: true;
|
package/dist/senders.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createEmailCopy } from "./email-copy.js";
|
|
2
|
-
import {
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { open, realpath, stat, readdir } from 'node:fs/promises';
|
|
5
5
|
import { join, relative, isAbsolute } from 'node:path';
|
|
@@ -96,12 +96,25 @@ function sender(where, deliver, cleanup = () => { }) {
|
|
|
96
96
|
});
|
|
97
97
|
return result;
|
|
98
98
|
}
|
|
99
|
+
function loadSes() {
|
|
100
|
+
try {
|
|
101
|
+
return createRequire(import.meta.url)('@aws-sdk/client-sesv2');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error('SES delivery requires the optional @aws-sdk/client-sesv2 package');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
99
107
|
export function createSesSender(options) {
|
|
100
108
|
const where = location(options), from = normalizeEmail(options.from);
|
|
101
109
|
if (!/^[a-z]{2}(?:-[a-z]+)+-\d$/.test(options.region))
|
|
102
110
|
throw new Error('Invalid SES region');
|
|
103
|
-
const
|
|
104
|
-
|
|
111
|
+
const sdk = options.transport ? undefined : loadSes();
|
|
112
|
+
const client = sdk ? new sdk.SESv2Client({ region: options.region, maxAttempts: 2, ...(options.credentials ? { credentials: options.credentials } : {}) }) : undefined;
|
|
113
|
+
return sender(where, async (message, signal) => {
|
|
114
|
+
const input = { FromEmailAddress: from, Destination: { ToAddresses: [message.email] }, Content: { Simple: { Subject: { Data: message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: message.text, Charset: 'UTF-8' } } } } };
|
|
115
|
+
const command = sdk ? new sdk.SendEmailCommand(input) : { input };
|
|
116
|
+
await (options.transport ?? ((value, init) => client.send(value, init)))(command, { abortSignal: signal });
|
|
117
|
+
}, () => client?.destroy());
|
|
105
118
|
}
|
|
106
119
|
/** Development only. File output uses exclusive 0600 notices in an existing private directory outside the project. */
|
|
107
120
|
export async function createDevelopmentSender(options) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jimhoyd/urlcode-auth",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
},
|
|
25
25
|
"exports": {
|
|
26
26
|
".": {
|
|
27
|
-
"development": "./src/index.ts",
|
|
28
27
|
"types": "./dist/index.d.ts",
|
|
29
28
|
"default": "./dist/index.js"
|
|
30
29
|
}
|
|
@@ -34,17 +33,15 @@
|
|
|
34
33
|
"README.md",
|
|
35
34
|
"LICENSE",
|
|
36
35
|
"SECURITY.md",
|
|
37
|
-
"
|
|
38
|
-
"THIRD_PARTY_NOTICES.md",
|
|
39
|
-
"THREAT-MODEL.md"
|
|
36
|
+
"THIRD_PARTY_NOTICES.md"
|
|
40
37
|
],
|
|
41
38
|
"devDependencies": {
|
|
42
39
|
"@jimhoyd/urlcode": "file:../..",
|
|
40
|
+
"@aws-sdk/client-sesv2": "3.1135.0",
|
|
43
41
|
"@types/node": "26.5.1",
|
|
44
42
|
"typescript": "6.0.3"
|
|
45
43
|
},
|
|
46
44
|
"dependencies": {
|
|
47
|
-
"@aws-sdk/client-sesv2": "3.1135.0",
|
|
48
45
|
"@simplewebauthn/server": "14.0.2",
|
|
49
46
|
"bcryptjs": "3.0.3",
|
|
50
47
|
"jose": "6.2.12",
|
|
@@ -52,8 +49,14 @@
|
|
|
52
49
|
"otpauth": "9.5.2"
|
|
53
50
|
},
|
|
54
51
|
"peerDependencies": {
|
|
55
|
-
"@jimhoyd/urlcode": ">=0.4.
|
|
56
|
-
"@jimhoyd/urlcode-ui": ">=0.4.
|
|
52
|
+
"@jimhoyd/urlcode": ">=0.4.6 <0.5.0",
|
|
53
|
+
"@jimhoyd/urlcode-ui": ">=0.4.6 <0.5.0",
|
|
54
|
+
"@aws-sdk/client-sesv2": ">=3.1135.0 <4.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@aws-sdk/client-sesv2": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
57
60
|
},
|
|
58
61
|
"bin": {
|
|
59
62
|
"urlcode-auth": "./dist/cli.js"
|
package/IMPLEMENTATION-STATUS.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# Auth implementation status
|
|
2
|
-
|
|
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.
|
|
16
|
-
|
|
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.
|
|
18
|
-
|
|
19
|
-
Resumable verification-first password/passkey signup (including waitlist approval) and opt-in email-mediated factor recovery with a 24-hour cancellation window and recovery-session-only reenrollment are implemented.
|
|
20
|
-
|
|
21
|
-
Mandatory verification/TOTP enrollment, operator standard/hardened presets and explicit pinned configuration migration are implemented; hardened requires email and breach-screening adapters.
|
|
22
|
-
|
|
23
|
-
Kit adoption (urlcode-auth issue #9, core plan §7.2) is implemented: every account screen is an `auth/*` kit template with a declared view model and sample view (`authTemplates`, `authUiTemplates`, `authCatalogue`); `authExtension({ ui })` renders every screen through `ui.kit.page`. The `ui` extension is required: activation refuses when it is absent, or when the runtime has not activated it because `extensions.ui` is missing from `urlcode.yaml` or declared after `extensions.auth`. The shared-primitive fallback that earlier releases used without the kit has been removed, along with its compile-on-demand template cache and the `pageResponse` document helper that served it (no longer exported). The HTTP suites run once, on the kit path; a doctor-style suite renders every template with its sample and with the view a real request computes, checks escaping of user-controlled values and the nonce-bound CSP, and a separate test covers the activation refusal. A themed browser walkthrough of the account pages remains a manual acceptance step.
|
|
24
|
-
|
|
25
|
-
Project-level lifecycle hooks (urlcode-auth#35) are implemented: `beforeRegister`, `onSignUp` and `onDelete` in `extensions.auth.config.hooks` (README.md), run trusted and in-process — the same default as any `function`/`middleware` route, no special case. Core's extension-hook primitive resolves and imports them eagerly, exposes their typed contracts through extension inspection, and rejects `sandbox: true` under the trusted-only v1 hook contract. `beforeRegister` covers the immediate `/register` endpoint and the resumable `/signup/begin` step; `onSignUp` fires after a genuinely new account is created (not an existing-account signup attempt that resolves to sign-in); `onDelete` fires when the account owner schedules their own deletion, not yet from an administrator-initiated deletion or the background purge.
|
|
26
|
-
|
|
27
|
-
## Additional implemented acceptance
|
|
28
|
-
|
|
29
|
-
- 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.
|
|
30
|
-
- Integrated maker/checker manual recovery, staged administrative account actions and audited identifier reveal/notes. Manual recovery is an operator process; public lost-everything intake and recovery contacts remain later scope.
|
|
31
|
-
- Offline `auth-baseline` runs 17 synthetic checks on a passing run (extra failure-only markers are recorded when a probe, deadline or cleanup fails); anonymous `verify-deployment` inspects headers/cookies without claiming provider readiness.
|
|
32
|
-
- Local browser walkthrough exercised identifier-first password login, account page, admin dashboard, filtered directory and masked detail. It found and corrected the no-referrer/Origin form failure. This is not a complete WCAG 2.2 AA assessment.
|
|
33
|
-
|
|
34
|
-
- The source-only [synthetic recovery drill](RECOVERY-DRILL.md) exercises online backup, isolated reopen, configuration/key refusal and explicit session revocation after snapshot restore. Its 18 checks do not establish production disaster recovery or RTO/RPO.
|
|
35
|
-
|
|
36
|
-
## Remaining first-release acceptance
|
|
37
|
-
|
|
38
|
-
- Complete accessibility assessment, browser/device WebAuthn coverage, deployment/soak/backup-recovery exercises and independent security review.
|
|
39
|
-
- Operator wiring of sender monitoring and lifecycle delivery policy. Hooks and security notices are best-effort after commit, without a durable retry queue (signed webhooks/retries are later scope).
|
|
40
|
-
- Refresh the recorded package and CI evidence whenever code or dependency pins change; the merged implementation baseline is recorded in ACCEPTANCE.md.
|
|
41
|
-
|
|
42
|
-
Live Google/Apple/SES testing is explicitly deferred by the project owner and is not a blocker for local implementation. It remains unverified. Synthetic signed protocol tests do not establish vendor configuration or delivery readiness.
|
|
43
|
-
|
|
44
|
-
## Agreed architecture corrections
|
|
45
|
-
|
|
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.
|
|
47
|
-
|
|
48
|
-
## Recorded acceptance
|
|
49
|
-
|
|
50
|
-
See [ACCEPTANCE.md](ACCEPTANCE.md) for exact merged revisions, automated coverage,
|
|
51
|
-
clean-install evidence and the remaining operational validation boundary.
|
package/THREAT-MODEL.md
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# Auth threat model
|
|
2
|
-
|
|
3
|
-
## Scope and assets
|
|
4
|
-
|
|
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
|
|
9
|
-
account ownership, verified identifiers, credential material, sessions, factor and
|
|
10
|
-
recovery proofs, operator role grants, audit history and private profile data.
|
|
11
|
-
SQLite files and backups contain sensitive account data: file permissions are a
|
|
12
|
-
boundary, not whole-database encryption. TOTP secrets and protocol state are sealed;
|
|
13
|
-
passwords are hashed, and reusable browser capabilities are stored as hashes.
|
|
14
|
-
|
|
15
|
-
## Entry points and trust boundaries
|
|
16
|
-
|
|
17
|
-
| Entry point | Untrusted input | Authority boundary |
|
|
18
|
-
| --- | --- | --- |
|
|
19
|
-
| Mounted auth HTTP routes | Query, form/JSON, cookies, origin, provider responses | Bounded parsing, browser binding, CSRF, maintained protocol verification, final SQLite transaction |
|
|
20
|
-
| Public signup | Identifier, credential, profile and consent | Verification-first state machine; unique identifiers and reviewed default role; no metadata grants |
|
|
21
|
-
| OIDC/WebAuthn completion | State, claims, signed assertions | Issuer/subject binding or RP/origin/UV checks; current account and credential versions rechecked at issuance |
|
|
22
|
-
| Self-service security changes | Password/factor proof and session | Fresh non-impersonated authority; current version; atomic proof use and session revocation |
|
|
23
|
-
| Email/manual recovery | Mailbox capabilities or reviewed case evidence | Explicit operator opt-in; cancellation/cooldown or two current administrators; restricted reenrollment |
|
|
24
|
-
| Operator modules and CLI | Configuration, keys, import data, callbacks | Trusted operator installation outside the project; bounded imports; explicit configuration migration |
|
|
25
|
-
| Auth service API called by admin | Actor token, target and reason | Worker rechecks current permissions, delegation ceiling, freshness and target state |
|
|
26
|
-
|
|
27
|
-
A same-origin frontend can exercise browser authority even without reading an
|
|
28
|
-
HttpOnly cookie. Do not host adversarial frontend scripts on this origin. A hostile
|
|
29
|
-
host process, operator module, dependency or OS user able to read the key material
|
|
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
|
|
33
|
-
readiness or independent assessment.
|
|
34
|
-
|
|
35
|
-
## Required invariants
|
|
36
|
-
|
|
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.
|
|
42
|
-
- Required signup verification precedes stored credentials. Existing accounts are
|
|
43
|
-
never overwritten by a duplicate signup, linked by email alone, or upgraded from
|
|
44
|
-
untrusted metadata.
|
|
45
|
-
- Proof verification and authority issuance must share account/credential version
|
|
46
|
-
checks; retries, parallel workers and A→B→A configuration changes cannot revive
|
|
47
|
-
consumed or stale authority.
|
|
48
|
-
- A passkey cannot serve as both primary and second factor in the same sign-in.
|
|
49
|
-
Remembered-device authority never grants fresh admin/credential authority.
|
|
50
|
-
- Recovery replacement-factor enrollment belongs to the restoration session.
|
|
51
|
-
Another party possessing an old password cannot win an enrollment race.
|
|
52
|
-
- Administrative restoration is a human evidence procedure. Software enforces
|
|
53
|
-
two-person approval and delivery gates; it does not establish legal identity.
|
|
54
|
-
- Limits fail closed, cleanup is bounded, and expiry is checked during use rather
|
|
55
|
-
than relying on a sweep. Network callbacks cannot authorize incomplete state.
|
|
56
|
-
- Diagnostic output, public responses, browser JavaScript, tests and repository
|
|
57
|
-
artifacts must not contain real passwords, reusable credentials or customer data.
|
|
58
|
-
|
|
59
|
-
## Review and evidence
|
|
60
|
-
|
|
61
|
-
For an auth change, trace the final transaction, not just the form or preflight.
|
|
62
|
-
Check replay, concurrency, expiry, browser/account binding, privilege changes,
|
|
63
|
-
configuration migration, fallback methods, last usable access and secret handling.
|
|
64
|
-
Run `npm run verify`; for exports/build/CLI changes also exercise the installed
|
|
65
|
-
local tarballs across core/auth/admin. Protocol fixtures must use synthetic keys.
|
|
66
|
-
|
|
67
|
-
Tests cover these mechanisms but are not a penetration test, real-provider setup,
|
|
68
|
-
load/soak result or disaster-recovery exercise. Live Google/Apple/SES verification
|
|
69
|
-
is separately deferred. Operator callbacks and policy data require their own
|
|
70
|
-
review, monitoring, incident handling and periodically tested backups.
|