@acms-subir/core 0.3.10 → 0.3.12

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 (97) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +11 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/preview.mjs +1 -1
  8. package/deploy/Dockerfile +5 -1
  9. package/deploy/compose.yml +21 -0
  10. package/docs/live-video.md +1 -1
  11. package/docs/theme-authoring.md +31 -0
  12. package/integration.mjs +39 -0
  13. package/migrate.mjs +5 -0
  14. package/package.json +9 -11
  15. package/src/actions/index.ts +5 -5
  16. package/src/components/DesignManager.tsx +29 -10
  17. package/src/components/EmailSettings.tsx +53 -11
  18. package/src/components/ThemeTransfer.tsx +211 -0
  19. package/src/components/auth/ClerkSession.astro +80 -0
  20. package/src/components/auth/ReverifyForms.astro +61 -0
  21. package/src/components/auth/clerk.ts +169 -0
  22. package/src/components/import/ImportExport.tsx +16 -6
  23. package/src/components/import/runner.ts +3 -0
  24. package/src/layouts/Admin.astro +40 -9
  25. package/src/layouts/Site.astro +6 -1
  26. package/src/lib/api.ts +6 -3
  27. package/src/lib/auth/accounts.ts +94 -0
  28. package/src/lib/auth/config.ts +22 -0
  29. package/src/lib/auth/login-limit.ts +15 -0
  30. package/src/lib/auth/request.ts +161 -0
  31. package/src/lib/auth/store.ts +72 -0
  32. package/src/lib/auth.ts +3 -0
  33. package/src/lib/backup/archive.ts +26 -0
  34. package/src/lib/backup/offsite.ts +173 -0
  35. package/src/lib/backup/store.ts +28 -0
  36. package/src/lib/cache/config.ts +7 -0
  37. package/src/lib/cache/memory.ts +39 -0
  38. package/src/lib/cache/policy.ts +89 -0
  39. package/src/lib/cache/purge.ts +89 -0
  40. package/src/lib/designPreview.ts +2 -2
  41. package/src/lib/dynamicBlocks.ts +1 -1
  42. package/src/lib/editor/auth.ts +35 -5
  43. package/src/lib/editor/http.ts +4 -1
  44. package/src/lib/editor/store.ts +13 -7
  45. package/src/lib/editor/token-route.ts +2 -1
  46. package/src/lib/email/accounts.ts +4 -0
  47. package/src/lib/email/service.ts +1 -0
  48. package/src/lib/email/templates.ts +2 -1
  49. package/src/lib/email/types.ts +1 -0
  50. package/src/lib/mobile/http.ts +4 -3
  51. package/src/lib/mobile/route.ts +41 -4
  52. package/src/lib/net.ts +82 -14
  53. package/src/lib/repo/migrate-runner.ts +96 -0
  54. package/src/lib/repo/migrations.ts +76 -0
  55. package/src/lib/repo/sql.ts +56 -24
  56. package/src/lib/repo/sqlite.ts +14 -1
  57. package/src/lib/repo/types.ts +12 -1
  58. package/src/lib/scheduler.ts +7 -0
  59. package/src/lib/theme-transfer.ts +163 -0
  60. package/src/lib/theme.ts +5 -0
  61. package/src/lib/webhooks.ts +7 -2
  62. package/src/middleware.ts +41 -11
  63. package/src/pages/[...slug].astro +3 -2
  64. package/src/pages/account/[action].astro +6 -0
  65. package/src/pages/admin/app-auth.astro +21 -7
  66. package/src/pages/admin/design.astro +2 -1
  67. package/src/pages/admin/import.astro +5 -3
  68. package/src/pages/admin/index.astro +3 -2
  69. package/src/pages/admin/integrations.astro +3 -2
  70. package/src/pages/admin/login.astro +236 -25
  71. package/src/pages/admin/logout.ts +16 -2
  72. package/src/pages/admin/settings.astro +31 -5
  73. package/src/pages/admin/users.astro +350 -59
  74. package/src/pages/api/backup.ts +10 -26
  75. package/src/pages/api/design/export.ts +72 -0
  76. package/src/pages/api/design/import.ts +41 -0
  77. package/src/pages/api/email/settings.ts +9 -5
  78. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  79. package/src/pages/api/live/settings.ts +2 -2
  80. package/src/pages/api/media/upload.ts +2 -2
  81. package/src/pages/api/push/admin.ts +3 -2
  82. package/src/pages/api/restore.ts +6 -3
  83. package/src/pages/api/v1/calendars.ts +1 -1
  84. package/src/pages/api/v1/links.ts +1 -1
  85. package/src/pages/api/v1/openapi.json.ts +1 -1
  86. package/src/pages/api/v1/push/topics.ts +1 -1
  87. package/src/pages/api/v1/resources.ts +1 -1
  88. package/src/pages/api/v1/theme.ts +1 -1
  89. package/src/pages/blog/[slug].astro +3 -2
  90. package/src/pages/events/[slug].astro +3 -2
  91. package/src/pages/preview/[type]/[id].astro +3 -3
  92. package/src/styles/admin.css +103 -16
  93. package/src/styles/email-settings.css +6 -0
  94. package/src/theme-sdk/calendar.ts +48 -0
  95. package/src/theme-sdk/index.ts +2 -0
  96. package/src/virtual.d.ts +1 -0
  97. 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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; }
@@ -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 { validateSession, SESSION_COOKIE } from './auth';
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 validateSession(cookies.get(SESSION_COOKIE)?.value, locals))) return NONE;
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' };
@@ -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
@@ -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
- export async function tokenGrant(repo: ContentRepository, form: Record<string, string>, now = Date.now()): Promise<TokenResponse> {
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 spent = await repo.editor.consumeCode(await sha256Hex(code), now);
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 {
@@ -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
- return failure(error);
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
  }
@@ -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
  }