@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.
- package/CHANGELOG.md +51 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +17 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +23 -2
- package/docs/theme-authoring.md +6 -0
- package/integration.mjs +74 -4
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +11 -31
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +42 -12
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +8 -4
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- package/src/lib/editor/me.ts +4 -0
- package/src/lib/editor/openapi.ts +49 -1
- package/src/lib/editor/roles.ts +20 -0
- package/src/lib/editor/schedule.ts +340 -0
- package/src/lib/editor/schemas.ts +36 -1
- package/src/lib/editor/site-data.ts +110 -0
- package/src/lib/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +9 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +8 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/store.ts +5 -0
- package/src/lib/mobile/access-admin.ts +34 -0
- package/src/lib/mobile/access.ts +28 -0
- package/src/lib/mobile/calendar.ts +26 -13
- package/src/lib/mobile/content.ts +21 -1
- package/src/lib/mobile/forms.ts +77 -0
- package/src/lib/mobile/guest-store.ts +62 -0
- package/src/lib/mobile/guest.ts +160 -0
- package/src/lib/mobile/http.ts +6 -5
- package/src/lib/mobile/links.ts +69 -0
- package/src/lib/mobile/manage.ts +114 -0
- package/src/lib/mobile/openapi.ts +40 -4
- package/src/lib/mobile/route.ts +63 -4
- package/src/lib/mobile/schemas.ts +52 -1
- package/src/lib/mobile/search.ts +95 -0
- package/src/lib/mobile/site.ts +8 -2
- package/src/lib/monitoring.ts +171 -0
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +104 -0
- package/src/lib/repo/sql.ts +122 -44
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +31 -3
- package/src/lib/scheduler.ts +14 -0
- package/src/lib/schema.ts +35 -1
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +43 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/mobile/access.ts +22 -0
- package/src/pages/api/monitoring.ts +22 -0
- package/src/pages/api/ops/status.ts +2 -1
- package/src/pages/api/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
- package/src/pages/api/v1/admin/bookings/index.ts +10 -2
- package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
- package/src/pages/api/v1/admin/calendars/index.ts +12 -0
- package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
- package/src/pages/api/v1/admin/forms/index.ts +6 -0
- package/src/pages/api/v1/admin/links.ts +13 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
- package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
- package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/index.ts +12 -0
- package/src/pages/api/v1/admin/schedule.ts +7 -0
- package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
- package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
- package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
- package/src/pages/api/v1/bookings/manage/index.ts +6 -0
- package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/forms/[id]/index.ts +7 -0
- package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
- package/src/pages/api/v1/guest/bookings.ts +6 -0
- package/src/pages/api/v1/guest/code.ts +6 -0
- package/src/pages/api/v1/guest/revoke.ts +6 -0
- package/src/pages/api/v1/guest/token.ts +6 -0
- package/src/pages/api/v1/links.ts +7 -0
- package/src/pages/api/v1/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +169 -16
- package/src/styles/email-settings.css +6 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
package/cli/mobile-doctor.mjs
CHANGED
|
@@ -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).` });
|
package/cli/monitor.mjs
ADDED
|
@@ -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 '
|
|
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 ["
|
|
38
|
+
CMD ["sh", "-c", "node ./acms/migrate.mjs && exec node ./dist/server/entry.mjs"]
|
package/deploy/compose.yml
CHANGED
|
@@ -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))"]
|
package/docs/live-video.md
CHANGED
|
@@ -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
|
package/docs/theme-authoring.md
CHANGED
|
@@ -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,
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acms-subir/core",
|
|
3
|
-
"version": "0.3.
|
|
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
|
}
|