@acms-subir/core 0.3.9 → 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.
Files changed (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +17 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/mobile-doctor.mjs +9 -0
  8. package/cli/monitor.mjs +189 -0
  9. package/cli/preview.mjs +1 -1
  10. package/deploy/Dockerfile +5 -1
  11. package/deploy/compose.yml +21 -0
  12. package/docs/live-video.md +1 -1
  13. package/docs/mobile.md +23 -2
  14. package/docs/theme-authoring.md +6 -0
  15. package/integration.mjs +74 -4
  16. package/migrate.mjs +5 -0
  17. package/package.json +9 -11
  18. package/src/actions/index.ts +11 -31
  19. package/src/components/AppAccess.tsx +117 -0
  20. package/src/components/DesignManager.tsx +29 -10
  21. package/src/components/EmailSettings.tsx +53 -11
  22. package/src/components/MonitoringSettings.tsx +112 -0
  23. package/src/components/ThemeTransfer.tsx +211 -0
  24. package/src/components/auth/ClerkSession.astro +80 -0
  25. package/src/components/auth/ReverifyForms.astro +61 -0
  26. package/src/components/auth/clerk.ts +169 -0
  27. package/src/components/import/ImportExport.tsx +16 -6
  28. package/src/components/import/runner.ts +3 -0
  29. package/src/layouts/Admin.astro +42 -12
  30. package/src/layouts/Site.astro +6 -1
  31. package/src/lib/api.ts +8 -4
  32. package/src/lib/auth/accounts.ts +94 -0
  33. package/src/lib/auth/config.ts +22 -0
  34. package/src/lib/auth/login-limit.ts +15 -0
  35. package/src/lib/auth/request.ts +161 -0
  36. package/src/lib/auth/store.ts +72 -0
  37. package/src/lib/auth.ts +3 -0
  38. package/src/lib/backup/archive.ts +26 -0
  39. package/src/lib/backup/content.ts +1 -1
  40. package/src/lib/backup/offsite.ts +173 -0
  41. package/src/lib/backup/store.ts +28 -0
  42. package/src/lib/booking/availability.ts +15 -2
  43. package/src/lib/booking/service.ts +53 -1
  44. package/src/lib/cache/config.ts +7 -0
  45. package/src/lib/cache/memory.ts +39 -0
  46. package/src/lib/cache/policy.ts +89 -0
  47. package/src/lib/cache/purge.ts +89 -0
  48. package/src/lib/calendar/admin.ts +41 -0
  49. package/src/lib/designPreview.ts +2 -2
  50. package/src/lib/dynamicBlocks.ts +1 -1
  51. package/src/lib/editor/auth.ts +35 -5
  52. package/src/lib/editor/http.ts +4 -1
  53. package/src/lib/editor/me.ts +4 -0
  54. package/src/lib/editor/openapi.ts +49 -1
  55. package/src/lib/editor/roles.ts +20 -0
  56. package/src/lib/editor/schedule.ts +340 -0
  57. package/src/lib/editor/schemas.ts +36 -1
  58. package/src/lib/editor/site-data.ts +110 -0
  59. package/src/lib/editor/store.ts +13 -7
  60. package/src/lib/editor/token-route.ts +2 -1
  61. package/src/lib/email/accounts.ts +4 -0
  62. package/src/lib/email/service.ts +9 -1
  63. package/src/lib/email/store.ts +10 -0
  64. package/src/lib/email/templates.ts +8 -1
  65. package/src/lib/email/types.ts +1 -0
  66. package/src/lib/forms/store.ts +5 -0
  67. package/src/lib/mobile/access-admin.ts +34 -0
  68. package/src/lib/mobile/access.ts +28 -0
  69. package/src/lib/mobile/calendar.ts +26 -13
  70. package/src/lib/mobile/content.ts +21 -1
  71. package/src/lib/mobile/forms.ts +77 -0
  72. package/src/lib/mobile/guest-store.ts +62 -0
  73. package/src/lib/mobile/guest.ts +160 -0
  74. package/src/lib/mobile/http.ts +6 -5
  75. package/src/lib/mobile/links.ts +69 -0
  76. package/src/lib/mobile/manage.ts +114 -0
  77. package/src/lib/mobile/openapi.ts +40 -4
  78. package/src/lib/mobile/route.ts +63 -4
  79. package/src/lib/mobile/schemas.ts +52 -1
  80. package/src/lib/mobile/search.ts +95 -0
  81. package/src/lib/mobile/site.ts +8 -2
  82. package/src/lib/monitoring.ts +171 -0
  83. package/src/lib/net.ts +82 -14
  84. package/src/lib/repo/migrate-runner.ts +96 -0
  85. package/src/lib/repo/migrations.ts +104 -0
  86. package/src/lib/repo/sql.ts +122 -44
  87. package/src/lib/repo/sqlite.ts +14 -1
  88. package/src/lib/repo/types.ts +31 -3
  89. package/src/lib/scheduler.ts +14 -0
  90. package/src/lib/schema.ts +35 -1
  91. package/src/lib/theme-transfer.ts +163 -0
  92. package/src/lib/theme.ts +5 -0
  93. package/src/lib/webhooks.ts +7 -2
  94. package/src/middleware.ts +41 -11
  95. package/src/pages/[...slug].astro +3 -2
  96. package/src/pages/account/[action].astro +6 -0
  97. package/src/pages/admin/app-auth.astro +21 -7
  98. package/src/pages/admin/calendar/resources/link.astro +2 -0
  99. package/src/pages/admin/design.astro +2 -1
  100. package/src/pages/admin/import.astro +5 -3
  101. package/src/pages/admin/index.astro +3 -2
  102. package/src/pages/admin/integrations.astro +3 -2
  103. package/src/pages/admin/login.astro +236 -25
  104. package/src/pages/admin/logout.ts +16 -2
  105. package/src/pages/admin/mobile.astro +6 -2
  106. package/src/pages/admin/settings.astro +43 -5
  107. package/src/pages/admin/users.astro +350 -59
  108. package/src/pages/api/backup.ts +10 -26
  109. package/src/pages/api/design/export.ts +72 -0
  110. package/src/pages/api/design/import.ts +41 -0
  111. package/src/pages/api/email/settings.ts +9 -5
  112. package/src/pages/api/health.ts +17 -0
  113. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  114. package/src/pages/api/live/settings.ts +2 -2
  115. package/src/pages/api/media/upload.ts +2 -2
  116. package/src/pages/api/mobile/access.ts +22 -0
  117. package/src/pages/api/monitoring.ts +22 -0
  118. package/src/pages/api/ops/status.ts +2 -1
  119. package/src/pages/api/push/admin.ts +3 -2
  120. package/src/pages/api/restore.ts +6 -3
  121. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
  122. package/src/pages/api/v1/admin/bookings/index.ts +10 -2
  123. package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
  124. package/src/pages/api/v1/admin/calendars/index.ts +12 -0
  125. package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
  126. package/src/pages/api/v1/admin/forms/index.ts +6 -0
  127. package/src/pages/api/v1/admin/links.ts +13 -0
  128. package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
  129. package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
  130. package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
  131. package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
  132. package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
  133. package/src/pages/api/v1/admin/resources/index.ts +12 -0
  134. package/src/pages/api/v1/admin/schedule.ts +7 -0
  135. package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
  136. package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
  137. package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
  138. package/src/pages/api/v1/bookings/manage/index.ts +6 -0
  139. package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
  140. package/src/pages/api/v1/calendars.ts +1 -1
  141. package/src/pages/api/v1/forms/[id]/index.ts +7 -0
  142. package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
  143. package/src/pages/api/v1/guest/bookings.ts +6 -0
  144. package/src/pages/api/v1/guest/code.ts +6 -0
  145. package/src/pages/api/v1/guest/revoke.ts +6 -0
  146. package/src/pages/api/v1/guest/token.ts +6 -0
  147. package/src/pages/api/v1/links.ts +7 -0
  148. package/src/pages/api/v1/openapi.json.ts +1 -1
  149. package/src/pages/api/v1/push/topics.ts +1 -1
  150. package/src/pages/api/v1/resources.ts +1 -1
  151. package/src/pages/api/v1/search.ts +7 -0
  152. package/src/pages/api/v1/theme.ts +1 -1
  153. package/src/pages/blog/[slug].astro +3 -2
  154. package/src/pages/book/[resource]/[link].astro +3 -3
  155. package/src/pages/book/manage/[token].astro +11 -7
  156. package/src/pages/events/[slug].astro +3 -2
  157. package/src/pages/preview/[type]/[id].astro +3 -3
  158. package/src/styles/admin.css +169 -16
  159. package/src/styles/email-settings.css +6 -0
  160. package/src/virtual.d.ts +1 -0
  161. 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
+ }
@@ -147,7 +147,8 @@ export function slotsForDay(ctx: AvailabilityContext, date: string, minutes?: nu
147
147
  return out;
148
148
  }
149
149
  const step = Math.max(5, ctx.link.step) * MIN;
150
- const lengths = minutes ? [minutes] : linkLengths(ctx.link);
150
+ // a requested length must be one the booking type offers (a 30-minute slot can't be held for 8 hours)
151
+ const lengths = minutes ? linkLengths(ctx.link).filter((l) => l === minutes) : linkLengths(ctx.link);
151
152
  const out: Slot[] = [];
152
153
  const [y, m, d] = date.split('-').map(Number);
153
154
  for (const [a, b] of dayRanges(ctx.resource, ctx.link, date)) {
@@ -195,10 +196,22 @@ export function nextOpenings(ctx: AvailabilityContext, count: number, horizonDay
195
196
  return out;
196
197
  }
197
198
 
199
+ /**
200
+ * Day bookings run midnight to midnight in the resource zone, so across a DST
201
+ * change a "1 day" span lasts 23 or 25 hours. Callers send start + n × 1440
202
+ * minutes; this snaps the end to the real local midnight (other modes unchanged).
203
+ */
204
+ export function spanEnd(resource: Resource, link: BookingLink, siteTimezone: string, start: number, end: number): number {
205
+ if (link.mode !== 'day') return end;
206
+ const tz = resourceZone(resource, siteTimezone);
207
+ return localMidnight(addDaysYmd(ymd(start, tz), Math.max(1, Math.round((end - start) / DAY))), tz);
208
+ }
209
+
198
210
  // server-side check before an insert (the DB guard backs it up for races)
199
211
  export function isBookable(ctx: AvailabilityContext, start: number, end: number): boolean {
200
212
  const tz = resourceZone(ctx.resource, ctx.siteTimezone);
201
- const minutes = Math.round((end - start) / MIN);
213
+ // day spans are counted in local days (a 25-hour DST day is still 1440)
214
+ const minutes = ctx.link.mode === 'day' ? Math.round((end - start) / DAY) * 1440 : Math.round((end - start) / MIN);
202
215
  return slotsForDay(ctx, ymd(start, tz), minutes).some((s) => s.start === start && s.end === end);
203
216
  }
204
217
 
@@ -12,7 +12,7 @@ import { kickEmail } from '../email/service';
12
12
  import { manageUrl as manageUrlFor, eventUrl } from '../calendar/config';
13
13
  import { eventTimeZone, locationLine } from '../calendar/events';
14
14
  import { bookingEmail, whenText, type BookingEmail } from './emails';
15
- import { holdOf, isBookable, resourceZone, type AvailabilityContext, type Interval } from './availability';
15
+ import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
16
16
  import { decideStatus, linkApproval } from './approval';
17
17
  import { eventHolds } from './holds';
18
18
 
@@ -165,6 +165,54 @@ export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'>
165
165
 
166
166
  // ---- workflows ---------------------------------------------------------------
167
167
 
168
+ // App access limit: new bookings + RSVPs per address per day, on every public
169
+ // channel. Staff adding a booking by hand is never capped.
170
+ async function checkEmailCap(repo: ContentRepository, email: string, source: Booking['source'] | undefined, settings: Settings, now = Date.now()) {
171
+ const cap = settings.mobileAccess.bookingsPerEmailDay;
172
+ if (!cap || source === 'admin') return;
173
+ if (await repo.countBookingsByEmail(email, now - 86_400_000) >= cap) throw new BookingError(429, 'That’s the most bookings one email address can make in a day. Please try again tomorrow or contact us.');
174
+ }
175
+
176
+ /**
177
+ * What the booker may still do themselves (manage page, apps). A booking
178
+ * type's change cutoff closes guest changes that many hours before the
179
+ * start; staff can always act from the admin.
180
+ */
181
+ export function guestChanges(b: Booking, link: BookingLink | null, now = Date.now()) {
182
+ const active = b.status === 'pending' || b.status === 'confirmed';
183
+ const cutoff = b.kind === 'booking' ? (link?.changeCutoff ?? 0) : 0;
184
+ const closed = active && cutoff > 0 && b.start - now < cutoff * 3_600_000;
185
+ const open = active && b.end > now && !closed;
186
+ return {
187
+ cancel: open,
188
+ reschedule: open && b.kind === 'booking' && b.start > now && !!link?.active,
189
+ // hours before the start when guest changes close; 0 = until it ends
190
+ cutoffHours: cutoff,
191
+ closed,
192
+ };
193
+ }
194
+
195
+ export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions): Promise<Booking> {
196
+ const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
197
+ if (b.status === 'cancelled') return b;
198
+ const can = guestChanges(b, link, opts.now);
199
+ if (!can.cancel) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to cancel.` : 'This booking can’t be cancelled.');
200
+ return setBookingStatus(repo, b, 'cancelled', opts);
201
+ }
202
+
203
+ /** the link as a guest moving a booking sees it: no new time inside the change cutoff */
204
+ export const guestMoveLink = (link: BookingLink): BookingLink =>
205
+ link.changeCutoff > 0 ? { ...link, minNotice: Math.max(link.minNotice, link.changeCutoff * 60) } : link;
206
+
207
+ export async function guestReschedule(repo: ContentRepository, b: Booking, start: number, end: number, opts: FlowOptions): Promise<Booking> {
208
+ const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
209
+ const can = guestChanges(b, link, opts.now);
210
+ if (!can.reschedule) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to move it.` : 'This booking can’t be moved.');
211
+ // the new time must respect the cutoff too
212
+ if (link && link.changeCutoff > 0 && start - (opts.now ?? Date.now()) < link.changeCutoff * 3_600_000) throw new BookingError(409, `Pick a time at least ${link.changeCutoff} hours from now.`);
213
+ return rescheduleBooking(repo, b, start, end, opts);
214
+ }
215
+
168
216
  export async function createResourceBooking(
169
217
  repo: ContentRepository,
170
218
  args: { resource: Resource; link: BookingLink; start: number; end: number; booker: BookerInput; source?: Booking['source']; status?: BookingStatus },
@@ -173,7 +221,9 @@ export async function createResourceBooking(
173
221
  const { resource, link } = args;
174
222
  if (!link.active) throw new BookingError(410, 'This booking link is turned off.');
175
223
  const settings = await repo.getSettings();
224
+ args = { ...args, end: spanEnd(resource, link, settings.timezone, args.start, args.end) };
176
225
  const booker = validateBooker(link, args.booker);
226
+ await checkEmailCap(repo, booker.email, args.source, settings, opts.now);
177
227
  // staff adding a booking by hand may ignore notice windows, never overlaps
178
228
  const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
179
229
  if (args.source !== 'admin' && !isBookable(ctx, args.start, args.end)) throw new BookingError(409, 'That time was just taken or isn’t available. Please pick another.');
@@ -219,6 +269,7 @@ export async function createRsvp(
219
269
  if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
220
270
  const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
221
271
  const booker = validateBooker(null, args.booker);
272
+ await checkEmailCap(repo, booker.email, args.source, await repo.getSettings(), opts.now);
222
273
  const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
223
274
  if (formResponse) booker.answers = displayAnswers(formResponse);
224
275
  const b = await repo.createBooking({
@@ -264,6 +315,7 @@ export async function rescheduleBooking(repo: ContentRepository, b: Booking, sta
264
315
  if (b.status !== 'pending' && b.status !== 'confirmed') throw new BookingError(409, 'Only active bookings can be moved.');
265
316
  const [resource, link, settings] = await Promise.all([repo.getResource(b.resourceId), repo.getBookingLink(b.linkId), repo.getSettings()]);
266
317
  if (!resource || !link) throw new BookingError(404, 'This booking’s room or link no longer exists.');
318
+ end = spanEnd(resource, link, settings.timezone, start, end);
267
319
  if (!opts.skipRules) {
268
320
  const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now(), b.id);
269
321
  if (!isBookable(ctx, start, end)) throw new BookingError(409, 'That time isn’t available.');
@@ -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; }
@@ -0,0 +1,41 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import type { Calendar } from '../schema';
3
+ import { slugify } from '../schema';
4
+ import { ApiError } from '../api';
5
+ import { normalizeCalendarSrc } from './src';
6
+ import { safeColor } from './events';
7
+
8
+ // Create or edit a calendar: the admin's Calendar settings and editor apps
9
+ // share these rules (feed URL normalization, unique slug, safe color).
10
+ export interface CalendarInput {
11
+ name: string; slug?: string; color?: string; description?: string;
12
+ kind?: 'native' | 'feed'; feedUrl?: string; visibility?: Calendar['visibility'];
13
+ }
14
+ export async function saveCalendar(repo: ContentRepository, id: string | undefined, data: CalendarInput): Promise<Calendar> {
15
+ const existing = id ? await repo.getCalendar(id) : null;
16
+ if (id && !existing) throw new ApiError(404, 'Calendar not found.');
17
+ const kind = existing?.kind ?? data.kind ?? 'native';
18
+ let feedUrl = existing?.feedUrl ?? '';
19
+ if (kind === 'feed' && (data.feedUrl !== undefined || !existing)) {
20
+ const normalized = normalizeCalendarSrc(data.feedUrl ?? '');
21
+ if (!normalized) throw new ApiError(400, 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
22
+ feedUrl = normalized;
23
+ }
24
+ const base = slugify(data.slug || data.name);
25
+ let slug = base;
26
+ for (let n = 2; ; n++) {
27
+ const clash = await repo.getCalendarBySlug(slug);
28
+ if (!clash || clash.id === id) break;
29
+ slug = `${base}-${n}`;
30
+ }
31
+ const fields = {
32
+ name: data.name, slug, color: safeColor(data.color ?? existing?.color ?? '#4f46e5'),
33
+ description: data.description ?? existing?.description ?? '', feedUrl,
34
+ visibility: data.visibility ?? existing?.visibility ?? 'public',
35
+ };
36
+ if (existing) {
37
+ await repo.updateCalendar(existing.id, fields);
38
+ return (await repo.getCalendar(existing.id))!;
39
+ }
40
+ return repo.createCalendar({ ...fields, kind });
41
+ }
@@ -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