@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
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
+ import type { BookingLink, CalEvent, Calendar, Resource, Settings } from '../schema';
3
4
  import { ApiError } from '../api';
4
5
  import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl } from '../calendar/config';
5
6
  import { calendarToJson, eventToJson } from '../calendar/api';
@@ -27,7 +28,10 @@ export async function listEvents(repo: ContentRepository, url: URL) {
27
28
  requireCalendar();
28
29
  const q = eventsQuery.parse(queryParams(url));
29
30
  const locale = mobileLocale(q.locale);
30
- const [settings, calendars, events] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
31
+ const term = q.q?.trim() ?? '';
32
+ const [settings, calendars, events, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(),
33
+ // the search index's matches (body text, word prefixes), so "more" from /search continues here
34
+ term ? repo.searchPublished(term, { limit: 200, collections: [] }).then((r) => new Set(r.events.map(({ doc }) => doc.id))) : null]);
31
35
  // days are the site's calendar days (a New York site's "today" starts at midnight there, not UTC)
32
36
  const tz = settings.timezone || 'UTC';
33
37
  // a plain day is the site's midnight; a date-time is that exact instant
@@ -41,25 +45,30 @@ export async function listEvents(repo: ContentRepository, url: URL) {
41
45
  if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
42
46
  const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
43
47
  const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
44
- && (!q.q || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q.q.toLowerCase())));
48
+ && (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
45
49
  const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
46
50
  occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
47
51
  const start = (q.page - 1) * q.limit;
48
52
  const origin = siteOrigin(settings, url);
49
53
  return {
50
54
  locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
51
- occurrences: occurrences.slice(start, start + q.limit).map(o => {
52
- const event = eventToJson(o.event, origin, publicCalendars, settings.timezone);
53
- event.url = localizedUrl(event.url, origin, locale);
54
- // Only configured public event fields; no undeclared imported metadata.
55
- event.fields = Object.fromEntries(CALENDAR.eventFields.filter(f => Object.hasOwn(o.event.fields, f.key)).map(f => [f.key, o.event.fields[f.key]]));
56
- event.image = safeUrl(o.event.cover, origin);
57
- event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
58
- return { id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, event };
59
- }),
55
+ occurrences: occurrences.slice(start, start + q.limit).map(o => ({
56
+ id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay,
57
+ event: publicEventDto(o.event, origin, publicCalendars, settings.timezone, locale),
58
+ })),
60
59
  pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
61
60
  };
62
61
  }
62
+ // e is already localized; publicCalendars limits which calendars are named
63
+ export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, siteTz: string, locale: string | null) {
64
+ const event = eventToJson(e, origin, publicCalendars, siteTz);
65
+ event.url = localizedUrl(event.url, origin, locale);
66
+ // Only configured public event fields; no undeclared imported metadata.
67
+ event.fields = Object.fromEntries(CALENDAR.eventFields.filter(f => Object.hasOwn(e.fields, f.key)).map(f => [f.key, e.fields[f.key]]));
68
+ event.image = safeUrl(e.cover, origin);
69
+ event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
70
+ return event;
71
+ }
63
72
  export async function listResources(repo: ContentRepository, url: URL) {
64
73
  requireCalendar(true);
65
74
  const q = z.object(pageQuery).strict().parse(queryParams(url));
@@ -85,8 +94,12 @@ export async function getAvailability(repo: ContentRepository, url: URL) {
85
94
  const resource = await repo.getResourceBySlug(q.resource);
86
95
  const link = resource ? await repo.getBookingLinkBySlug(resource.id, q.link) : null;
87
96
  if (!resource || !link || !link.active) throw new ApiError(404, 'Resource or booking link not found.');
88
- const settings = await repo.getSettings();
89
- const ctx = await availabilityContext(repo, resource, link, settings);
97
+ return availabilityFor(repo, resource, link, await repo.getSettings(), q);
98
+ }
99
+ /** open slots for a link; excludeBookingId frees that booking's own time (moving it) */
100
+ export async function availabilityFor(repo: ContentRepository, resource: Resource, link: BookingLink, settings: Settings,
101
+ q: { from?: string; days: number; minutes?: number }, excludeBookingId?: string) {
102
+ const ctx = await availabilityContext(repo, resource, link, settings, Date.now(), excludeBookingId);
90
103
  const timezone = resource.timezone || settings.timezone || 'UTC';
91
104
  const from = q.from ?? ymd(Date.now(), timezone);
92
105
  return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: confirmsInstantly(linkApproval(link, settings)),
@@ -4,7 +4,8 @@ import type { ContentRepository } from '../repo/types';
4
4
  import type { Page, Post } from '../schema';
5
5
  import { publicTags } from '../schema';
6
6
  import { CONTENT, collectionUrl } from '../content';
7
- import { I18N, localeHomeUrl } from '../i18n';
7
+ import { I18N, localeHomeUrl, normalizePageLocale } from '../i18n';
8
+ import { SEARCH } from '../search-config';
8
9
  import { siteOrigin } from '../seo';
9
10
  import { renderMarkdown } from '../markdown';
10
11
  import { blocksToHtml } from '../blocksToHtml';
@@ -16,6 +17,7 @@ import { localeQuery, mobileLocale, localizedUrl } from './locale';
16
17
  export const contentQuery = z.object({
17
18
  ...pageQuery, kind: z.enum(['post', 'page']).optional(),
18
19
  collection: z.string().min(1).max(80).optional(), locale: localeQuery,
20
+ q: z.string().max(100).optional(),
19
21
  }).strict();
20
22
  export function safeUrl(value: string, origin: string, link = false): string {
21
23
  if (!value) return '';
@@ -61,12 +63,30 @@ export function contentDto(raw: Page | Post, kind: 'page' | 'post', origin: stri
61
63
  function visible(doc: Page | Post, kind: 'page' | 'post') {
62
64
  return doc.status === 'published' && (kind === 'page' || Object.hasOwn(CONTENT.collections, (doc as Post).collection));
63
65
  }
66
+ // most matches a search can page through, per kind
67
+ export const SEARCH_WINDOW = 200;
64
68
  export async function listContent(repo: ContentRepository, url: URL) {
65
69
  const q = contentQuery.parse(queryParams(url));
66
70
  if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
67
71
  if (q.collection && q.kind === 'page') throw new ApiError(400, 'Pages do not belong to post collections.');
68
72
  const locale = mobileLocale(q.locale);
69
73
  const origin = siteOrigin(await repo.getSettings(), url);
74
+ const term = q.q?.trim();
75
+ if (term) {
76
+ if (!SEARCH.enabled) throw new ApiError(400, 'Search is disabled on this site.');
77
+ // ranked matches, posts before pages; deep pages stop at SEARCH_WINDOW
78
+ const end = q.page * q.limit;
79
+ if (end - q.limit >= SEARCH_WINDOW) return { locale, items: [], pagination: { page: q.page, limit: q.limit, nextPage: null } };
80
+ const found = await repo.searchPublished(term, { locale: normalizePageLocale(locale ?? ''), limit: Math.min(end + 1, SEARCH_WINDOW), collections: q.collection ? [q.collection] : Object.keys(CONTENT.collections) });
81
+ const hits = [
82
+ ...(q.kind === 'page' ? [] : found.posts.map(({ doc }) => ({ doc, kind: 'post' as const }))),
83
+ ...(q.kind === 'post' || q.collection ? [] : found.pages.map(({ doc }) => ({ doc, kind: 'page' as const }))),
84
+ ];
85
+ return {
86
+ locale, items: hits.slice(end - q.limit, end).map(({ doc, kind }) => contentDto(doc, kind, origin, false, locale)),
87
+ pagination: { page: q.page, limit: q.limit, nextPage: hits.length > end && end < SEARCH_WINDOW ? q.page + 1 : null },
88
+ };
89
+ }
70
90
  const rows = await repo.listPublicContent({ ...q, locale: undefined, collections: Object.keys(CONTENT.collections), defaultLocale: I18N.defaultLocale });
71
91
  const items = await Promise.all(rows.slice(0, q.limit).map(async (r) => {
72
92
  const doc = r.kind === 'post' ? await repo.getPost(r.id) : await repo.getPage(r.id);
@@ -0,0 +1,77 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { SavedForm } from '../forms/schema';
4
+ import { collectResponse, formVersion } from '../forms/service';
5
+ import { ApiError, readJson } from '../api';
6
+ import { sha256Hex } from '../api-keys';
7
+ import { queryParams } from './http';
8
+ import { requireAccess } from './access';
9
+ import type { PublicContext } from './route';
10
+
11
+ // Published general forms for apps: read the questions, send answers. The
12
+ // same validation and storage as the web form (/forms/<id>), plus app
13
+ // limits: an Idempotency-Key per response (retries never duplicate), 10
14
+ // responses per address per form per minute, and a per-form daily ceiling
15
+ // from Admin → Mobile app so one form can't be flooded.
16
+
17
+ const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
+
19
+ async function publishedForm(repo: ContentRepository, id: string): Promise<SavedForm> {
20
+ // ids are UUIDs; anything else can't be a form
21
+ const form = id && id.length <= 100 ? await repo.forms.get(id) : null;
22
+ if (!form || form.kind !== 'form' || form.purpose !== 'general' || form.status === 'draft') throw new ApiError(404, 'Form not found.');
23
+ if (form.status === 'archived') throw new ApiError(410, 'This form is no longer accepting responses.');
24
+ return form;
25
+ }
26
+
27
+ export async function getForm(repo: ContentRepository, url: URL, id: string) {
28
+ z.object({}).strict().parse(queryParams(url));
29
+ await requireAccess(repo, 'forms');
30
+ const form = await publishedForm(repo, id);
31
+ const d = form.definition;
32
+ return {
33
+ id: form.id, version: await formVersion(d),
34
+ title: d.title || form.name, intro: d.intro, submitLabel: d.submitLabel || 'Submit', confirmation: d.confirmation,
35
+ fields: d.fields.map((f) => ({
36
+ id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
37
+ placeholder: f.placeholder ?? '', width: f.width ?? 'full', allowOther: f.allowOther ?? false,
38
+ })),
39
+ };
40
+ }
41
+
42
+ export const formResponseInput = z.object({
43
+ // field id → answer; checkbox groups send a list. "Other" answers send the typed text.
44
+ answers: z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)])).refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
45
+ // the version from GET /forms/{id}; a changed form answers 409 instead of saving
46
+ version: z.string().max(100).optional(),
47
+ // spam trap: apps leave it out or empty
48
+ website: z.string().max(200).optional(),
49
+ }).strict();
50
+
51
+ export async function submitFormResponse(c: PublicContext) {
52
+ const settings = await requireAccess(c.repo, 'forms');
53
+ const id = c.params.id ?? '';
54
+ const key = c.request.headers.get('idempotency-key') ?? '';
55
+ if (!UUID4.test(key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
56
+ const body = formResponseInput.parse(await readJson(c.request, 50_000));
57
+ if (body.website) throw new ApiError(400, 'Could not submit this form.');
58
+ const form = await publishedForm(c.repo, id);
59
+ if (!await c.repo.email.rateLimit(`forms:app:${await sha256Hex(`${form.id}:${c.address}`)}`, 10, 60_000)) throw new ApiError(429, 'Too many responses. Please try again in a minute.');
60
+ const answers = Object.fromEntries(Object.entries(body.answers).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => x.trim()).filter(Boolean).join('\n') : v]));
61
+ // the key is the stored response id: a retry of a saved response answers
62
+ // as before and costs nothing (also after the form changed); the same key
63
+ // with different answers is a 409
64
+ const prior = await c.repo.forms.receipt(form.id, key.toLowerCase());
65
+ if (prior) {
66
+ const unchanged = JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition);
67
+ if (unchanged && JSON.stringify(await collectResponse(form.definition, answers, body.version)) !== prior)
68
+ throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new response.');
69
+ return { received: true as const, confirmation: form.definition.confirmation };
70
+ }
71
+ const response = await collectResponse(form.definition, answers, body.version);
72
+ // only valid new responses count toward the daily ceiling
73
+ const cap = settings.mobileAccess.responsesPerFormDay;
74
+ if (cap && !await c.repo.email.rateLimit(`forms:day:${form.id}`, cap, 86_400_000)) throw new ApiError(429, 'This form has had a lot of responses today. Please try again tomorrow.', 86_400);
75
+ await c.repo.forms.submit(form.id, response, key.toLowerCase());
76
+ return { received: true as const, confirmation: form.definition.confirmation };
77
+ }
@@ -0,0 +1,62 @@
1
+ import type { SqlDriver } from '../repo/sql';
2
+
3
+ // Guest sign-in storage: one pending code per address and one session per
4
+ // signed-in device. Only SHA-256 hashes of codes and tokens are stored.
5
+
6
+ /** guest app sessions end after this long unused */
7
+ export const GUEST_IDLE_MS = 90 * 86_400_000;
8
+
9
+ export interface GuestSession { id: string; email: string; deviceName: string; createdAt: number; lastSeenAt: number }
10
+ const session = (r: Record<string, unknown>): GuestSession => ({ id: String(r.id), email: String(r.email), deviceName: String(r.device_name ?? ''), createdAt: Number(r.created_at), lastSeenAt: Number(r.last_seen_at) });
11
+
12
+ export class GuestStore {
13
+ constructor(private driver: () => Promise<SqlDriver>) {}
14
+ /** replaces any earlier code for the address (a new request voids the old code) */
15
+ async saveCode(email: string, codeHash: string, expiresAt: number, now = Date.now()) {
16
+ await (await this.driver()).run(`INSERT INTO guest_codes (email, code_hash, attempts, expires_at, created_at) VALUES (?, ?, 0, ?, ?)
17
+ ON CONFLICT(email) DO UPDATE SET code_hash = excluded.code_hash, attempts = 0, expires_at = excluded.expires_at, created_at = excluded.created_at`, [email, codeHash, expiresAt, now]);
18
+ }
19
+ async code(email: string) {
20
+ const r = (await (await this.driver()).all('SELECT code_hash, attempts, expires_at, created_at FROM guest_codes WHERE email = ?', [email]))[0];
21
+ return r ? { codeHash: String(r.code_hash), attempts: Number(r.attempts), expiresAt: Number(r.expires_at), createdAt: Number(r.created_at) } : null;
22
+ }
23
+ /**
24
+ * counts one verification attempt in a single statement, so concurrent
25
+ * guesses can't exceed `maxTries`; returns the code hash it was counted
26
+ * against, or null when there is no live code with tries left
27
+ */
28
+ async attempt(email: string, maxTries: number, now = Date.now()) {
29
+ const r = (await (await this.driver()).all('UPDATE guest_codes SET attempts = attempts + 1 WHERE email = ? AND attempts < ? AND expires_at > ? RETURNING code_hash, attempts', [email, maxTries, now]))[0];
30
+ return r ? { codeHash: String(r.code_hash), attempts: Number(r.attempts) } : null;
31
+ }
32
+ /** deletes this exact code; true for exactly one caller, so a code signs in once */
33
+ async consumeCode(email: string, codeHash: string) {
34
+ return (await (await this.driver()).all('DELETE FROM guest_codes WHERE email = ? AND code_hash = ? RETURNING email', [email, codeHash])).length > 0;
35
+ }
36
+ async deleteCode(email: string) { await (await this.driver()).run('DELETE FROM guest_codes WHERE email = ?', [email]); }
37
+
38
+ /** keeps the newest `keep` sessions per address so old devices age out */
39
+ async createSession(s: { id: string; tokenHash: string; email: string; deviceName: string }, now = Date.now(), keep = 20) {
40
+ const d = await this.driver();
41
+ await d.batch([
42
+ { sql: 'INSERT INTO guest_sessions (id, token_hash, email, device_name, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)', args: [s.id, s.tokenHash, s.email, s.deviceName, now, now] },
43
+ { sql: 'DELETE FROM guest_sessions WHERE email = ? AND id NOT IN (SELECT id FROM guest_sessions WHERE email = ? ORDER BY last_seen_at DESC LIMIT ?)', args: [s.email, s.email, keep] },
44
+ ]);
45
+ }
46
+ async sessionByHash(tokenHash: string) {
47
+ const r = (await (await this.driver()).all('SELECT * FROM guest_sessions WHERE token_hash = ?', [tokenHash]))[0];
48
+ return r ? session(r) : null;
49
+ }
50
+ async touch(id: string, now = Date.now()) { await (await this.driver()).run('UPDATE guest_sessions SET last_seen_at = ? WHERE id = ?', [now, id]); }
51
+ async deleteSession(id: string) { await (await this.driver()).run('DELETE FROM guest_sessions WHERE id = ?', [id]); }
52
+ /** every device signs out (owner switch "Sign out all") */
53
+ async deleteAll() { await (await this.driver()).run('DELETE FROM guest_sessions'); }
54
+ async count(since = 0) { return Number((await (await this.driver()).all('SELECT COUNT(*) AS n FROM guest_sessions WHERE last_seen_at >= ?', [since]))[0]?.n ?? 0); }
55
+ /** drops expired codes and sessions unused for `idleMs` */
56
+ async purge(idleMs: number, now = Date.now()) {
57
+ await (await this.driver()).batch([
58
+ { sql: 'DELETE FROM guest_codes WHERE expires_at <= ?', args: [now] },
59
+ { sql: 'DELETE FROM guest_sessions WHERE last_seen_at <= ?', args: [now - idleMs] },
60
+ ]);
61
+ }
62
+ }
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { Booking } from '../schema';
4
+ import { ApiError, readJson } from '../api';
5
+ import { sha256Hex } from '../api-keys';
6
+ import { randomToken, safeEqual } from '../editor/auth';
7
+ import { CALENDAR, bookingsEnabled, manageUrl } from '../calendar/config';
8
+ import { encrypt, encryptionAvailable, resolveConfig, validAddress } from '../email/config';
9
+ import { kickEmail } from '../email/service';
10
+ import { guestChanges } from '../booking/service';
11
+ import { siteOrigin } from '../seo';
12
+ import { waitUntilFrom } from '../webhooks';
13
+ import { pageQuery, queryParams } from './http';
14
+ import { failureLock } from './access';
15
+ import type { PublicContext } from './route';
16
+ import { GUEST_IDLE_MS } from './guest-store';
17
+ export { GUEST_IDLE_MS };
18
+
19
+ // Guest sign-in for "My bookings": the visitor proves they read an inbox.
20
+ // POST /guest/code emails a 6-digit code (10 minutes, 5 tries, only its hash
21
+ // is stored); POST /guest/token trades email + code for a per-device token
22
+ // (opaque, hashed at rest, ends after 90 days unused). The token reads that
23
+ // address's bookings and nothing else — it never reaches staff routes.
24
+ // Replies never say whether an address has bookings.
25
+
26
+ export const GUEST_PREFIX = 'acms_guest_';
27
+ const CODE_MS = 10 * 60_000;
28
+ const MAX_TRIES = 5;
29
+ // wrong codes per address per day before sign-in for it pauses (guessing 6 digits stays hopeless)
30
+ const EMAIL_FAILURES_PER_DAY = 10;
31
+ const SITE_CODES_PER_HOUR = 200;
32
+ const TOKEN = /^acms_guest_[0-9a-f]{64}$/;
33
+
34
+ /** guest sign-in needs the owner switch, working email and an encryption key */
35
+ export async function guestSignInReady(repo: ContentRepository, locals?: unknown) {
36
+ const settings = await repo.getSettings();
37
+ if (!settings.mobileAccess.guestSignIn || !CALENDAR.enabled) return false;
38
+ const c = resolveConfig(await repo.email.config(), locals);
39
+ return !c.error && c.provider !== 'none' && c.config.enabled && encryptionAvailable(locals);
40
+ }
41
+ async function requireGuest(c: PublicContext) {
42
+ if (!await guestSignInReady(c.repo, c.locals)) throw new ApiError(404, 'This feature is turned off.');
43
+ }
44
+ const emailField = z.string().trim().toLowerCase().max(254).refine(validAddress, 'Enter a valid email address.');
45
+ const hash = (email: string, code: string) => sha256Hex(`guest-code:${email}:${code}`);
46
+ function sixDigits() {
47
+ // rejection sampling keeps every code equally likely
48
+ const buf = new Uint32Array(1);
49
+ do crypto.getRandomValues(buf); while (buf[0] >= 4_294_000_000);
50
+ return String(buf[0] % 1_000_000).padStart(6, '0');
51
+ }
52
+
53
+ export const codeInput = z.object({ email: emailField }).strict();
54
+ export async function requestCode(c: PublicContext) {
55
+ await requireGuest(c);
56
+ const { email } = codeInput.parse(await readJson(c.request, 1000));
57
+ if (!await c.repo.email.rateLimit(`guest:code:ip:${await sha256Hex(c.address)}`, 20, 15 * 60_000)) throw new ApiError(429, 'Too many requests. Try again in a few minutes.', 900);
58
+ // site-wide ceiling: rotating client addresses can't turn this into a mail cannon
59
+ if (!await c.repo.email.rateLimit('guest:code:site', SITE_CODES_PER_HOUR, 3_600_000)) throw new ApiError(429, 'Sign-in is busy right now. Try again in a little while.', 3600);
60
+ const emailKey = await sha256Hex(email);
61
+ // an address that already got 3 codes, or that ran out of guesses today, quietly gets no more
62
+ if (!await c.repo.email.rateLimited(`guest:fail:email:${emailKey}`, EMAIL_FAILURES_PER_DAY)
63
+ && await c.repo.email.rateLimit(`guest:code:email:${emailKey}`, 3, 15 * 60_000)) {
64
+ const now = Date.now(), code = sixDigits(), dedupe = `guest-code:${crypto.randomUUID()}`;
65
+ await c.repo.guests.saveCode(email, await hash(email, code), now + CODE_MS, now);
66
+ const config = await c.repo.email.config();
67
+ await c.repo.email.enqueue({ dedupe, kind: 'guest-code', recipient: email, configVersion: config?.version ?? 0, expiresAt: now + CODE_MS,
68
+ payload: JSON.stringify({ issuedAt: now, code: encrypt(code, `guest-code:${dedupe}`, c.locals) }) });
69
+ await kickEmail(c.repo, c.locals, waitUntilFrom(c.locals));
70
+ }
71
+ return { sent: true as const, expiresInMinutes: CODE_MS / 60_000 };
72
+ }
73
+
74
+ export const tokenInput = z.object({
75
+ email: emailField, code: z.string().regex(/^\d{6}$/, 'Enter the 6-digit code.'),
76
+ // shown to the owner ("Ana's iPhone"); optional
77
+ deviceName: z.string().trim().max(80).default(''),
78
+ }).strict();
79
+ export async function exchangeCode(c: PublicContext) {
80
+ await requireGuest(c);
81
+ const body = tokenInput.parse(await readJson(c.request, 1000));
82
+ const lock = await failureLock(c.repo, 'guest-code', c.address, 30);
83
+ await lock.check();
84
+ // per address, across every code: new codes reset the 5 tries, not this budget
85
+ const failKey = `guest:fail:email:${await sha256Hex(body.email)}`;
86
+ if (await c.repo.email.rateLimited(failKey, EMAIL_FAILURES_PER_DAY)) {
87
+ await c.repo.guests.deleteCode(body.email);
88
+ throw new ApiError(400, 'That code is wrong or has expired. Check the latest email or ask for a new code.');
89
+ }
90
+ // the try is counted and the code consumed atomically: concurrent requests
91
+ // can't share one code or squeeze in extra guesses (spent codes stay until
92
+ // purged; attempt() already refuses them)
93
+ const tried = await c.repo.guests.attempt(body.email, MAX_TRIES);
94
+ const ok = !!tried && safeEqual(tried.codeHash, await hash(body.email, body.code)) && await c.repo.guests.consumeCode(body.email, tried.codeHash);
95
+ if (!ok) {
96
+ await lock.miss();
97
+ await c.repo.email.rateLimit(failKey, EMAIL_FAILURES_PER_DAY, 86_400_000);
98
+ throw new ApiError(400, 'That code is wrong or has expired. Check the latest email or ask for a new code.');
99
+ }
100
+ const token = randomToken(GUEST_PREFIX);
101
+ await c.repo.guests.createSession({ id: crypto.randomUUID(), tokenHash: await sha256Hex(token), email: body.email, deviceName: body.deviceName });
102
+ return { accessToken: token, tokenType: 'Bearer' as const, email: body.email, idleDays: GUEST_IDLE_MS / 86_400_000 };
103
+ }
104
+
105
+ /** the signed-in guest behind `Authorization: Bearer acms_guest_…` */
106
+ export async function authenticateGuest(c: PublicContext) {
107
+ await requireGuest(c);
108
+ const raw = /^Bearer (\S+)$/.exec(c.request.headers.get('authorization') ?? '')?.[1] ?? '';
109
+ const lock = await failureLock(c.repo, 'guest-token', c.address);
110
+ await lock.check();
111
+ const session = TOKEN.test(raw) ? await c.repo.guests.sessionByHash(await sha256Hex(raw)) : null;
112
+ const now = Date.now();
113
+ if (!session || session.lastSeenAt <= now - GUEST_IDLE_MS) {
114
+ await lock.miss();
115
+ if (session) await c.repo.guests.deleteSession(session.id);
116
+ throw new ApiError(401, 'Sign in again.');
117
+ }
118
+ // sliding expiry without a write on every request
119
+ if (now - session.lastSeenAt > 3_600_000) await c.repo.guests.touch(session.id, now);
120
+ return session;
121
+ }
122
+
123
+ export const myBookingsQuery = z.object({ when: z.enum(['upcoming', 'past']).default('upcoming'), ...pageQuery }).strict();
124
+ export async function myBookings(c: PublicContext) {
125
+ const q = myBookingsQuery.parse(queryParams(c.url));
126
+ const session = await authenticateGuest(c);
127
+ const settings = await c.repo.getSettings();
128
+ const origin = siteOrigin(settings, c.url);
129
+ const now = Date.now();
130
+ const kinds: Booking['kind'][] = bookingsEnabled() ? ['booking', 'rsvp'] : ['rsvp'];
131
+ const start = (q.page - 1) * q.limit;
132
+ // filtered, ordered and paged in SQL (one extra row says whether there is a next page)
133
+ const [list, resources, links, events] = await Promise.all([
134
+ c.repo.listBookings({ email: session.email, kinds, ...(q.when === 'upcoming' ? { from: now } : { endedBy: now, newestFirst: true }), limit: q.limit + 1, offset: start }),
135
+ c.repo.listResources(), c.repo.listBookingLinks(), c.repo.listEvents(),
136
+ ]);
137
+ const res = new Map(resources.map((r) => [r.id, r])), lnk = new Map(links.map((l) => [l.id, l])), ev = new Map(events.map((e) => [e.id, e]));
138
+ return {
139
+ email: session.email,
140
+ items: list.slice(0, q.limit).map((b) => {
141
+ const resource = res.get(b.resourceId), link = lnk.get(b.linkId) ?? null, event = ev.get(b.eventId);
142
+ return {
143
+ id: b.id, reference: b.id.slice(0, 8).toUpperCase(), kind: b.kind as 'booking' | 'rsvp', status: b.status,
144
+ start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(), seats: b.seats,
145
+ title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
146
+ // use with the Booking-Token header on /bookings/manage/*
147
+ manageToken: b.manageToken, manageUrl: origin + manageUrl(b.manageToken),
148
+ can: guestChanges(b, link, now),
149
+ };
150
+ }),
151
+ pagination: { page: q.page, limit: q.limit, nextPage: list.length > q.limit ? q.page + 1 : null },
152
+ };
153
+ }
154
+
155
+ export async function revokeGuest(c: PublicContext) {
156
+ const session = await authenticateGuest(c);
157
+ await c.repo.guests.deleteSession(session.id);
158
+ return { signedOut: true as const };
159
+ }
160
+
@@ -8,7 +8,7 @@ const headers = {
8
8
  'Access-Control-Allow-Origin': '*',
9
9
  'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS',
10
10
  // Authorization carries editor-app bearer tokens; no route reads cookies
11
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Idempotency-Key, If-None-Match',
11
+ 'Access-Control-Allow-Headers': 'Authorization, Booking-Token, Content-Type, Idempotency-Key, If-None-Match',
12
12
  'Access-Control-Expose-Headers': 'ETag, Retry-After, Idempotency-Replayed',
13
13
  'X-Content-Type-Options': 'nosniff',
14
14
  'Cache-Control': 'no-store',
@@ -35,7 +35,7 @@ export function failure(error: unknown) {
35
35
  code: codes[status] ?? 'internal_error',
36
36
  message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
37
37
  ...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
38
- } }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': '60' } : {}),
38
+ } }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': String((error as ApiError).retryAfter ?? 60) } : {}),
39
39
  ...(status === 401 ? { 'WWW-Authenticate': 'Bearer realm="acms", error="invalid_token"' } : {}) } });
40
40
  }
41
41
  export async function limitRequest(repo: ContentRepository, address: string, write = false) {
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { Link } from '../schema';
4
+ import { siteOrigin } from '../seo';
5
+ import { socialIconPath } from '../socialIcons';
6
+ import { safeUrl } from './content';
7
+ import { queryParams } from './http';
8
+ import { requireAccess } from './access';
9
+
10
+ // The links page (link-in-bio) for apps: the same visible blocks in the same
11
+ // order. Buttons point at /go/<id> so taps still count; raw HTML embeds stay
12
+ // website-only and arrive as `embed` with the page URL to open instead.
13
+
14
+ const HEX = /^#[0-9a-f]{6}$/i;
15
+ const str = (v: unknown) => (typeof v === 'string' ? v : '');
16
+
17
+ function icon(l: Link, origin: string) {
18
+ const value = l.icon.trim();
19
+ if (!value) return null;
20
+ if (value.startsWith('<svg')) return { type: 'svg' as const, value };
21
+ if (value === 'favicon') {
22
+ try { return { type: 'image' as const, value: `https://www.google.com/s2/favicons?domain=${new URL(l.url).hostname}&sz=64` }; } catch { return null; }
23
+ }
24
+ if (value.startsWith('http') || value.startsWith('/')) { const url = safeUrl(value, origin); return url ? { type: 'image' as const, value: url } : null; }
25
+ return { type: 'emoji' as const, value: value.slice(0, 16) };
26
+ }
27
+
28
+ function shown(l: Link) {
29
+ if (l.meta?.hidden) return false;
30
+ if (l.kind === 'link' || l.kind === 'email' || l.kind === 'phone') return Boolean(l.url.trim());
31
+ if (l.kind === 'heading') return Boolean(l.label.trim());
32
+ if (l.kind === 'text' || l.kind === 'html') return Boolean(l.html.trim());
33
+ return true;
34
+ }
35
+
36
+ function item(l: Link, origin: string, page: string) {
37
+ const style = { color: HEX.test(str(l.meta.color)) ? str(l.meta.color) : null, textColor: HEX.test(str(l.meta.textColor)) ? str(l.meta.textColor) : null };
38
+ switch (l.kind) {
39
+ case 'heading': return { id: l.id, kind: 'heading' as const, label: l.label };
40
+ case 'text': return { id: l.id, kind: 'text' as const, text: l.html };
41
+ case 'spacer': return { id: l.id, kind: 'spacer' as const, size: (['s', 'm', 'l'].includes(str(l.meta.size)) ? str(l.meta.size) : 'm') as 's' | 'm' | 'l' };
42
+ case 'live': return { id: l.id, kind: 'live' as const };
43
+ case 'html': return { id: l.id, kind: 'embed' as const, url: page };
44
+ case 'email': return { id: l.id, kind: 'email' as const, label: l.label || l.url, url: `mailto:${l.url.trim()}`, icon: icon(l, origin), ...style };
45
+ case 'phone': return { id: l.id, kind: 'phone' as const, label: l.label || l.url, url: `tel:${l.url.replace(/[^+\d]/g, '')}`, icon: icon(l, origin), ...style };
46
+ case 'vcard': {
47
+ const m = l.meta;
48
+ return { id: l.id, kind: 'contact' as const, label: l.label || 'Save contact', icon: icon(l, origin), ...style,
49
+ contact: { firstName: str(m.firstName), lastName: str(m.lastName), organization: str(m.org), phone: str(m.phone), email: str(m.email), url: safeUrl(str(m.url), origin) } };
50
+ }
51
+ default: return { id: l.id, kind: 'link' as const, label: l.label, url: `${origin}/go/${encodeURIComponent(l.id)}`, destination: safeUrl(l.url, origin), icon: icon(l, origin), ...style };
52
+ }
53
+ }
54
+
55
+ export async function getLinks(repo: ContentRepository, url: URL) {
56
+ z.object({}).strict().parse(queryParams(url));
57
+ const settings = await requireAccess(repo, 'links');
58
+ const origin = siteOrigin(settings, url);
59
+ const [links, nav] = await Promise.all([repo.listLinks(), repo.listNavItems()]);
60
+ const page = `${origin}/links`;
61
+ return {
62
+ name: settings.linksName || settings.siteTitle,
63
+ bio: settings.linksBio || settings.tagline,
64
+ avatar: safeUrl(settings.linksAvatar, origin),
65
+ url: page,
66
+ socials: nav.filter((n) => n.area === 'social').map((n) => ({ label: n.label, url: safeUrl(n.url, origin, true), iconPath: socialIconPath(n.url) })).filter((n) => n.url),
67
+ items: links.filter(shown).map((l) => item(l, origin, page)).filter((i) => !('url' in i) || i.url),
68
+ };
69
+ }
@@ -0,0 +1,114 @@
1
+ import { z } from 'zod';
2
+ import type { Booking, BookingLink, CalEvent, Resource, Settings } from '../schema';
3
+ import { ApiError, readJson } from '../api';
4
+ import { bookingsEnabled, eventUrl, manageUrl } from '../calendar/config';
5
+ import { eventTimeZone, locationLine } from '../calendar/events';
6
+ import { resourceZone, linkLengths } from '../booking/availability';
7
+ import { bookingIcs } from '../booking/emails';
8
+ import { guestCancel, guestChanges, guestMoveLink, guestReschedule } from '../booking/service';
9
+ import { siteOrigin } from '../seo';
10
+ import { waitUntilFrom } from '../webhooks';
11
+ import { isoDay, queryParams } from './http';
12
+ import { requireCalendar, availabilityFor } from './calendar';
13
+ import { requireAccess, failureLock } from './access';
14
+ import type { PublicContext } from './route';
15
+
16
+ // A booker's own booking, from an app: the private token every confirmation
17
+ // email links to is the credential. It travels in the Booking-Token header,
18
+ // never the URL, so it stays out of access logs and analytics. Unknown tokens
19
+ // count against the caller's address (20 misses / 10 min → locked out).
20
+
21
+ export const BOOKING_TOKEN_HEADER = 'Booking-Token';
22
+ const TOKEN = /^[A-Za-z0-9_-]{16,128}$/;
23
+
24
+ interface Managed { booking: Booking; settings: Settings; resource: Resource | null; link: BookingLink | null; event: CalEvent | null; origin: string }
25
+
26
+ async function managed(c: PublicContext): Promise<Managed> {
27
+ requireCalendar();
28
+ const settings = await requireAccess(c.repo, 'bookingChanges');
29
+ const token = c.request.headers.get(BOOKING_TOKEN_HEADER) ?? '';
30
+ if (!token) throw new ApiError(400, `Send the booking’s token in the ${BOOKING_TOKEN_HEADER} header.`);
31
+ const lock = await failureLock(c.repo, 'booking-token', c.address);
32
+ await lock.check();
33
+ const booking = TOKEN.test(token) ? await c.repo.getBookingByToken(token) : null;
34
+ if (!booking || booking.kind === 'block' || (!bookingsEnabled() && booking.kind !== 'rsvp')) {
35
+ await lock.miss();
36
+ throw new ApiError(404, 'Booking not found.');
37
+ }
38
+ const [resource, link, event] = await Promise.all([
39
+ booking.resourceId ? c.repo.getResource(booking.resourceId) : null,
40
+ booking.linkId ? c.repo.getBookingLink(booking.linkId) : null,
41
+ booking.eventId ? c.repo.getEvent(booking.eventId) : null,
42
+ ]);
43
+ return { booking, settings, resource, link, event, origin: siteOrigin(settings, c.url) };
44
+ }
45
+
46
+ function view(m: Managed, booking = m.booking) {
47
+ const { resource, link, event, settings, origin } = m;
48
+ const can = guestChanges(booking, link);
49
+ return {
50
+ booking: {
51
+ id: booking.id, reference: booking.id.slice(0, 8).toUpperCase(), kind: booking.kind as 'booking' | 'rsvp', status: booking.status,
52
+ start: new Date(booking.start).toISOString(), end: new Date(booking.end).toISOString(),
53
+ allDay: event ? event.allDay : link?.mode === 'day',
54
+ timezone: event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC',
55
+ seats: booking.seats, name: booking.name, email: booking.email, phone: booking.phone, answers: booking.answers,
56
+ createdAt: new Date(booking.createdAt).toISOString(),
57
+ },
58
+ title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
59
+ location: event ? locationLine(event.location) : resource?.name ?? '',
60
+ resource: resource ? { slug: resource.slug, name: resource.name } : null,
61
+ link: link ? { slug: link.slug, name: link.name, mode: link.mode, lengths: linkLengths(link) } : null,
62
+ event: event ? { slug: event.slug, title: event.title, url: origin + eventUrl(event.slug) } : null,
63
+ // the web page for this booking (the same private link the emails carry)
64
+ manageUrl: origin + manageUrl(booking.manageToken),
65
+ can,
66
+ };
67
+ }
68
+ const flow = (m: Managed, c: PublicContext) => ({ origin: m.origin, locals: c.locals, waitUntil: waitUntilFrom(c.locals) });
69
+
70
+ export const getManaged = async (c: PublicContext) => view(await managed(c));
71
+
72
+ export async function cancelManaged(c: PublicContext) {
73
+ const m = await managed(c);
74
+ return view(m, await guestCancel(c.repo, m.booking, flow(m, c)));
75
+ }
76
+
77
+ export const manageAvailabilityQuery = z.object({
78
+ from: isoDay.optional(), days: z.coerce.number().int().min(1).max(31).default(7), minutes: z.coerce.number().int().min(1).max(44640).optional(),
79
+ }).strict();
80
+ export async function managedAvailability(c: PublicContext) {
81
+ const q = manageAvailabilityQuery.parse(queryParams(c.url));
82
+ const m = await managed(c);
83
+ if (!guestChanges(m.booking, m.link).reschedule || !m.resource || !m.link) throw new ApiError(409, 'This booking can’t be moved.');
84
+ // only times the move itself would accept (outside the change cutoff)
85
+ return availabilityFor(c.repo, m.resource, guestMoveLink(m.link), m.settings, q, m.booking.id);
86
+ }
87
+
88
+ export const rescheduleInput = z.object({
89
+ start: z.string().datetime({ offset: true }),
90
+ // range/day links: the new length; default keeps the current length
91
+ minutes: z.number().int().min(1).max(44640).optional(),
92
+ }).strict();
93
+ export async function rescheduleManaged(c: PublicContext) {
94
+ const body = rescheduleInput.parse(await readJson(c.request, 2000));
95
+ const m = await managed(c);
96
+ const start = Date.parse(body.start);
97
+ const minutes = body.minutes ?? Math.round((m.booking.end - m.booking.start) / 60_000);
98
+ // availability rules check everything else (hours, notice, overlaps)
99
+ if (m.link?.mode === 'range' && body.minutes !== undefined && !linkLengths(m.link).includes(minutes)) throw new ApiError(400, 'Pick one of the booking type’s lengths.');
100
+ return view(m, await guestReschedule(c.repo, m.booking, start, start + minutes * 60_000, flow(m, c)));
101
+ }
102
+
103
+ export async function managedIcs(c: PublicContext) {
104
+ z.object({}).strict().parse(queryParams(c.url));
105
+ const m = await managed(c);
106
+ const v = view(m);
107
+ const body = bookingIcs(m.booking, v.title, v.location, v.manageUrl, new URL(m.origin).host, m.booking.status === 'cancelled' ? 'CANCEL' : 'REQUEST', { allDay: v.booking.allDay });
108
+ return new Response(body, { headers: {
109
+ 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'attachment; filename="booking.ics"',
110
+ 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff',
111
+ } });
112
+ }
113
+
114
+ export type ManagedBooking = ReturnType<typeof view>;