@jimhoyd/urlcode-auth 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
Files changed (79) hide show
  1. package/IMPLEMENTATION-STATUS.md +3 -1
  2. package/README.md +56 -0
  3. package/SECURITY.md +1 -1
  4. package/THREAT-MODEL.md +4 -2
  5. package/dist/abuse-http.d.ts +8 -0
  6. package/dist/abuse-http.js +74 -0
  7. package/dist/abuse-store.d.ts +5 -0
  8. package/dist/abuse-store.js +40 -0
  9. package/dist/abuse.d.ts +27 -0
  10. package/dist/abuse.js +34 -0
  11. package/dist/admin-account-operations.d.ts +83 -0
  12. package/dist/admin-account-operations.js +50 -0
  13. package/dist/admin-account-store.d.ts +22 -0
  14. package/dist/admin-account-store.js +185 -0
  15. package/dist/auth-baseline.d.ts +30 -0
  16. package/dist/auth-baseline.js +153 -0
  17. package/dist/auth-core.d.ts +655 -0
  18. package/dist/auth-core.js +1066 -0
  19. package/dist/auth-flows.d.ts +30 -0
  20. package/dist/auth-flows.js +228 -0
  21. package/dist/auth-signup.d.ts +12 -0
  22. package/dist/auth-signup.js +154 -0
  23. package/dist/auth-store.d.ts +81 -0
  24. package/dist/auth-store.js +1601 -0
  25. package/dist/auth-templates.d.ts +13 -0
  26. package/dist/auth-templates.js +74 -0
  27. package/dist/auth-ui.d.ts +106 -0
  28. package/dist/auth-ui.js +205 -0
  29. package/dist/auth.d.ts +49 -0
  30. package/dist/auth.js +532 -0
  31. package/dist/backup.d.ts +18 -0
  32. package/dist/backup.js +121 -0
  33. package/dist/challenge-ui.d.ts +11 -0
  34. package/dist/challenge-ui.js +18 -0
  35. package/dist/challenge.d.ts +21 -0
  36. package/dist/challenge.js +65 -0
  37. package/dist/cli.d.ts +2 -0
  38. package/dist/cli.js +137 -0
  39. package/dist/deployment-check.d.ts +16 -0
  40. package/dist/deployment-check.js +41 -0
  41. package/dist/disposable-domain-data.d.ts +1 -0
  42. package/dist/disposable-domain-data.js +8886 -0
  43. package/dist/disposable-domains.d.ts +3 -0
  44. package/dist/disposable-domains.js +17 -0
  45. package/dist/email-copy.d.ts +114 -0
  46. package/dist/email-copy.js +58 -0
  47. package/dist/factor-recovery.d.ts +46 -0
  48. package/dist/factor-recovery.js +71 -0
  49. package/dist/index.d.ts +42 -0
  50. package/dist/index.js +18 -0
  51. package/dist/lifecycle-hooks.d.ts +76 -0
  52. package/dist/lifecycle-hooks.js +106 -0
  53. package/dist/manual-recovery-store.d.ts +25 -0
  54. package/dist/manual-recovery-store.js +129 -0
  55. package/dist/manual-recovery.d.ts +87 -0
  56. package/dist/manual-recovery.js +35 -0
  57. package/dist/oidc.d.ts +31 -0
  58. package/dist/oidc.js +54 -0
  59. package/dist/passkeys.d.ts +24 -0
  60. package/dist/passkeys.js +29 -0
  61. package/dist/password-policy.d.ts +7 -0
  62. package/dist/password-policy.js +72 -0
  63. package/dist/presentation.d.ts +15 -0
  64. package/dist/presentation.js +458 -0
  65. package/dist/presets.d.ts +18 -0
  66. package/dist/presets.js +17 -0
  67. package/dist/providers.d.ts +13 -0
  68. package/dist/providers.js +15 -0
  69. package/dist/registration.d.ts +45 -0
  70. package/dist/registration.js +130 -0
  71. package/dist/scaffold.d.ts +44 -0
  72. package/dist/scaffold.js +212 -0
  73. package/dist/second-factor-flows.d.ts +28 -0
  74. package/dist/second-factor-flows.js +76 -0
  75. package/dist/senders.d.ts +86 -0
  76. package/dist/senders.js +153 -0
  77. package/dist/user-query.d.ts +28 -0
  78. package/dist/user-query.js +81 -0
  79. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  # Auth implementation status
2
2
 
3
- Status: `@jimhoyd/urlcode-auth` 0.1.0-alpha.1 is the first npm release (core issue #78); it requires the published `@jimhoyd/urlcode` 0.4.x and `@jimhoyd/urlcode-ui` 0.1.x alphas. The implemented auth and shared-presentation work is merged to main. The source plan is URLCode PR #54; cross-repository release acceptance is tracked in https://github.com/jimhoyd-com/urlcode/issues/58. The generic core extension contract from PR #59 is merged. Implementation and synthetic acceptance do not establish production readiness.
3
+ Status: `@jimhoyd/urlcode-auth` 0.1.0-alpha.3 is the current npm release. It supersedes 0.1.0-alpha.2, the first working one (core issue #78; 0.1.0-alpha.1 was published from an unbuilt checkout and never worked, see RELEASE-SECURITY.md in urlcode core), and carries the work merged since — the trusted project-level lifecycle hooks (`beforeRegister`, `onSignUp`, `onDelete`), the versioned JSON form-endpoint API contract, and the `ExtensionActivation.root` fixture fixes for core 0.4.0-alpha.2. It requires `@jimhoyd/urlcode` 0.4.0-alpha.2 or newer — `src/auth.ts` resolves project lifecycle hooks through `ExtensionActivation.root`, which does not exist in 0.4.0-alpha.1 — and `@jimhoyd/urlcode-ui` 0.1.x alphas. The implemented auth and shared-presentation work is merged to main. The source plan is URLCode PR #54; cross-repository release acceptance is tracked in https://github.com/jimhoyd-com/urlcode/issues/58. The generic core extension contract from PR #59 is merged. Implementation and synthetic acceptance do not establish production readiness.
4
4
 
5
5
  Implemented and covered by automated tests: durable SQLite accounts; bounded scrypt and hash migration; email/password and numeric email codes; OIDC with explicit linking; Google/Apple adapters; WebAuthn registration, login and step-up; TOTP/recovery; opaque sessions and revocation; role ceilings; registration modes; terms and scoped metadata; email change cooldown/cancellation; deletion grace; exports; key rotation; backup/restore; operator CLI/scaffolding; SES/development senders; safe themes and locale catalogue; admin service operations including dual-approval cases and bounded impersonation. Device recognition supports notices; separate opt-in, revocable remembered-device authority can exempt ordinary MFA without granting fresh step-up. Explicit passkey second-factor enrollment requires an independent credential. Optional breach checking is an operator-selected external service.
6
6
 
@@ -10,6 +10,8 @@ Mandatory verification/TOTP enrollment, operator standard/hardened presets and e
10
10
 
11
11
  Kit adoption (urlcode-auth issue #9, core plan §7.2) is implemented: every account screen is an `auth/*` kit template with a declared view model and sample view (`authTemplates`, `authUiTemplates`, `authCatalogue`); `authExtension({ ui })` renders through `ui.kit.page` when the host supplies the `ui` extension and through the shared primitives otherwise. The HTTP suites run under both render paths; a doctor-style suite renders every template with its sample and with the view a real request computes, checks escaping of user-controlled values on kit pages and the nonce-bound CSP. A themed browser walkthrough of the account pages remains a manual acceptance step.
12
12
 
13
+ Project-level lifecycle hooks (urlcode-auth#35) are implemented: `beforeRegister`, `onSignUp` and `onDelete` in `extensions.auth.config.hooks` (README.md), run trusted and in-process — the same default as any `function`/`middleware` route, no special case. A configured hook's module is resolved and imported eagerly at activation, so a missing module or a broken/missing export fails activation rather than the first request; `sandbox: true` on a hook is refused explicitly at activation (core has no dispatch primitive yet to isolate a hook call, jimhoyd-com/urlcode#151) rather than silently ignored. `beforeRegister` covers the immediate `/register` endpoint and the resumable `/signup/begin` step; `onSignUp` fires after a genuinely new account is created (not an existing-account signup attempt that resolves to sign-in); `onDelete` fires when the account owner schedules their own deletion, not yet from an administrator-initiated deletion or the background purge.
14
+
13
15
  ## Additional implemented acceptance
14
16
 
15
17
  - Bounded localized email copy, durable progressive password backoff, trusted-client and signup-domain velocity budgets, optional fixed-origin Turnstile verification/widget, and pinned disposable-domain data.
package/README.md CHANGED
@@ -83,6 +83,62 @@ The external host creates an AuthService and supplies `authExtension({service, c
83
83
 
84
84
  Registration starts off. Bootstrap the first administrator through `urlcode-auth bootstrap --operator-file /absolute/operator-service.mjs`, supplying `{email,password}` as bounded JSON on stdin. Never place passwords in command arguments or source files. The command returns account metadata, not the session token. A role/default-role configuration change is a reviewed operator change, not an administration-page edit.
85
85
 
86
+ ## Project-level lifecycle hooks
87
+
88
+ A project can name its own function per lifecycle point in `extensions.auth.config.hooks`, using the same `{source, export}` shape (or a bare string, defaulting to the module's default export) `function`/`middleware` routes already use — the behavior-layer counterpart to `urlcode-ui`'s presentation layering (urlcode-auth#35, urlcode's docs/EXTENSIONS.md "Project-level lifecycle hooks"):
89
+
90
+ ```yaml
91
+ extensions:
92
+ auth:
93
+ version: '1'
94
+ config:
95
+ registration: open
96
+ hooks:
97
+ beforeRegister: ./hooks/registration-rule.mjs # bare string: default export
98
+ onSignUp:
99
+ source: ./hooks/on-signup.mjs
100
+ export: provisionWorkspace
101
+ onDelete: ./hooks/on-delete.mjs
102
+ ```
103
+
104
+ Three lifecycle points are implemented:
105
+
106
+ - **`beforeRegister(input: {email, profile?})`** runs before an account is
107
+ created, from the immediate `/register` endpoint and from the resumable
108
+ `/signup/begin` step, and returns a typed verdict: `{allow: true}` lets the
109
+ attempt continue, `{allow: false, reason}` rejects it and the `reason` is
110
+ surfaced to the caller the same way any other registration rejection is (a
111
+ `403` with that message). This is how "only `@acme.com` may register"
112
+ becomes portable project code instead of a fork.
113
+ - **`onSignUp(input: {accountId, email})`** is a side-effect hook (no
114
+ verdict) that fires once, after a *new* account is actually created — from
115
+ the immediate `/register` endpoint and from `/signup/complete` (an
116
+ existing-account signup attempt that resolves to sign-in, not a new
117
+ account, never fires it). Use it for something like provisioning a
118
+ workspace after sign-up.
119
+ - **`onDelete(input: {accountId, email})`** fires when the account owner
120
+ schedules their own deletion through the account page's `/delete` endpoint
121
+ (the deletion grace period still applies and can still be cancelled). It
122
+ does not yet fire from an administrator-initiated deletion or from the
123
+ background purge once the grace period elapses.
124
+
125
+ These hooks are first-party project code, the same trust category as any
126
+ `function`/`middleware` route: **trusted, in-process execution by default**,
127
+ following the runtime's trust model with no special case (urlcode's
128
+ docs/SPIKE-DEFAULT-TRUST-MODEL.md). A missing module, a module that fails to
129
+ import, or a named export that is not a function fails **activation** —
130
+ before this extension serves a single request — never the first request
131
+ that happens to reach the hook.
132
+
133
+ **`sandbox: true` is not implemented for these hooks and is refused
134
+ explicitly at activation**, naming the hook: `hook <name>: sandbox: true is
135
+ not yet supported for project-level hooks, see jimhoyd-com/urlcode-auth#35`.
136
+ Core's trusted/sandboxed dispatch is wired to route dispatch, not exposed to
137
+ extensions (jimhoyd-com/urlcode#151), so this package has no way to actually
138
+ isolate a hook call yet; accepting the field and running it trusted anyway
139
+ would misrepresent the isolation a project believes it configured. Declare a
140
+ hook without `sandbox` (or with `sandbox: false`) to use it today.
141
+
86
142
  ## Authentication and presentation
87
143
 
88
144
  `createAuthService` owns a private SQLite database outside the application directory. Its operations enforce authority, fresh authentication, delegation ceilings, replay protection and transaction boundaries. Callers must preserve the distinction between unrestricted operator APIs and actor-token administrative APIs. `authExtension` adds HTTP cookies, same-origin CSRF checks, bounded bodies and trusted pages.
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ This repository is an actively reviewed implementation, not an independent secur
4
4
 
5
5
  ## Trusted and untrusted components
6
6
 
7
- Operator modules, their dependencies, configuration, database directory, encryption/CSRF keys, identity providers and mail transport are trusted. Project routes and sandboxed guest code do not gain authority to load host modules. Core extension activation requires an explicitly supplied registry and a reviewed exact project revision pin. Never turn revision inspection into automatic approval.
7
+ Operator modules, their dependencies, configuration, database directory, encryption/CSRF keys, identity providers and mail transport are trusted. Project routes are trusted and run in-process with full Node access by default; `sandbox: true` opts a route into the isolated QuickJS/WASM worker pool instead. Neither trusted nor sandboxed project routes gain authority to load auth's own host modules — that boundary is enforced by the host-file/operator-registration mechanism below, independent of a route's own `sandbox` setting. Core extension activation requires an explicitly supplied registry and a reviewed exact project revision pin. Never turn revision inspection into automatic approval.
8
8
 
9
9
  Auth is Node/SQLite only. It refuses unpatched SQLite versions and requires private database files. Keep the database, WAL/SHM, backups, operator modules and key files outside the application project and inaccessible to guest filesystem access. Do not run the host as a shared hostile operating-system user. Filesystem permission and symlink checks do not defend against an attacker who already controls the operator account or its parent directories.
10
10
 
package/THREAT-MODEL.md CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  ## Scope and assets
4
4
 
5
- This package is a trusted Node host service. Application server code remains
6
- untrusted WASM under the core runtime's capability model. The auth service protects
5
+ This package is a trusted Node host service. Under the core runtime's current
6
+ capability model, application `function`/`middleware` routes run trusted and
7
+ in-process with full Node access by default; a route opts into isolated
8
+ QuickJS/WASM execution only by declaring `sandbox: true`. The auth service protects
7
9
  account ownership, verified identifiers, credential material, sessions, factor and
8
10
  recovery proofs, operator role grants, audit history and private profile data.
9
11
  SQLite files and backups contain sensitive account data: file permissions are a
@@ -0,0 +1,8 @@
1
+ import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
2
+ import type { AuthService } from './auth-core.ts';
3
+ import type { AuthChallenge } from './challenge.ts';
4
+ import { AuthHttp } from './auth-ui.ts';
5
+ import type { UiHost } from './auth-ui.ts';
6
+ import type { PresentationContext } from './presentation.ts';
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?: AuthChallenge, ui?: UiHost): (request: ExtensionRequest, presentation?: PresentationContext) => Promise<import("./auth-ui.ts").AuthHttpResponse | undefined>;
@@ -0,0 +1,74 @@
1
+ import { AuthHttp, AuthHttpError, jsonResponse, screenResponse, wantsJson } from "./auth-ui.js";
2
+ import { isHoneypotFilled } from "./registration.js";
3
+ /** Entry requests only: callback and code/token redemption keep their own bound proofs. */
4
+ export function createAbuseGuard(service, http, mount, challenge, ui) {
5
+ const policy = service.getAbusePolicy();
6
+ if (policy?.challengeAfter !== undefined && !challenge)
7
+ throw new Error('Challenge policy requires an operator verifier');
8
+ let active = 0;
9
+ return async (request, presentation) => {
10
+ const path = request.path.slice(mount.length), signup = path === '/register' || path === '/signup/begin';
11
+ const entry = ['/login', '/register', '/signup/begin', '/forgot-password', '/send-email-code', '/recover-factor', '/passkeys/login/options'].includes(path) || /^\/providers\/[a-z][a-z0-9-]{0,31}\/start$/.test(path);
12
+ if (request.method !== 'POST' || !entry)
13
+ return;
14
+ if (request.body.byteLength > 16384)
15
+ throw new AuthHttpError(413, 'Request body too large');
16
+ let fields = {};
17
+ const type = request.headers.get('content-type')?.split(';')[0];
18
+ try {
19
+ const raw = new TextDecoder('utf-8', { fatal: true }).decode(request.body);
20
+ if (type === 'application/json') {
21
+ const value = JSON.parse(raw);
22
+ if (!value || typeof value !== 'object' || Array.isArray(value))
23
+ throw new Error();
24
+ fields = value;
25
+ }
26
+ else if (type === 'application/x-www-form-urlencoded') {
27
+ const entries = [...new URLSearchParams(raw)];
28
+ if (new Set(entries.map(([key]) => key)).size !== entries.length)
29
+ throw new Error();
30
+ fields = Object.fromEntries(entries);
31
+ }
32
+ else
33
+ throw new Error();
34
+ }
35
+ catch {
36
+ throw new AuthHttpError(400, 'Invalid authentication request');
37
+ }
38
+ if (Object.keys(fields).length > 64 || fields.csrf !== undefined && typeof fields.csrf !== 'string' || fields.challengeToken !== undefined && (typeof fields.challengeToken !== 'string' || fields.challengeToken.length > 2048))
39
+ throw new AuthHttpError(400, 'Invalid authentication request');
40
+ http.verify(request, { csrf: typeof fields.csrf === 'string' ? fields.csrf : '' });
41
+ if (signup && isHoneypotFilled(fields.website))
42
+ return jsonResponse(202, { message: 'Registration request received.' });
43
+ if (!policy)
44
+ return;
45
+ const admission = await service.admitAuthRequest({ client: request.client, ...(signup ? { signupEmail: typeof fields.email === 'string' ? fields.email : '' } : {}) });
46
+ if (!admission.challengeRequired)
47
+ return;
48
+ let passed = false;
49
+ if (challenge && typeof fields.challengeToken === 'string' && fields.challengeToken && request.client && active < 32) {
50
+ active++;
51
+ const controller = new AbortController();
52
+ let timer;
53
+ const verification = Promise.resolve().then(() => challenge.verify({ token: fields.challengeToken, client: request.client, signal: controller.signal }));
54
+ void verification.finally(() => { active--; }).catch(() => { });
55
+ try {
56
+ passed = await Promise.race([verification, new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(false); }, 5000); })]);
57
+ }
58
+ catch {
59
+ passed = false;
60
+ }
61
+ finally {
62
+ if (timer)
63
+ clearTimeout(timer);
64
+ }
65
+ }
66
+ if (passed !== true) {
67
+ const source = 'Challenge required. Return to the form and try again.', message = presentation?.textSource(source) ?? source;
68
+ if (wantsJson(request))
69
+ return jsonResponse(403, { error: message, challengeRequired: true });
70
+ const retry = signup ? '/signup' : path === '/forgot-password' || path === '/recover-factor' ? path : path === '/send-email-code' ? '/email-code' : '/login';
71
+ return screenResponse('Verification required', { name: 'auth/status', view: { alert: true, message, href: mount + retry, label: presentation?.textSource('Try again') ?? 'Try again' } }, { status: 403, presentation, ui });
72
+ }
73
+ };
74
+ }
@@ -0,0 +1,5 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { AuthAbusePolicy } from './abuse.ts';
3
+ export declare function abuseOperation(operation: string, args: Record<string, unknown>, db: DatabaseSync, policy: AuthAbusePolicy | undefined, fail: (status: number, code: string) => never): {
4
+ value: unknown;
5
+ } | undefined;
@@ -0,0 +1,40 @@
1
+ export function abuseOperation(operation, args, db, policy, fail) {
2
+ if (!operation.startsWith('abuse'))
3
+ return;
4
+ const now = Number(args.now), key = String(args.key);
5
+ const ensure = (count = 1) => { db.prepare('DELETE FROM auth_abuse WHERE key IN (SELECT key FROM auth_abuse WHERE expires<=? LIMIT 1000)').run(now); if (Number(db.prepare('SELECT count(*) AS n FROM auth_abuse').get()?.n) + count > 100000)
6
+ fail(503, 'auth_capacity_reached'); };
7
+ if (operation === 'abuseBackoffCheck') {
8
+ const row = db.prepare('SELECT blocked_until FROM auth_abuse WHERE key=? AND expires>?').get(key, now);
9
+ return { value: Boolean(row && Number(row.blocked_until) > now) };
10
+ }
11
+ if (operation === 'abuseFailure') {
12
+ const config = policy?.passwordBackoff;
13
+ if (!config)
14
+ return { value: undefined };
15
+ const row = db.prepare('SELECT count FROM auth_abuse WHERE key=? AND expires>?').get(key, now);
16
+ if (!row)
17
+ ensure();
18
+ const count = Math.min(64, Number(row?.count ?? 0) + 1), delay = count < config.threshold ? 0 : Math.min(config.maxDelayMs, config.initialDelayMs * 2 ** Math.min(30, count - config.threshold));
19
+ db.prepare('INSERT INTO auth_abuse VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET count=excluded.count,expires=excluded.expires,blocked_until=excluded.blocked_until').run(key, count, now + config.resetAfterMs, now + delay);
20
+ return { value: undefined };
21
+ }
22
+ if (operation === 'abuseAdmit') {
23
+ const limits = args.limits;
24
+ let challengeRequired = false;
25
+ const rows = limits.map(item => ({ item, row: db.prepare('SELECT count,expires FROM auth_abuse WHERE key=? AND expires>?').get(item.key, now) }));
26
+ if (rows.some(({ item, row }) => Number(row?.count ?? 0) >= item.limit))
27
+ fail(429, 'auth_rate_limited');
28
+ const added = rows.filter(({ row }) => !row).length;
29
+ if (added)
30
+ ensure(added);
31
+ for (const { item, row } of rows) {
32
+ const count = Number(row?.count ?? 0) + 1;
33
+ db.prepare('INSERT INTO auth_abuse VALUES(?,?,?,0) ON CONFLICT(key) DO UPDATE SET count=excluded.count,expires=excluded.expires,blocked_until=0').run(item.key, count, row ? Number(row.expires) : now + item.windowMs);
34
+ if (item.challengeAfter !== undefined && count > item.challengeAfter)
35
+ challengeRequired = true;
36
+ }
37
+ return { value: { challengeRequired } };
38
+ }
39
+ return;
40
+ }
@@ -0,0 +1,27 @@
1
+ export interface VelocityLimit {
2
+ limit: number;
3
+ windowMs: number;
4
+ }
5
+ export interface AuthAbuseOptions {
6
+ passwordBackoff?: {
7
+ threshold?: number;
8
+ initialDelayMs?: number;
9
+ maxDelayMs?: number;
10
+ resetAfterMs?: number;
11
+ };
12
+ client?: VelocityLimit;
13
+ signupClient?: VelocityLimit;
14
+ signupDomain?: VelocityLimit;
15
+ challengeAfter?: number;
16
+ }
17
+ export interface AuthAbusePolicy extends Omit<AuthAbuseOptions, 'passwordBackoff'> {
18
+ passwordBackoff?: {
19
+ threshold: number;
20
+ initialDelayMs: number;
21
+ maxDelayMs: number;
22
+ resetAfterMs: number;
23
+ };
24
+ }
25
+ export declare function normalizeAbusePolicy(value: AuthAbuseOptions | undefined): AuthAbusePolicy | undefined;
26
+ /** Pseudonymous, bounded counter keys. No raw address, email or domain is stored. */
27
+ export declare const abuseKey: (scope: string, value: string) => string;
package/dist/abuse.js ADDED
@@ -0,0 +1,34 @@
1
+ import { createHash } from 'node:crypto';
2
+ const integer = (value, min, max) => typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max;
3
+ export function normalizeAbusePolicy(value) {
4
+ if (value === undefined)
5
+ return;
6
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['passwordBackoff', 'client', 'signupClient', 'signupDomain', 'challengeAfter'].includes(key)))
7
+ throw new Error('Invalid abuse policy');
8
+ const policy = {};
9
+ for (const name of ['client', 'signupClient', 'signupDomain']) {
10
+ const item = value[name];
11
+ if (item !== undefined) {
12
+ if (!item || Object.keys(item).some(key => !['limit', 'windowMs'].includes(key)) || !integer(item.limit, 1, 100000) || !integer(item.windowMs, 1000, 86400000))
13
+ throw new Error('Invalid velocity limit');
14
+ policy[name] = { limit: item.limit, windowMs: item.windowMs };
15
+ }
16
+ }
17
+ if (value.passwordBackoff !== undefined) {
18
+ const item = value.passwordBackoff;
19
+ if (!item || Object.keys(item).some(key => !['threshold', 'initialDelayMs', 'maxDelayMs', 'resetAfterMs'].includes(key)))
20
+ throw new Error('Invalid backoff policy');
21
+ const backoff = { threshold: item.threshold ?? 5, initialDelayMs: item.initialDelayMs ?? 1000, maxDelayMs: item.maxDelayMs ?? 900000, resetAfterMs: item.resetAfterMs ?? 86400000 };
22
+ if (!integer(backoff.threshold, 1, 20) || !integer(backoff.initialDelayMs, 100, 60000) || !integer(backoff.maxDelayMs, backoff.initialDelayMs, 86400000) || !integer(backoff.resetAfterMs, backoff.maxDelayMs, 604800000))
23
+ throw new Error('Invalid backoff policy');
24
+ policy.passwordBackoff = backoff;
25
+ }
26
+ if (value.challengeAfter !== undefined) {
27
+ if (!policy.client || !integer(value.challengeAfter, 1, policy.client.limit - 1))
28
+ throw new Error('Challenge threshold requires a client limit');
29
+ policy.challengeAfter = value.challengeAfter;
30
+ }
31
+ return Object.freeze(policy);
32
+ }
33
+ /** Pseudonymous, bounded counter keys. No raw address, email or domain is stored. */
34
+ export const abuseKey = (scope, value) => createHash('sha256').update('urlcode-auth-abuse:' + scope + '\0' + value).digest('hex');
@@ -0,0 +1,83 @@
1
+ import type { AuthStore } from './auth-store.ts';
2
+ export type AdminAccountAction = 'verify-email' | 'force-password-reset' | 'schedule-deletion' | 'cancel-deletion' | 'remove-passkey' | 'remove-external' | 'request-email-change' | 'assign-roles' | 'resend-verification';
3
+ export type AdminAccountDelivery = {
4
+ kind: 'token';
5
+ accountId: string;
6
+ email: string;
7
+ locale?: string;
8
+ purpose: 'verify-email' | 'reset-password' | 'cancel-deletion' | 'verify-email-change' | 'cancel-email-change';
9
+ token: string;
10
+ } | {
11
+ kind: 'notice';
12
+ accountId: string;
13
+ email: string;
14
+ locale?: string;
15
+ action: AdminAccountAction;
16
+ };
17
+ export type AdminAccountRequest = {
18
+ actorToken: string;
19
+ accountIds: string[];
20
+ reason: string;
21
+ } & ({
22
+ action: 'assign-roles';
23
+ roles: string[];
24
+ } | {
25
+ action: 'request-email-change';
26
+ email: string;
27
+ } | {
28
+ action: 'remove-passkey';
29
+ credentialId: string;
30
+ } | {
31
+ action: 'remove-external';
32
+ externalId: string;
33
+ } | {
34
+ action: 'verify-email' | 'force-password-reset' | 'schedule-deletion' | 'cancel-deletion' | 'resend-verification';
35
+ });
36
+ export interface AdminAuthenticationMethods {
37
+ accountId: string;
38
+ password: boolean;
39
+ totp: boolean;
40
+ passkeys: {
41
+ id: string;
42
+ secondFactor: boolean;
43
+ added?: number;
44
+ lastUsed?: number;
45
+ }[];
46
+ external: {
47
+ id: string;
48
+ provider: string;
49
+ added?: number;
50
+ lastUsed?: number;
51
+ }[];
52
+ }
53
+ export interface AdminAccountService {
54
+ inspectAccountAuthentication(input: {
55
+ actorToken: string;
56
+ accountId: string;
57
+ reason: string;
58
+ }): Promise<AdminAuthenticationMethods>;
59
+ stageAccountAdministration(input: AdminAccountRequest): Promise<{
60
+ operationId: string;
61
+ deliveries: AdminAccountDelivery[];
62
+ }>;
63
+ completeAccountAdministration(input: {
64
+ actorToken: string;
65
+ operationId: string;
66
+ }): Promise<{
67
+ affected: number;
68
+ }>;
69
+ cancelAccountAdministration(input: {
70
+ actorToken: string;
71
+ operationId: string;
72
+ }): Promise<void>;
73
+ }
74
+ interface Dependencies {
75
+ store: AuthStore;
76
+ check(): void;
77
+ now(): number;
78
+ roles: Record<string, string[]>;
79
+ permittedEmail(value: string): string;
80
+ }
81
+ /** Operator-side service. Only declared delivery callbacks receive staged tokens. */
82
+ export declare function createAdminAccountOperations(deps: Dependencies): AdminAccountService;
83
+ export {};
@@ -0,0 +1,50 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { AuthError } from "./auth-store.js";
3
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
4
+ const valid = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
5
+ /** Operator-side service. Only declared delivery callbacks receive staged tokens. */
6
+ export function createAdminAccountOperations(deps) {
7
+ const fail = () => { throw new AuthError(400, 'invalid_administration'); };
8
+ const actor = (value) => { deps.check(); if (!/^[A-Za-z0-9_-]{43}$/.test(value))
9
+ fail(); return hash(value); };
10
+ const reason = (value) => { if (!valid(value, 256) || !value.trim())
11
+ fail(); return value.trim(); };
12
+ return {
13
+ async inspectAccountAuthentication(input) { const actorHash = actor(input.actorToken); if (!valid(input.accountId, 256))
14
+ fail(); return deps.store.call('adminAccountInspect', { hash: actorHash, accountId: input.accountId, reason: reason(input.reason), now: deps.now() }); },
15
+ async stageAccountAdministration(input) {
16
+ const actorHash = actor(input.actorToken), why = reason(input.reason);
17
+ if (!Array.isArray(input.accountIds) || input.accountIds.length < 1 || input.accountIds.length > 50 || new Set(input.accountIds).size !== input.accountIds.length || input.accountIds.some(value => !valid(value, 256)))
18
+ fail();
19
+ if (!['verify-email', 'force-password-reset', 'schedule-deletion', 'cancel-deletion', 'remove-passkey', 'remove-external', 'request-email-change', 'assign-roles', 'resend-verification'].includes(input.action))
20
+ fail();
21
+ if (input.accountIds.length > 1 && !['assign-roles', 'resend-verification'].includes(input.action))
22
+ fail();
23
+ const parameters = {};
24
+ if (input.action === 'assign-roles') {
25
+ if (!Array.isArray(input.roles) || input.roles.length < 1 || input.roles.length > 32 || new Set(input.roles).size !== input.roles.length || input.roles.some(value => !Object.hasOwn(deps.roles, value)))
26
+ fail();
27
+ parameters.roles = [...input.roles];
28
+ }
29
+ if (input.action === 'request-email-change')
30
+ parameters.email = deps.permittedEmail(input.email);
31
+ if (input.action === 'remove-passkey') {
32
+ if (!valid(input.credentialId, 1024))
33
+ fail();
34
+ parameters.credentialId = input.credentialId;
35
+ }
36
+ if (input.action === 'remove-external') {
37
+ if (!/^[a-f0-9]{64}$/.test(input.externalId))
38
+ fail();
39
+ parameters.externalId = input.externalId;
40
+ }
41
+ const raw = new Map(input.accountIds.map(id => [id, { primary: randomBytes(32).toString('base64url'), secondary: randomBytes(32).toString('base64url') }]));
42
+ const result = await deps.store.call('adminAccountStage', { hash: actorHash, operationId: randomUUID(), accountIds: input.accountIds, action: input.action, reason: why, parameters, tokens: input.accountIds.map(accountId => ({ accountId, primaryHash: hash(raw.get(accountId).primary), secondaryHash: hash(raw.get(accountId).secondary) })), now: deps.now() });
43
+ return { operationId: result.operationId, deliveries: result.deliveries.map(delivery => delivery.kind === 'notice' ? delivery : { kind: 'token', accountId: delivery.accountId, email: delivery.email, ...(delivery.locale ? { locale: delivery.locale } : {}), purpose: delivery.purpose, token: raw.get(delivery.accountId)[delivery.tokenSlot] }) };
44
+ },
45
+ async completeAccountAdministration(input) { const actorHash = actor(input.actorToken); if (!valid(input.operationId, 256))
46
+ fail(); return deps.store.call('adminAccountComplete', { hash: actorHash, operationId: input.operationId, now: deps.now() }); },
47
+ async cancelAccountAdministration(input) { const actorHash = actor(input.actorToken); if (!valid(input.operationId, 256))
48
+ fail(); await deps.store.call('adminAccountCancel', { hash: actorHash, operationId: input.operationId, now: deps.now() }); },
49
+ };
50
+ }
@@ -0,0 +1,22 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { AuthRecord, SessionRecord } from './auth-store.ts';
3
+ interface Context {
4
+ db: DatabaseSync;
5
+ now: number;
6
+ deletionGraceMs: number;
7
+ roles: Record<string, string[]>;
8
+ account(id: string): AuthRecord | null;
9
+ fresh(hash: string, now: number): {
10
+ user: AuthRecord;
11
+ session: SessionRecord;
12
+ };
13
+ isRestricted(user: AuthRecord): boolean;
14
+ save(user: AuthRecord): void;
15
+ audit(actor: string, action: string, subject: string, now: number, reason?: string): void;
16
+ fail(status: number, code: string): never;
17
+ }
18
+ /** All calls are inside the existing store revision/active-key checked transaction. */
19
+ export declare function adminAccountOperation(operation: string, args: Record<string, unknown>, context: Context): {
20
+ value: unknown;
21
+ } | undefined;
22
+ export {};