@acms-subir/core 0.3.9 → 0.3.10

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 (85) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/main.mjs +7 -1
  4. package/cli/mobile-doctor.mjs +9 -0
  5. package/cli/monitor.mjs +189 -0
  6. package/docs/mobile.md +23 -2
  7. package/integration.mjs +35 -4
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +6 -26
  10. package/src/components/AppAccess.tsx +117 -0
  11. package/src/components/MonitoringSettings.tsx +112 -0
  12. package/src/layouts/Admin.astro +2 -3
  13. package/src/lib/api.ts +2 -1
  14. package/src/lib/backup/content.ts +1 -1
  15. package/src/lib/booking/availability.ts +15 -2
  16. package/src/lib/booking/service.ts +53 -1
  17. package/src/lib/calendar/admin.ts +41 -0
  18. package/src/lib/editor/me.ts +4 -0
  19. package/src/lib/editor/openapi.ts +49 -1
  20. package/src/lib/editor/roles.ts +20 -0
  21. package/src/lib/editor/schedule.ts +340 -0
  22. package/src/lib/editor/schemas.ts +36 -1
  23. package/src/lib/editor/site-data.ts +110 -0
  24. package/src/lib/email/service.ts +8 -1
  25. package/src/lib/email/store.ts +10 -0
  26. package/src/lib/email/templates.ts +6 -0
  27. package/src/lib/forms/store.ts +5 -0
  28. package/src/lib/mobile/access-admin.ts +34 -0
  29. package/src/lib/mobile/access.ts +28 -0
  30. package/src/lib/mobile/calendar.ts +26 -13
  31. package/src/lib/mobile/content.ts +21 -1
  32. package/src/lib/mobile/forms.ts +77 -0
  33. package/src/lib/mobile/guest-store.ts +62 -0
  34. package/src/lib/mobile/guest.ts +160 -0
  35. package/src/lib/mobile/http.ts +2 -2
  36. package/src/lib/mobile/links.ts +69 -0
  37. package/src/lib/mobile/manage.ts +114 -0
  38. package/src/lib/mobile/openapi.ts +40 -4
  39. package/src/lib/mobile/route.ts +24 -2
  40. package/src/lib/mobile/schemas.ts +52 -1
  41. package/src/lib/mobile/search.ts +95 -0
  42. package/src/lib/mobile/site.ts +8 -2
  43. package/src/lib/monitoring.ts +171 -0
  44. package/src/lib/repo/migrations.ts +28 -0
  45. package/src/lib/repo/sql.ts +66 -20
  46. package/src/lib/repo/types.ts +19 -2
  47. package/src/lib/scheduler.ts +7 -0
  48. package/src/lib/schema.ts +35 -1
  49. package/src/pages/admin/calendar/resources/link.astro +2 -0
  50. package/src/pages/admin/mobile.astro +6 -2
  51. package/src/pages/admin/settings.astro +12 -0
  52. package/src/pages/api/health.ts +17 -0
  53. package/src/pages/api/mobile/access.ts +22 -0
  54. package/src/pages/api/monitoring.ts +22 -0
  55. package/src/pages/api/ops/status.ts +2 -1
  56. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
  57. package/src/pages/api/v1/admin/bookings/index.ts +10 -2
  58. package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
  59. package/src/pages/api/v1/admin/calendars/index.ts +12 -0
  60. package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
  61. package/src/pages/api/v1/admin/forms/index.ts +6 -0
  62. package/src/pages/api/v1/admin/links.ts +13 -0
  63. package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
  64. package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
  65. package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
  66. package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
  67. package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
  68. package/src/pages/api/v1/admin/resources/index.ts +12 -0
  69. package/src/pages/api/v1/admin/schedule.ts +7 -0
  70. package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
  71. package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
  72. package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
  73. package/src/pages/api/v1/bookings/manage/index.ts +6 -0
  74. package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
  75. package/src/pages/api/v1/forms/[id]/index.ts +7 -0
  76. package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
  77. package/src/pages/api/v1/guest/bookings.ts +6 -0
  78. package/src/pages/api/v1/guest/code.ts +6 -0
  79. package/src/pages/api/v1/guest/revoke.ts +6 -0
  80. package/src/pages/api/v1/guest/token.ts +6 -0
  81. package/src/pages/api/v1/links.ts +7 -0
  82. package/src/pages/api/v1/search.ts +7 -0
  83. package/src/pages/book/[resource]/[link].astro +3 -3
  84. package/src/pages/book/manage/[token].astro +11 -7
  85. package/src/styles/admin.css +66 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.10
4
+
5
+ ### Patch Changes
6
+
7
+ - Apps can now do most of what the website does, with protection against abuse built in.
8
+
9
+ - **Manage a booking from the app.** People can see, cancel, move or add to their calendar a booking made in the app or on the website, using its private link. The token goes in a header, never the URL, and repeated wrong tokens lock the caller out for a while. New `manageBooking`, `cancelBooking`, `rescheduleAvailability`, `rescheduleBooking` and `bookingIcs` in the client; `useManagedBooking`, `useCancelBooking`, `useRescheduleBooking` and `useRescheduleAvailability` in native.
10
+ - **"My bookings" with guest sign-in.** A visitor enters their email, gets a 6-digit code, and sees every booking and RSVP made with that address. Codes and tokens are stored hashed, codes expire after 10 minutes and 5 tries (counted safely even when requests arrive at once), each code signs in only once, and replies never reveal whether an address has bookings. Needs email sending. `useGuest()` and `useMyBookings()` in native; the guest token is saved per site and only ever sent to the site that issued it.
11
+ - **Links page and forms in apps.** `GET /api/v1/links` and published forms (`GET /api/v1/forms/{id}`, `POST …/responses`) with the web form's validation, retry-safe submissions and a daily limit per form (retries and invalid answers don't use it up). `useLinks`, `useForm` and `useSubmitForm`.
12
+ - **Staff run the schedule from the staff app.** A week or month schedule view in one request, booking on someone's behalf, moving bookings, blocking time, and managing rooms, booking types, hours and calendars. Form responses and the links page too. Changing rooms, hours and calendars is limited to administrators and owners by default; reading form responses and editing raw HTML link blocks always are. `me().permissions` reports what each person may do.
13
+ - **Admin → Mobile app → App access** (owners): switch each app feature off instantly, sign every guest device out, and set limits (bookings per email per day, default 10, which also applies to website bookings but not to staff booking for someone; responses per form per day) and who may change the schedule setup.
14
+ - **Change cutoff for booking types:** "Guests can change until X hours before" stops guests cancelling or moving close to the start, on the website and in apps. Off by default.
15
+ - **Fix: booking lengths.** A booking (website, app or staff) can only use one of its booking type's lengths. Before, sending a custom `minutes` could hold a 30-minute slot type for hours.
16
+ - **Fix: day bookings across a clock change.** A whole-day booking on a day when clocks go back or forward (23 or 25 hours long) could not be booked or moved. The end now snaps to the real local midnight on the website, in apps and for staff.
17
+ - **Fix: rooms with all-day hours and single-day overrides.** The staff app now accepts hours that run to midnight (`00:00` end) and a date override without an end date, and refuses impossible times like `09:99`.
18
+ - **Fix: client retries.** The client no longer automatically repeats requests that can't safely run twice (sign-in code requests and exchanges, cancelling and moving a booking). When a rate limit asks for a longer wait than the request timeout, the 429 is returned at once with its `retryAfter` instead of ending as a timeout. Rate limits now give their real wait in `Retry-After` (for example an hour for the sign-in email cap).
19
+ - **Links page edits can't overwrite each other.** The staff app's links page carries a revision: a save made from an out-of-date copy (someone else changed it in an app or on the website) answers 409 and saves nothing, and a save that fails partway leaves the page as it was. Click counts don't count as changes.
20
+
21
+ - Search in apps. Apps can now search the site the same way the website's search box does: titles count most, then excerpts, tags and body text, and results keep up as people type.
22
+
23
+ - **Search one collection:** `GET /api/v1/content?q=` returns ranked matches in the normal list format, so `posts({ collection: 'sermons', q })` in the client and `usePosts({ collection: 'sermons', q })` in native show a filtered sermon list with no other changes.
24
+ - **Search everything:** the new `GET /api/v1/search?q=` returns the top matches in posts, pages and events on public calendars, each with a plain-text snippet and the ranges of the matched words to highlight, plus a "more" flag for each group. Use `client.search(text)` or the native `useSearch(text)` hook, which waits for typing to pause and fetches nothing for a blank box. `useDebounced(value)` is exported for your own search fields.
25
+ - Both follow `acms({ search })`: when search is off, `/api/v1/search` isn't registered and `q` returns a 400. Documented in the OpenAPI spec and `docs/mobile-api-v1.md`.
26
+ - Event results: public events are found even when private events match the query more strongly, and `GET /api/v1/events?q=` matches the same events as `/search` (including body text and word beginnings), so a "more events" screen continues the same results by date.
27
+
28
+ - Uptime monitoring with Uptime Kuma, so you know within minutes when a site, its app API or its scheduled jobs stop.
29
+
30
+ - **`GET /api/health`**: a public health check for monitors. 503 when the database or the scheduler is down, `degraded` (still 200) when email is stuck. It names only the failing part.
31
+ - **Scheduler heartbeat**: after every scheduled run the site reports to a Kuma push monitor, which goes down when the reports stop (no reminders, scheduled posts or emails). Set the URL in Admin → Settings → Monitoring or `UPTIME_PUSH_URL`.
32
+ - **`npx acms monitor kuma`**: creates the website, app API, staff app API, health and scheduler monitors plus a status page in your Kuma (1.23 or 2.x) in one go. Reruns update instead of duplicating; `--print` lists them to add by hand. The Kuma login comes from `KUMA_USERNAME` / `KUMA_PASSWORD` (+ `KUMA_TOTP`).
33
+ - **Admin → Settings → Monitoring** (owners): this site's own health, each Kuma monitor's state and 24-hour uptime, and the heartbeat with a "Send test" button.
34
+ - `acms mobile doctor` warns when `/api/health` isn't ok, and the authenticated `/api/ops/status` now answers 503 when it reports a problem.
35
+
3
36
  ## 0.3.9
4
37
 
5
38
  ### Patch Changes
package/cli/common.mjs CHANGED
@@ -15,8 +15,8 @@ export async function project(root=process.cwd()) {
15
15
  return {cwd,pkg,core,version:(await readJson(resolve(core,'package.json'))).version};
16
16
  }
17
17
  export function parse(argv) {
18
- const positionals=[],flags={};const values=new Set(['cwd','site','from','output','baseline','file','url','token-env','revision','days','port','format','tool','theme','as','name','dir','bundle']);
19
- const booleans=new Set(['json','help','yes','dry-run','check','next','latest','provision','draft','build','optimize','force','native','offline','mobile','no-config']);
18
+ const positionals=[],flags={};const values=new Set(['cwd','site','from','output','baseline','file','url','token-env','revision','days','port','format','tool','theme','as','name','dir','bundle','kuma']);
19
+ const booleans=new Set(['json','help','yes','dry-run','check','next','latest','provision','draft','build','optimize','force','native','offline','mobile','no-config','print']);
20
20
  for(let i=0;i<argv.length;i++) {
21
21
  let a=argv[i];if(a==='-h')a='--help';if(a==='-y')a='--yes';
22
22
  if(!a.startsWith('--')){positionals.push(a);continue;}
package/cli/main.mjs CHANGED
@@ -7,6 +7,7 @@ import { sectionCommand } from './sections.mjs';
7
7
  import { remoteCommand } from './remote.mjs';
8
8
  import { addMobile, mobileCommand, listApps } from './mobile.mjs';
9
9
  import { mobileDoctor, renderDoctor } from './mobile-doctor.mjs';
10
+ import { monitorCommand, renderMonitor } from './monitor.mjs';
10
11
  const HELP=`acms — ACMS project, theme and agent tools
11
12
  Discovery: inspect | schema theme | docs theme | docs sections | docs mobile
12
13
  agent init [--mobile] write .agents/skills (theme; mobile when an app exists)
@@ -44,6 +45,10 @@ Mobile: add mobile [client] [--dir mobile] [--name "Riverside"]
44
45
  client: events news booking push
45
46
  editor: editor-content editor-writer editor-sections
46
47
  editor-translations editor-media editor-bookings
48
+ Monitoring: monitor kuma [--url https://site] [--kuma https://kuma] [--print]
49
+ website, app API, staff app API, health and scheduler
50
+ monitors + a status page in Uptime Kuma; reruns update.
51
+ Login: KUMA_USERNAME, KUMA_PASSWORD (+ KUMA_TOTP)
47
52
  Maintenance: update [--check|--next|--latest] | doctor
48
53
  add cloudflare [--provision] | add docker
49
54
  Global: --json --cwd <dir> --site <profile> --dry-run --yes --help
@@ -105,6 +110,7 @@ export async function main() {
105
110
  if(!flags.json){console.log(renderDoctor(data));return;}
106
111
  }
107
112
  else if(cmd==='mobile')data=await mobileCommand(sub,rest,flags);
113
+ else if(cmd==='monitor'){data=await monitorCommand(sub,flags);if(!flags.json){console.log(renderMonitor(data));return;}}
108
114
  else if(['update','doctor','add'].includes(cmd)) {
109
115
  if(flags['dry-run']&&cmd!=='doctor')data={dryRun:true,command:parsed.args,action:cmd==='update'?'Check/install core version':'Write deployment configuration and install dependencies'};
110
116
  else {
@@ -126,7 +132,7 @@ export async function main() {
126
132
  }
127
133
  else if(cmd==='agent'&&sub==='init')data=await agentInit(flags);
128
134
  else if(cmd==='inspect') {
129
- const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
135
+ const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
130
136
  } else if(cmd==='theme')data=await themeCommand(sub,rest,flags);
131
137
  else if(['site','design','content','media'].includes(cmd))data=await remoteCommand(cmd,sub,rest,flags);
132
138
  else fail('USAGE','Unknown command.',[],['Run acms --help']);
@@ -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/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/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'],
@@ -231,7 +233,7 @@ function resolveI18n(i18n, contentBases) {
231
233
  }
232
234
 
233
235
  // 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
236
+ // removes the /search page(s), /api/search and /api/v1/search from the route table, and the
235
237
  // layout skips the header trigger + modal (lib/search-config.ts).
236
238
  function resolveSearch(search) {
237
239
  if (search === undefined) return { enabled: true };
@@ -471,7 +473,8 @@ function acmsVitePlugin(userConfig) {
471
473
  * browser (/admin/app-auth, OAuth 2.0 + PKCE; only the listed redirect URIs
472
474
  * receive codes) and edit through the bearer-token /api/v1/admin/* API.
473
475
  * @param {{enabled?: boolean}} [options.search]
474
- * Public site search: the /search page, the /api/search JSON endpoint, and
476
+ * Public site search: the /search page, the /api/search JSON endpoint, app
477
+ * search (/api/v1/search and /api/v1/content?q=), and
475
478
  * the header search modal. On by default — set `enabled: false` to remove
476
479
  * all three.
477
480
  * @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]
@@ -532,6 +535,9 @@ export default function acms(options = {}) {
532
535
  ['/api/v1/openapi.json', 'api/v1/openapi.json.ts'],
533
536
  ['/api/v1/content', 'api/v1/content/index.ts'],
534
537
  ['/api/v1/content/[id]', 'api/v1/content/[id].ts'],
538
+ ['/api/v1/links', 'api/v1/links.ts'],
539
+ ['/api/v1/forms/[id]', 'api/v1/forms/[id]/index.ts'],
540
+ ['/api/v1/forms/[id]/responses', 'api/v1/forms/[id]/responses.ts'],
535
541
  ];
536
542
  if (mobile.ios) routes.push(['/.well-known/apple-app-site-association', '.well-known/apple-app-site-association.ts']);
537
543
  if (mobile.android) routes.push(['/.well-known/assetlinks.json', '.well-known/assetlinks.json.ts']);
@@ -541,9 +547,10 @@ export default function acms(options = {}) {
541
547
  ['/api/v1/devices/[token]', 'api/v1/devices/[token].ts'],
542
548
  ['/api/v1/push/topics', 'api/v1/push/topics.ts'],
543
549
  ['/api/push/admin', 'api/push/admin.ts'],
544
- ['/admin/mobile', 'admin/mobile.astro'],
545
550
  );
546
551
  }
552
+ // Admin → Mobile app: app access switches always; notifications when push is on
553
+ routes.push(['/admin/mobile', 'admin/mobile.astro'], ['/api/mobile/access', 'api/mobile/access.ts']);
547
554
  if (mobile.editor.enabled) {
548
555
  // editor apps: browser sign-in (OAuth 2.0 + PKCE) and the bearer-only admin API
549
556
  routes.push(
@@ -564,6 +571,19 @@ export default function acms(options = {}) {
564
571
  ['/api/v1/admin/media', 'api/v1/admin/media/index.ts'],
565
572
  ['/api/v1/admin/bookings', 'api/v1/admin/bookings/index.ts'],
566
573
  ['/api/v1/admin/bookings/[id]/[action]', 'api/v1/admin/bookings/[id]/[action].ts'],
574
+ // schedule + setup (calendar/booking handlers answer 404 when those modules are off)
575
+ ['/api/v1/admin/schedule', 'api/v1/admin/schedule.ts'],
576
+ ['/api/v1/admin/resources', 'api/v1/admin/resources/index.ts'],
577
+ ['/api/v1/admin/resources/[id]', 'api/v1/admin/resources/[id]/index.ts'],
578
+ ['/api/v1/admin/resources/[id]/links', 'api/v1/admin/resources/[id]/links/index.ts'],
579
+ ['/api/v1/admin/resources/[id]/links/[linkId]', 'api/v1/admin/resources/[id]/links/[linkId].ts'],
580
+ ['/api/v1/admin/resources/[id]/blocks', 'api/v1/admin/resources/[id]/blocks/index.ts'],
581
+ ['/api/v1/admin/resources/[id]/blocks/[blockId]', 'api/v1/admin/resources/[id]/blocks/[blockId].ts'],
582
+ ['/api/v1/admin/calendars', 'api/v1/admin/calendars/index.ts'],
583
+ ['/api/v1/admin/calendars/[id]', 'api/v1/admin/calendars/[id].ts'],
584
+ ['/api/v1/admin/forms', 'api/v1/admin/forms/index.ts'],
585
+ ['/api/v1/admin/forms/[id]/responses', 'api/v1/admin/forms/[id]/responses.ts'],
586
+ ['/api/v1/admin/links', 'api/v1/admin/links.ts'],
567
587
  );
568
588
  }
569
589
  if (legal.enabled) routes.push(['/privacy', 'legal/privacy.astro'], ['/terms', 'legal/terms.astro']);
@@ -577,7 +597,7 @@ export default function acms(options = {}) {
577
597
  if (l !== i18n.defaultLocale) routes.push([`/${l}`, 'index.astro']);
578
598
  }
579
599
  if (search.enabled) {
580
- routes.push(['/search', 'search.astro'], ['/api/search', 'api/search.ts']);
600
+ routes.push(['/search', 'search.astro'], ['/api/search', 'api/search.ts'], ['/api/v1/search', 'api/v1/search.ts']);
581
601
  // a locale-prefixed search page per non-default locale, mirroring the
582
602
  // per-locale home routes; search.astro derives its locale from the path
583
603
  for (const l of i18n.locales) {
@@ -604,6 +624,17 @@ export default function acms(options = {}) {
604
624
  ['/admin/calendar/integrations', 'admin/calendar/integrations.astro'],
605
625
  ['/api/calendar/docs', 'api/calendar/docs.ts'],
606
626
  [`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
627
+ // the booker's own booking from an app (token in the Booking-Token header)
628
+ ['/api/v1/bookings/manage', 'api/v1/bookings/manage/index.ts'],
629
+ ['/api/v1/bookings/manage/cancel', 'api/v1/bookings/manage/cancel.ts'],
630
+ ['/api/v1/bookings/manage/availability', 'api/v1/bookings/manage/availability.ts'],
631
+ ['/api/v1/bookings/manage/reschedule', 'api/v1/bookings/manage/reschedule.ts'],
632
+ ['/api/v1/bookings/manage/ics', 'api/v1/bookings/manage/ics.ts'],
633
+ // guest sign-in for "My bookings" (answers 404 until email is set up)
634
+ ['/api/v1/guest/code', 'api/v1/guest/code.ts'],
635
+ ['/api/v1/guest/token', 'api/v1/guest/token.ts'],
636
+ ['/api/v1/guest/bookings', 'api/v1/guest/bookings.ts'],
637
+ ['/api/v1/guest/revoke', 'api/v1/guest/revoke.ts'],
607
638
  ['/admin/calendar/bookings', 'admin/calendar/bookings.astro'],
608
639
  );
609
640
  if (calendar.bookings) {
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.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,13 +14,14 @@ import { CONTENT, collectionUrl } from '../lib/content';
14
14
  import { invalidateRedirectCache } from '../middleware';
15
15
  import { isPrivateHost } from '../lib/net';
16
16
  import { normalizeCalendarSrc } from '../lib/calendar/src';
17
+ import { saveCalendar } from '../lib/calendar/admin';
17
18
  import { getCalendarEvents } from '../lib/calendar/ics';
18
19
  import { siteLocale } from '../lib/dates';
19
20
  import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
20
21
  import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
21
22
  import { holdConflicts } from '../lib/booking/holds';
22
23
  import { setBookingStatus } from '../lib/booking/service';
23
- import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes, safeColor } from '../lib/calendar/events';
24
+ import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from '../lib/calendar/events';
24
25
  import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
25
26
  import { loadFeed } from '../lib/calendar/ics';
26
27
  import { NATIVE_ALL } from '../lib/calendar/render';
@@ -1212,32 +1213,11 @@ export const server = {
1212
1213
  handler: async ({ id, ...data }, ctx) => {
1213
1214
  await requireAdmin(ctx);
1214
1215
  const repo = await getRepo(ctx.locals);
1215
- const existing = id ? await repo.getCalendar(id) : null;
1216
- if (id && !existing) throw new ActionError({ code: 'NOT_FOUND', message: 'Calendar not found.' });
1217
- const kind = existing?.kind ?? data.kind ?? 'native';
1218
- let feedUrl = existing?.feedUrl ?? '';
1219
- if (kind === 'feed' && (data.feedUrl !== undefined || !existing)) {
1220
- const normalized = normalizeCalendarSrc(data.feedUrl ?? '');
1221
- if (!normalized) throw new ActionError({ code: 'BAD_REQUEST', message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.' });
1222
- feedUrl = normalized;
1216
+ try { return { calendar: await saveCalendar(repo, id, data) }; }
1217
+ catch (e) {
1218
+ if (e instanceof ApiError) throw new ActionError({ code: e.status === 404 ? 'NOT_FOUND' : 'BAD_REQUEST', message: e.message });
1219
+ throw e;
1223
1220
  }
1224
- const base = slugify(data.slug || data.name);
1225
- let slug = base;
1226
- for (let n = 2; ; n++) {
1227
- const clash = await repo.getCalendarBySlug(slug);
1228
- if (!clash || clash.id === id) break;
1229
- slug = `${base}-${n}`;
1230
- }
1231
- const fields = {
1232
- name: data.name, slug, color: safeColor(data.color ?? existing?.color ?? '#4f46e5'),
1233
- description: data.description ?? existing?.description ?? '', feedUrl,
1234
- visibility: data.visibility ?? existing?.visibility ?? 'public',
1235
- };
1236
- if (existing) {
1237
- await repo.updateCalendar(existing.id, fields);
1238
- return { calendar: (await repo.getCalendar(existing.id))! };
1239
- }
1240
- return { calendar: await repo.createCalendar({ ...fields, kind }) };
1241
1221
  },
1242
1222
  }),
1243
1223
  reorder: defineAction({