@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
@@ -208,6 +208,15 @@ async function doctorApp(app, flags, ctx) {
208
208
  else c.fail('site', 'Site API', `${url}/api/v1/site returned ${r.status}${r.json ? '' : ' (not the ACMS API)'}.`, { hint: 'Check EXPO_PUBLIC_ACMS_URL points at the site origin and the site runs @acms-subir/core with the mobile API.' });
209
209
  }
210
210
  const live = !!site;
211
+ // the site's own health (what Uptime Kuma watches): a warning, never a failure,
212
+ // since a local dev server usually has no scheduler running
213
+ if (live) {
214
+ const h = await getJson(`${url}/api/health`);
215
+ const bad = Object.entries(h.json?.checks ?? {}).filter(([, v]) => v !== 'ok' && v !== 'off').map(([k, v]) => `${k} ${v}`);
216
+ if (h.status === 200 && h.json?.status === 'ok') c.pass('health', 'Site health', 'Database, scheduler and email are working (/api/health).');
217
+ else if (h.status === 200 || h.status === 503) c.warn('health', 'Site health', `/api/health reports ${h.json?.status ?? 'down'}${bad.length ? `: ${bad.join(', ')}` : ''}.`, { hint: 'A stopped scheduler means no reminders, scheduled posts or emails: check the cron (Cloudflare) or the runner (Docker). Watch it with npx acms monitor kuma.' });
218
+ else c.warn('health', 'Site health', `/api/health returned ${h.status}.`, { hint: 'Update the site (npx acms update): older releases have no health check.' });
219
+ }
211
220
 
212
221
  // 6. app ids
213
222
  if (!appJson) c.fail('app-ids', 'App identity', 'app.json is missing or not valid JSON.', { hint: `Restore ${app.dir}/app.json (see the starter: npx acms add mobile --dry-run).` });
@@ -0,0 +1,189 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomInt } from 'node:crypto';
4
+ import { fail } from './common.mjs';
5
+ import { listApps } from './mobile.mjs';
6
+
7
+ // `acms monitor kuma`: puts a site's monitors into Uptime Kuma (website, app
8
+ // API, staff app API, health, scheduler heartbeat) in one group, plus a
9
+ // status page for Settings → Monitoring. Kuma has no REST API for this, so
10
+ // it speaks Kuma's own dashboard protocol (Socket.IO over a WebSocket),
11
+ // tested with Kuma 1.23 and 2.x. Running it again updates, never duplicates.
12
+ // `--print` lists the same monitors to add by hand.
13
+ // Login comes from KUMA_USERNAME / KUMA_PASSWORD (+ KUMA_TOTP for 2FA):
14
+ // never arguments, never stored.
15
+
16
+ const TIMEOUT = 15_000;
17
+
18
+ /** the monitors for one site; `kuma` is the Kuma version major (1 or 2) */
19
+ export function monitorPlan(site, opts = {}) {
20
+ const base = { method: 'GET', retryInterval: 60, resendInterval: 0, timeout: 30, ignoreTls: false, upsideDown: false, expiryNotification: false, maxredirects: 10, notificationIDList: {} };
21
+ const http = (name, path, extra = {}) => ({ ...base, type: 'http', name, url: `${site}${path}`, interval: 60, maxretries: 1, accepted_statuscodes: ['200-299'], ...extra });
22
+ const list = [
23
+ { key: 'website', what: 'The website answers', ...http('Website', '/') },
24
+ // the case to catch: the app API removed or broken (a 404 or a wrong answer)
25
+ { key: 'app-api', what: 'The app API answers as v1', ...http('App API', '/api/v1/site', { type: 'json-query', jsonPath: 'data.apiVersion', expectedValue: '1', ...(opts.kuma === 1 ? {} : { jsonPathOperator: '==' }) }) },
26
+ ...(opts.staff === false ? [] : [{ key: 'staff-api', what: 'The staff app API exists (401 = up)', ...http('Staff app API', '/api/v1/admin/me', { accepted_statuscodes: ['401'] }) }]),
27
+ { key: 'health', what: 'Database, scheduler and email (503 = down)', ...http('Health', '/api/health') },
28
+ { key: 'scheduler', what: 'The scheduler reported in the last 3 min', ...base, type: 'push', name: 'Scheduler', url: 'https://', interval: 180, maxretries: 0, accepted_statuscodes: ['200-299'], pushToken: opts.pushToken ?? token() },
29
+ ];
30
+ // Kuma 2 stores alert conditions; Kuma 1 refuses columns it doesn't have
31
+ return opts.kuma === 1 ? list : list.map((m) => ({ ...m, conditions: [] }));
32
+ }
33
+ function token(n = 32) {
34
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
35
+ return Array.from({ length: n }, () => chars[randomInt(chars.length)]).join('');
36
+ }
37
+
38
+ // ---- a minimal Socket.IO v4 client (WebSocket transport only) -----------------
39
+
40
+ export function kumaSocket(base) {
41
+ const url = `${base.replace(/^http/, 'ws')}/socket.io/?EIO=4&transport=websocket`;
42
+ const ws = new WebSocket(url);
43
+ const acks = new Map(), latest = {};
44
+ let next = 0, closed = false;
45
+ const ready = new Promise((resolve, reject) => {
46
+ const timer = setTimeout(() => reject(new Error('timeout')), TIMEOUT);
47
+ ws.onerror = () => { clearTimeout(timer); reject(new Error('unreachable')); };
48
+ ws.onclose = () => { closed = true; for (const [, a] of acks) a.reject(new Error('closed')); acks.clear(); };
49
+ ws.onmessage = ({ data }) => {
50
+ const s = String(data);
51
+ if (s === '2') return ws.send('3'); // ping → pong
52
+ if (s.startsWith('0')) return ws.send('40'); // open → join the default namespace
53
+ if (s.startsWith('44')) { clearTimeout(timer); return reject(new Error('refused')); }
54
+ if (s.startsWith('40')) { clearTimeout(timer); return resolve(); }
55
+ const ack = /^43(\d+)([\s\S]*)$/.exec(s);
56
+ if (ack) { const a = acks.get(Number(ack[1])); acks.delete(Number(ack[1])); a?.resolve(JSON.parse(ack[2])[0]); return; }
57
+ if (s.startsWith('42')) { try { const [event, payload] = JSON.parse(s.slice(2)); latest[event] = payload; } catch { /* not ours */ } }
58
+ };
59
+ });
60
+ const call = (event, ...args) => new Promise((resolve, reject) => {
61
+ if (closed) return reject(new Error('closed'));
62
+ const id = next++;
63
+ const timer = setTimeout(() => { acks.delete(id); reject(new Error('timeout')); }, TIMEOUT);
64
+ acks.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); } });
65
+ ws.send(`42${id}${JSON.stringify([event, ...args])}`);
66
+ });
67
+ /** waits until Kuma has pushed `event` (sent by the server after login), optionally one that passes `test` */
68
+ const wait = async (event, test = (v) => !!v, ms = 8000) => { for (let t = 0; t < ms && !test(latest[event]); t += 100) await new Promise((r) => setTimeout(r, 100)); return latest[event]; };
69
+ return { ready, call, wait, latest, close: () => { closed = true; ws.close(); } };
70
+ }
71
+
72
+ // ---- the command --------------------------------------------------------------
73
+
74
+ function origin(raw, what) {
75
+ let u;
76
+ try { u = new URL(raw); } catch { fail('USAGE', `${what} must be an address like https://example.com.`); }
77
+ if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) fail('USAGE', `${what} must be an http(s) address without credentials.`);
78
+ return u.origin;
79
+ }
80
+ /** --url, else the site address baked into the mobile app (.env) */
81
+ async function siteAddress(flags) {
82
+ if (flags.url) return origin(flags.url, '--url');
83
+ for (const app of await listApps().catch(() => [])) {
84
+ const env = await readFile(join(app.dir, '.env'), 'utf8').catch(() => '');
85
+ const v = env.match(/^EXPO_PUBLIC_ACMS_URL=(.*)$/m)?.[1]?.trim();
86
+ if (v && /^https:\/\//.test(v)) return origin(v, 'EXPO_PUBLIC_ACMS_URL');
87
+ }
88
+ fail('USAGE', 'Which site? Pass --url https://your-site.', [], ['acms monitor kuma --url https://your-site --kuma https://your-kuma']);
89
+ }
90
+ async function probe(url) {
91
+ try { const r = await fetch(url, { signal: AbortSignal.timeout(8000), headers: { accept: 'application/json' } }); return { status: r.status, json: await r.json().catch(() => null) }; }
92
+ catch { return { status: 0, json: null }; }
93
+ }
94
+ const slugFor = (host) => `acms-${host.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase()}`.slice(0, 60);
95
+
96
+ export async function monitorCommand(sub, flags) {
97
+ if (sub !== 'kuma') fail('USAGE', 'Use: acms monitor kuma --url https://your-site --kuma https://your-kuma [--print]');
98
+ const site = await siteAddress(flags);
99
+ const host = new URL(site).host;
100
+ // what the site has: staff app API (401) or not (404); the site name for labels
101
+ const [info, staff] = await Promise.all([probe(`${site}/api/v1/site`), probe(`${site}/api/v1/admin/me`)]);
102
+ const warnings = [];
103
+ if (info.status !== 200) warnings.push(`${site}/api/v1/site answered ${info.status || 'nothing'}: check the address (Kuma will show the App API as down until it answers).`);
104
+ const hasStaff = staff.status === 401;
105
+ if (!hasStaff) warnings.push('The staff app API isn’t on (no mobile.editor in the site config), so there is no Staff app API monitor.');
106
+ const group = info.json?.data?.name ? `${info.json.data.name} (${host})` : host;
107
+
108
+ const kumaRaw = flags.kuma ?? process.env.KUMA_URL;
109
+ if (flags.print || !kumaRaw) {
110
+ if (!flags.print) warnings.push('No --kuma address (or KUMA_URL): listing the monitors to add by hand.');
111
+ return { mode: 'print', site, group, monitors: monitorPlan(site, { staff: hasStaff }).map(view), warnings };
112
+ }
113
+ const kuma = origin(kumaRaw, '--kuma');
114
+ const username = process.env.KUMA_USERNAME, password = process.env.KUMA_PASSWORD;
115
+ if (!username || !password) fail('AUTH', 'Set KUMA_USERNAME and KUMA_PASSWORD (and KUMA_TOTP when Kuma uses two-factor sign-in).', [], ['read -rs KUMA_PASSWORD && export KUMA_PASSWORD # keeps it out of shell history']);
116
+
117
+ const k = kumaSocket(kuma);
118
+ try {
119
+ try { await k.ready; } catch (e) { fail('NETWORK', `Couldn’t reach Uptime Kuma at ${kuma} (${e.message}).`, [], ['Check --kuma, or run with --print to add the monitors by hand.']); }
120
+ let login = await k.call('login', { username, password, token: process.env.KUMA_TOTP ?? '' });
121
+ if (login?.tokenRequired) fail('AUTH', 'Kuma asks for a two-factor code: set KUMA_TOTP to the current code and run again.');
122
+ if (!login?.ok) fail('AUTH', `Kuma refused the sign-in${login?.msg ? `: ${login.msg}` : ''}.`);
123
+ // the version only comes with the info Kuma sends after login
124
+ const info = await k.wait('info', (v) => !!v?.version);
125
+ const major = Number(String(info?.version ?? '2').split('.')[0]) || 2;
126
+ const existing = Object.values(await k.wait('monitorList') ?? {});
127
+ const ok = (r, what) => { if (!r?.ok) throw Object.assign(new Error(`${what}: ${r?.msg ?? 'no answer'}`), { kuma: true }); return r; };
128
+
129
+ // the group, then each monitor inside it (matched by name, so reruns update)
130
+ let parent = existing.find((m) => m.type === 'group' && m.name === group);
131
+ const results = [];
132
+ if (!parent) {
133
+ const { key: _k, what: _w, ...first } = monitorPlan(site, { kuma: major })[0];
134
+ const r = ok(await k.call('add', { ...first, type: 'group', name: group, url: 'https://', parent: null }), 'group');
135
+ parent = { id: r.monitorID };
136
+ results.push({ name: group, action: 'created', type: 'group' });
137
+ }
138
+ const children = existing.filter((m) => m.parent === parent.id);
139
+ const pushToken = children.find((m) => m.type === 'push' && m.name === 'Scheduler')?.pushToken;
140
+ const ids = [];
141
+ for (const { key: _k, what: _w, ...m } of monitorPlan(site, { kuma: major, staff: hasStaff, pushToken })) {
142
+ const old = children.find((c) => c.name === m.name);
143
+ const r = old
144
+ ? ok(await k.call('editMonitor', { ...old, ...m, id: old.id, parent: parent.id }), m.name)
145
+ : ok(await k.call('add', { ...m, parent: parent.id }), m.name);
146
+ ids.push(r.monitorID ?? old.id);
147
+ results.push({ name: m.name, action: old ? 'updated' : 'created', type: m.type, url: m.type === 'push' ? null : m.url });
148
+ }
149
+ const push = `${kuma}/api/push/${pushToken ?? (await k.call('getMonitor', ids[ids.length - 1]))?.monitor?.pushToken}`;
150
+
151
+ // a public status page with these monitors, for Settings → Monitoring
152
+ const slug = slugFor(host);
153
+ let page = await k.call('getStatusPage', slug);
154
+ if (!page?.ok) { ok(await k.call('addStatusPage', group, slug), 'status page'); page = await k.call('getStatusPage', slug); }
155
+ ok(page, 'status page');
156
+ ok(await k.call('saveStatusPage', slug, { ...page.config, title: group, published: true }, page.config.icon, [{ name: group, monitorList: ids.map((id) => ({ id })) }]), 'status page');
157
+
158
+ return { mode: 'kuma', site, kuma, kumaVersion: info?.version ?? null, group, monitors: results, pushUrl: push, statusPage: `${kuma}/status/${slug}`, warnings };
159
+ } catch (e) {
160
+ if (!e?.kuma) throw e;
161
+ // Kuma changed its dashboard protocol (or refused a field): fall back to the list
162
+ return { mode: 'print', site, group, monitors: monitorPlan(site, { staff: hasStaff }).map(view), warnings: [...warnings, `Kuma didn’t accept the setup (${e.message}). Add these by hand, or update ACMS.`] };
163
+ } finally { k.close(); }
164
+ }
165
+
166
+ const view = (m) => ({ name: m.name, type: m.type, url: m.type === 'push' ? null : m.url, interval: m.interval, check: m.what,
167
+ ...(m.jsonPath ? { jsonQuery: `${m.jsonPath} == ${m.expectedValue}` } : {}), acceptedStatusCodes: m.accepted_statuscodes, retries: m.maxretries });
168
+
169
+ export function renderMonitor(d) {
170
+ const out = [];
171
+ for (const w of d.warnings) out.push(`! ${w}`);
172
+ if (d.mode === 'print') {
173
+ out.push(`Add these to Uptime Kuma in a group named "${d.group}":`, '');
174
+ for (const m of d.monitors) {
175
+ out.push(` ${m.name} (${m.type === 'json-query' ? 'HTTP(s) - Json Query' : m.type === 'push' ? 'Push' : 'HTTP(s)'}, every ${m.interval} s${m.retries ? `, ${m.retries} retry` : ''})`);
176
+ if (m.url) out.push(` URL: ${m.url}`);
177
+ if (m.jsonQuery) out.push(` Json Query: ${m.jsonQuery}`);
178
+ if (m.acceptedStatusCodes.join() !== '200-299') out.push(` Accepted status codes: ${m.acceptedStatusCodes.join(', ')}`);
179
+ out.push(` Checks: ${m.check}`);
180
+ }
181
+ out.push('', 'Then paste the Scheduler monitor’s push URL and a status page with these monitors into Admin → Settings → Monitoring.');
182
+ return out.join('\n');
183
+ }
184
+ out.push(`Uptime Kuma ${d.kumaVersion ?? ''} at ${d.kuma}: ${d.group}`);
185
+ for (const m of d.monitors) out.push(` ✓ ${m.action.padEnd(7)} ${m.name}${m.url ? ` ${m.url}` : ''}`);
186
+ out.push('', 'Paste into Admin → Settings → Monitoring:', ` Scheduler heartbeat: ${d.pushUrl}`, ` Status page: ${d.statusPage}`,
187
+ '', 'Alerts: in Kuma, add a notification (email, Telegram, Discord…) to the group’s monitors.');
188
+ return out.join('\n');
189
+ }
package/cli/preview.mjs CHANGED
@@ -17,7 +17,7 @@ export async function prepareFixture(p,dir,slug){
17
17
  await writeFile(join(root,'package.json'),JSON.stringify({name:'acms-isolated-preview',type:'module',private:true}));
18
18
  await writeFile(join(root,'astro.config.mjs'),`import {defineConfig,passthroughImageService} from 'astro/config';\nimport react from '@astrojs/react';\nimport node from '@astrojs/node';\nimport cloudflare from '@astrojs/cloudflare';\nimport acms from '@acms-subir/core';\nexport default defineConfig({output:'server',vite:{resolve:{preserveSymlinks:true}},integrations:[react(),acms({seed:{siteTitle:'Theme preview — synthetic content'}})],adapter:process.env.DEPLOY_TARGET==='cloudflare'?cloudflare({platformProxy:{enabled:false},imageService:'passthrough'}):node({mode:'standalone'}),image:{service:passthroughImageService()}});\n`);
19
19
  await writeFile(join(root,'src/pages/acms-theme-preview/setup.astro'),`---
20
- import {getRepo} from '@acms-subir/core/lib/repo/index';
20
+ import {getRepo} from ${JSON.stringify(join(CORE,'src/lib/repo/index.ts'))};
21
21
  const repo=await getRepo(Astro.locals);
22
22
  let home=await repo.getPageBySlug('preview-home');
23
23
  if(!home){
package/deploy/Dockerfile CHANGED
@@ -25,10 +25,14 @@ ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
25
25
  RUN npm install --no-save --no-audit --no-fund "@libsql/client@^0.15.0"
26
26
  COPY --from=build /app/dist ./dist
27
27
  COPY --from=build /app/node_modules/@acms-subir/core/email-runner.mjs ./email-runner.mjs
28
+ # acms migrate: applies pending migrations under the database lock before
29
+ # the server starts (the app's own first-request migration is the fallback)
30
+ COPY --from=build /app/node_modules/@acms-subir/core/migrate.mjs ./acms/migrate.mjs
31
+ COPY --from=build /app/node_modules/@acms-subir/core/cli/migrate.mjs /app/node_modules/@acms-subir/core/cli/migrations.generated.mjs ./acms/cli/
28
32
  # ./data holds the SQLite database and uploaded media — mount a volume here.
29
33
  # Only data/ needs to be writable (a chown -R of /app would copy-on-write
30
34
  # duplicate every node_modules file into a fresh layer)
31
35
  RUN mkdir -p data && chown node:node data
32
36
  USER node
33
37
  EXPOSE 4321
34
- CMD ["node", "./dist/server/entry.mjs"]
38
+ CMD ["sh", "-c", "node ./acms/migrate.mjs && exec node ./dist/server/entry.mjs"]
@@ -14,8 +14,29 @@ services:
14
14
  - PORT=4321
15
15
  - ADMIN_PASSWORD=${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
16
16
  - SESSION_SECRET=${SESSION_SECRET:?set SESSION_SECRET in .env}
17
+ # Optional Clerk staff authentication (provider selected in astro.config).
18
+ - PUBLIC_CLERK_PUBLISHABLE_KEY=${PUBLIC_CLERK_PUBLISHABLE_KEY:-}
19
+ - CLERK_SECRET_KEY=${CLERK_SECRET_KEY:-}
20
+ - CLERK_AUTHORIZED_PARTIES=${CLERK_AUTHORIZED_PARTIES:-}
21
+ - CLERK_JWT_KEY=${CLERK_JWT_KEY:-}
22
+ - CLERK_OWNER_USER_ID=${CLERK_OWNER_USER_ID:-}
23
+ - CLERK_OWNER_LOCAL_USER_ID=${CLERK_OWNER_LOCAL_USER_ID:-}
17
24
  - EMAIL_RUNNER_SECRET=${EMAIL_RUNNER_SECRET:?set EMAIL_RUNNER_SECRET in .env}
18
25
  - EMAIL_ENCRYPTION_KEY=${EMAIL_ENCRYPTION_KEY:-}
26
+ # Optional: behind the Cloudflare proxy, clear its cache on every content
27
+ # change (token needs Zone → Cache Purge). See docs/caching.md.
28
+ - CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID:-}
29
+ - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:-}
30
+ # Optional scheduled off-site backups to S3-compatible storage (R2,
31
+ # Oracle Object Storage, AWS S3). See docs/production-deployment.md.
32
+ - BACKUP_S3_ENDPOINT=${BACKUP_S3_ENDPOINT:-}
33
+ - BACKUP_S3_BUCKET=${BACKUP_S3_BUCKET:-}
34
+ - BACKUP_S3_REGION=${BACKUP_S3_REGION:-}
35
+ - BACKUP_S3_ACCESS_KEY_ID=${BACKUP_S3_ACCESS_KEY_ID:-}
36
+ - BACKUP_S3_SECRET_ACCESS_KEY=${BACKUP_S3_SECRET_ACCESS_KEY:-}
37
+ - BACKUP_PREFIX=${BACKUP_PREFIX:-}
38
+ - BACKUP_KEEP=${BACKUP_KEEP:-}
39
+ - BACKUP_EVERY_HOURS=${BACKUP_EVERY_HOURS:-}
19
40
  - SQLITE_URL=file:./data/acms.db
20
41
  healthcheck:
21
42
  test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/api/v1/content?limit=1').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
@@ -45,7 +45,7 @@ Rate-limit/quota errors back off. The UI reports estimated workload and provider
45
45
  - **Design → Pages:** add a **Live video** section; choose `card` or `player`, preview and publish using the existing design flow.
46
46
  - **Links:** add a **Live video** block and save. This is a compact card referencing the same site-wide status.
47
47
  - **Older widget pages:** add a **Live video** widget and choose its display mode. Rich-text-only/custom coded homepages must opt into a Design section or explicitly render the component.
48
- - **Custom themes:** import `LiveCard.astro` from `@acms-subir/core/components/LiveCard.astro`, or use the built-in `live` section. ACMS never injects a new placement automatically into a custom template. If a custom route renders the component, set `Astro.response.headers.set('Cache-Control', 'no-store')` in that route's frontmatter; the built-in page routes handle this for you.
48
+ - **Custom themes:** import `LiveCard.astro` from `@acms-subir/core/theme/components/LiveCard.astro`, or use the built-in `live` section. ACMS never injects a new placement automatically into a custom template. If a custom route renders the component, set `Astro.response.headers.set('Cache-Control', 'no-store')` in that route's frontmatter; the built-in page routes handle this for you.
49
49
 
50
50
  ### How it looks
51
51
 
package/docs/mobile.md CHANGED
@@ -113,11 +113,15 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
113
113
 
114
114
  | Hook | For |
115
115
  | --- | --- |
116
- | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`) |
116
+ | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`) |
117
117
  | `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
118
- | `usePosts({ collection, limit })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`) |
118
+ | `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
119
+ | `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
119
120
  | `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90). `from`/`to` take a site-local day (`'2026-10-01'`) or an ISO date-time with a zone (`new Date().toISOString()`) |
120
121
  | `useEvent(slug)` | one event |
122
+ | `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
123
+ | `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
124
+ | `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
121
125
  | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
122
126
  | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
123
127
  | `usePushRegistration({ topics })` / `usePushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications) |
@@ -228,6 +232,23 @@ choose the calendar, better still: they add an **Upcoming events** section
228
232
  (its events source has a calendar and a limit) to the home page in the admin,
229
233
  and `<Sections>` renders it with no app code at all. Say so in your summary.
230
234
 
235
+ **Search one collection as people type.** Check `useSite().data?.features.search`
236
+ first; when it is false, hide the field. Pass a debounced value to `usePosts`:
237
+
238
+ ```tsx
239
+ const [text, setText] = useState('');
240
+ const q = useDebounced(text);
241
+ const sermons = usePosts({ collection: 'sermons', q });
242
+ // <TextInput value={text} onChangeText={setText} />, then sermons.items;
243
+ // q && !sermons.isFetching && !sermons.items.length → "No sermons match"
244
+ ```
245
+
246
+ For a search screen across everything, `useSearch(text)` returns groups
247
+ (posts, pages, events) with snippets. Group `posts` by `collection` for
248
+ headings, and when `more.posts` is true link to the collection list with that
249
+ `q`. Search matches the main-language text, so Spanish words find content only
250
+ where the original is Spanish.
251
+
231
252
  **A native version of a coded section.** From the site folder:
232
253
  `npx acms section new visit-banner --theme <slug>` (new) or, for an existing
233
254
  coded section, write `mobile/sections/VisitBanner.tsx` typed with
@@ -91,6 +91,12 @@ The theme API version covers documented template/section contracts and public co
91
91
 
92
92
  `theme diff` compares against the creation snapshot; `--baseline <folder>` selects another version. Runtime settings/navigation differences are available in `design pull`.
93
93
 
94
+ ## Moving a theme between sites or machines
95
+
96
+ Design → **Export** downloads the design as the Design screen shows it (draft over live) as one `<slug>-<date>.acms-theme.zip`: `manifest.json` (format `acms-theme` v1, theme slug/name/version, core version), `design.json` (token overrides, logo/dark logo/favicon/cover, section layouts keyed by page slug, link targets by URL, and navigation only when ticked) and `media/<key>` with `media.json` for the images it uses. It never contains posts, page text, keys or other settings. Design → **Import** previews the file against the receiving site, uploads its images (kept by id, so `/media/` URLs stay valid), and stages everything as a draft; publish it from Review as usual. Layouts land on the page with the same slug (the exported home page falls back to this site's home); pages with no match are reported and skipped. Section links follow the item at the same URL, otherwise keep their typed address. Owners only; editors cannot export or import.
97
+
98
+ Theme **code** is not in the file: it is compiled into the build. When the file names a theme this site lacks, the import keeps the current theme and says so. Move `src/themes/<slug>/` with git, or `acms theme pack` / `acms theme install`, then rebuild. The same endpoints are scriptable with a scoped key: `GET /api/design/export` (`design:read`, plus `media:read` unless `?media=0`; `?nav=1` adds menus) and `POST /api/design/import` with `{ revision, design, nav?, dryRun? }` (`design:write-draft`).
99
+
94
100
  ## Sharing, versions and rollback
95
101
 
96
102
  `theme pack` produces a deterministic theme-only tgz. `theme install` validates the archive before writing, rejects traversal/links/unexpected files/incompatible core versions, and never runs install scripts. Use `--force` for intentional replacement: the previous directory is retained in `.acms/theme-backups/`. `.acms/themes.lock.json` records the pinned version, digest and backup path. Copy a retained backup to a new theme identity or re-pack/install it to roll code back; rebuild and redeploy. No automatic download of theme dependencies occurs.
package/integration.mjs CHANGED
@@ -71,6 +71,8 @@ const ROUTES = [
71
71
  ['/api/email/webhooks/[provider]', 'api/email/webhooks/[provider].ts'],
72
72
  ['/api/email/run', 'api/email/run.ts'],
73
73
  ['/api/ops/status', 'api/ops/status.ts'],
74
+ ['/api/health', 'api/health.ts'],
75
+ ['/api/monitoring', 'api/monitoring.ts'],
74
76
  ['/admin/ai', 'admin/ai.astro'],
75
77
  ['/admin/import', 'admin/import.astro'],
76
78
  ['/admin/tags', 'admin/tags.astro'],
@@ -86,6 +88,8 @@ const ROUTES = [
86
88
  ['/api/design/keys', 'api/design/keys.ts'],
87
89
  ['/api/design/capabilities', 'api/design/capabilities.ts'],
88
90
  ['/api/design/media', 'api/design/media.ts'],
91
+ ['/api/design/export', 'api/design/export.ts'],
92
+ ['/api/design/import', 'api/design/import.ts'],
89
93
  ['/api/design/ai', 'api/design/ai.ts'],
90
94
  ['/api/design/translate', 'api/design/translate.ts'],
91
95
  ['/api/agent', 'api/agent/index.ts'],
@@ -231,8 +235,30 @@ function resolveI18n(i18n, contentBases) {
231
235
  }
232
236
 
233
237
  // Defaults + validation for acms({ search }). Search ships on; disabling it
234
- // removes the /search page(s) and /api/search from the route table, and the
238
+ // removes the /search page(s), /api/search and /api/v1/search from the route table, and the
235
239
  // layout skips the header trigger + modal (lib/search-config.ts).
240
+ // acms({ cache }): CDN caching of public pages and the public app API.
241
+ // `false` turns it off (every response is private, no-store); `memory` is the
242
+ // Node-only in-process cache of public API data, keyed by content version.
243
+ function resolveCache(cache) {
244
+ const defaults = { enabled: true, maxAge: 60, staleWhileRevalidate: 86400, memory: true };
245
+ if (cache === undefined || cache === true) return defaults;
246
+ if (cache === false) return { ...defaults, enabled: false, memory: false };
247
+ if (!cache || typeof cache !== 'object' || Array.isArray(cache)) throw new Error('acms: cache must be false or { maxAge?, staleWhileRevalidate?, memory? }');
248
+ const unknown = Object.keys(cache).filter((k) => !['maxAge', 'staleWhileRevalidate', 'memory'].includes(k));
249
+ if (unknown.length > 0) {
250
+ throw new Error(`acms: unknown cache key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')} — valid keys are maxAge, staleWhileRevalidate and memory`);
251
+ }
252
+ const out = { ...defaults, ...cache };
253
+ for (const k of ['maxAge', 'staleWhileRevalidate']) {
254
+ if (!Number.isInteger(out[k]) || out[k] < 0 || out[k] > 31_536_000) throw new Error(`acms: cache.${k} must be a whole number of seconds (0–31536000)`);
255
+ }
256
+ if (typeof out.memory !== 'boolean') throw new Error('acms: cache.memory must be true or false');
257
+ // maxAge 0 means "don't let the CDN store anything" — same as cache: false
258
+ if (out.maxAge === 0) return { ...out, enabled: false };
259
+ return out;
260
+ }
261
+
236
262
  function resolveSearch(search) {
237
263
  if (search === undefined) return { enabled: true };
238
264
  const unknown = Object.keys(search).filter((k) => k !== 'enabled');
@@ -471,7 +497,8 @@ function acmsVitePlugin(userConfig) {
471
497
  * browser (/admin/app-auth, OAuth 2.0 + PKCE; only the listed redirect URIs
472
498
  * receive codes) and edit through the bearer-token /api/v1/admin/* API.
473
499
  * @param {{enabled?: boolean}} [options.search]
474
- * Public site search: the /search page, the /api/search JSON endpoint, and
500
+ * Public site search: the /search page, the /api/search JSON endpoint, app
501
+ * search (/api/v1/search and /api/v1/content?q=), and
475
502
  * the header search modal. On by default — set `enabled: false` to remove
476
503
  * all three.
477
504
  * @param {false | {enabled?: boolean, base?: string, eventsBase?: string, defaultView?: 'month' | 'week' | 'list', eventFields?: Array<{key: string, label?: string, type?: 'text' | 'textarea' | 'number' | 'url' | 'select', options?: string[]}>}} [options.calendar]
@@ -490,6 +517,15 @@ function acmsVitePlugin(userConfig) {
490
517
  * pasted map links still work.
491
518
  * @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
492
519
  * Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
520
+ * @param {{provider?: 'builtin' | 'clerk'}} [options.auth] Staff authentication provider. Defaults to builtin.
521
+ * @param {false | {maxAge?: number, staleWhileRevalidate?: number, memory?: boolean}} [options.cache]
522
+ * Shared caching of public pages and the public app API (`/api/v1/*`):
523
+ * `Cache-Control: public, s-maxage=<maxAge>, stale-while-revalidate=<staleWhileRevalidate>`
524
+ * (defaults 60 and 86400). Admin, previews, signed-in visitors, bookings,
525
+ * forms and every non-GET request stay `private, no-store`. Every content
526
+ * change clears the cache (Workers Cache, or the Cloudflare zone when
527
+ * CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN are set). `memory: false` turns
528
+ * off the Node in-process API cache. `cache: false` turns all of it off.
493
529
  * @returns {import('astro').AstroIntegration}
494
530
  */
495
531
  export default function acms(options = {}) {
@@ -500,6 +536,7 @@ export default function acms(options = {}) {
500
536
  ]);
501
537
  const i18n = resolveI18n(options.i18n, contentBases);
502
538
  const search = resolveSearch(options.search);
539
+ const cache = resolveCache(options.cache);
503
540
  const mobile = resolveMobile(options.mobile);
504
541
  const calendar = resolveCalendar(options.calendar, contentBases, i18n.locales);
505
542
  const maps = resolveMaps(options.maps);
@@ -507,13 +544,18 @@ export default function acms(options = {}) {
507
544
  if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
508
545
  if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
509
546
  if (legal.updatedAt && (!/^\d{4}-\d{2}-\d{2}$/.test(legal.updatedAt) || !Number.isFinite(Date.parse(legal.updatedAt)) || new Date(legal.updatedAt).toISOString().slice(0,10) !== legal.updatedAt)) throw new Error('acms: legal.updatedAt must be a valid YYYY-MM-DD date');
547
+ const auth = { provider: options.auth?.provider === undefined ? 'builtin' : options.auth.provider };
548
+ if (options.auth !== undefined && (!options.auth || typeof options.auth !== 'object' || Array.isArray(options.auth) || Object.keys(options.auth).some(k => k !== 'provider'))) throw new Error('acms: auth accepts only provider');
549
+ if (!['builtin', 'clerk'].includes(auth.provider)) throw new Error('acms: auth.provider must be builtin or clerk');
510
550
  const userConfig = {
551
+ auth,
511
552
  coreVersion: CORE_VERSION,
512
553
  brand: options.brand ?? {},
513
554
  seed: options.seed ?? {},
514
555
  content,
515
556
  i18n,
516
557
  search,
558
+ cache,
517
559
  mobile,
518
560
  calendar,
519
561
  maps,
@@ -532,6 +574,9 @@ export default function acms(options = {}) {
532
574
  ['/api/v1/openapi.json', 'api/v1/openapi.json.ts'],
533
575
  ['/api/v1/content', 'api/v1/content/index.ts'],
534
576
  ['/api/v1/content/[id]', 'api/v1/content/[id].ts'],
577
+ ['/api/v1/links', 'api/v1/links.ts'],
578
+ ['/api/v1/forms/[id]', 'api/v1/forms/[id]/index.ts'],
579
+ ['/api/v1/forms/[id]/responses', 'api/v1/forms/[id]/responses.ts'],
535
580
  ];
536
581
  if (mobile.ios) routes.push(['/.well-known/apple-app-site-association', '.well-known/apple-app-site-association.ts']);
537
582
  if (mobile.android) routes.push(['/.well-known/assetlinks.json', '.well-known/assetlinks.json.ts']);
@@ -541,9 +586,10 @@ export default function acms(options = {}) {
541
586
  ['/api/v1/devices/[token]', 'api/v1/devices/[token].ts'],
542
587
  ['/api/v1/push/topics', 'api/v1/push/topics.ts'],
543
588
  ['/api/push/admin', 'api/push/admin.ts'],
544
- ['/admin/mobile', 'admin/mobile.astro'],
545
589
  );
546
590
  }
591
+ // Admin → Mobile app: app access switches always; notifications when push is on
592
+ routes.push(['/admin/mobile', 'admin/mobile.astro'], ['/api/mobile/access', 'api/mobile/access.ts']);
547
593
  if (mobile.editor.enabled) {
548
594
  // editor apps: browser sign-in (OAuth 2.0 + PKCE) and the bearer-only admin API
549
595
  routes.push(
@@ -564,6 +610,19 @@ export default function acms(options = {}) {
564
610
  ['/api/v1/admin/media', 'api/v1/admin/media/index.ts'],
565
611
  ['/api/v1/admin/bookings', 'api/v1/admin/bookings/index.ts'],
566
612
  ['/api/v1/admin/bookings/[id]/[action]', 'api/v1/admin/bookings/[id]/[action].ts'],
613
+ // schedule + setup (calendar/booking handlers answer 404 when those modules are off)
614
+ ['/api/v1/admin/schedule', 'api/v1/admin/schedule.ts'],
615
+ ['/api/v1/admin/resources', 'api/v1/admin/resources/index.ts'],
616
+ ['/api/v1/admin/resources/[id]', 'api/v1/admin/resources/[id]/index.ts'],
617
+ ['/api/v1/admin/resources/[id]/links', 'api/v1/admin/resources/[id]/links/index.ts'],
618
+ ['/api/v1/admin/resources/[id]/links/[linkId]', 'api/v1/admin/resources/[id]/links/[linkId].ts'],
619
+ ['/api/v1/admin/resources/[id]/blocks', 'api/v1/admin/resources/[id]/blocks/index.ts'],
620
+ ['/api/v1/admin/resources/[id]/blocks/[blockId]', 'api/v1/admin/resources/[id]/blocks/[blockId].ts'],
621
+ ['/api/v1/admin/calendars', 'api/v1/admin/calendars/index.ts'],
622
+ ['/api/v1/admin/calendars/[id]', 'api/v1/admin/calendars/[id].ts'],
623
+ ['/api/v1/admin/forms', 'api/v1/admin/forms/index.ts'],
624
+ ['/api/v1/admin/forms/[id]/responses', 'api/v1/admin/forms/[id]/responses.ts'],
625
+ ['/api/v1/admin/links', 'api/v1/admin/links.ts'],
567
626
  );
568
627
  }
569
628
  if (legal.enabled) routes.push(['/privacy', 'legal/privacy.astro'], ['/terms', 'legal/terms.astro']);
@@ -577,7 +636,7 @@ export default function acms(options = {}) {
577
636
  if (l !== i18n.defaultLocale) routes.push([`/${l}`, 'index.astro']);
578
637
  }
579
638
  if (search.enabled) {
580
- routes.push(['/search', 'search.astro'], ['/api/search', 'api/search.ts']);
639
+ routes.push(['/search', 'search.astro'], ['/api/search', 'api/search.ts'], ['/api/v1/search', 'api/v1/search.ts']);
581
640
  // a locale-prefixed search page per non-default locale, mirroring the
582
641
  // per-locale home routes; search.astro derives its locale from the path
583
642
  for (const l of i18n.locales) {
@@ -604,6 +663,17 @@ export default function acms(options = {}) {
604
663
  ['/admin/calendar/integrations', 'admin/calendar/integrations.astro'],
605
664
  ['/api/calendar/docs', 'api/calendar/docs.ts'],
606
665
  [`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
666
+ // the booker's own booking from an app (token in the Booking-Token header)
667
+ ['/api/v1/bookings/manage', 'api/v1/bookings/manage/index.ts'],
668
+ ['/api/v1/bookings/manage/cancel', 'api/v1/bookings/manage/cancel.ts'],
669
+ ['/api/v1/bookings/manage/availability', 'api/v1/bookings/manage/availability.ts'],
670
+ ['/api/v1/bookings/manage/reschedule', 'api/v1/bookings/manage/reschedule.ts'],
671
+ ['/api/v1/bookings/manage/ics', 'api/v1/bookings/manage/ics.ts'],
672
+ // guest sign-in for "My bookings" (answers 404 until email is set up)
673
+ ['/api/v1/guest/code', 'api/v1/guest/code.ts'],
674
+ ['/api/v1/guest/token', 'api/v1/guest/token.ts'],
675
+ ['/api/v1/guest/bookings', 'api/v1/guest/bookings.ts'],
676
+ ['/api/v1/guest/revoke', 'api/v1/guest/revoke.ts'],
607
677
  ['/admin/calendar/bookings', 'admin/calendar/bookings.astro'],
608
678
  );
609
679
  if (calendar.bookings) {
package/migrate.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // acms-migrate: apply pending schema migrations under the database lock.
3
+ // The Docker image runs this before the server; see cli/migrate.mjs.
4
+ import { main } from './cli/migrate.mjs';
5
+ await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,22 +24,16 @@
24
24
  "bin": {
25
25
  "acms": "cli.mjs",
26
26
  "acms-email-runner": "email-runner.mjs",
27
- "acms-check-deployment": "ops-check.mjs"
27
+ "acms-check-deployment": "ops-check.mjs",
28
+ "acms-migrate": "migrate.mjs"
28
29
  },
29
30
  "exports": {
30
31
  ".": "./integration.mjs",
31
32
  "./package.json": "./package.json",
32
33
  "./actions": "./src/actions/index.ts",
33
- "./middleware": "./src/middleware.ts",
34
- "./worker": "./src/worker.ts",
35
- "./lib/*": "./src/lib/*",
36
- "./pages/*": "./src/pages/*",
37
- "./components/*": "./src/components/*",
38
- "./layouts/*": "./src/layouts/*",
39
- "./styles/*": "./src/styles/*",
40
- "./themes/*": "./src/themes/*",
41
34
  "./theme": "./src/theme-sdk/index.ts",
42
35
  "./theme/schema": "./src/theme-sdk/contract.mjs",
36
+ "./theme/components/LiveCard.astro": "./src/components/LiveCard.astro",
43
37
  "./theme/components/*": "./src/components/site/*"
44
38
  },
45
39
  "files": [
@@ -50,12 +44,14 @@
50
44
  "email-runner.mjs",
51
45
  "deploy",
52
46
  "ops-check.mjs",
47
+ "migrate.mjs",
53
48
  "cli",
54
49
  "docs",
55
50
  "section-library",
56
51
  "mobile-template"
57
52
  ],
58
53
  "dependencies": {
54
+ "@clerk/backend": "^3.22.0",
59
55
  "@cloudflare/workers-types": "^5.20260927.1",
60
56
  "@dnd-kit/core": "^6.3.1",
61
57
  "@dnd-kit/sortable": "^10.0.0",
@@ -109,10 +105,12 @@
109
105
  "@types/react-dom": "^19.2.4",
110
106
  "@types/sanitize-html": "^2.16.1",
111
107
  "@types/turndown": "^5.0.6",
108
+ "esbuild": "^0.28.1",
112
109
  "miniflare": "^5.20260801.0-alpha",
113
110
  "vitest": "^3.2.0"
114
111
  },
115
112
  "scripts": {
116
- "test": "vitest run"
113
+ "test": "vitest run",
114
+ "gen:migrations": "node scripts/gen-migrations.mjs"
117
115
  }
118
116
  }