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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/abuse-http.d.ts +8 -0
  2. package/dist/abuse-http.js +74 -0
  3. package/dist/abuse-store.d.ts +5 -0
  4. package/dist/abuse-store.js +40 -0
  5. package/dist/abuse.d.ts +27 -0
  6. package/dist/abuse.js +34 -0
  7. package/dist/admin-account-operations.d.ts +83 -0
  8. package/dist/admin-account-operations.js +50 -0
  9. package/dist/admin-account-store.d.ts +22 -0
  10. package/dist/admin-account-store.js +185 -0
  11. package/dist/auth-baseline.d.ts +30 -0
  12. package/dist/auth-baseline.js +153 -0
  13. package/dist/auth-core.d.ts +655 -0
  14. package/dist/auth-core.js +1066 -0
  15. package/dist/auth-flows.d.ts +30 -0
  16. package/dist/auth-flows.js +228 -0
  17. package/dist/auth-signup.d.ts +11 -0
  18. package/dist/auth-signup.js +145 -0
  19. package/dist/auth-store.d.ts +81 -0
  20. package/dist/auth-store.js +1601 -0
  21. package/dist/auth-templates.d.ts +13 -0
  22. package/dist/auth-templates.js +74 -0
  23. package/dist/auth-ui.d.ts +106 -0
  24. package/dist/auth-ui.js +205 -0
  25. package/dist/auth.d.ts +49 -0
  26. package/dist/auth.js +503 -0
  27. package/dist/backup.d.ts +18 -0
  28. package/dist/backup.js +121 -0
  29. package/dist/challenge-ui.d.ts +11 -0
  30. package/dist/challenge-ui.js +18 -0
  31. package/dist/challenge.d.ts +21 -0
  32. package/dist/challenge.js +65 -0
  33. package/dist/cli.d.ts +2 -0
  34. package/dist/cli.js +137 -0
  35. package/dist/deployment-check.d.ts +16 -0
  36. package/dist/deployment-check.js +41 -0
  37. package/dist/disposable-domain-data.d.ts +1 -0
  38. package/dist/disposable-domain-data.js +8886 -0
  39. package/dist/disposable-domains.d.ts +3 -0
  40. package/dist/disposable-domains.js +17 -0
  41. package/dist/email-copy.d.ts +114 -0
  42. package/dist/email-copy.js +58 -0
  43. package/dist/factor-recovery.d.ts +46 -0
  44. package/dist/factor-recovery.js +71 -0
  45. package/dist/index.d.ts +42 -0
  46. package/dist/index.js +18 -0
  47. package/dist/manual-recovery-store.d.ts +25 -0
  48. package/dist/manual-recovery-store.js +129 -0
  49. package/dist/manual-recovery.d.ts +87 -0
  50. package/dist/manual-recovery.js +35 -0
  51. package/dist/oidc.d.ts +31 -0
  52. package/dist/oidc.js +54 -0
  53. package/dist/passkeys.d.ts +24 -0
  54. package/dist/passkeys.js +29 -0
  55. package/dist/password-policy.d.ts +7 -0
  56. package/dist/password-policy.js +72 -0
  57. package/dist/presentation.d.ts +15 -0
  58. package/dist/presentation.js +458 -0
  59. package/dist/presets.d.ts +18 -0
  60. package/dist/presets.js +17 -0
  61. package/dist/providers.d.ts +13 -0
  62. package/dist/providers.js +15 -0
  63. package/dist/registration.d.ts +45 -0
  64. package/dist/registration.js +130 -0
  65. package/dist/scaffold.d.ts +44 -0
  66. package/dist/scaffold.js +212 -0
  67. package/dist/second-factor-flows.d.ts +28 -0
  68. package/dist/second-factor-flows.js +76 -0
  69. package/dist/senders.d.ts +86 -0
  70. package/dist/senders.js +153 -0
  71. package/dist/user-query.d.ts +28 -0
  72. package/dist/user-query.js +81 -0
  73. package/package.json +1 -1
package/dist/backup.js ADDED
@@ -0,0 +1,121 @@
1
+ import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
2
+ import { DatabaseSync, backup } from 'node:sqlite';
3
+ import { open, lstat, stat, realpath, mkdtemp, rm, link } from 'node:fs/promises';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ function outside(project, path) { const rel = relative(project, path); return Boolean(rel && (isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep))); }
6
+ function patched(version) { const [a = 0, b = 0, c = 0] = version.split('.').map(Number); return a > 3 || a === 3 && (b > 51 || b === 51 && c >= 3 || b === 50 && c >= 7 || b === 44 && c >= 6); }
7
+ async function snapshot(sourceInput, destinationInput, projectRoot) {
8
+ if (!isMainThread || !patched(process.versions.sqlite || ''))
9
+ throw new Error('Backup requires a patched SQLite host');
10
+ if (!isAbsolute(sourceInput) || !isAbsolute(destinationInput) || !isAbsolute(projectRoot))
11
+ throw new Error('Backup paths must be absolute');
12
+ const project = await realpath(projectRoot), sourceInfo = await lstat(sourceInput), source = await realpath(sourceInput), parent = await realpath(dirname(destinationInput)), destination = join(parent, basename(destinationInput));
13
+ if (!outside(project, source) || !outside(project, destination))
14
+ throw new Error('Auth backups and databases must be outside the project');
15
+ if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink() || sourceInfo.nlink !== 1 || process.platform !== 'win32' && (sourceInfo.mode & 0o077) !== 0 || sourceInfo.size > 1073741824)
16
+ throw new Error('Backup source must be a private regular database below 1 GiB');
17
+ const parentInfo = await stat(parent);
18
+ if (!parentInfo.isDirectory() || process.platform !== 'win32' && (parentInfo.mode & 0o077) !== 0)
19
+ throw new Error('Backup destination directory must be private');
20
+ if (resolve(source) === resolve(destination))
21
+ throw new Error('Backup cannot replace its source');
22
+ try {
23
+ await lstat(destination);
24
+ throw new Error('Backup destination already exists');
25
+ }
26
+ catch (error) {
27
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
28
+ throw error;
29
+ }
30
+ const temporary = await mkdtemp(join(parent, '.urlcode-backup-')), file = join(temporary, 'snapshot.sqlite');
31
+ let worker;
32
+ try {
33
+ const created = await open(file, 'wx', 0o600);
34
+ await created.close();
35
+ worker = new Worker(new URL(import.meta.url), { workerData: { urlcodeAuthBackup: true, source, destination: file }, env: {}, execArgv: [], stdout: true, stderr: true, resourceLimits: { maxOldGenerationSizeMb: 32 } });
36
+ worker.stdout.resume();
37
+ worker.stderr.resume();
38
+ const active = worker;
39
+ await new Promise((accept, reject) => {
40
+ let done = false;
41
+ const finish = (error) => {
42
+ if (done)
43
+ return;
44
+ done = true;
45
+ clearTimeout(timer);
46
+ if (error)
47
+ reject(error);
48
+ else
49
+ accept();
50
+ };
51
+ const timer = setTimeout(() => { void active.terminate(); finish(new Error('Backup exceeded time limit')); }, 30000);
52
+ active.once('message', (message) => finish(message.ok ? undefined : new Error('Database backup validation failed')));
53
+ active.once('error', () => finish(new Error('Database backup failed')));
54
+ active.once('exit', code => {
55
+ if (!done)
56
+ finish(new Error(`Database backup worker exited (${code})`));
57
+ });
58
+ });
59
+ await active.terminate();
60
+ worker = undefined;
61
+ const copied = await lstat(file);
62
+ if (!copied.isFile() || copied.size > 1073741824)
63
+ throw new Error('Backup exceeds size limit');
64
+ const handle = await open(file, 'r');
65
+ try {
66
+ await handle.sync();
67
+ }
68
+ finally {
69
+ await handle.close();
70
+ }
71
+ // Linking is atomic and refuses any existing destination, including a raced-in symlink.
72
+ await link(file, destination);
73
+ await rm(file);
74
+ const directory = await open(parent, 'r');
75
+ try {
76
+ await directory.sync();
77
+ }
78
+ finally {
79
+ await directory.close();
80
+ }
81
+ return { format: 'urlcode-auth-sqlite-v1', bytes: copied.size };
82
+ }
83
+ finally {
84
+ await worker?.terminate();
85
+ await rm(temporary, { recursive: true, force: true });
86
+ }
87
+ }
88
+ /** Consistent online SQLite snapshot, including committed WAL pages. Never copies a live database file. */
89
+ export function createBackup(options) { return snapshot(options.database, options.destination, options.projectRoot); }
90
+ /** Restores to a new isolated path only. Operator retains matching keys and static configuration separately. */
91
+ export function restoreBackup(options) { return snapshot(options.backup, options.destination, options.projectRoot); }
92
+ if (!isMainThread && workerData?.urlcodeAuthBackup) {
93
+ const source = new DatabaseSync(String(workerData.source), { readOnly: true, allowExtension: false });
94
+ try {
95
+ source.exec('PRAGMA trusted_schema=OFF; PRAGMA query_only=ON; PRAGMA busy_timeout=1000;');
96
+ if (source.prepare('PRAGMA user_version').get()?.user_version !== 1 || source.prepare('PRAGMA application_id').get()?.application_id !== 1430345032)
97
+ throw new Error('Unsupported auth database');
98
+ const schema = source.prepare("SELECT type,name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'").all();
99
+ if (schema.some(row => !['table', 'index'].includes(String(row.type)) || !String(row.name).startsWith('auth_')))
100
+ throw new Error('Unexpected database schema');
101
+ await backup(source, String(workerData.destination), { rate: 128 });
102
+ const check = new DatabaseSync(String(workerData.destination), { readOnly: true, allowExtension: false });
103
+ try {
104
+ check.exec('PRAGMA trusted_schema=OFF; PRAGMA query_only=ON;');
105
+ const integrity = check.prepare('PRAGMA integrity_check').all();
106
+ if (integrity.length !== 1 || Object.values(integrity[0])[0] !== 'ok' || check.prepare('PRAGMA foreign_key_check').all().length)
107
+ throw new Error('Invalid backup integrity');
108
+ }
109
+ finally {
110
+ check.close();
111
+ }
112
+ parentPort.postMessage({ ok: true });
113
+ }
114
+ catch {
115
+ parentPort.postMessage({ ok: false });
116
+ }
117
+ finally {
118
+ source.close();
119
+ parentPort.close();
120
+ }
121
+ }
@@ -0,0 +1,11 @@
1
+ export interface TurnstileWidget {
2
+ siteKey: string;
3
+ action: 'auth';
4
+ }
5
+ export declare const turnstileOrigin = "https://challenges.cloudflare.com";
6
+ export declare const turnstileScript: string;
7
+ /** Only typed operator configuration can add this fixed third-party widget to trusted package forms. */
8
+ export declare function addTurnstileWidgets(markup: string, widget?: TurnstileWidget): {
9
+ markup: string;
10
+ enabled: boolean;
11
+ };
@@ -0,0 +1,18 @@
1
+ export const turnstileOrigin = 'https://challenges.cloudflare.com';
2
+ export const turnstileScript = turnstileOrigin + '/turnstile/v0/api.js';
3
+ /** Only typed operator configuration can add this fixed third-party widget to trusted package forms. */
4
+ export function addTurnstileWidgets(markup, widget) {
5
+ if (!widget)
6
+ return { markup, enabled: false };
7
+ if (typeof widget.siteKey !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(widget.siteKey) || widget.action !== 'auth' || Object.keys(widget).some(key => !['siteKey', 'action'].includes(key)))
8
+ throw new Error('Invalid challenge widget');
9
+ let count = 0;
10
+ const result = markup.replace(/<form\b([^>]*)>/gi, (tag, attributes) => {
11
+ if (!/(?:^|\s)method=(?:"post"|'post')(?=\s|$)/i.test(attributes))
12
+ return tag;
13
+ if (++count > 16)
14
+ throw new Error('Too many challenge forms');
15
+ return tag + `<div class="cf-turnstile" data-sitekey="${widget.siteKey}" data-action="auth" data-response-field-name="challengeToken"></div>`;
16
+ });
17
+ return { markup: result, enabled: count > 0 };
18
+ }
@@ -0,0 +1,21 @@
1
+ export interface AuthChallengeInput {
2
+ token: string;
3
+ client: string;
4
+ signal: AbortSignal;
5
+ }
6
+ export interface AuthChallenge {
7
+ verify(input: AuthChallengeInput): Promise<boolean>;
8
+ widget?: {
9
+ siteKey: string;
10
+ action: 'auth';
11
+ };
12
+ }
13
+ export interface TurnstileChallengeOptions {
14
+ secret: string;
15
+ siteKey: string;
16
+ hostname: string;
17
+ fetch?: typeof fetch;
18
+ timeoutMs?: number;
19
+ }
20
+ /** Fixed upstream only. Siteverify tokens are single-use; never cache a verdict. */
21
+ export declare function createTurnstileChallenge(options: TurnstileChallengeOptions): AuthChallenge;
@@ -0,0 +1,65 @@
1
+ import { isIP } from 'node:net';
2
+ /** Fixed upstream only. Siteverify tokens are single-use; never cache a verdict. */
3
+ export function createTurnstileChallenge(options) {
4
+ const key = (value) => typeof value === 'string' && /^[A-Za-z0-9_-]{10,256}$/.test(value);
5
+ let hostname;
6
+ try {
7
+ hostname = new URL('https://' + options.hostname).hostname;
8
+ }
9
+ catch {
10
+ throw new Error('Invalid Turnstile hostname');
11
+ }
12
+ const timeout = options.timeoutMs ?? 5000;
13
+ if (!key(options.secret) || !key(options.siteKey) || hostname !== options.hostname || !hostname || /[\/:@?#]/.test(hostname) || !Number.isInteger(timeout) || timeout < 10 || timeout > 5000)
14
+ throw new Error('Invalid Turnstile configuration');
15
+ let active = 0;
16
+ return Object.freeze({ widget: Object.freeze({ siteKey: options.siteKey, action: 'auth' }), async verify(input) {
17
+ if (input.signal.aborted || !isIP(input.client) || typeof input.token !== 'string' || input.token.length < 1 || input.token.length > 2048 || /[\x00-\x20\x7f]/.test(input.token) || active >= 32)
18
+ return false;
19
+ active++;
20
+ const controller = new AbortController();
21
+ let timer;
22
+ const pending = (async () => {
23
+ const response = await (options.fetch ?? fetch)('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: new URLSearchParams({ secret: options.secret, response: input.token, remoteip: input.client }), signal: AbortSignal.any([input.signal, controller.signal]) });
24
+ if (response.status !== 200 || Number(response.headers.get('content-length') || 0) > 8192) {
25
+ await response.body?.cancel();
26
+ return false;
27
+ }
28
+ const reader = response.body?.getReader();
29
+ if (!reader)
30
+ return false;
31
+ const chunks = [];
32
+ let size = 0;
33
+ try {
34
+ for (;;) {
35
+ const item = await reader.read();
36
+ if (item.done)
37
+ break;
38
+ size += item.value.byteLength;
39
+ if (size > 8192)
40
+ return false;
41
+ chunks.push(item.value);
42
+ }
43
+ }
44
+ finally {
45
+ await reader.cancel().catch(() => { });
46
+ reader.releaseLock();
47
+ }
48
+ const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
49
+ const timestamp = typeof value.challenge_ts === 'string' ? Date.parse(value.challenge_ts) : NaN;
50
+ return value.success === true && value.hostname === hostname && value.action === 'auth' && Number.isFinite(timestamp) && timestamp <= Date.now() + 60000 && timestamp >= Date.now() - 300000;
51
+ })();
52
+ // A verifier that ignores cancellation retains its slot instead of allowing unbounded work.
53
+ void pending.finally(() => { active--; }).catch(() => { });
54
+ try {
55
+ return await Promise.race([pending, new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(false); }, timeout); })]);
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ finally {
61
+ if (timer)
62
+ clearTimeout(timer);
63
+ }
64
+ } });
65
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ import { verifyDeployment } from "./deployment-check.js";
11
+ import { parseArgs } from 'node:util';
12
+ import { isAbsolute } from 'node:path';
13
+ import { realpath, stat } from 'node:fs/promises';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { createBackup, restoreBackup } from "./backup.js";
16
+ import { initAuthentication } from "./scaffold.js";
17
+ async function input() {
18
+ const chunks = [];
19
+ let size = 0;
20
+ for await (const part of process.stdin) {
21
+ const bytes = Buffer.from(part);
22
+ size += bytes.length;
23
+ if (size > 1048576)
24
+ throw new Error('Input exceeds limit');
25
+ chunks.push(bytes);
26
+ }
27
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
28
+ if (!value || typeof value !== 'object' || Array.isArray(value))
29
+ throw new Error('Invalid input');
30
+ return value;
31
+ }
32
+ function string(value) {
33
+ if (typeof value !== 'string' || !value)
34
+ throw new Error('Required input missing');
35
+ return value;
36
+ }
37
+ let service;
38
+ try {
39
+ const { values, positionals } = parseArgs({ allowPositionals: true, options: { 'operator-file': { type: 'string' }, directory: { type: 'string' }, help: { type: 'boolean' } } });
40
+ const command = positionals[0];
41
+ if (values.help || !command)
42
+ process.stdout.write('urlcode-auth init --directory NEW_DIRECTORY\nurlcode-auth bootstrap|users|sessions|revoke|audit|import|rotate-key|purge|cleanup|configuration|doctor|validate --operator-file /absolute/operator/auth.mjs\nurlcode-auth auth-baseline (offline synthetic checks)\nurlcode-auth verify-deployment (JSON origin/authMount on stdin)\nurlcode-auth backup|restore (JSON paths on stdin)\nSecrets and operation data use bounded JSON stdin, never argv. Operator module default-exports an AuthService.\n');
43
+ else {
44
+ if (positionals.length !== 1)
45
+ throw new Error('Invalid command');
46
+ let output;
47
+ if (command === 'init') {
48
+ output = await initAuthentication(string(values.directory));
49
+ }
50
+ else if (command === 'auth-baseline') {
51
+ if (values['operator-file'] || values.directory)
52
+ throw new Error('Baseline accepts no operator files');
53
+ const { runAuthBaseline } = await import("./auth-baseline.js");
54
+ const result = await runAuthBaseline();
55
+ output = result;
56
+ if (!result.passed)
57
+ process.exitCode = 1;
58
+ }
59
+ else if (command === 'verify-deployment') {
60
+ const data = await input();
61
+ const result = await verifyDeployment({ origin: string(data.origin), authMount: string(data.authMount), ...(data.allowDevelopment === true ? { allowDevelopment: true } : {}), ...(data.allowTurnstile === true ? { allowTurnstile: true } : {}) });
62
+ output = result;
63
+ if (!result.passed)
64
+ process.exitCode = 1;
65
+ }
66
+ else if (command === 'backup' || command === 'restore') {
67
+ const data = await input(), destination = string(data.destination), projectRoot = string(data.projectRoot);
68
+ output = command === 'backup' ? await createBackup({ database: string(data.database), destination, projectRoot }) : await restoreBackup({ backup: string(data.backup), destination, projectRoot });
69
+ }
70
+ else {
71
+ if (!['bootstrap', 'users', 'sessions', 'revoke', 'audit', 'import', 'rotate-key', 'purge', 'cleanup', 'configuration', 'doctor', 'validate'].includes(command))
72
+ throw new Error('Invalid command');
73
+ if (!values['operator-file'] || !isAbsolute(values['operator-file']))
74
+ throw new Error('Provide an absolute operator file');
75
+ const file = await realpath(values['operator-file']), info = await stat(file);
76
+ if (!info.isFile() || info.size > 1048576)
77
+ throw new Error('Invalid operator file');
78
+ service = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href))).default;
79
+ if (!service || typeof service.close !== 'function' || typeof service.bootstrapAdmin !== 'function')
80
+ throw new Error('Invalid operator service');
81
+ const data = ['bootstrap', 'sessions', 'revoke', 'import'].includes(command) ? await input() : {};
82
+ if (command === 'bootstrap')
83
+ output = (await service.bootstrapAdmin({ email: string(data.email), password: string(data.password) })).user;
84
+ else if (command === 'users')
85
+ output = await service.listUsers({ limit: 100 });
86
+ else if (command === 'audit')
87
+ output = await service.listAudit({ limit: 100 });
88
+ else if (command === 'rotate-key')
89
+ output = await service.rotateEncryptionKey();
90
+ else if (command === 'purge')
91
+ output = await service.purgeDeleted();
92
+ else if (command === 'cleanup')
93
+ output = await service.cleanup({ limit: 1000 });
94
+ else if (command === 'configuration')
95
+ output = { revision: await service.getConfigurationRevision(), registration: service.getRegistrationMode(), security: service.getSecurityPolicy(), roles: service.getRoles() };
96
+ else if (command === 'validate') {
97
+ const { validateAuthService } = await import("./auth-baseline.js");
98
+ output = await validateAuthService(service);
99
+ }
100
+ else if (command === 'doctor')
101
+ output = { database: 'ready', registration: service.getRegistrationMode(), security: service.getSecurityPolicy(), accounts: (await service.dashboard()).users, liveProviders: 'unverified' };
102
+ else if (command === 'import') {
103
+ if (!Array.isArray(data.users))
104
+ throw new Error('Users array required');
105
+ const users = data.users.map((user) => {
106
+ if (!user || typeof user !== 'object' || Array.isArray(user))
107
+ throw new Error('Invalid user');
108
+ const row = user;
109
+ if (Object.keys(row).some(key => !['email', 'passwordHash', 'emailVerified'].includes(key)) || row.emailVerified !== undefined && typeof row.emailVerified !== 'boolean')
110
+ throw new Error('Invalid user field');
111
+ return { email: string(row.email), passwordHash: string(row.passwordHash), ...(typeof row.emailVerified === 'boolean' ? { emailVerified: row.emailVerified } : {}) };
112
+ });
113
+ output = await service.importUsers(users);
114
+ }
115
+ else if (command === 'sessions')
116
+ output = await service.listSessions(string(data.accountId));
117
+ else {
118
+ await service.revokeSessions(string(data.accountId));
119
+ output = { revoked: true };
120
+ }
121
+ }
122
+ process.stdout.write(JSON.stringify(output) + '\n');
123
+ }
124
+ }
125
+ catch {
126
+ process.stderr.write('Auth operation failed; check command, operator configuration and input.\n');
127
+ process.exitCode = 1;
128
+ }
129
+ finally {
130
+ try {
131
+ await service?.close();
132
+ }
133
+ catch {
134
+ process.stderr.write('Auth cleanup failed.\n');
135
+ process.exitCode = 1;
136
+ }
137
+ }
@@ -0,0 +1,16 @@
1
+ export interface DeploymentCheckOptions {
2
+ origin: string;
3
+ authMount: string;
4
+ allowDevelopment?: boolean;
5
+ allowTurnstile?: boolean;
6
+ }
7
+ export interface DeploymentCheckResult {
8
+ passed: boolean;
9
+ checks: {
10
+ name: string;
11
+ passed: boolean;
12
+ }[];
13
+ liveProviders: 'unverified';
14
+ }
15
+ /** Anonymous, read-only checks. No credentials, redirects, mail sends or account mutations. */
16
+ export declare function verifyDeployment(options: DeploymentCheckOptions, transport?: typeof fetch): Promise<DeploymentCheckResult>;
@@ -0,0 +1,41 @@
1
+ /** Anonymous, read-only checks. No credentials, redirects, mail sends or account mutations. */
2
+ export async function verifyDeployment(options, transport = fetch) {
3
+ const origin = new URL(options.origin);
4
+ if (origin.origin !== options.origin || origin.username || origin.password || !(origin.protocol === 'https:' || options.allowDevelopment === true && origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))
5
+ throw new Error('Canonical HTTPS origin required');
6
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(options.authMount) || options.authMount.length > 256)
7
+ throw new Error('Explicit auth mount required');
8
+ const checks = [];
9
+ for (const [path, expected] of [['/login', 200], ['/account', 401]]) {
10
+ const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 5000);
11
+ const prefix = path === '/login' ? 'login' : 'anonymous-account';
12
+ try {
13
+ const response = await transport(options.origin + options.authMount + path, { method: 'GET', headers: { accept: 'text/html' }, credentials: 'omit', redirect: 'manual', signal: controller.signal });
14
+ const add = (name, passed) => checks.push({ name: prefix + '.' + name, passed });
15
+ add('status', response.status === expected);
16
+ add('no-store', /(?:^|,)\s*no-store\s*(?:,|$)/i.test(response.headers.get('cache-control') ?? ''));
17
+ add('private-referrer', ['no-referrer', 'strict-origin'].includes(response.headers.get('referrer-policy') ?? ''));
18
+ add('nosniff', response.headers.get('x-content-type-options') === 'nosniff');
19
+ const directives = (response.headers.get('content-security-policy') ?? '').split(';').map(item => item.trim().split(/\s+/)).filter(item => item[0]);
20
+ const csp = new Map(directives.map(([name, ...values]) => [name, values.join(' ')]));
21
+ const sources = (name) => (csp.get(name) ?? '').split(/\s+/).filter(Boolean);
22
+ const challengeSource = (source) => options.allowTurnstile === true && source === 'https://challenges.cloudflare.com';
23
+ add('content-security-policy', csp.size === directives.length && csp.get('default-src') === "'none'" && csp.get('base-uri') === "'none'" && csp.get('frame-ancestors') === "'none'" && csp.get('form-action') === "'self'" && sources('script-src').every(source => /^'nonce-[A-Za-z0-9+/]{16,}={0,2}'$/.test(source) || challengeSource(source)) && sources('frame-src').every(source => source === "'none'" || challengeSource(source)) && sources('connect-src').every(source => source === "'none'" || source === "'self'" || challengeSource(source)));
24
+ const cookies = response.headers.getSetCookie();
25
+ add('cookie-policy', cookies.every(cookie => {
26
+ const [pair, ...attributes] = cookie.split(';').map(value => value.trim());
27
+ const attrs = new Map(attributes.map(value => { const at = value.indexOf('='); return at < 0 ? [value.toLowerCase(), ''] : [value.slice(0, at).toLowerCase(), value.slice(at + 1)]; }));
28
+ return pair?.startsWith('__Host-') === true && !pair.startsWith('__Host-urlcode-session=') && attrs.has('secure') && attrs.has('httponly') && attrs.get('path') === '/' && !attrs.has('domain') && ['Strict', 'Lax'].includes(attrs.get('samesite') ?? '');
29
+ }));
30
+ // Cancel without reading account markup, debug bodies or unexpected proxy output.
31
+ await response.body?.cancel();
32
+ }
33
+ catch {
34
+ checks.push({ name: prefix + '.reachable', passed: false });
35
+ }
36
+ finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ return { passed: checks.every(check => check.passed), checks, liveProviders: 'unverified' };
41
+ }
@@ -0,0 +1 @@
1
+ export declare const disposableDomainData: readonly string[];