@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 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
- `@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.
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
- 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.
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
- `createSesSender({region, from, origin, authMount, credentials?})` returns a callable token sender with `sendEmailCode`, `notify` and `close`. Wire callbacks explicitly into the auth/admin factories. Production credentials come from operator configuration or the SDK credential chain; never put them in YAML. Delivery is bounded and cancellable; services cannot guarantee that an email reaches an inbox.
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.
@@ -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
@@ -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.once('message', (message) => {
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);
@@ -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";
@@ -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",
@@ -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, and the runtime activates extensions in urlcode.yaml order,
87
- // which core writes in --with order. So ui must be named, and named first.
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('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');
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?: SESv2ClientConfig['credentials'];
61
+ credentials?: SesCredentials | (() => Promise<SesCredentials>);
64
62
  /** Trusted injection for tests; production uses the AWS SDK with bounded retries. */
65
- transport?: (command: SendEmailCommand, options: {
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 { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
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 client = options.transport ? undefined : new SESv2Client({ region: options.region, maxAttempts: 2, ...(options.credentials ? { credentials: options.credentials } : {}) });
104
- return sender(where, async (message, signal) => { await (options.transport ?? ((command, init) => client.send(command, init)))(new SendEmailCommand({ FromEmailAddress: from, Destination: { ToAddresses: [message.email] }, Content: { Simple: { Subject: { Data: message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: message.text, Charset: 'UTF-8' } } } } }), { abortSignal: signal }); }, () => client?.destroy());
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.2",
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
- "IMPLEMENTATION-STATUS.md",
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.2 <0.5.0",
56
- "@jimhoyd/urlcode-ui": ">=0.4.2 <0.5.0"
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"
@@ -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.