@acms-subir/core 0.3.10 → 0.3.11
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/CHANGELOG.md +18 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +11 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/theme-authoring.md +6 -0
- package/integration.mjs +39 -0
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +5 -5
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +40 -9
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +6 -3
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- package/src/lib/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +1 -0
- package/src/lib/email/templates.ts +2 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/mobile/http.ts +4 -3
- package/src/lib/mobile/route.ts +41 -4
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +76 -0
- package/src/lib/repo/sql.ts +56 -24
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +12 -1
- package/src/lib/scheduler.ts +7 -0
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/settings.astro +31 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/links.ts +1 -1
- package/src/pages/api/v1/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +103 -16
- package/src/styles/email-settings.css +6 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { runtimeValue } from '../email/config';
|
|
4
|
+
import { backupArchive } from './archive';
|
|
5
|
+
|
|
6
|
+
// Scheduled off-site backups: the scheduler writes the same ZIP as Admin →
|
|
7
|
+
// Backup to object storage every BACKUP_EVERY_HOURS (default 24) and keeps
|
|
8
|
+
// the newest BACKUP_KEEP (default 14). Targets, first match wins:
|
|
9
|
+
// - an R2 bucket bound as BACKUPS (Cloudflare Workers)
|
|
10
|
+
// - any S3-compatible bucket: BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET,
|
|
11
|
+
// BACKUP_S3_ACCESS_KEY_ID, BACKUP_S3_SECRET_ACCESS_KEY, BACKUP_S3_REGION
|
|
12
|
+
// (R2's S3 API, Oracle Object Storage, AWS S3, MinIO…)
|
|
13
|
+
// The archive is built in memory, so on Workers the site (content + media)
|
|
14
|
+
// must fit well inside the isolate's memory limit.
|
|
15
|
+
|
|
16
|
+
export interface BackupTarget {
|
|
17
|
+
describe: string;
|
|
18
|
+
put(key: string, body: Uint8Array): Promise<void>;
|
|
19
|
+
list(prefix: string): Promise<string[]>;
|
|
20
|
+
delete(key: string): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
23
|
+
|
|
24
|
+
const LEASE_MS = 30 * 60_000;
|
|
25
|
+
const RETRY_MS = 15 * 60_000;
|
|
26
|
+
export const BACKUP_NAME = /^acms-backup-\d{8}T\d{6}Z\.zip$/;
|
|
27
|
+
export const backupKey = (prefix: string, now: number) =>
|
|
28
|
+
`${prefix}acms-backup-${new Date(now).toISOString().replace(/[:-]|\.\d{3}/g, '')}.zip`;
|
|
29
|
+
|
|
30
|
+
// ---- S3 (SigV4, path-style: works for R2, Oracle and AWS alike) ----------
|
|
31
|
+
|
|
32
|
+
const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex');
|
|
33
|
+
const hmac = (key: string | Buffer, data: string) => createHmac('sha256', key).update(data).digest();
|
|
34
|
+
const rfc3986 = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
35
|
+
|
|
36
|
+
export interface S3Credentials { accessKeyId: string; secretAccessKey: string; region: string }
|
|
37
|
+
/** SigV4 headers for one S3 request (host, x-amz-date, x-amz-content-sha256, authorization). */
|
|
38
|
+
export function signS3(method: string, url: URL, payloadHash: string, creds: S3Credentials, now = new Date(), extra: Record<string, string> = {}): Headers {
|
|
39
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''), day = amzDate.slice(0, 8);
|
|
40
|
+
const values: Record<string, string> = { host: url.host, 'x-amz-content-sha256': payloadHash, 'x-amz-date': amzDate };
|
|
41
|
+
for (const [k, v] of Object.entries(extra)) values[k.toLowerCase()] = v;
|
|
42
|
+
const names = Object.keys(values).sort(), signed = names.join(';');
|
|
43
|
+
const path = url.pathname.split('/').map((p) => rfc3986(decodeURIComponent(p))).join('/');
|
|
44
|
+
const query = [...url.searchParams].map(([k, v]) => [rfc3986(k), rfc3986(v)]).sort(([a, av], [b, bv]) => a < b ? -1 : a > b ? 1 : av < bv ? -1 : 1).map(([k, v]) => `${k}=${v}`).join('&');
|
|
45
|
+
const canonical = [method, path, query, names.map((n) => `${n}:${values[n].trim()}\n`).join(''), signed, payloadHash].join('\n');
|
|
46
|
+
const scope = `${day}/${creds.region}/s3/aws4_request`;
|
|
47
|
+
const key = hmac(hmac(hmac(hmac(`AWS4${creds.secretAccessKey}`, day), creds.region), 's3'), 'aws4_request');
|
|
48
|
+
const signature = hmac(key, `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${sha256(canonical)}`).toString('hex');
|
|
49
|
+
const headers = new Headers(values);
|
|
50
|
+
headers.delete('host'); // fetch sets it
|
|
51
|
+
headers.set('authorization', `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${signature}`);
|
|
52
|
+
return headers;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const xmlText = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
56
|
+
|
|
57
|
+
export function s3Target(endpoint: string, bucket: string, creds: S3Credentials, fetcher: Fetch = fetch): BackupTarget {
|
|
58
|
+
const base = endpoint.replace(/\/+$/, '');
|
|
59
|
+
const objectUrl = (key: string) => new URL(`${base}/${rfc3986(bucket)}/${key.split('/').map(rfc3986).join('/')}`);
|
|
60
|
+
async function send(method: string, url: URL, body?: Uint8Array, extra: Record<string, string> = {}) {
|
|
61
|
+
const headers = signS3(method, url, sha256(body ?? ''), creds, new Date(), extra);
|
|
62
|
+
const res = await fetcher(url.href, { method, headers, body: body as BodyInit | undefined, signal: AbortSignal.timeout(10 * 60_000) });
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const text = await res.text().catch(() => '');
|
|
65
|
+
const code = text.match(/<Code>([^<]*)<\/Code>/)?.[1];
|
|
66
|
+
throw new Error(`S3 ${method} failed (${res.status}${code ? ` ${code}` : ''})`);
|
|
67
|
+
}
|
|
68
|
+
return res;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
describe: `${new URL(base).host}/${bucket}`,
|
|
72
|
+
async put(key, body) { await send('PUT', objectUrl(key), body, { 'content-type': 'application/zip' }); },
|
|
73
|
+
async list(prefix) {
|
|
74
|
+
const keys: string[] = [];
|
|
75
|
+
let token = '';
|
|
76
|
+
for (let page = 0; page < 100; page++) {
|
|
77
|
+
const url = new URL(`${base}/${rfc3986(bucket)}`);
|
|
78
|
+
url.searchParams.set('list-type', '2');
|
|
79
|
+
url.searchParams.set('prefix', prefix);
|
|
80
|
+
if (token) url.searchParams.set('continuation-token', token);
|
|
81
|
+
const xml = await (await send('GET', url)).text();
|
|
82
|
+
for (const m of xml.matchAll(/<Key>([^<]*)<\/Key>/g)) keys.push(xmlText(m[1]));
|
|
83
|
+
token = /<IsTruncated>true<\/IsTruncated>/.test(xml) ? xmlText(xml.match(/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/)?.[1] ?? '') : '';
|
|
84
|
+
if (!token) break;
|
|
85
|
+
}
|
|
86
|
+
return keys;
|
|
87
|
+
},
|
|
88
|
+
async delete(key) { await send('DELETE', objectUrl(key)); },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- R2 binding ------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
interface R2Like {
|
|
95
|
+
put(key: string, body: Uint8Array, opts?: unknown): Promise<unknown>;
|
|
96
|
+
list(opts: { prefix: string; cursor?: string }): Promise<{ objects: Array<{ key: string }>; truncated: boolean; cursor?: string }>;
|
|
97
|
+
delete(key: string): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
export function r2Target(bucket: R2Like): BackupTarget {
|
|
100
|
+
return {
|
|
101
|
+
describe: 'R2 binding BACKUPS',
|
|
102
|
+
async put(key, body) { await bucket.put(key, body, { httpMetadata: { contentType: 'application/zip' } }); },
|
|
103
|
+
async list(prefix) {
|
|
104
|
+
const keys: string[] = [];
|
|
105
|
+
let cursor: string | undefined;
|
|
106
|
+
for (let page = 0; page < 100; page++) {
|
|
107
|
+
const r = await bucket.list({ prefix, cursor });
|
|
108
|
+
keys.push(...r.objects.map((o) => o.key));
|
|
109
|
+
if (!r.truncated) break;
|
|
110
|
+
cursor = r.cursor;
|
|
111
|
+
}
|
|
112
|
+
return keys;
|
|
113
|
+
},
|
|
114
|
+
async delete(key) { await bucket.delete(key); },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- configuration + the scheduler job --------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface OffsiteConfig { target: BackupTarget; prefix: string; keep: number; everyMs: number }
|
|
121
|
+
export function offsiteConfig(locals?: unknown, fetcher?: Fetch): OffsiteConfig | null {
|
|
122
|
+
const v = (k: string) => runtimeValue(k, locals).trim();
|
|
123
|
+
const binding = (locals as { runtime?: { env?: { BACKUPS?: R2Like } } } | undefined)?.runtime?.env?.BACKUPS;
|
|
124
|
+
const s3 = { endpoint: v('BACKUP_S3_ENDPOINT'), bucket: v('BACKUP_S3_BUCKET'), accessKeyId: v('BACKUP_S3_ACCESS_KEY_ID'), secretAccessKey: v('BACKUP_S3_SECRET_ACCESS_KEY') };
|
|
125
|
+
const anyS3 = Object.values(s3).some(Boolean);
|
|
126
|
+
if (!binding?.put && !anyS3) return null;
|
|
127
|
+
const keep = Number(v('BACKUP_KEEP') || 14), hours = Number(v('BACKUP_EVERY_HOURS') || 24);
|
|
128
|
+
if (!Number.isInteger(keep) || keep < 1 || keep > 1000) throw new Error('BACKUP_KEEP must be a whole number from 1 to 1000.');
|
|
129
|
+
if (!Number.isFinite(hours) || hours < 1 || hours > 24 * 31) throw new Error('BACKUP_EVERY_HOURS must be between 1 and 744.');
|
|
130
|
+
let prefix = v('BACKUP_PREFIX') || 'acms-backups/';
|
|
131
|
+
if (!prefix.endsWith('/')) prefix += '/';
|
|
132
|
+
let target: BackupTarget;
|
|
133
|
+
if (binding?.put) target = r2Target(binding);
|
|
134
|
+
else {
|
|
135
|
+
const missing = Object.entries(s3).filter(([, x]) => !x).map(([k]) => `BACKUP_S3_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`);
|
|
136
|
+
if (missing.length) throw new Error(`Off-site backups need ${missing.join(', ')}.`);
|
|
137
|
+
let url: URL;
|
|
138
|
+
try { url = new URL(s3.endpoint); } catch { throw new Error('BACKUP_S3_ENDPOINT must be an https:// URL.'); }
|
|
139
|
+
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) throw new Error('BACKUP_S3_ENDPOINT must be an https:// URL.');
|
|
140
|
+
target = s3Target(url.origin, s3.bucket, { accessKeyId: s3.accessKeyId, secretAccessKey: s3.secretAccessKey, region: v('BACKUP_S3_REGION') || 'auto' }, fetcher);
|
|
141
|
+
}
|
|
142
|
+
return { target, prefix, keep, everyMs: hours * 3_600_000 };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Scheduler job. Returns 1 when it wrote a backup, 0 when none was due or
|
|
147
|
+
* nothing is configured. Throws when a backup failed; the lease then holds
|
|
148
|
+
* retries off for 15 minutes so a broken bucket isn't hammered every minute.
|
|
149
|
+
*/
|
|
150
|
+
export async function runOffsiteBackup(repo: ContentRepository, locals?: unknown, opts: { now?: number; fetch?: Fetch } = {}): Promise<number> {
|
|
151
|
+
const config = offsiteConfig(locals, opts.fetch);
|
|
152
|
+
if (!config) return 0;
|
|
153
|
+
const now = opts.now ?? Date.now();
|
|
154
|
+
if (!await repo.backups.claim(now, config.everyMs, LEASE_MS)) return 0;
|
|
155
|
+
const key = backupKey(config.prefix, now);
|
|
156
|
+
try {
|
|
157
|
+
const body = new Uint8Array(await new Response(await backupArchive(repo, locals, now)).arrayBuffer());
|
|
158
|
+
await config.target.put(key, body);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
await repo.backups.fail(now, e instanceof Error ? e.message : 'Backup failed', RETRY_MS).catch(() => {});
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
await repo.backups.succeed(now, key);
|
|
164
|
+
// keep the newest N of our own files; anything else under the prefix stays
|
|
165
|
+
try {
|
|
166
|
+
const ours = (await config.target.list(config.prefix)).filter((k) => BACKUP_NAME.test(k.slice(config.prefix.length))).sort();
|
|
167
|
+
for (const old of ours.slice(0, Math.max(0, ours.length - config.keep))) await config.target.delete(old);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
// the backup itself landed; the next run prunes again
|
|
170
|
+
console.error('acms backup: pruning old backups failed', e);
|
|
171
|
+
}
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SqlDriver } from '../repo/sql';
|
|
2
|
+
|
|
3
|
+
// Scheduled off-site backups: when the last one ran, and a lease so two
|
|
4
|
+
// overlapping scheduler runs (a slow upload, two containers) never upload
|
|
5
|
+
// the same backup twice.
|
|
6
|
+
export interface BackupStatus { lastAt: number; leaseUntil: number; lastKey: string; lastError: string; failedAt: number }
|
|
7
|
+
export class BackupStore {
|
|
8
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
9
|
+
async status(): Promise<BackupStatus> {
|
|
10
|
+
const r = (await (await this.driver()).all('SELECT * FROM backup_state WHERE id = 1'))[0] ?? {};
|
|
11
|
+
return { lastAt: Number(r.last_at ?? 0), leaseUntil: Number(r.lease_until ?? 0), lastKey: String(r.last_key ?? ''), lastError: String(r.last_error ?? ''), failedAt: Number(r.failed_at ?? 0) };
|
|
12
|
+
}
|
|
13
|
+
/** take the lease when a backup is due (last success older than everyMs, no live lease) */
|
|
14
|
+
async claim(now: number, everyMs: number, leaseMs: number): Promise<boolean> {
|
|
15
|
+
const rows = await (await this.driver()).all(
|
|
16
|
+
'UPDATE backup_state SET lease_until = ? WHERE id = 1 AND lease_until <= ? AND last_at <= ? RETURNING id',
|
|
17
|
+
[now + leaseMs, now, now - everyMs],
|
|
18
|
+
);
|
|
19
|
+
return rows.length > 0;
|
|
20
|
+
}
|
|
21
|
+
async succeed(now: number, key: string) {
|
|
22
|
+
await (await this.driver()).run("UPDATE backup_state SET last_at = ?, last_key = ?, lease_until = 0, last_error = '' WHERE id = 1", [now, key]);
|
|
23
|
+
}
|
|
24
|
+
/** keep the failure, and hold the lease until the retry time */
|
|
25
|
+
async fail(now: number, error: string, retryMs: number) {
|
|
26
|
+
await (await this.driver()).run('UPDATE backup_state SET lease_until = ?, last_error = ?, failed_at = ? WHERE id = 1', [now + retryMs, error.slice(0, 500), now]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
|
|
3
|
+
// acms({ cache }) resolved by integration.mjs. Mutable so tests can flip it.
|
|
4
|
+
export const CACHE: { enabled: boolean; maxAge: number; staleWhileRevalidate: number; memory: boolean } = {
|
|
5
|
+
enabled: true, maxAge: 60, staleWhileRevalidate: 86400, memory: true,
|
|
6
|
+
...((userConfig as { cache?: Partial<typeof CACHE> }).cache ?? {}),
|
|
7
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// In-process cache of public app API bodies (Node: one long-lived server).
|
|
2
|
+
// Keys carry the content version, so a change makes old entries unreachable;
|
|
3
|
+
// the purge loop also drops them all. Bounded by entries and bytes, oldest
|
|
4
|
+
// out first — the sustained memory workload must stay flat.
|
|
5
|
+
|
|
6
|
+
const MAX_ENTRIES = 500;
|
|
7
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const store = new Map<string, string>();
|
|
9
|
+
let bytes = 0;
|
|
10
|
+
|
|
11
|
+
export function memoryGet(key: string): string | undefined {
|
|
12
|
+
const hit = store.get(key);
|
|
13
|
+
if (hit === undefined) return undefined;
|
|
14
|
+
// refresh recency
|
|
15
|
+
store.delete(key);
|
|
16
|
+
store.set(key, hit);
|
|
17
|
+
return hit;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function memorySet(key: string, body: string) {
|
|
21
|
+
const size = body.length * 2;
|
|
22
|
+
if (size > MAX_BYTES / 8) return; // one huge body must not flush everything else
|
|
23
|
+
const old = store.get(key);
|
|
24
|
+
if (old !== undefined) { bytes -= old.length * 2; store.delete(key); }
|
|
25
|
+
store.set(key, body);
|
|
26
|
+
bytes += size;
|
|
27
|
+
for (const [k, v] of store) {
|
|
28
|
+
if (store.size <= MAX_ENTRIES && bytes <= MAX_BYTES) break;
|
|
29
|
+
store.delete(k);
|
|
30
|
+
bytes -= v.length * 2;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearMemoryCache() {
|
|
35
|
+
store.clear();
|
|
36
|
+
bytes = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const memoryStats = () => ({ entries: store.size, bytes });
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CACHE } from './config';
|
|
2
|
+
import { CALENDAR } from '../calendar/config';
|
|
3
|
+
import { I18N } from '../i18n';
|
|
4
|
+
|
|
5
|
+
// The one place that decides whether a response may be stored by a shared
|
|
6
|
+
// cache (Cloudflare, a CDN). Public GET pages and the public app API are
|
|
7
|
+
// cacheable; anything personal, interactive or staff-only is private.
|
|
8
|
+
|
|
9
|
+
export const PRIVATE_CACHE_CONTROL = 'private, no-store';
|
|
10
|
+
export const publicCacheControl = () => `public, s-maxage=${CACHE.maxAge}, stale-while-revalidate=${CACHE.staleWhileRevalidate}`;
|
|
11
|
+
// publicResponse's own header: browsers/apps always revalidate by ETag. The
|
|
12
|
+
// policy upgrades it with s-maxage so a CDN can share it.
|
|
13
|
+
export const API_REVALIDATE = 'public, max-age=0, must-revalidate';
|
|
14
|
+
|
|
15
|
+
// Sign-in state lives in these cookies (built-in session, Clerk, design
|
|
16
|
+
// preview). Any of them means the page may be personalized.
|
|
17
|
+
const PRIVATE_COOKIE = /(?:^|;\s*)(?:acms_session|acms_preview|__session(?:_[^=;]+)?|__client_uat(?:_[^=;]+)?|__clerk_db_jwt(?:_[^=;]+)?)=/;
|
|
18
|
+
const CACHEABLE_STATUS = new Set([200, 203, 301, 308, 404, 410]);
|
|
19
|
+
// Paths that set their own long-lived headers (hashed media, built assets).
|
|
20
|
+
const OWN_HEADERS = /^\/(?:media|_astro)\//;
|
|
21
|
+
|
|
22
|
+
function privatePrefixes() {
|
|
23
|
+
return [
|
|
24
|
+
'/admin', '/preview', '/account', '/go', '/forms', '/_actions', '/_server-islands', '/embed/book',
|
|
25
|
+
...(CALENDAR.enabled ? [CALENDAR.bookBase] : []),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
// Public app API routes that are personal, transactional or write-only.
|
|
29
|
+
const PRIVATE_API = /^\/api\/v1\/(?:admin|auth|guest|bookings|availability|devices|push|forms)(?:\/|$)/;
|
|
30
|
+
|
|
31
|
+
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}.`);
|
|
32
|
+
|
|
33
|
+
/** strip a non-default locale prefix: /es/events/x is cached like /events/x */
|
|
34
|
+
function barePath(pathname: string) {
|
|
35
|
+
if (!I18N.enabled) return pathname;
|
|
36
|
+
for (const l of I18N.locales) {
|
|
37
|
+
if (l !== I18N.defaultLocale && (pathname === `/${l}` || pathname.startsWith(`/${l}/`))) return pathname.slice(l.length + 1) || '/';
|
|
38
|
+
}
|
|
39
|
+
return pathname;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isPrivateRequest(request: Request, pathname = new URL(request.url).pathname): boolean {
|
|
43
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return true;
|
|
44
|
+
if (request.headers.has('authorization')) return true;
|
|
45
|
+
if (PRIVATE_COOKIE.test(request.headers.get('cookie') ?? '')) return true;
|
|
46
|
+
const path = barePath(pathname);
|
|
47
|
+
if (path.startsWith('/api/')) return !path.startsWith('/api/v1/') || PRIVATE_API.test(path);
|
|
48
|
+
return privatePrefixes().some((p) => under(path, p));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const warned = new Set<string>();
|
|
52
|
+
function warnCookie(pathname: string) {
|
|
53
|
+
if (warned.has(pathname) || warned.size >= 100) return;
|
|
54
|
+
warned.add(pathname);
|
|
55
|
+
console.warn(`acms cache: ${pathname} set a cookie on a public page, so it was served private (not cached)`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Set Cache-Control on a finished response. Routes that already chose a
|
|
60
|
+
* private/no-store policy, or their own public one, are left alone; the
|
|
61
|
+
* policy only fills the default and never loosens a route's choice.
|
|
62
|
+
*/
|
|
63
|
+
export function applyCachePolicy(request: Request, response: Response, pathname = new URL(request.url).pathname): Response {
|
|
64
|
+
if (!CACHE.enabled) return response;
|
|
65
|
+
if (OWN_HEADERS.test(pathname)) return response;
|
|
66
|
+
const current = response.headers.get('cache-control');
|
|
67
|
+
const set = (value: string) => {
|
|
68
|
+
try { response.headers.set('Cache-Control', value); return response; }
|
|
69
|
+
catch {
|
|
70
|
+
// immutable headers (a proxied/fetched response): copy it
|
|
71
|
+
const copy = new Response(response.body, response);
|
|
72
|
+
copy.headers.set('Cache-Control', value);
|
|
73
|
+
return copy;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
if (isPrivateRequest(request, pathname)) {
|
|
77
|
+
if (current && /no-store/.test(current) && !/public/.test(current)) return response;
|
|
78
|
+
return set(PRIVATE_CACHE_CONTROL);
|
|
79
|
+
}
|
|
80
|
+
// a route that decided for itself (no-store live status, immutable files)
|
|
81
|
+
if (current && current !== API_REVALIDATE) return response;
|
|
82
|
+
if (!CACHEABLE_STATUS.has(response.status)) return current ? response : set(PRIVATE_CACHE_CONTROL);
|
|
83
|
+
// one stray cookie would hand one visitor's cookie to everyone
|
|
84
|
+
if (response.headers.has('set-cookie')) {
|
|
85
|
+
warnCookie(pathname);
|
|
86
|
+
return set(PRIVATE_CACHE_CONTROL);
|
|
87
|
+
}
|
|
88
|
+
return set(publicCacheControl());
|
|
89
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import { runtimeValue } from '../email/config';
|
|
3
|
+
import { CACHE } from './config';
|
|
4
|
+
import { clearMemoryCache } from './memory';
|
|
5
|
+
|
|
6
|
+
// Clearing shared caches when content changes. Each install is one site, so
|
|
7
|
+
// a change clears everything. The database records the last version cleared
|
|
8
|
+
// (cache_purged_version); anything newer is cleared on the next pass:
|
|
9
|
+
// - right after a write request (middleware, in the background)
|
|
10
|
+
// - the once-a-minute traffic sweep and every scheduler run, which retry a
|
|
11
|
+
// purge that failed (Cloudflare API down, rate limited)
|
|
12
|
+
// Targets: Workers Cache (ctx.cache, Cloudflare deployments) and the
|
|
13
|
+
// Cloudflare zone API (CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN, Docker
|
|
14
|
+
// behind the Cloudflare proxy). The Node memory cache is keyed by version
|
|
15
|
+
// and simply dropped.
|
|
16
|
+
|
|
17
|
+
type Locals = { runtime?: { scheduled?: boolean; ctx?: { cache?: { purge(o: { purgeEverything?: boolean }): Promise<{ success: boolean; errors?: Array<{ message: string }> }> } } } };
|
|
18
|
+
export type PurgeFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
19
|
+
|
|
20
|
+
// A burst of saves clears once; the scheduler (≤ 1 min) picks up the rest.
|
|
21
|
+
const MIN_INTERVAL_MS = 10_000;
|
|
22
|
+
let lastAttempt = 0;
|
|
23
|
+
let inflight: Promise<number> | null = null;
|
|
24
|
+
|
|
25
|
+
// The adapter gives pages a wrapper ctx with only waitUntil. worker.ts tags
|
|
26
|
+
// each request with the real ExecutionContext; the middleware copies its
|
|
27
|
+
// Workers Cache handle onto locals.runtime.ctx for the purge.
|
|
28
|
+
export const EXECUTION_CONTEXT = Symbol.for('acms.executionContext');
|
|
29
|
+
export function bindExecutionContext(request: Request, locals: unknown) {
|
|
30
|
+
const exec = Reflect.get(request, EXECUTION_CONTEXT) as { cache?: unknown } | undefined;
|
|
31
|
+
const ctx = (locals as { runtime?: { ctx?: Record<string, unknown> } } | undefined)?.runtime?.ctx;
|
|
32
|
+
if (exec?.cache && ctx && !ctx.cache) ctx.cache = exec.cache;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function purgeTargets(locals?: unknown) {
|
|
36
|
+
const workers = (locals as Locals | undefined)?.runtime?.ctx?.cache;
|
|
37
|
+
const zone = runtimeValue('CLOUDFLARE_ZONE_ID', locals).trim();
|
|
38
|
+
const token = runtimeValue('CLOUDFLARE_API_TOKEN', locals).trim();
|
|
39
|
+
return { workers: workers && typeof workers.purge === 'function' ? workers : null, zone: zone && token ? { zone, token } : null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function purgeZone(zone: string, token: string, fetcher: PurgeFetch) {
|
|
43
|
+
const res = await fetcher(`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(zone)}/purge_cache`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ purge_everything: true }),
|
|
47
|
+
signal: AbortSignal.timeout(10_000),
|
|
48
|
+
});
|
|
49
|
+
const body = await res.json().catch(() => null) as { success?: boolean; errors?: Array<{ message?: string }> } | null;
|
|
50
|
+
if (!res.ok || !body?.success) throw new Error(`Cloudflare purge failed (${res.status}${body?.errors?.[0]?.message ? `: ${body.errors[0].message}` : ''})`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear caches if content changed since the last successful purge. Returns
|
|
55
|
+
* 1 when it purged, 0 when nothing was due (or throttled). Throws when a
|
|
56
|
+
* purge failed, so the scheduler job reports it and the next run retries.
|
|
57
|
+
*/
|
|
58
|
+
export function syncCachePurge(repo: ContentRepository, locals?: unknown, opts: { force?: boolean; fetch?: PurgeFetch; now?: number } = {}): Promise<number> {
|
|
59
|
+
if (!CACHE.enabled) return Promise.resolve(0);
|
|
60
|
+
const now = opts.now ?? Date.now();
|
|
61
|
+
// another request is already purging; never await its promise (Workers
|
|
62
|
+
// forbid I/O across requests) — a newer version is picked up next pass
|
|
63
|
+
if (inflight) return Promise.resolve(0);
|
|
64
|
+
if (!opts.force && now - lastAttempt < MIN_INTERVAL_MS) return Promise.resolve(0);
|
|
65
|
+
const run = (async () => {
|
|
66
|
+
const { version, purged } = await repo.getCacheState();
|
|
67
|
+
if (version <= purged) return 0;
|
|
68
|
+
lastAttempt = now;
|
|
69
|
+
clearMemoryCache();
|
|
70
|
+
const targets = purgeTargets(locals);
|
|
71
|
+
// A Workers cron run may not carry ctx.cache even when Workers Cache is
|
|
72
|
+
// on. Leave the version unpurged so the next page request (which has
|
|
73
|
+
// the fetch context) clears it, instead of recording a purge that
|
|
74
|
+
// never reached the cache.
|
|
75
|
+
if (!targets.workers && (locals as Locals | undefined)?.runtime?.scheduled) return 0;
|
|
76
|
+
if (targets.workers) {
|
|
77
|
+
const result = await targets.workers.purge({ purgeEverything: true });
|
|
78
|
+
if (!result.success) throw new Error(`Workers Cache purge failed${result.errors?.[0]?.message ? `: ${result.errors[0].message}` : ''}`);
|
|
79
|
+
}
|
|
80
|
+
if (targets.zone) await purgeZone(targets.zone.zone, targets.zone.token, opts.fetch ?? fetch);
|
|
81
|
+
await repo.markCachePurged(version);
|
|
82
|
+
return 1;
|
|
83
|
+
})();
|
|
84
|
+
inflight = run;
|
|
85
|
+
return run.finally(() => { if (inflight === run) inflight = null; });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** test hook: forget the throttle between cases */
|
|
89
|
+
export function resetPurgeThrottle() { lastAttempt = 0; }
|
package/src/lib/designPreview.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AstroCookies } from 'astro';
|
|
2
2
|
import type { NavItem, Settings } from './schema';
|
|
3
3
|
import { draftNavItems, pickDesignDraft, shareEpoch, toNavItems } from './drafts';
|
|
4
|
-
import {
|
|
4
|
+
import { validatePreviewSession } from './auth/request';
|
|
5
5
|
import { verifyDesignToken } from './preview';
|
|
6
6
|
import { getRepo } from './repo';
|
|
7
7
|
|
|
@@ -86,7 +86,7 @@ async function compute(url: URL, cookies: AstroCookies, locals: unknown, liveSet
|
|
|
86
86
|
return { role: 'reviewer', source: 'draft', active: !showLive, showLive, ended: false, draft, expiresAt: exp };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
if (!(await
|
|
89
|
+
if (!(await validatePreviewSession(cookies, locals))) return NONE;
|
|
90
90
|
if (c.startsWith('v.')) {
|
|
91
91
|
const v = await repo.getDesignVersion(c.slice(2));
|
|
92
92
|
if (!v) return { ...NONE, role: 'admin' };
|
package/src/lib/dynamicBlocks.ts
CHANGED
|
@@ -162,7 +162,7 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
if (hasForms) {
|
|
165
|
-
const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter(t => t.includes('data-dyn="form"')))];
|
|
165
|
+
const tags = [...new Set((html.match(PLACEHOLDER) ?? ([] as string[])).filter(t => t.includes('data-dyn="form"')))];
|
|
166
166
|
await Promise.all(tags.map(async tag => {
|
|
167
167
|
const form = await repo.forms.get(attr(tag, 'form'));
|
|
168
168
|
// A same-origin frame gives each visitor an uncached form/receipt even
|
package/src/lib/editor/auth.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { AUTH } from '../auth/config';
|
|
2
|
+
import { validateMobileBinding, authEnvironment, ClerkUnavailableError } from '../auth/request';
|
|
3
|
+
import type { ClerkBinding } from '../auth/store';
|
|
1
4
|
import { Role } from '../schema';
|
|
2
5
|
import { createHmac } from 'node:crypto';
|
|
3
6
|
import { env } from '../env';
|
|
@@ -129,10 +132,12 @@ export function checkConsentTicket(ticket: string, session: string, p: Authorize
|
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/** approve: a one-time code bound to the user, app, return address and challenge */
|
|
132
|
-
export async function issueCode(repo: ContentRepository, user: { id: string; sessionVersion: number }, p: AuthorizeParams, deviceName: string, now = Date.now()): Promise<string> {
|
|
135
|
+
export async function issueCode(repo: ContentRepository, user: { id: string; sessionVersion: number }, p: AuthorizeParams, deviceName: string, now = Date.now(), binding?: ClerkBinding): Promise<string> {
|
|
136
|
+
if (AUTH.provider === 'clerk' && !binding) throw new ApiError(401, 'Clerk sign-in is required.');
|
|
133
137
|
const code = randomToken(CODE_PREFIX);
|
|
134
138
|
await repo.editor.createCode({ hash: await sha256Hex(code), userId: user.id, clientId: p.clientId, redirectUri: p.redirectUri,
|
|
135
|
-
challenge: p.codeChallenge, deviceName: cleanDeviceName(deviceName) || 'Phone', sessionVersion: user.sessionVersion, expiresAt: now + EDITOR.codeMs }, now);
|
|
139
|
+
challenge: p.codeChallenge, authProvider: AUTH.provider, deviceName: cleanDeviceName(deviceName) || 'Phone', sessionVersion: user.sessionVersion, expiresAt: now + EDITOR.codeMs }, now);
|
|
140
|
+
if (binding) await repo.auth.bindMobile('code', await sha256Hex(code), binding);
|
|
136
141
|
return code;
|
|
137
142
|
}
|
|
138
143
|
|
|
@@ -164,13 +169,28 @@ function tokenResponse(t: { access: string; refresh: string }, user: { id: strin
|
|
|
164
169
|
user: { id: user.id, name: user.name, email: user.email, role: Role.parse(user.role) }, device };
|
|
165
170
|
}
|
|
166
171
|
|
|
167
|
-
|
|
172
|
+
async function mobileBindingActive(...args: Parameters<typeof validateMobileBinding>) {
|
|
173
|
+
try { return await validateMobileBinding(...args); }
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error instanceof ClerkUnavailableError) throw new ApiError(503, error.message);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function tokenGrant(repo: ContentRepository, form: Record<string, string>, now = Date.now(), locals?: unknown): Promise<TokenResponse> {
|
|
168
181
|
const grant = form.grant_type;
|
|
169
182
|
if (grant === 'authorization_code') {
|
|
170
183
|
const { code = '', code_verifier: verifier = '', redirect_uri: redirectUri = '', client_id: clientId = '' } = form;
|
|
171
184
|
if (!code || !verifier || !redirectUri || !clientId) throw new OAuthError(400, 'invalid_request', 'code, code_verifier, redirect_uri and client_id are required.');
|
|
172
185
|
if (!wellFormed(code, CODE_PREFIX)) throw invalidGrant('The sign-in code is invalid or expired.');
|
|
173
|
-
const
|
|
186
|
+
const hash = await sha256Hex(code);
|
|
187
|
+
// Provider outages must not burn a usable one-time credential. The later
|
|
188
|
+
// atomic consume still enforces single use and detects concurrent replays.
|
|
189
|
+
if (AUTH.provider === 'clerk') {
|
|
190
|
+
const candidate = await repo.editor.getCode(hash);
|
|
191
|
+
if (!candidate || candidate.authProvider !== AUTH.provider || !await mobileBindingActive(repo, 'code', hash, candidate.userId, locals)) throw invalidGrant('The original sign-in is no longer active. Sign in again.');
|
|
192
|
+
}
|
|
193
|
+
const spent = await repo.editor.consumeCode(hash, now);
|
|
174
194
|
if (!spent) throw invalidGrant('The sign-in code is invalid or expired.');
|
|
175
195
|
const c = spent.code;
|
|
176
196
|
if (spent.reused) {
|
|
@@ -182,11 +202,14 @@ export async function tokenGrant(repo: ContentRepository, form: Record<string, s
|
|
|
182
202
|
// every check runs; the code is already burned whatever the outcome
|
|
183
203
|
const checks = [safeEqual(clientId, c.clientId), safeEqual(redirectUri, c.redirectUri), VERIFIER.test(verifier) && safeEqual(await s256(verifier), c.challenge), allowedRedirect(c.redirectUri)];
|
|
184
204
|
if (checks.includes(false)) throw invalidGrant('The sign-in code doesn’t match this app, return address or verifier.');
|
|
205
|
+
if (c.authProvider !== AUTH.provider) throw invalidGrant('The original sign-in is no longer active. Sign in again.');
|
|
185
206
|
const user = await liveUser(repo, c.userId, c.sessionVersion);
|
|
186
207
|
if (!user) throw invalidGrant('This account changed since sign-in. Sign in again.');
|
|
187
208
|
const device = { id: crypto.randomUUID(), name: c.deviceName };
|
|
188
209
|
const t = await pair(now);
|
|
189
|
-
await repo.editor.createDevice({ id: device.id, userId: user.id, name: device.name, clientId: c.clientId, sessionVersion: c.sessionVersion }, t.hashed, now);
|
|
210
|
+
await repo.editor.createDevice({ id: device.id, userId: user.id, name: device.name, clientId: c.clientId, sessionVersion: c.sessionVersion, authProvider: c.authProvider }, t.hashed, now);
|
|
211
|
+
const binding = AUTH.provider === 'clerk' ? await repo.auth.mobile('code', c.hash) : null;
|
|
212
|
+
if (binding) await repo.auth.bindMobile('device', device.id, binding);
|
|
190
213
|
await repo.editor.bindCode(c.hash, device.id);
|
|
191
214
|
await repo.editor.prune(now).catch(() => {});
|
|
192
215
|
return tokenResponse(t.plain, user, device);
|
|
@@ -196,6 +219,11 @@ export async function tokenGrant(repo: ContentRepository, form: Record<string, s
|
|
|
196
219
|
if (!token) throw new OAuthError(400, 'invalid_request', 'refresh_token is required.');
|
|
197
220
|
if (!wellFormed(token, REFRESH_PREFIX)) throw invalidGrant('The refresh token is invalid or expired.');
|
|
198
221
|
const hash = await sha256Hex(token);
|
|
222
|
+
if (AUTH.provider === 'clerk') {
|
|
223
|
+
const deviceId = await repo.editor.deviceForToken(hash);
|
|
224
|
+
const candidate = deviceId ? await repo.editor.getDevice(deviceId) : null;
|
|
225
|
+
if (!candidate || candidate.revokedAt !== null || candidate.authProvider !== AUTH.provider || !await mobileBindingActive(repo, 'device', candidate.id, candidate.userId, locals)) throw invalidGrant('The original sign-in is no longer active. Sign in again.');
|
|
226
|
+
}
|
|
199
227
|
const spent = await repo.editor.spendRefresh(hash, now);
|
|
200
228
|
if (!spent) throw invalidGrant('The refresh token is invalid or expired.');
|
|
201
229
|
// A rotated token presented again is theft, except for a short grace: the
|
|
@@ -209,6 +237,7 @@ export async function tokenGrant(repo: ContentRepository, form: Record<string, s
|
|
|
209
237
|
const device = await repo.editor.getDevice(spent.deviceId);
|
|
210
238
|
if (!device || device.revokedAt !== null) throw invalidGrant('This device was signed out. Sign in again.');
|
|
211
239
|
if (form.client_id && !safeEqual(form.client_id, device.clientId)) throw invalidGrant('The refresh token belongs to another app.');
|
|
240
|
+
if (device.authProvider !== AUTH.provider) throw invalidGrant('The original sign-in is no longer active. Sign in again.');
|
|
212
241
|
const user = await liveUser(repo, device.userId, device.sessionVersion);
|
|
213
242
|
if (!user) {
|
|
214
243
|
await repo.editor.revokeDevice(device.id, 'account changed', now);
|
|
@@ -254,6 +283,7 @@ export async function authenticateDevice(request: Request, repo: ContentReposito
|
|
|
254
283
|
await repo.editor.revokeDevice(access.device.id, 'password changed', now);
|
|
255
284
|
throw new ApiError(401, 'This account changed. Sign in again.');
|
|
256
285
|
}
|
|
286
|
+
if (access.device.authProvider !== AUTH.provider || (AUTH.provider === 'clerk' && !await mobileBindingActive(repo, 'device', access.device.id, access.user.id, authEnvironment(request), !['GET','HEAD'].includes(request.method)))) throw new ApiError(401, 'The original sign-in is no longer active. Sign in again.');
|
|
257
287
|
await repo.editor.touch(access.device.id, now);
|
|
258
288
|
const { user, device } = access;
|
|
259
289
|
return {
|
package/src/lib/editor/http.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function editorRoute(handler: (c: EditorContext) => Promise<unknown>, opt
|
|
|
25
25
|
let who: EditorPrincipal;
|
|
26
26
|
try { who = await authenticateDevice(context.request, repo, now); }
|
|
27
27
|
catch (error) {
|
|
28
|
+
if (error instanceof ApiError && error.status === 503) throw error;
|
|
28
29
|
// failed bearer attempts are throttled per client address
|
|
29
30
|
if (!await repo.email.rateLimit(`editor:fail:${await sha256Hex(address(context))}`, 30, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
|
|
30
31
|
throw error;
|
|
@@ -37,7 +38,9 @@ export function editorRoute(handler: (c: EditorContext) => Promise<unknown>, opt
|
|
|
37
38
|
} catch (error) {
|
|
38
39
|
// expected failures carry a status (ApiError, BookingError) or zod issues; only surprises are logged (never request data)
|
|
39
40
|
if (typeof (error as { status?: unknown })?.status !== 'number' && !(error instanceof Error && 'issues' in error)) console.error('acms editor api: request failed:', error instanceof Error ? error.message : error);
|
|
40
|
-
|
|
41
|
+
const result = failure(error);
|
|
42
|
+
if (result.status === 503) result.headers.set('Retry-After', '10');
|
|
43
|
+
return result;
|
|
41
44
|
}
|
|
42
45
|
};
|
|
43
46
|
}
|
package/src/lib/editor/store.ts
CHANGED
|
@@ -4,10 +4,12 @@ import type { SqlDriver } from '../repo/sql';
|
|
|
4
4
|
type Row = Record<string, unknown>;
|
|
5
5
|
|
|
6
6
|
export interface EditorDevice {
|
|
7
|
+
authProvider?: string;
|
|
7
8
|
id: string; userId: string; name: string; clientId: string; sessionVersion: number;
|
|
8
9
|
createdAt: number; lastUsedAt: number; revokedAt: number | null; revokedReason: string;
|
|
9
10
|
}
|
|
10
11
|
export interface EditorCode {
|
|
12
|
+
authProvider?: string;
|
|
11
13
|
hash: string; userId: string; clientId: string; redirectUri: string; challenge: string;
|
|
12
14
|
deviceName: string; sessionVersion: number; createdAt: number; expiresAt: number; usedAt: number | null; deviceId: string | null;
|
|
13
15
|
}
|
|
@@ -19,11 +21,11 @@ export interface EditorAccess {
|
|
|
19
21
|
export interface EditorActivity { id: string; createdAt: number; userId: string; deviceId: string; action: string; target: string }
|
|
20
22
|
|
|
21
23
|
function device(r: Row): EditorDevice {
|
|
22
|
-
return { id: String(r.id), userId: String(r.user_id), name: String(r.name), clientId: String(r.client_id), sessionVersion: Number(r.session_version),
|
|
24
|
+
return { authProvider: String(r.auth_provider ?? 'builtin'), id: String(r.id), userId: String(r.user_id), name: String(r.name), clientId: String(r.client_id), sessionVersion: Number(r.session_version),
|
|
23
25
|
createdAt: Number(r.created_at), lastUsedAt: Number(r.last_used_at), revokedAt: r.revoked_at == null ? null : Number(r.revoked_at), revokedReason: String(r.revoked_reason ?? '') };
|
|
24
26
|
}
|
|
25
27
|
function code(r: Row): EditorCode {
|
|
26
|
-
return { hash: String(r.hash), userId: String(r.user_id), clientId: String(r.client_id), redirectUri: String(r.redirect_uri), challenge: String(r.challenge),
|
|
28
|
+
return { authProvider: String(r.auth_provider ?? 'builtin'), hash: String(r.hash), userId: String(r.user_id), clientId: String(r.client_id), redirectUri: String(r.redirect_uri), challenge: String(r.challenge),
|
|
27
29
|
deviceName: String(r.device_name), sessionVersion: Number(r.session_version), createdAt: Number(r.created_at), expiresAt: Number(r.expires_at),
|
|
28
30
|
usedAt: r.used_at == null ? null : Number(r.used_at), deviceId: r.device_id == null ? null : String(r.device_id) };
|
|
29
31
|
}
|
|
@@ -38,8 +40,12 @@ export class EditorStore {
|
|
|
38
40
|
|
|
39
41
|
// ---- authorization codes ----
|
|
40
42
|
async createCode(c: Omit<EditorCode, 'usedAt' | 'deviceId' | 'createdAt'>, now: number) {
|
|
41
|
-
await this.run(`INSERT INTO editor_codes (hash,user_id,client_id,redirect_uri,challenge,device_name,session_version,created_at,expires_at,used_at,device_id)
|
|
42
|
-
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)`, [c.hash, c.userId, c.clientId, c.redirectUri, c.challenge, c.deviceName, c.sessionVersion, now, c.expiresAt]);
|
|
43
|
+
await this.run(`INSERT INTO editor_codes (hash,user_id,client_id,redirect_uri,challenge,device_name,session_version,created_at,expires_at,used_at,device_id,auth_provider)
|
|
44
|
+
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL,?)`, [c.hash, c.userId, c.clientId, c.redirectUri, c.challenge, c.deviceName, c.sessionVersion, now, c.expiresAt, c.authProvider ?? 'builtin']);
|
|
45
|
+
}
|
|
46
|
+
async getCode(hash: string): Promise<EditorCode | null> {
|
|
47
|
+
const rows = await this.all('SELECT * FROM editor_codes WHERE hash=?', [hash]);
|
|
48
|
+
return rows[0] ? code(rows[0]) : null;
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
51
|
* Burn a code (single use, even when the rest of the exchange fails).
|
|
@@ -55,10 +61,10 @@ export class EditorStore {
|
|
|
55
61
|
|
|
56
62
|
// ---- devices + tokens ----
|
|
57
63
|
/** a new device and its first token pair, atomically */
|
|
58
|
-
async createDevice(d: Pick<EditorDevice, 'id' | 'userId' | 'name' | 'clientId' | 'sessionVersion'>, tokens: { access: string; accessExpires: number; refresh: string; refreshExpires: number }, now: number) {
|
|
64
|
+
async createDevice(d: Pick<EditorDevice, 'id' | 'userId' | 'name' | 'clientId' | 'sessionVersion' | 'authProvider'>, tokens: { access: string; accessExpires: number; refresh: string; refreshExpires: number }, now: number) {
|
|
59
65
|
await (await this.driver()).batch([
|
|
60
|
-
{ sql: `INSERT INTO editor_devices (id,user_id,name,client_id,session_version,created_at,last_used_at,revoked_at,revoked_reason) VALUES (?,?,?,?,?,?,?,NULL,'')`,
|
|
61
|
-
args: [d.id, d.userId, d.name, d.clientId, d.sessionVersion, now, now] },
|
|
66
|
+
{ sql: `INSERT INTO editor_devices (id,user_id,name,client_id,session_version,created_at,last_used_at,revoked_at,revoked_reason,auth_provider) VALUES (?,?,?,?,?,?,?,NULL,'',?)`,
|
|
67
|
+
args: [d.id, d.userId, d.name, d.clientId, d.sessionVersion, now, now, d.authProvider ?? 'builtin'] },
|
|
62
68
|
...this.tokenRows(d.id, tokens, now),
|
|
63
69
|
]);
|
|
64
70
|
}
|