@acms-subir/core 0.2.0 → 0.3.1

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 (254) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/live-video.md +91 -0
  15. package/docs/theme-authoring.md +103 -0
  16. package/email-runner.mjs +32 -0
  17. package/integration.mjs +189 -2
  18. package/ops-check.mjs +27 -0
  19. package/package.json +27 -5
  20. package/src/actions/index.ts +578 -49
  21. package/src/components/AiCleaner.tsx +291 -0
  22. package/src/components/AiSettings.tsx +575 -0
  23. package/src/components/CommandPalette.tsx +200 -0
  24. package/src/components/ContentList.astro +246 -0
  25. package/src/components/DesignAccess.tsx +182 -0
  26. package/src/components/DesignManager.tsx +418 -46
  27. package/src/components/EmailSettings.tsx +521 -0
  28. package/src/components/EventPage.astro +178 -0
  29. package/src/components/LegalPage.astro +27 -0
  30. package/src/components/LinksManager.tsx +119 -58
  31. package/src/components/LiveCard.astro +174 -0
  32. package/src/components/LiveManager.tsx +910 -0
  33. package/src/components/NavManager.tsx +42 -43
  34. package/src/components/ThemeScript.astro +50 -0
  35. package/src/components/ThemeSections.astro +58 -0
  36. package/src/components/ThemeSectionsEditor.tsx +513 -0
  37. package/src/components/WidgetRenderer.astro +3 -0
  38. package/src/components/booking/BookingFlow.astro +150 -0
  39. package/src/components/booking/HoursEditor.astro +63 -0
  40. package/src/components/builder/PageBuilder.tsx +10 -2
  41. package/src/components/editor/EventPanel.tsx +401 -0
  42. package/src/components/editor/FormatDock.tsx +81 -0
  43. package/src/components/editor/LivePane.tsx +122 -0
  44. package/src/components/editor/MobileToolbar.tsx +93 -0
  45. package/src/components/editor/PostEditor.tsx +605 -223
  46. package/src/components/editor/PreviewOverlay.tsx +242 -43
  47. package/src/components/editor/extensions/calendar.tsx +51 -7
  48. package/src/components/editor/extensions/events.tsx +153 -0
  49. package/src/components/editor/extensions/slash.tsx +7 -1
  50. package/src/components/site/Pager.astro +24 -0
  51. package/src/components/site/PostCard.astro +64 -0
  52. package/src/components/site/TagLabel.astro +11 -0
  53. package/src/layouts/Admin.astro +289 -49
  54. package/src/layouts/Site.astro +352 -59
  55. package/src/lib/agent-api.ts +13 -0
  56. package/src/lib/agent-discovery.ts +40 -0
  57. package/src/lib/agent.ts +114 -0
  58. package/src/lib/ai-config.ts +58 -0
  59. package/src/lib/ai-schema.ts +22 -0
  60. package/src/lib/ai.ts +90 -0
  61. package/src/lib/api-keys.ts +65 -0
  62. package/src/lib/api.ts +56 -0
  63. package/src/lib/auth.ts +25 -5
  64. package/src/lib/backup/content.ts +36 -0
  65. package/src/lib/backup/unzip.ts +12 -1
  66. package/src/lib/backup/zip.ts +1 -1
  67. package/src/lib/booking/agent.ts +108 -0
  68. package/src/lib/booking/availability.ts +221 -0
  69. package/src/lib/booking/emails.ts +115 -0
  70. package/src/lib/booking/forms.ts +77 -0
  71. package/src/lib/booking/service.ts +277 -0
  72. package/src/lib/booking/view.ts +54 -0
  73. package/src/lib/calendar/agent-schema.ts +48 -0
  74. package/src/lib/calendar/agent.ts +136 -0
  75. package/src/lib/calendar/api.ts +81 -0
  76. package/src/lib/calendar/cards.ts +102 -0
  77. package/src/lib/calendar/config.ts +52 -0
  78. package/src/lib/calendar/data.ts +130 -0
  79. package/src/lib/calendar/events.ts +261 -0
  80. package/src/lib/calendar/feeds.ts +43 -0
  81. package/src/lib/calendar/hooks.ts +38 -0
  82. package/src/lib/calendar/http.ts +30 -0
  83. package/src/lib/calendar/ics-write.ts +120 -0
  84. package/src/lib/calendar/ics.ts +15 -5
  85. package/src/lib/calendar/import.ts +203 -0
  86. package/src/lib/calendar/render.ts +120 -37
  87. package/src/lib/calendar/rrule.ts +149 -0
  88. package/src/lib/calendar/seo.ts +48 -0
  89. package/src/lib/design-service.ts +58 -0
  90. package/src/lib/designDiff.ts +145 -0
  91. package/src/lib/designPreview.ts +110 -12
  92. package/src/lib/documents/cleanup.ts +31 -0
  93. package/src/lib/documents/extract.ts +154 -0
  94. package/src/lib/documents/vendor.d.ts +4 -0
  95. package/src/lib/drafts.ts +60 -6
  96. package/src/lib/dynamicBlocks.ts +40 -8
  97. package/src/lib/email/accounts.ts +55 -0
  98. package/src/lib/email/config.ts +86 -0
  99. package/src/lib/email/migration.ts +24 -0
  100. package/src/lib/email/providers.ts +89 -0
  101. package/src/lib/email/service.ts +78 -0
  102. package/src/lib/email/store.ts +167 -0
  103. package/src/lib/email/templates.ts +18 -0
  104. package/src/lib/email/types.ts +32 -0
  105. package/src/lib/email/webhooks.ts +87 -0
  106. package/src/lib/email.ts +15 -0
  107. package/src/lib/env.ts +1 -1
  108. package/src/lib/legal/index.ts +25 -0
  109. package/src/lib/live/client.ts +88 -0
  110. package/src/lib/live/crypto.ts +28 -0
  111. package/src/lib/live/migration.ts +15 -0
  112. package/src/lib/live/providers.ts +152 -0
  113. package/src/lib/live/service.ts +170 -0
  114. package/src/lib/live/signals.ts +89 -0
  115. package/src/lib/live/store.ts +49 -0
  116. package/src/lib/live/types.ts +52 -0
  117. package/src/lib/live/view.ts +78 -0
  118. package/src/lib/mobile/bookings.ts +73 -0
  119. package/src/lib/mobile/calendar.ts +85 -0
  120. package/src/lib/mobile/content.ts +85 -0
  121. package/src/lib/mobile/http.ts +63 -0
  122. package/src/lib/mobile/route.ts +20 -0
  123. package/src/lib/ops.ts +8 -0
  124. package/src/lib/preview.ts +21 -1
  125. package/src/lib/repo/migrations.ts +221 -0
  126. package/src/lib/repo/sql.ts +907 -68
  127. package/src/lib/repo/types.ts +125 -5
  128. package/src/lib/scheduler.ts +27 -0
  129. package/src/lib/schema.ts +316 -1
  130. package/src/lib/search-i18n.ts +3 -0
  131. package/src/lib/socialIcons.ts +32 -0
  132. package/src/lib/tagColor.ts +11 -0
  133. package/src/lib/theme.ts +59 -40
  134. package/src/lib/themeTokens.ts +183 -0
  135. package/src/lib/umami.ts +108 -0
  136. package/src/lib/webhook-sql.ts +68 -0
  137. package/src/lib/webhooks.ts +147 -0
  138. package/src/lib/widgets.ts +1 -0
  139. package/src/middleware.ts +45 -3
  140. package/src/pages/404.astro +23 -0
  141. package/src/pages/[...slug].astro +14 -5
  142. package/src/pages/account/[action].astro +53 -0
  143. package/src/pages/admin/ai.astro +12 -0
  144. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  145. package/src/pages/admin/c/[collection]/index.astro +127 -92
  146. package/src/pages/admin/calendar/bookings.astro +400 -0
  147. package/src/pages/admin/calendar/calendars.astro +265 -0
  148. package/src/pages/admin/calendar/events/[id].astro +56 -0
  149. package/src/pages/admin/calendar/events/index.astro +408 -0
  150. package/src/pages/admin/calendar/index.ts +4 -0
  151. package/src/pages/admin/calendar/integrations.astro +4 -0
  152. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  153. package/src/pages/admin/calendar/resources/index.astro +106 -0
  154. package/src/pages/admin/calendar/resources/link.astro +196 -0
  155. package/src/pages/admin/design.astro +10 -3
  156. package/src/pages/admin/email.astro +13 -0
  157. package/src/pages/admin/index.astro +232 -85
  158. package/src/pages/admin/integrations.astro +237 -0
  159. package/src/pages/admin/links.astro +3 -3
  160. package/src/pages/admin/live.astro +12 -0
  161. package/src/pages/admin/login.astro +14 -10
  162. package/src/pages/admin/navigation.astro +8 -2
  163. package/src/pages/admin/pages/[id].astro +4 -1
  164. package/src/pages/admin/pages/index.astro +41 -48
  165. package/src/pages/admin/settings.astro +418 -367
  166. package/src/pages/admin/tags.astro +4 -4
  167. package/src/pages/admin/users.astro +38 -28
  168. package/src/pages/api/agent/docs.ts +33 -0
  169. package/src/pages/api/agent/index.ts +21 -0
  170. package/src/pages/api/ai/clean.ts +13 -0
  171. package/src/pages/api/ai/draft.ts +14 -0
  172. package/src/pages/api/ai/settings.ts +53 -0
  173. package/src/pages/api/ai/test.ts +12 -0
  174. package/src/pages/api/backup.ts +8 -21
  175. package/src/pages/api/calendar/availability.ts +35 -0
  176. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  177. package/src/pages/api/calendar/bookings/index.ts +73 -0
  178. package/src/pages/api/calendar/calendars.ts +35 -0
  179. package/src/pages/api/calendar/docs.ts +66 -0
  180. package/src/pages/api/calendar/events/[id].ts +63 -0
  181. package/src/pages/api/calendar/events/index.ts +112 -0
  182. package/src/pages/api/calendar/resources.ts +34 -0
  183. package/src/pages/api/design/capabilities.ts +10 -0
  184. package/src/pages/api/design/index.ts +58 -0
  185. package/src/pages/api/design/keys.ts +24 -0
  186. package/src/pages/api/design/media.ts +32 -0
  187. package/src/pages/api/email/run.ts +11 -0
  188. package/src/pages/api/email/settings.ts +61 -0
  189. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  190. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  191. package/src/pages/api/live/settings.ts +45 -0
  192. package/src/pages/api/live/status.ts +7 -0
  193. package/src/pages/api/live/thumb.ts +48 -0
  194. package/src/pages/api/mcp.ts +59 -0
  195. package/src/pages/api/media/upload.ts +2 -2
  196. package/src/pages/api/ops/status.ts +39 -0
  197. package/src/pages/api/restore.ts +27 -37
  198. package/src/pages/api/search.ts +18 -4
  199. package/src/pages/api/v1/availability.ts +7 -0
  200. package/src/pages/api/v1/bookings.ts +17 -0
  201. package/src/pages/api/v1/calendars.ts +7 -0
  202. package/src/pages/api/v1/content/[id].ts +7 -0
  203. package/src/pages/api/v1/content/index.ts +7 -0
  204. package/src/pages/api/v1/events.ts +7 -0
  205. package/src/pages/api/v1/resources.ts +7 -0
  206. package/src/pages/api/webhooks/docs.ts +61 -0
  207. package/src/pages/author/[slug].astro +43 -35
  208. package/src/pages/blog/[slug].astro +137 -66
  209. package/src/pages/blog/index.astro +95 -30
  210. package/src/pages/blog/tag/[slug].astro +45 -40
  211. package/src/pages/book/[resource]/[link].astro +70 -0
  212. package/src/pages/book/[resource]/index.astro +63 -0
  213. package/src/pages/book/[resource].ics.ts +38 -0
  214. package/src/pages/book/index.astro +47 -0
  215. package/src/pages/book/manage/[token].astro +108 -0
  216. package/src/pages/calendar/[slug].ics.ts +27 -0
  217. package/src/pages/calendar/feed.ics.ts +15 -0
  218. package/src/pages/calendar/index.astro +191 -0
  219. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  220. package/src/pages/embed/calendar/[slug].astro +43 -0
  221. package/src/pages/events/[slug].astro +74 -0
  222. package/src/pages/events/[slug].ics.ts +25 -0
  223. package/src/pages/index.astro +12 -3
  224. package/src/pages/legal/privacy.astro +4 -0
  225. package/src/pages/legal/terms.astro +4 -0
  226. package/src/pages/links.astro +29 -3
  227. package/src/pages/llms.txt.ts +4 -0
  228. package/src/pages/preview/[type]/[id].astro +27 -6
  229. package/src/pages/search.astro +40 -20
  230. package/src/pages/sitemap.xml.ts +16 -1
  231. package/src/styles/admin.css +924 -105
  232. package/src/styles/cards.css +92 -74
  233. package/src/styles/editor.css +492 -114
  234. package/src/styles/email-settings.css +117 -0
  235. package/src/styles/live-admin.css +126 -0
  236. package/src/styles/live.css +199 -0
  237. package/src/styles/sections.css +12 -0
  238. package/src/styles/site.css +938 -281
  239. package/src/styles/theme-editor.css +135 -0
  240. package/src/theme-sdk/contract.d.mts +619 -0
  241. package/src/theme-sdk/contract.mjs +81 -0
  242. package/src/theme-sdk/index.ts +15 -0
  243. package/src/theme-sdk/tokens.d.mts +148 -0
  244. package/src/theme-sdk/tokens.mjs +55 -0
  245. package/src/themes/editorial/templates/home.astro +4 -4
  246. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  247. package/src/themes/editorial/theme.css +27 -37
  248. package/src/themes/editorial/theme.json +25 -3
  249. package/src/themes/field/theme.json +27 -9
  250. package/src/themes/ink/theme.json +30 -12
  251. package/src/themes/night/theme.css +4 -7
  252. package/src/themes/night/theme.json +28 -10
  253. package/src/virtual.d.ts +14 -0
  254. package/src/worker.ts +5 -1
@@ -0,0 +1,66 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { CALENDAR } from '../../../lib/calendar/config';
3
+
4
+ // Human-readable reference for the calendar + bookings API (plain text, so
5
+ // it reads in a browser, curl, or pasted into an AI assistant).
6
+ export const GET: APIRoute = ({ url }) => {
7
+ const o = url.origin;
8
+ const b = CALENDAR.bookings;
9
+ const text = `# Calendar${b ? ' & bookings' : ''} API
10
+
11
+ Base: ${o}
12
+ Auth: public reads need no key. Everything else: Authorization: Bearer <key>
13
+ Create keys in the admin: Team → API & webhooks. The agent key works too (all permissions).
14
+ All endpoints send CORS headers, so browsers on other sites can call them.
15
+ Times: ISO 8601. Event fields start/end are wall-clock in the event timezone ("2026-10-03T11:00", or "2026-10-03" when allDay).
16
+ Errors: { "error": "message" } with 400 invalid · 401 no/invalid key · 403 missing permission · 404 not found · 409 conflict (slot taken).
17
+
18
+ ## Events
19
+
20
+ GET /api/calendar/events?from=YYYY-MM-DD&to=YYYY-MM-DD&calendar=<slug>&q=<text>&limit=200
21
+ → { timezone, from, to, occurrences: [{ id, start, end, allDay, event }] } (repeats expanded)
22
+ GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
23
+ GET /api/calendar/events/<id|slug>
24
+ POST /api/calendar/events (events:write) → creates a DRAFT
25
+ { "title": "Fall Festival", "start": "2026-10-03T11:00", "end": "2026-10-03T16:00",
26
+ "content": "Markdown…", "excerpt": "…", "location": { "name": "Back lawn", "address": "…", "url": "" },
27
+ "cta": { "label": "Volunteer", "href": "https://…", "style": "primary" },
28
+ "rrule": "FREQ=WEEKLY;BYDAY=TU", "calendars": ["community"], "tags": ["family"], "publish": false }
29
+ PATCH /api/calendar/events/<id|slug> (events:write) same fields; live events stage edits unless "publish": true
30
+ DELETE /api/calendar/events/<id|slug> (events:write)
31
+ GET /api/calendar/calendars → calendars + subscribe feed URLs
32
+
33
+ Feeds (paste into Google / Apple / Outlook):
34
+ ${o}${CALENDAR.base}.ics · ${o}${CALENDAR.base}/<slug>.ics · ${o}${CALENDAR.eventsBase}/<slug>.ics
35
+ ${b ? `
36
+ ## Bookings
37
+
38
+ GET /api/calendar/resources → resources, their links (rules, lengths, questions, autoConfirm)
39
+ GET /api/calendar/availability?resource=<slug>&link=<slug>&from=YYYY-MM-DD&days=7[&minutes=360]
40
+ → { timezone, mode, lengths, autoConfirm, days: [{ date, slots: [{ start, end, minutes }] }] }
41
+ POST /api/calendar/bookings (no key needed — same rules as the booking page)
42
+ { "resource": "fellowship-hall", "link": "event", "start": "2026-10-17T16:00:00Z", "minutes": 360,
43
+ "name": "Maria Delgado", "email": "maria@example.com", "phone": "", "answers": { "Expected guests": "60" } }
44
+ RSVP to an event occurrence: { "event": "<slug>", "start": "<occurrence ISO>", "seats": 2, "name": "…", "email": "…" }
45
+ → 201 { booking, manageToken } status "confirmed" (auto-confirm links) or "pending" (approval)
46
+ → 409 if the time was just taken
47
+ GET /api/calendar/bookings?status=pending&resource=<slug>&from=…&to=… (bookings:read)
48
+ GET /api/calendar/bookings/<id> (bookings:read)
49
+ PATCH /api/calendar/bookings/<id> { "status": "confirmed" | "declined" | "cancelled", "start": "<ISO>", "notes": "…", "message": "…" } (bookings:write)
50
+ Guests manage their own booking at ${o}${CALENDAR.bookBase}/manage/<manageToken>.
51
+ ` : ''}
52
+ ## Webhooks
53
+
54
+ Full webhook reference (including posts, pages, design, media and restores): /api/webhooks/docs
55
+
56
+ Add endpoints in Team → API & webhooks. Events: event.published, event.updated, event.unpublished, event.deleted${b ? ', booking.created, booking.confirmed, booking.declined, booking.cancelled, booking.rescheduled' : ''}.
57
+ Each POST has headers X-Acms-Event, X-Acms-Delivery, X-Acms-Timestamp and
58
+ X-Acms-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>.
59
+ Body: { "id", "type", "createdAt", "data": { ... } }. Failed deliveries retry after 1, 10 and 60 minutes.
60
+
61
+ ## MCP / agent tools
62
+
63
+ ${o}/api/mcp and ${o}/api/agent expose list_events, get_event, list_calendars, create_event, update_event, publish_event${b ? ', list_resources, check_availability, list_bookings, create_booking, update_booking' : ''}.
64
+ `;
65
+ return new Response(text, { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Access-Control-Allow-Origin': '*' } });
66
+ };
@@ -0,0 +1,63 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../lib/repo';
3
+ import { ApiError, json, readJson } from '../../../../lib/api';
4
+ import { authenticate, hasScope, requireScope } from '../../../../lib/api-keys';
5
+ import { siteOrigin } from '../../../../lib/seo';
6
+ import { CALENDAR } from '../../../../lib/calendar/config';
7
+ import { eventIsPublic } from '../../../../lib/calendar/events';
8
+ import { eventToJson } from '../../../../lib/calendar/api';
9
+ import { runAgentTool } from '../../../../lib/agent';
10
+ import { eventDeleted } from '../../../../lib/calendar/hooks';
11
+ import { mergeEventDraft } from '../../../../lib/drafts';
12
+ import { waitUntilFrom } from '../../../../lib/webhooks';
13
+ import { fail, preflight, withCors } from '../../../../lib/calendar/http';
14
+
15
+ // One event by id or slug.
16
+ // GET → public events for anyone; drafts/private with events:read
17
+ // PATCH → events:write; body like create; live events stage a pending
18
+ // draft unless "publish": true (applies it and publishes)
19
+ // DELETE → events:write
20
+ export const OPTIONS: APIRoute = preflight;
21
+
22
+ async function find(repo: Awaited<ReturnType<typeof getRepo>>, key: string) {
23
+ return (await repo.getEvent(key)) ?? (await repo.getEventBySlug(key));
24
+ }
25
+
26
+ export const GET: APIRoute = async ({ request, locals, url, params }) => {
27
+ try {
28
+ if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar module is disabled.');
29
+ const repo = await getRepo(locals);
30
+ const [e, settings, calendars, who] = await Promise.all([find(repo, params.id!), repo.getSettings(), repo.listCalendars(), authenticate(request, repo)]);
31
+ const map = new Map(calendars.map((c) => [c.id, c]));
32
+ const full = who ? hasScope(who, 'events:read') : false;
33
+ if (!e || (!full && !eventIsPublic(e, map))) throw new ApiError(404, 'Event not found.');
34
+ const view = full ? mergeEventDraft(e) : e;
35
+ return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
36
+ } catch (err) { return fail(err); }
37
+ };
38
+
39
+ export const PATCH: APIRoute = async ({ request, locals, url, params }) => {
40
+ try {
41
+ const repo = await getRepo(locals);
42
+ await requireScope(request, repo, 'events:write');
43
+ const e = await find(repo, params.id!);
44
+ if (!e) throw new ApiError(404, 'Event not found.');
45
+ const { publish, ...body } = (await readJson(request)) as Record<string, unknown>;
46
+ const ctx = { origin: siteOrigin(await repo.getSettings(), url), waitUntil: waitUntilFrom(locals) };
47
+ if (Object.keys(body).length) await runAgentTool(repo, { tool: 'update_event', arguments: { ...body, id: e.id } }, true, ctx);
48
+ if (publish === true) await runAgentTool(repo, { tool: 'publish_event', arguments: { id: e.id } }, true, ctx);
49
+ return withCors(json(await runAgentTool(repo, { tool: 'get_event', arguments: { id: e.id } }, true, ctx)));
50
+ } catch (err) { return fail(err); }
51
+ };
52
+
53
+ export const DELETE: APIRoute = async ({ request, locals, params }) => {
54
+ try {
55
+ const repo = await getRepo(locals);
56
+ await requireScope(request, repo, 'events:write');
57
+ const e = await find(repo, params.id!);
58
+ if (!e) throw new ApiError(404, 'Event not found.');
59
+ await repo.deleteEvent(e.id);
60
+ await eventDeleted(repo, e, { waitUntil: waitUntilFrom(locals) });
61
+ return withCors(json({ deleted: e.id }));
62
+ } catch (err) { return fail(err); }
63
+ };
@@ -0,0 +1,112 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../lib/repo';
3
+ import { ApiError, apiError, json } from '../../../../lib/api';
4
+ import { authenticate, hasScope } from '../../../../lib/api-keys';
5
+ import { siteOrigin } from '../../../../lib/seo';
6
+ import { CALENDAR } from '../../../../lib/calendar/config';
7
+ import { eventIsListed, eventIsPublic, expandEvents, parseWall } from '../../../../lib/calendar/events';
8
+ import { eventToJson } from '../../../../lib/calendar/api';
9
+ import { zonedToUtc } from '../../../../lib/calendar/ics';
10
+ import { isoWithOffset } from '../../../../lib/calendar/seo';
11
+ import { eventTimeZone } from '../../../../lib/calendar/events';
12
+ import { requireScope } from '../../../../lib/api-keys';
13
+ import { readJson } from '../../../../lib/api';
14
+ import { runAgentTool } from '../../../../lib/agent';
15
+ import { waitUntilFrom } from '../../../../lib/webhooks';
16
+ import { fail } from '../../../../lib/calendar/http';
17
+
18
+ // Public read API for other sites, apps and lobby screens.
19
+ // GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
20
+ // → { timezone, from, to, occurrences: [{ id, start, end, allDay, event }] }
21
+ // GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
22
+ // Anonymous callers see public calendars (and an unlisted one when asked by
23
+ // slug). A key with events:read also sees private calendars.
24
+ const CORS = {
25
+ 'Access-Control-Allow-Origin': '*',
26
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
27
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
28
+ };
29
+ const withCors = (res: Response) => {
30
+ for (const [k, v] of Object.entries(CORS)) res.headers.set(k, v);
31
+ return res;
32
+ };
33
+
34
+ export const OPTIONS: APIRoute = () => new Response(null, { status: 204, headers: CORS });
35
+
36
+ export const GET: APIRoute = async ({ request, locals, url }) => {
37
+ try {
38
+ if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar module is disabled.');
39
+ const repo = await getRepo(locals);
40
+ const principal = await authenticate(request, repo);
41
+ if (request.headers.get('authorization') && !principal) throw new ApiError(401, 'Invalid API key.');
42
+ const full = principal ? hasScope(principal, 'events:read') : false;
43
+ const [settings, calendars, published] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
44
+ const tz = settings.timezone || 'UTC';
45
+ const map = new Map(calendars.map((c) => [c.id, c]));
46
+ const origin = siteOrigin(settings, url);
47
+
48
+ const slug = url.searchParams.get('calendar');
49
+ const cal = slug ? calendars.find((c) => c.slug === slug) : null;
50
+ if (slug && (!cal || (cal.visibility === 'private' && !full))) throw new ApiError(404, 'Unknown calendar.');
51
+ const q = (url.searchParams.get('q') ?? '').trim().toLowerCase();
52
+ let events = published.filter((e) => {
53
+ if (cal) return e.calendarIds.includes(cal.id) && (full || eventIsPublic(e, map));
54
+ return full ? true : eventIsListed(e, map);
55
+ });
56
+ if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
57
+
58
+ if (url.searchParams.get('expand') === '0') {
59
+ return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone)) }));
60
+ }
61
+ const day = (param: string, fallback: number) => {
62
+ const w = parseWall(url.searchParams.get(param) ?? '');
63
+ return w ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, tz) : fallback;
64
+ };
65
+ const now = Date.now();
66
+ const from = day('from', now);
67
+ const to = Math.min(day('to', from + 90 * 86_400_000), from + 400 * 86_400_000);
68
+ if (to <= from) throw new ApiError(400, '"to" must be after "from".');
69
+ const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get('limit')) || 200));
70
+ const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to }).slice(0, limit);
71
+ const cache = new Map<string, ReturnType<typeof eventToJson>>();
72
+ const toJson = (id: string) => {
73
+ if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone));
74
+ return cache.get(id)!;
75
+ };
76
+ return withCors(json({
77
+ timezone: tz,
78
+ from: new Date(from).toISOString(),
79
+ to: new Date(to).toISOString(),
80
+ occurrences: occ.map((o) => {
81
+ const etz = eventTimeZone(o.event, settings);
82
+ return {
83
+ id: `${o.event.id}:${o.start}`,
84
+ start: o.allDay ? isoWithOffset(o.start, etz).slice(0, 10) : isoWithOffset(o.start, etz),
85
+ end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
86
+ allDay: o.allDay,
87
+ event: toJson(o.event.id),
88
+ };
89
+ }),
90
+ }));
91
+ } catch (error) {
92
+ return withCors(apiError(error));
93
+ }
94
+ };
95
+
96
+ // POST /api/calendar/events (events:write) — creates a DRAFT from the same
97
+ // fields as the create_event agent tool (title, start, end, allDay, timezone,
98
+ // rrule, content (Markdown), excerpt, location, cta, calendars, tags…).
99
+ // "publish": true publishes it in the same call.
100
+ export const POST: APIRoute = async ({ request, locals, url }) => {
101
+ try {
102
+ const repo = await getRepo(locals);
103
+ await requireScope(request, repo, 'events:write');
104
+ const { publish, ...body } = (await readJson(request)) as Record<string, unknown>;
105
+ const ctx = { origin: siteOrigin(await repo.getSettings(), url), waitUntil: waitUntilFrom(locals) };
106
+ const created = (await runAgentTool(repo, { tool: 'create_event', arguments: body }, true, ctx)) as { id: string };
107
+ if (publish === true) await runAgentTool(repo, { tool: 'publish_event', arguments: { id: created.id } }, true, ctx);
108
+ return withCors(json(await runAgentTool(repo, { tool: 'get_event', arguments: { id: created.id } }, true, ctx), 201));
109
+ } catch (err) {
110
+ return fail(err);
111
+ }
112
+ };
@@ -0,0 +1,34 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { ApiError, json } from '../../../lib/api';
4
+ import { authenticate, hasScope } from '../../../lib/api-keys';
5
+ import { siteOrigin } from '../../../lib/seo';
6
+ import { bookingLinkUrl, bookingsEnabled, resourceUrl } from '../../../lib/calendar/config';
7
+ import { describeLink, linkLengths } from '../../../lib/booking/availability';
8
+ import { fail, preflight, withCors } from '../../../lib/calendar/http';
9
+
10
+ // GET /api/calendar/resources → public resources and their public links
11
+ // (a resources:read key adds unlisted ones and switched-off links).
12
+ export const OPTIONS: APIRoute = preflight;
13
+
14
+ export const GET: APIRoute = async ({ request, locals, url }) => {
15
+ try {
16
+ if (!bookingsEnabled()) throw new ApiError(404, 'Bookings are disabled.');
17
+ const repo = await getRepo(locals);
18
+ const who = await authenticate(request, repo);
19
+ const full = who ? hasScope(who, 'resources:read') : false;
20
+ const [settings, resources, links] = await Promise.all([repo.getSettings(), repo.listResources(), repo.listBookingLinks()]);
21
+ const origin = siteOrigin(settings, url);
22
+ return withCors(json({
23
+ resources: resources.filter((r) => full || r.visibility === 'public').map((r) => ({
24
+ id: r.id, slug: r.slug, name: r.name, kind: r.kind, description: r.description, capacity: r.capacity, amenities: r.amenities,
25
+ photo: r.photo ? new URL(r.photo, origin).href : '', timezone: r.timezone || settings.timezone || 'UTC', url: origin + resourceUrl(r.slug),
26
+ links: links.filter((l) => l.resourceId === r.id && (full || (l.active && l.visibility === 'public'))).map((l) => ({
27
+ id: l.id, slug: l.slug, name: l.name, description: l.description, mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
28
+ autoConfirm: l.autoConfirm, url: origin + bookingLinkUrl(r.slug, l.slug),
29
+ questions: l.questions.map((q) => ({ id: q.id, label: q.label, type: q.type, required: q.required, options: q.options })),
30
+ })),
31
+ })),
32
+ }));
33
+ } catch (err) { return fail(err); }
34
+ };
@@ -0,0 +1,10 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { apiError, json } from '../../../lib/api';
4
+ import { DESIGN_SCOPES, designIdentity } from '../../../lib/design-service';
5
+ export const GET: APIRoute = async ({request,cookies,locals}) => {
6
+ try {
7
+ const identity = await designIdentity(request,cookies,await getRepo(locals),DESIGN_SCOPES);
8
+ return json({version:1,themeApiVersion:1,scopes:identity.scopes,capabilities:{design:true,media:true,revisionRequired:true,codeUpload:false},endpoints:{design:'/api/design',media:'/api/design/media'}});
9
+ } catch(error) { return apiError(error); }
10
+ };
@@ -0,0 +1,58 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { apiError, ApiError, json, readJson } from '../../../lib/api';
4
+ import { DesignRequest, designIdentity, publicDesign, validateSectionDesign, validateDesignValues } from '../../../lib/design-service';
5
+ import { listThemes, getTheme } from '../../../lib/theme';
6
+ import { diffDesign, summarizeChanges } from '../../../lib/designDiff';
7
+ import { pickDesignDraft, shareEpoch } from '../../../lib/drafts';
8
+ import { createDesignToken } from '../../../lib/preview';
9
+ import { ThemeTokens } from '../../../theme-sdk/contract.mjs';
10
+ import type { ContentRepository } from '../../../lib/repo/types';
11
+
12
+ async function state(repo:ContentRepository, scopes: string[]) {
13
+ const [s,nav,pages,versions]=await Promise.all([repo.getSettings(),repo.listNavItems(),repo.listPages(),repo.listDesignVersions()]);
14
+ return {revision:s.designRevision,live:{...publicDesign(s),navItems:nav.map(({id,label,url,area})=>({id,label,url,area}))},draft:s.draftDesign,
15
+ changes:diffDesign(s,nav,s.draftDesign),scopes,
16
+ themes:listThemes().map(t=>({slug:t.slug,name:t.name,version:t.version,sections:t.sections})),
17
+ pages:pages.map(p=>({id:p.id,title:p.title,slug:p.slug,isHome:p.isHome})),
18
+ versions:versions.map(({snapshot,...v})=>v), audit:await repo.listDesignAudit()};
19
+ }
20
+ export const GET:APIRoute=async({request,cookies,locals})=>{
21
+ try {const repo=await getRepo(locals);const id=await designIdentity(request,cookies,repo,'design:read');return json(await state(repo,id.scopes));} catch(e){return apiError(e);}
22
+ };
23
+ export const POST:APIRoute=async({request,cookies,locals})=>{
24
+ try {
25
+ const repo=await getRepo(locals), input=DesignRequest.parse(await readJson(request,500_000));
26
+ const identity=await designIdentity(request,cookies,repo,input.operation==='publish'?'design:publish':'design:write-draft');
27
+ const current=await repo.getSettings();
28
+ if(current.designRevision!==input.revision)throw new ApiError(409,'Design changed. Pull the latest revision before saving.');
29
+ if(input.operation==='stage') {
30
+ validateDesignValues(input.patch);
31
+ const draft={...current.draftDesign,...input.patch};
32
+ if(input.patch.activeTheme && !listThemes().some(t=>t.slug===input.patch.activeTheme))throw new ApiError(400,'Theme is not installed. Install locally, build and deploy its code first.');
33
+ const theme=getTheme(String(draft.activeTheme??current.activeTheme));
34
+ ThemeTokens.parse({...theme.tokens,...draft.designOverrides as object});
35
+ const pageIds=new Set((await repo.listPages()).map(p=>p.id));
36
+ if(input.patch.sectionPages && Object.keys(input.patch.sectionPages).some(id=>!pageIds.has(id)))throw new ApiError(400,'A section layout refers to a missing page.');
37
+ await repo.setDesignDraft(draft,input.revision,identity.actor);
38
+ } else if(input.operation==='publish') {
39
+ const settings={...current,...pickDesignDraft(current.draftDesign??{})};
40
+ validateSectionDesign(settings,getTheme(settings.activeTheme));
41
+ await repo.applyDesignDraft({expectedRevision:input.revision,author:identity.actor,summary:summarizeChanges(diffDesign(current,await repo.listNavItems(),current.draftDesign))});
42
+ } else if(input.operation==='discard') await repo.setDesignDraft(null,input.revision,identity.actor);
43
+ else if(input.operation==='restore') {
44
+ const version=await repo.getDesignVersion(input.id);if(!version)throw new ApiError(404,'Version not found.');
45
+ await repo.setDesignDraft({...version.snapshot,shareEpoch:crypto.randomUUID()},input.revision,identity.actor);
46
+ } else {
47
+ if(!current.draftDesign)throw new ApiError(409,'There is no draft to share.');
48
+ if(input.operation==='revoke')await repo.setDesignDraft({...current.draftDesign,shareEpoch:crypto.randomUUID()},input.revision,identity.actor);
49
+ else {
50
+ if(shareEpoch(current.draftDesign)==='legacy-disabled')throw new ApiError(409,'Save the draft before sharing.');
51
+ const {token,expiresAt}=createDesignToken(shareEpoch(current.draftDesign),input.days*86400000);
52
+ return json({url:`${new URL(request.url).origin}/?preview=${token}`,expiresAt,revision:input.revision});
53
+ }
54
+ }
55
+ // Write-only credentials receive no private content inventory.
56
+ return json({ok:true,revision:(await repo.getSettings()).designRevision});
57
+ }catch(e){return apiError(e);}
58
+ };
@@ -0,0 +1,24 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { z } from 'zod';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { apiError, json, readJson, requireSession, ApiError } from '../../../lib/api';
5
+ import { DESIGN_SCOPES } from '../../../lib/design-service';
6
+ import { generateAgentKey } from '../../../lib/ai-config';
7
+ export const ALL:APIRoute=async({request,cookies,locals})=>{
8
+ try {
9
+ const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);
10
+ if(request.method==='GET')return json({keys:(await repo.listApiKeys()).filter(k=>k.scopes.some(s=>DESIGN_SCOPES.includes(s as typeof DESIGN_SCOPES[number])))});
11
+ if(request.method==='POST') {
12
+ const input=z.object({name:z.string().min(1).max(100),scopes:z.array(z.enum(DESIGN_SCOPES)).min(1)}).strict().parse(await readJson(request,10000));
13
+ const {token,keyHash,keyPrefix}=generateAgentKey();
14
+ const key=await repo.createApiKey({name:input.name,scopes:input.scopes,keyHash,prefix:keyPrefix});return json({key,token},201);
15
+ }
16
+ if(request.method==='DELETE') {
17
+ const {id}=z.object({id:z.string()}).strict().parse(await readJson(request,10000));
18
+ const key=(await repo.listApiKeys()).find(k=>k.id===id);
19
+ if(!key||!key.scopes.some(s=>DESIGN_SCOPES.includes(s as typeof DESIGN_SCOPES[number])))throw new ApiError(404,'Design key not found.');
20
+ await repo.deleteApiKey(id);return json({ok:true});
21
+ }
22
+ throw new ApiError(405,'Method not allowed.');
23
+ }catch(e){return apiError(e);}
24
+ };
@@ -0,0 +1,32 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { getMediaStore } from '../../../lib/media/store';
4
+ import { apiError, ApiError, json } from '../../../lib/api';
5
+ import { designIdentity } from '../../../lib/design-service';
6
+ export const GET:APIRoute=async({request,cookies,locals})=>{
7
+ try{const repo=await getRepo(locals);await designIdentity(request,cookies,repo,'media:read');return json({media:await repo.listMedia()});}catch(e){return apiError(e);}
8
+ };
9
+ export const POST:APIRoute=async({request,cookies,locals})=>{
10
+ try {
11
+ const repo=await getRepo(locals);await designIdentity(request,cookies,repo,'media:write');
12
+ const mime=request.headers.get('content-type')??'',ext=({'image/png':'png','image/jpeg':'jpg','image/webp':'webp','image/gif':'gif'} as Record<string,string>)[mime];
13
+ if(!ext)throw new ApiError(415,'Upload a compressed PNG, JPEG, WebP or GIF image.');
14
+ const cap=10*1024*1024;
15
+ if(Number(request.headers.get('content-length'))>cap)throw new ApiError(413,'Image exceeds 10 MB.');
16
+ const reader=request.body?.getReader();if(!reader)throw new ApiError(400,'Image body required.');
17
+ const chunks:Uint8Array[]=[];let size=0;
18
+ try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>cap){await reader.cancel();throw new ApiError(413,'Image exceeds 10 MB.');}chunks.push(value);}}finally{reader.releaseLock();}
19
+ if(!size)throw new ApiError(400,'Image body required.');
20
+ const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}
21
+ const starts = (...values:number[]) => values.every((v,i)=>bytes[i]===v);
22
+ const ascii = (start:number,end:number) => new TextDecoder().decode(bytes.slice(start,end));
23
+ const matches = mime==='image/png' ? starts(137,80,78,71,13,10,26,10) : mime==='image/jpeg' ? starts(255,216,255) : mime==='image/webp' ? ascii(0,4)==='RIFF' && ascii(8,12)==='WEBP' : ['GIF87a','GIF89a'].includes(ascii(0,6));
24
+ if(!matches)throw new ApiError(415,'Image bytes do not match the declared format.');
25
+ const id=crypto.randomUUID(),key=`${id}/original.${ext}`,store=getMediaStore(locals);
26
+ await store.put(key,bytes.buffer,mime);
27
+ let media;
28
+ try { media=await repo.createMedia({id,key,filename:(request.headers.get('x-filename')||`image.${ext}`).slice(0,200),mime,width:0,height:0,size,alt:(request.headers.get('x-alt')??'').slice(0,2000),variants:[]});
29
+ } catch(error) { await store.delete(key).catch(()=>{}); throw error; }
30
+ return json({media},201);
31
+ }catch(e){return apiError(e);}
32
+ };
@@ -0,0 +1,11 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { authorizedRunner } from '../../../lib/ops';
4
+ import { runScheduledTasks } from '../../../lib/scheduler';
5
+ export const POST: APIRoute = async ({ request, locals }) => {
6
+ if (!authorizedRunner(request, locals)) return new Response('Unauthorized', { status: 401, headers: { 'Cache-Control': 'no-store' } });
7
+ try {
8
+ const report = await runScheduledTasks(await getRepo(locals), locals);
9
+ return Response.json({ ...report, accepted: report.jobs.email.count ?? 0 }, { status: report.ok ? 200 : 503, headers: { 'Cache-Control': 'no-store' } });
10
+ } catch { return Response.json({ ok: false }, { status: 503, headers: { 'Cache-Control': 'no-store' } }); }
11
+ };
@@ -0,0 +1,61 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { z } from 'zod';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
5
+ import { validateSession, SESSION_COOKIE } from '../../../lib/auth';
6
+ import { reauthenticate } from '../../../lib/email/accounts';
7
+ import { decrypt, encrypt, encryptionAvailable, resolveConfig, runtimeValue } from '../../../lib/email/config';
8
+ import { dispatchEmail } from '../../../lib/email/service';
9
+ import type { ContentRepository } from '../../../lib/repo/types';
10
+ import type { Credentials, PublicEmailSettings } from '../../../lib/email/types';
11
+ const Config = z.object({provider:z.enum(['auto','none','resend','mailgun','ses','cloudflare']),source:z.enum(['env','encrypted']),from:z.string().trim().max(320),replyTo:z.string().trim().max(320),domain:z.string().trim().max(253),region:z.string().trim().max(40),configurationSet:z.string().trim().max(200),snsTopicArn:z.string().trim().max(300),enabled:z.boolean(),reminders:z.boolean(),staffNotifications:z.boolean()}).strict();
12
+ const Secrets = z.object({apiKey:z.string().max(4096).optional(),webhookSecret:z.string().max(4096).optional(),accessKeyId:z.string().max(256).optional(),secretAccessKey:z.string().max(4096).optional(),sessionToken:z.string().max(8192).optional()}).strict();
13
+ const Input = z.discriminatedUnion('action',[
14
+ z.object({action:z.literal('save'),config:Config,credentials:Secrets.default({}),reauthPassword:z.string().max(1024)}).strict(),
15
+ z.object({action:z.literal('test'),to:z.string().trim().email().max(254)}).strict(),
16
+ z.object({action:z.literal('retry'),id:z.string().max(100)}).strict(),
17
+ ]);
18
+ export async function publicSettings(repo:ContentRepository,locals?:unknown):Promise<PublicEmailSettings> {
19
+ const record=await repo.email.config(),c=resolveConfig(record,locals);
20
+ return {version:c.version,config:c.config,provider:c.provider,configured:!c.error&&c.provider!=='none'&&c.config.enabled&&encryptionAvailable(locals),error:c.error||(!encryptionAvailable(locals)?'Configure EMAIL_ENCRYPTION_KEY for protected email delivery.':''),encryptionAvailable:encryptionAvailable(locals),environmentLocked:!!runtimeValue('EMAIL_PROVIDER',locals),credentialFields:Object.entries(c.credentials).filter(([,v])=>!!v).map(([k])=>k),activity:(await repo.email.list()).map(({payload,lease,fingerprint,rendered,...safe})=>safe),heartbeat:await repo.email.heartbeat()};
21
+ }
22
+ export const GET:APIRoute=async({request,cookies,locals})=>{
23
+ try{const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);return json(await publicSettings(repo,locals));}catch(e){return apiError(e);}
24
+ };
25
+ export const POST:APIRoute=async({request,cookies,locals})=>{
26
+ try{
27
+ const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);
28
+ const userId=(await validateSession(cookies.get(SESSION_COOKIE)?.value,repo))!;
29
+ const input=Input.parse(await readJson(request,20000));
30
+ if(input.action==='save'){
31
+ if(!await repo.email.rateLimit(`reauth:${userId}`,10,10*60000))throw new ApiError(429,'Too many attempts. Try again later.');
32
+ if(!await reauthenticate(repo,userId,input.reauthPassword))throw new ApiError(403,'Your current password is required to change email settings.');
33
+ const current=await repo.email.config();
34
+ if(runtimeValue('EMAIL_PROVIDER',locals)&&(input.config.provider!==runtimeValue('EMAIL_PROVIDER',locals)||input.config.source!=='env'))throw new ApiError(400,'Provider is managed by EMAIL_PROVIDER in the deployment environment.');
35
+ let encrypted='';
36
+ if(input.config.source==='encrypted'){
37
+ if(!encryptionAvailable(locals))throw new ApiError(400,'Configure EMAIL_ENCRYPTION_KEY before saving credentials.');
38
+ const saved=current?.config.provider===input.config.provider&&current.encrypted?decrypt<Credentials>(current.encrypted,'credentials',locals):{};
39
+ const credentials={...saved,...Object.fromEntries(Object.entries(input.credentials).filter(([,v])=>!!v?.trim()).map(([k,v])=>[k,v!.trim()]))};
40
+ encrypted=encrypt(credentials,'credentials',locals);
41
+ }
42
+ const candidate={version:0,config:input.config,encrypted,createdAt:Date.now()};
43
+ const c=resolveConfig(candidate,locals);
44
+ if(input.config.enabled&&input.config.provider!=='none'&&c.error)throw new ApiError(400,c.error);
45
+ await repo.email.saveConfig(input.config,encrypted);
46
+ return json(await publicSettings(repo,locals));
47
+ }
48
+ if(!await repo.email.rateLimit(`email-action:${userId}`,10,60000))throw new ApiError(429,'Please wait before another email action.');
49
+ if(input.action==='retry'){
50
+ if(!await repo.email.retry(input.id))throw new ApiError(409,'This email cannot safely be retried. Check its status or issue a fresh account link.');
51
+ await dispatchEmail(repo,locals,1);return json({message:'Retry queued.'});
52
+ }
53
+ const c=resolveConfig(await repo.email.config(),locals);
54
+ if(c.error||c.provider==='none'||!c.config.enabled)throw new ApiError(400,c.error||'Email is disabled.');
55
+ const id=crypto.randomUUID();
56
+ await repo.email.enqueue({id,dedupe:`test:${id}`,kind:'test',recipient:input.to,payload:'{}',configVersion:c.version,expiresAt:Date.now()+3600000});
57
+ await dispatchEmail(repo,locals,5);
58
+ const job=await repo.email.get(id);
59
+ return json({message:job?.state==='accepted'?'Test accepted by the provider. Delivery has not yet been confirmed.':`Test status: ${job?.state || 'queued'}. ${job?.error||''}`});
60
+ }catch(e){return apiError(e);}
61
+ };
@@ -0,0 +1,4 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../lib/repo';
3
+ import { receiveEmailWebhook } from '../../../../lib/email/webhooks';
4
+ export const POST:APIRoute=async({request,params,locals})=>receiveEmailWebhook(request,params.provider||'',await getRepo(locals),locals);
@@ -0,0 +1,19 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../../lib/repo';
3
+ import { ApiError } from '../../../../../lib/api';
4
+ import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../../../../lib/auth';
5
+ import { finishOAuth } from '../../../../../lib/live/service';
6
+ export const GET:APIRoute=async({cookies,locals,url})=>{
7
+ const headers={'Cache-Control':'no-store','Referrer-Policy':'no-referrer'};
8
+ try{
9
+ const repo=await getRepo(locals),session=cookies.get(SESSION_COOKIE)?.value;
10
+ // OAuth is intentionally a cross-site GET. Session-bound, one-use state provides CSRF protection.
11
+ const user=await validateSession(session,repo);
12
+ if(!session||!user||user!==OWNER_ENV&&(await repo.getUser(user))?.role!=='owner')throw new ApiError(403,'Sign in as the site owner and start authorization again.');
13
+ await finishOAuth(repo,session,url,locals);
14
+ return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=connected'}});
15
+ }catch{
16
+ // Never reflect callback codes, tokens or provider error bodies into an HTML page or URL.
17
+ return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=error'}});
18
+ }
19
+ };
@@ -0,0 +1,45 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { z } from 'zod';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
5
+ import { SESSION_COOKIE } from '../../../lib/auth';
6
+ import { Provider } from '../../../lib/live/types';
7
+ import { adminState, beginOAuth, disconnect, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
8
+ const Input=z.discriminatedUnion('action',[
9
+ z.object({action:z.literal('saveConnection'),provider:Provider,clientId:z.string().trim().min(1).max(200),clientSecret:z.string().trim().max(8192).default(''),channelInput:z.string().trim().max(2048).default(''),googleStatus:z.enum(['testing','production']).default('testing')}).strict(),
10
+ z.object({action:z.literal('test'),provider:Provider}).strict(),
11
+ z.object({action:z.literal('selectChannel'),provider:z.literal('youtube'),channelId:z.string().min(1).max(200)}).strict(),
12
+ z.object({action:z.literal('enable'),provider:Provider,enabled:z.boolean()}).strict(),
13
+ z.object({action:z.literal('authorize'),provider:z.literal('youtube')}).strict(),
14
+ z.object({action:z.literal('disconnect'),provider:Provider}).strict(),
15
+ z.object({action:z.literal('saveConfig'),config:z.unknown()}).strict(),
16
+ ]);
17
+ export const GET:APIRoute=async({request,cookies,locals,url})=>{
18
+ try{const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);return json(await adminState(repo,url,locals));}catch(e){return apiError(e);}
19
+ };
20
+ export const POST:APIRoute=async({request,cookies,locals,url})=>{
21
+ try{
22
+ const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);
23
+ // Browser-based credential changes must originate at this site.
24
+ if(request.headers.get('origin')!==url.origin)throw new ApiError(403,'Submit changes from this site’s admin.');
25
+ const input=Input.parse(await readJson(request,24_000));
26
+ switch(input.action){
27
+ case 'saveConnection':await saveConnection(repo,input,locals);break;
28
+ case 'test':await syncConnection(repo,input.provider,locals,true);break;
29
+ case 'authorize':return json({authorizationUrl:await beginOAuth(repo,cookies.get(SESSION_COOKIE)!.value,url,locals)});
30
+ case 'disconnect':await disconnect(repo,input.provider,locals);break;
31
+ case 'saveConfig':await repo.updateSettings({live:validateConfig(input.config)});break;
32
+ case 'selectChannel':{
33
+ const conn=await repo.live.get(input.provider);const selected=conn?.channels.find(c=>c.id===input.channelId);
34
+ if(!conn||!selected)throw new ApiError(400,'Choose a channel from the authorized account.');
35
+ if(!await repo.live.patch(input.provider,{channelId:selected.id,channelName:selected.name,snapshot:null,upcoming:null,upcomingCheckedAt:0,testedAt:0,enabled:false,lastAttemptAt:0},conn.revision))throw new ApiError(409,'Connection changed; reload and try again.');break;
36
+ }
37
+ case 'enable':{
38
+ const conn=await repo.live.get(input.provider);if(!conn)throw new ApiError(404,'Connection not found.');
39
+ if(input.enabled&&(!conn.testedAt||conn.testedAt<Date.now()-600_000||conn.error||!conn.channelId))throw new ApiError(400,'Run a successful connection test before enabling automatic checks.');
40
+ if(!await repo.live.patch(input.provider,{enabled:input.enabled,nextAt:0,...(!input.enabled?{snapshot:null,upcoming:null,upcomingCheckedAt:0}:{})},conn.revision))throw new ApiError(409,'Connection changed; reload and try again.');break;
41
+ }
42
+ }
43
+ return json(await adminState(repo,url,locals));
44
+ }catch(e){return apiError(e);}
45
+ };
@@ -0,0 +1,7 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { json, apiError } from '../../../lib/api';
4
+ import { publicState } from '../../../lib/live/service';
5
+ export const GET:APIRoute=async({locals})=>{
6
+ try{return json({snapshot:await publicState(await getRepo(locals)),serverNow:Date.now()});}catch(e){return apiError(e);}
7
+ };
@@ -0,0 +1,48 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { readResponseBytes } from '../../../lib/api';
4
+ import { liveState, thumbSource } from '../../../lib/live/service';
5
+
6
+ // Same-site proxy for the current broadcast's preview image, so visitors
7
+ // never contact YouTube or Twitch until they choose to play. The upstream URL
8
+ // comes only from the current snapshot (fixed hosts, validated ids); the
9
+ // query string is just a cache version.
10
+ const MAX_BYTES = 1_500_000;
11
+ const TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
12
+ let cached: { src: string; at: number; type: string; body: Uint8Array } | null = null;
13
+
14
+ const missing = () => new Response(null, { status: 404, headers: { 'Cache-Control': 'public, max-age=30' } });
15
+
16
+ export const GET: APIRoute = async ({ locals }) => {
17
+ try {
18
+ const { config, snapshot } = await liveState(await getRepo(locals));
19
+ const src = config.mode !== 'off' && config.display.thumbnail ? thumbSource(snapshot) : '';
20
+ if (!src) return missing();
21
+ if (cached?.src !== src || cached.at < Date.now() - 60_000) {
22
+ // YouTube serves _live stills only while broadcasting; fall back to the
23
+ // regular still (e.g. manual links to an ended stream).
24
+ const candidates = [src, src.replace('hqdefault_live', 'hqdefault')].filter((v, i, a) => a.indexOf(v) === i);
25
+ let found: typeof cached = null;
26
+ for (const url of candidates) {
27
+ // Twitch redirects to a placeholder when offline; treat that as missing.
28
+ const r = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(8_000) });
29
+ const type = (r.headers.get('content-type') ?? '').split(';')[0].trim();
30
+ if (r.status !== 200 || !TYPES.has(type)) { await r.body?.cancel(); continue; }
31
+ found = { src, at: Date.now(), type, body: await readResponseBytes(r, MAX_BYTES) };
32
+ break;
33
+ }
34
+ if (!found) return missing();
35
+ cached = found;
36
+ }
37
+ return new Response(cached.body, {
38
+ headers: {
39
+ 'Content-Type': cached.type,
40
+ 'Cache-Control': 'public, max-age=60',
41
+ 'X-Content-Type-Options': 'nosniff',
42
+ 'Content-Security-Policy': "default-src 'none'",
43
+ },
44
+ });
45
+ } catch {
46
+ return missing();
47
+ }
48
+ };