@acms-subir/core 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +17 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/mobile-doctor.mjs +9 -0
  8. package/cli/monitor.mjs +189 -0
  9. package/cli/preview.mjs +1 -1
  10. package/deploy/Dockerfile +5 -1
  11. package/deploy/compose.yml +21 -0
  12. package/docs/live-video.md +1 -1
  13. package/docs/mobile.md +23 -2
  14. package/docs/theme-authoring.md +6 -0
  15. package/integration.mjs +74 -4
  16. package/migrate.mjs +5 -0
  17. package/package.json +9 -11
  18. package/src/actions/index.ts +11 -31
  19. package/src/components/AppAccess.tsx +117 -0
  20. package/src/components/DesignManager.tsx +29 -10
  21. package/src/components/EmailSettings.tsx +53 -11
  22. package/src/components/MonitoringSettings.tsx +112 -0
  23. package/src/components/ThemeTransfer.tsx +211 -0
  24. package/src/components/auth/ClerkSession.astro +80 -0
  25. package/src/components/auth/ReverifyForms.astro +61 -0
  26. package/src/components/auth/clerk.ts +169 -0
  27. package/src/components/import/ImportExport.tsx +16 -6
  28. package/src/components/import/runner.ts +3 -0
  29. package/src/layouts/Admin.astro +42 -12
  30. package/src/layouts/Site.astro +6 -1
  31. package/src/lib/api.ts +8 -4
  32. package/src/lib/auth/accounts.ts +94 -0
  33. package/src/lib/auth/config.ts +22 -0
  34. package/src/lib/auth/login-limit.ts +15 -0
  35. package/src/lib/auth/request.ts +161 -0
  36. package/src/lib/auth/store.ts +72 -0
  37. package/src/lib/auth.ts +3 -0
  38. package/src/lib/backup/archive.ts +26 -0
  39. package/src/lib/backup/content.ts +1 -1
  40. package/src/lib/backup/offsite.ts +173 -0
  41. package/src/lib/backup/store.ts +28 -0
  42. package/src/lib/booking/availability.ts +15 -2
  43. package/src/lib/booking/service.ts +53 -1
  44. package/src/lib/cache/config.ts +7 -0
  45. package/src/lib/cache/memory.ts +39 -0
  46. package/src/lib/cache/policy.ts +89 -0
  47. package/src/lib/cache/purge.ts +89 -0
  48. package/src/lib/calendar/admin.ts +41 -0
  49. package/src/lib/designPreview.ts +2 -2
  50. package/src/lib/dynamicBlocks.ts +1 -1
  51. package/src/lib/editor/auth.ts +35 -5
  52. package/src/lib/editor/http.ts +4 -1
  53. package/src/lib/editor/me.ts +4 -0
  54. package/src/lib/editor/openapi.ts +49 -1
  55. package/src/lib/editor/roles.ts +20 -0
  56. package/src/lib/editor/schedule.ts +340 -0
  57. package/src/lib/editor/schemas.ts +36 -1
  58. package/src/lib/editor/site-data.ts +110 -0
  59. package/src/lib/editor/store.ts +13 -7
  60. package/src/lib/editor/token-route.ts +2 -1
  61. package/src/lib/email/accounts.ts +4 -0
  62. package/src/lib/email/service.ts +9 -1
  63. package/src/lib/email/store.ts +10 -0
  64. package/src/lib/email/templates.ts +8 -1
  65. package/src/lib/email/types.ts +1 -0
  66. package/src/lib/forms/store.ts +5 -0
  67. package/src/lib/mobile/access-admin.ts +34 -0
  68. package/src/lib/mobile/access.ts +28 -0
  69. package/src/lib/mobile/calendar.ts +26 -13
  70. package/src/lib/mobile/content.ts +21 -1
  71. package/src/lib/mobile/forms.ts +77 -0
  72. package/src/lib/mobile/guest-store.ts +62 -0
  73. package/src/lib/mobile/guest.ts +160 -0
  74. package/src/lib/mobile/http.ts +6 -5
  75. package/src/lib/mobile/links.ts +69 -0
  76. package/src/lib/mobile/manage.ts +114 -0
  77. package/src/lib/mobile/openapi.ts +40 -4
  78. package/src/lib/mobile/route.ts +63 -4
  79. package/src/lib/mobile/schemas.ts +52 -1
  80. package/src/lib/mobile/search.ts +95 -0
  81. package/src/lib/mobile/site.ts +8 -2
  82. package/src/lib/monitoring.ts +171 -0
  83. package/src/lib/net.ts +82 -14
  84. package/src/lib/repo/migrate-runner.ts +96 -0
  85. package/src/lib/repo/migrations.ts +104 -0
  86. package/src/lib/repo/sql.ts +122 -44
  87. package/src/lib/repo/sqlite.ts +14 -1
  88. package/src/lib/repo/types.ts +31 -3
  89. package/src/lib/scheduler.ts +14 -0
  90. package/src/lib/schema.ts +35 -1
  91. package/src/lib/theme-transfer.ts +163 -0
  92. package/src/lib/theme.ts +5 -0
  93. package/src/lib/webhooks.ts +7 -2
  94. package/src/middleware.ts +41 -11
  95. package/src/pages/[...slug].astro +3 -2
  96. package/src/pages/account/[action].astro +6 -0
  97. package/src/pages/admin/app-auth.astro +21 -7
  98. package/src/pages/admin/calendar/resources/link.astro +2 -0
  99. package/src/pages/admin/design.astro +2 -1
  100. package/src/pages/admin/import.astro +5 -3
  101. package/src/pages/admin/index.astro +3 -2
  102. package/src/pages/admin/integrations.astro +3 -2
  103. package/src/pages/admin/login.astro +236 -25
  104. package/src/pages/admin/logout.ts +16 -2
  105. package/src/pages/admin/mobile.astro +6 -2
  106. package/src/pages/admin/settings.astro +43 -5
  107. package/src/pages/admin/users.astro +350 -59
  108. package/src/pages/api/backup.ts +10 -26
  109. package/src/pages/api/design/export.ts +72 -0
  110. package/src/pages/api/design/import.ts +41 -0
  111. package/src/pages/api/email/settings.ts +9 -5
  112. package/src/pages/api/health.ts +17 -0
  113. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  114. package/src/pages/api/live/settings.ts +2 -2
  115. package/src/pages/api/media/upload.ts +2 -2
  116. package/src/pages/api/mobile/access.ts +22 -0
  117. package/src/pages/api/monitoring.ts +22 -0
  118. package/src/pages/api/ops/status.ts +2 -1
  119. package/src/pages/api/push/admin.ts +3 -2
  120. package/src/pages/api/restore.ts +6 -3
  121. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
  122. package/src/pages/api/v1/admin/bookings/index.ts +10 -2
  123. package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
  124. package/src/pages/api/v1/admin/calendars/index.ts +12 -0
  125. package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
  126. package/src/pages/api/v1/admin/forms/index.ts +6 -0
  127. package/src/pages/api/v1/admin/links.ts +13 -0
  128. package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
  129. package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
  130. package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
  131. package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
  132. package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
  133. package/src/pages/api/v1/admin/resources/index.ts +12 -0
  134. package/src/pages/api/v1/admin/schedule.ts +7 -0
  135. package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
  136. package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
  137. package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
  138. package/src/pages/api/v1/bookings/manage/index.ts +6 -0
  139. package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
  140. package/src/pages/api/v1/calendars.ts +1 -1
  141. package/src/pages/api/v1/forms/[id]/index.ts +7 -0
  142. package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
  143. package/src/pages/api/v1/guest/bookings.ts +6 -0
  144. package/src/pages/api/v1/guest/code.ts +6 -0
  145. package/src/pages/api/v1/guest/revoke.ts +6 -0
  146. package/src/pages/api/v1/guest/token.ts +6 -0
  147. package/src/pages/api/v1/links.ts +7 -0
  148. package/src/pages/api/v1/openapi.json.ts +1 -1
  149. package/src/pages/api/v1/push/topics.ts +1 -1
  150. package/src/pages/api/v1/resources.ts +1 -1
  151. package/src/pages/api/v1/search.ts +7 -0
  152. package/src/pages/api/v1/theme.ts +1 -1
  153. package/src/pages/blog/[slug].astro +3 -2
  154. package/src/pages/book/[resource]/[link].astro +3 -3
  155. package/src/pages/book/manage/[token].astro +11 -7
  156. package/src/pages/events/[slug].astro +3 -2
  157. package/src/pages/preview/[type]/[id].astro +3 -3
  158. package/src/styles/admin.css +169 -16
  159. package/src/styles/email-settings.css +6 -0
  160. package/src/virtual.d.ts +1 -0
  161. package/src/worker.ts +7 -1
@@ -4,6 +4,10 @@ import { processDueDeliveries } from './webhooks';
4
4
  import { dispatchEmail } from './email/service';
5
5
  import { decideOverdue, sendDueReminders } from './booking/service';
6
6
  import { runPushJobs } from './push/service';
7
+ import { GUEST_IDLE_MS } from './mobile/guest-store';
8
+ import { pushHeartbeat, pushUrlFor } from './monitoring';
9
+ import { syncCachePurge } from './cache/purge';
10
+ import { runOffsiteBackup } from './backup/offsite';
7
11
 
8
12
  // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
9
13
  // this heartbeat: monitoring must prove the independent scheduler ran.
@@ -20,6 +24,13 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
20
24
  live: () => syncDueLive(repo, locals),
21
25
  // queued first-publish notices, starting-soon reminders, receipts, retention
22
26
  push: () => runPushJobs(repo, { locals, now: startedAt }),
27
+ // expired guest sign-in codes and app sessions unused for 90 days
28
+ guests: () => repo.guests.purge(GUEST_IDLE_MS, startedAt).then(() => 0),
29
+ // clear shared caches after scheduled publishes, and retry a purge that
30
+ // failed after an admin save (Cloudflare API down or rate limited)
31
+ cache: () => syncCachePurge(repo, locals, { force: true }),
32
+ // off-site backup ZIP when one is due (BACKUP_* settings / BACKUPS binding)
33
+ backup: () => runOffsiteBackup(repo, locals),
23
34
  };
24
35
  const results: Record<string, { ok: boolean; count?: number }> = {};
25
36
  for (const [name, run] of Object.entries(jobs)) {
@@ -28,5 +39,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
28
39
  }
29
40
  const report = { startedAt, finishedAt: Date.now(), ok: Object.values(results).every(r => r.ok), jobs: results };
30
41
  await repo.email.recordScheduler(report);
42
+ // Uptime Kuma push monitor (Settings → Monitoring): a missing beat means the scheduler stopped
43
+ // (never allowed to fail the run)
44
+ await Promise.resolve().then(() => pushHeartbeat(pushUrlFor(settings.monitoring, locals), report)).catch(() => false);
31
45
  return report;
32
46
  }
package/src/lib/schema.ts CHANGED
@@ -425,7 +425,8 @@ export type WeeklyHours = z.infer<typeof WeeklyHours>;
425
425
  // a date (or inclusive date span) with replacement hours; [] = closed
426
426
  export const DateOverride = z.object({
427
427
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
428
- endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(''),
428
+ // '' = a single day
429
+ endDate: z.union([z.literal(''), z.string().regex(/^\d{4}-\d{2}-\d{2}$/)]).default(''),
429
430
  ranges: z.array(TimeRange).default([]),
430
431
  label: z.string().default(''),
431
432
  });
@@ -526,6 +527,35 @@ export const CalendarSettings = z.object({
526
527
  });
527
528
  export type CalendarSettings = z.infer<typeof CalendarSettings>;
528
529
 
530
+ // Admin → Mobile app → App access: owner switches and abuse limits for the
531
+ // public app routes. Turning a switch off answers 404 at once, no redeploy.
532
+ export const MobileAccess = z.object({
533
+ // visitors view, cancel and move a booking with its private token
534
+ bookingChanges: z.boolean().default(true),
535
+ // "My bookings" through an emailed sign-in code (also needs email sending)
536
+ guestSignIn: z.boolean().default(true),
537
+ // published forms accept responses from apps
538
+ forms: z.boolean().default(true),
539
+ links: z.boolean().default(true),
540
+ // new bookings + RSVPs per email address per day, every channel; 0 = no cap
541
+ bookingsPerEmailDay: z.number().int().min(0).max(1000).default(10),
542
+ // responses per form per day from apps; 0 = no cap
543
+ responsesPerFormDay: z.number().int().min(0).max(100000).default(500),
544
+ // who changes rooms, booking types, hours and calendars from staff apps
545
+ scheduleRoles: z.enum(['admins', 'staff']).default('admins'),
546
+ });
547
+ export type MobileAccess = z.infer<typeof MobileAccess>;
548
+
549
+ // Settings → Monitoring (owner): Uptime Kuma. pushUrl is a Kuma push monitor
550
+ // the scheduler reports to after every run (a small secret: left out of
551
+ // exports; UPTIME_PUSH_URL overrides it). statusPage is a public Kuma status
552
+ // page the admin card reads.
553
+ export const Monitoring = z.object({
554
+ pushUrl: z.string().max(500).default(''),
555
+ statusPage: z.string().max(500).default(''),
556
+ });
557
+ export type Monitoring = z.infer<typeof Monitoring>;
558
+
529
559
  export const BookingLink = z.object({
530
560
  id: z.string(),
531
561
  resourceId: z.string(),
@@ -543,6 +573,8 @@ export const BookingLink = z.object({
543
573
  bufferAfter: z.number().int().default(0),
544
574
  minNotice: z.number().int().default(120), // minutes
545
575
  maxAdvance: z.number().int().default(90), // days
576
+ // guests can cancel or move until this many hours before the start; 0 = any time
577
+ changeCutoff: z.number().int().min(0).max(8760).default(0),
546
578
  perDayCap: z.number().int().default(0), // 0 = unlimited
547
579
  // narrows the resource's hours; null = use the resource's hours as-is
548
580
  hours: WeeklyHours.nullable().default(null),
@@ -671,6 +703,8 @@ export function normalizeRedirectPath(input: string): string {
671
703
  export const Settings = z.object({
672
704
  live: LiveConfig.default({}),
673
705
  calendar: CalendarSettings.default({}),
706
+ mobileAccess: MobileAccess.default({}),
707
+ monitoring: Monitoring.default({}),
674
708
  // push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
675
709
  push: PushSettings.default({}),
676
710
  siteTitle: z.string().default('My Site'),
@@ -0,0 +1,163 @@
1
+ import { z } from 'zod';
2
+ import { SectionLayout, type SectionPages } from '../theme-sdk/contract.mjs';
3
+ import type { NavItem, Page, Settings } from './schema';
4
+
5
+ // Theme transfer (acms-theme v1): a theme as you see it on the Design screen,
6
+ // moved between sites or machines as one ZIP:
7
+ // manifest.json format, theme identity, core version, what's included
8
+ // design.json the database half: overrides, brand images, section
9
+ // layouts (keyed by page slug, not ID), optional navigation
10
+ // media/<key> brand and section images, ids and keys verbatim
11
+ // Theme code (src/themes/<slug>/) is compiled into the build, so it never
12
+ // installs at runtime; an import that names a theme this site lacks stages
13
+ // the rest and keeps the current theme.
14
+
15
+ export const THEME_TRANSFER_FORMAT = 'acms-theme';
16
+
17
+ export const ThemeTransferManifest = z.object({
18
+ format: z.literal(THEME_TRANSFER_FORMAT),
19
+ version: z.literal(1),
20
+ exportedAt: z.number().int().nonnegative(),
21
+ coreVersion: z.string().max(50).default(''),
22
+ source: z.object({ title: z.string().max(300).default(''), siteUrl: z.string().max(2000).default('') }).default({}),
23
+ theme: z.object({ slug: z.string().max(50), name: z.string().max(200).default(''), version: z.string().max(50).default(''), project: z.boolean().default(false) }),
24
+ includes: z.array(z.enum(['design', 'media', 'nav'])).max(10),
25
+ }).passthrough();
26
+ export type ThemeTransferManifest = z.infer<typeof ThemeTransferManifest>;
27
+
28
+ const PageKey = z.object({ slug: z.string().max(300), locale: z.string().max(20).default(''), isHome: z.boolean().default(false), title: z.string().max(500).default('') });
29
+ export const ThemeDesignFile = z.object({
30
+ activeTheme: z.string().max(50),
31
+ designOverrides: z.record(z.union([z.string().max(2000), z.number().finite()])).default({}),
32
+ siteLogo: z.string().max(2000).default(''),
33
+ siteLogoDark: z.string().max(2000).default(''),
34
+ siteFavicon: z.string().max(2000).default(''),
35
+ siteCover: z.string().max(2000).default(''),
36
+ layouts: z.array(z.object({ page: PageKey, layout: SectionLayout })).max(500).default([]),
37
+ // link refs (page:/post:/event:/booking:<id>) → the public URL they pointed
38
+ // at, so the importing site can find its own item at the same address
39
+ refs: z.record(z.string().max(200), z.string().max(2000)).default({}),
40
+ navItems: z.array(z.object({ label: z.string().max(200), url: z.string().max(2000), area: z.enum(['header', 'footer', 'social']) })).max(200).optional(),
41
+ });
42
+ export type ThemeDesignFile = z.infer<typeof ThemeDesignFile>;
43
+
44
+ type Pages = z.infer<typeof SectionPages>;
45
+ type PageRow = Pick<Page, 'id' | 'slug' | 'locale' | 'isHome' | 'title'>;
46
+ export interface LinkRef { ref: string; url: string }
47
+
48
+ const walk = (v: unknown, fn: (s: string) => string): unknown => {
49
+ if (typeof v === 'string') return fn(v);
50
+ if (Array.isArray(v)) return v.map((x) => walk(x, fn));
51
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, walk(x, fn)]));
52
+ return v;
53
+ };
54
+ const collectRefs = (v: unknown, out: Set<string>) => {
55
+ if (Array.isArray(v)) v.forEach((x) => collectRefs(x, out));
56
+ else if (v && typeof v === 'object') {
57
+ for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
58
+ if (k === 'ref' && typeof x === 'string') out.add(x); else collectRefs(x, out);
59
+ }
60
+ }
61
+ };
62
+
63
+ /** every uploaded-media key a value points at (/media/<id>/<file>) */
64
+ export function mediaKeysIn(value: unknown): string[] {
65
+ const keys = new Set<string>();
66
+ walk(value, (s) => {
67
+ for (const m of s.matchAll(/\/media\/([\w-]+\/[\w.-]+)/g)) keys.add(m[1]);
68
+ return s;
69
+ });
70
+ return [...keys];
71
+ }
72
+
73
+ /**
74
+ * The database half of an export: the design as the Design screen shows it
75
+ * (draft merged over live). Same-origin absolute media URLs become
76
+ * site-relative so they resolve on the importing site.
77
+ */
78
+ export function buildDesignExport(input: {
79
+ settings: Settings; navItems: Pick<NavItem, 'label' | 'url' | 'area'>[]; pages: PageRow[]; links: LinkRef[]; origin: string; includeNav: boolean;
80
+ }): ThemeDesignFile {
81
+ const { settings: s, origin } = input;
82
+ const local = (v: string) => (origin && v.startsWith(`${origin}/media/`) ? v.slice(origin.length) : v);
83
+ const byId = new Map(input.pages.map((p) => [p.id, p]));
84
+ const layouts = Object.entries(s.sectionPages as Pages).flatMap(([id, layout]) => {
85
+ const p = byId.get(id);
86
+ return p ? [{ page: { slug: p.slug, locale: p.locale ?? '', isHome: Boolean(p.isHome), title: p.title ?? '' }, layout: walk(layout, local) as Pages[string] }] : [];
87
+ });
88
+ const used = new Set<string>();
89
+ collectRefs(layouts, used);
90
+ const urls = new Map(input.links.map((l) => [l.ref, l.url]));
91
+ const refs = Object.fromEntries([...used].flatMap((r) => (urls.has(r) ? [[r, urls.get(r)!]] : [])));
92
+ return {
93
+ activeTheme: s.activeTheme,
94
+ designOverrides: (s.designOverrides ?? {}) as Record<string, string | number>,
95
+ siteLogo: local(s.siteLogo ?? ''), siteLogoDark: local(s.siteLogoDark ?? ''), siteFavicon: local(s.siteFavicon ?? ''), siteCover: local(s.siteCover ?? ''),
96
+ layouts, refs,
97
+ ...(input.includeNav ? { navItems: input.navItems.map(({ label, url, area }) => ({ label, url: local(url), area })) } : {}),
98
+ };
99
+ }
100
+
101
+ export interface DesignImportReport {
102
+ theme: { slug: string; installed: boolean };
103
+ layouts: { matched: string[]; skipped: string[] };
104
+ links: { kept: number; dropped: number };
105
+ nav: number | null;
106
+ }
107
+
108
+ /**
109
+ * The stage patch for an imported design file, against this site's pages,
110
+ * link targets and installed themes. Layouts land on the page with the same
111
+ * slug (and locale, else the home page for the exported home), replacing any
112
+ * staged layout there; pages without a match are reported, never created.
113
+ * Link refs follow the item at the same URL, else fall back to their href.
114
+ */
115
+ export function planDesignImport(input: {
116
+ file: ThemeDesignFile; pages: PageRow[]; links: LinkRef[]; installedThemes: string[];
117
+ stagedPages: Pages; includeNav: boolean;
118
+ }): { patch: Record<string, unknown>; report: DesignImportReport } {
119
+ const { file } = input;
120
+ const installed = input.installedThemes.includes(file.activeTheme);
121
+ const refByUrl = new Map(input.links.map((l) => [l.url, l.ref]));
122
+ let kept = 0, dropped = 0;
123
+ const remap = (v: unknown): unknown => {
124
+ if (Array.isArray(v)) return v.map(remap);
125
+ if (!v || typeof v !== 'object') return v;
126
+ const o = Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, remap(x)]));
127
+ if (typeof o.ref === 'string') {
128
+ const url = file.refs[o.ref];
129
+ const to = url !== undefined ? refByUrl.get(url) : undefined;
130
+ if (to) { o.ref = to; kept++; } else { delete o.ref; dropped++; }
131
+ }
132
+ return o;
133
+ };
134
+ const find = (k: ThemeDesignFile['layouts'][number]['page']) =>
135
+ input.pages.find((p) => p.slug === k.slug && (p.locale ?? '') === k.locale)
136
+ ?? input.pages.find((p) => p.slug === k.slug)
137
+ ?? (k.isHome ? input.pages.find((p) => p.isHome && (p.locale ?? '') === k.locale) ?? input.pages.find((p) => p.isHome) : undefined);
138
+ const sectionPages: Pages = { ...input.stagedPages };
139
+ const matched: string[] = [], skipped: string[] = [];
140
+ for (const { page, layout } of file.layouts) {
141
+ const label = page.isHome ? 'Home' : `/${page.slug}`;
142
+ const target = find(page);
143
+ if (!target) { skipped.push(label); continue; }
144
+ sectionPages[target.id] = { ...layout, sections: layout.sections.map((s) => ({ ...s, data: remap(s.data) as typeof s.data })) };
145
+ matched.push(label);
146
+ }
147
+ const patch: Record<string, unknown> = {
148
+ designOverrides: file.designOverrides,
149
+ siteLogo: file.siteLogo, siteLogoDark: file.siteLogoDark, siteFavicon: file.siteFavicon, siteCover: file.siteCover,
150
+ };
151
+ if (installed) patch.activeTheme = file.activeTheme;
152
+ if (matched.length) patch.sectionPages = sectionPages;
153
+ // an empty list never wipes the receiving site's menus
154
+ const nav = input.includeNav && file.navItems?.length ? file.navItems : null;
155
+ if (nav) patch.navItems = nav.map((n) => ({ ...n, id: `tmp-${crypto.randomUUID()}` }));
156
+ return {
157
+ patch,
158
+ report: { theme: { slug: file.activeTheme, installed }, layouts: { matched, skipped }, links: { kept, dropped }, nav: nav ? nav.length : null },
159
+ };
160
+ }
161
+
162
+ export const themeFileName = (slug: string, now: number) =>
163
+ `${slug.replace(/[^a-z0-9-]/gi, '') || 'theme'}-${new Date(now).toISOString().slice(0, 10)}.acms-theme.zip`;
package/src/lib/theme.ts CHANGED
@@ -194,6 +194,11 @@ export function listThemes(): Theme[] {
194
194
  return themes;
195
195
  }
196
196
 
197
+ /** true for a theme from the project's src/themes/ (its code lives in the site repo) */
198
+ export function isProjectTheme(slug: string): boolean {
199
+ return projectSlugs.has(slug);
200
+ }
201
+
197
202
  export function getTheme(slug: string): Theme {
198
203
  return themes.find((t) => t.slug === slug) ?? themes.find((t) => t.slug === DEFAULT_THEME) ?? themes[0];
199
204
  }
@@ -11,7 +11,7 @@
11
11
  // rejecting stale timestamps.
12
12
  import type { ContentRepository } from './repo/types';
13
13
  import type { Webhook, WebhookDelivery, WebhookEventType } from './schema';
14
- import { isPrivateHost } from './net';
14
+ import { isPrivateHost, resolvesToPrivate } from './net';
15
15
 
16
16
  export const MAX_ATTEMPTS = 4;
17
17
  // minutes until attempt n+1 (after the first immediate try)
@@ -82,7 +82,12 @@ export async function attemptDelivery(repo: ContentRepository, d: WebhookDeliver
82
82
  const claimed = await repo.claimDelivery(d.id, now);
83
83
  if (!claimed) return false;
84
84
  d = claimed;
85
- const fetcher: Fetcher = opts.fetcher ?? ((url, init) => fetch(url, init));
85
+ // the URL passed validateWebhookUrl when saved; re-check what its name
86
+ // resolves to now (dev allows local receivers, as validation does)
87
+ const fetcher: Fetcher = opts.fetcher ?? (async (url, init) => {
88
+ if (!import.meta.env?.DEV && await resolvesToPrivate(new URL(url).hostname)) throw new Error('private host');
89
+ return fetch(url, init);
90
+ });
86
91
  const ts = Math.floor(now / 1000);
87
92
  let code: number | null = null;
88
93
  try {
package/src/middleware.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { defineMiddleware } from 'astro:middleware';
2
+ import type { APIContext, MiddlewareNext } from 'astro';
2
3
  import { I18N } from './lib/i18n';
3
- import { validateSession, SESSION_COOKIE } from './lib/auth';
4
+ import { bindAuthEnvironment, getRequestAuth, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
5
+ import { AUTH } from './lib/auth/config';
4
6
  import { getRepo } from './lib/repo';
5
7
  import { CONTENT } from './lib/content';
6
8
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
@@ -9,6 +11,8 @@ import { decideOverdue, sendDueReminders } from './lib/booking/service';
9
11
  import { PUSH } from './lib/push/config';
10
12
  import { processPushQueue, runPushJobs } from './lib/push/service';
11
13
  import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
14
+ import { applyCachePolicy } from './lib/cache/policy';
15
+ import { bindExecutionContext, syncCachePurge } from './lib/cache/purge';
12
16
 
13
17
  // Legacy content-URL redirects, checked only on would-be 404s (below) so they
14
18
  // can never shadow real content. Tags moved from `${base}/tag/` to a
@@ -48,8 +52,16 @@ export function legacyContentRedirect(pathname: string): string | null {
48
52
  // Node target and local dev.
49
53
  let lastPublishCheck = 0;
50
54
 
51
- export const onRequest = defineMiddleware(async (context, next) => {
55
+ // Every response, early redirects included, leaves through the cache policy
56
+ // (lib/cache/policy.ts): public GET pages and the public app API get a
57
+ // shared-cache header; everything personal or staff-only is private.
58
+ export const onRequest = defineMiddleware(async (context, next) =>
59
+ applyCachePolicy(context.request, await handle(context, next), context.url.pathname));
60
+
61
+ async function handle(context: APIContext, next: MiddlewareNext): Promise<Response> {
52
62
  const { pathname } = context.url;
63
+ bindAuthEnvironment(context.request, context.locals);
64
+ bindExecutionContext(context.request, context.locals);
53
65
 
54
66
  // one canonical URL per page: trailing-slash variants 301 to the bare path.
55
67
  // GET pages only — the astro:actions client POSTs to /_actions/<name>/ with
@@ -77,21 +89,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
77
89
  }
78
90
  }
79
91
 
92
+ const auth = AUTH.provider === 'clerk' && shouldResolveClerk(context.request) ? await getRequestAuth(context.request, context.cookies, context.locals) : null;
93
+ if (auth?.headers.get('location') && context.request.method === 'GET' && !pathname.startsWith('/api/')) {
94
+ const headers = new Headers(auth.headers); headers.set('Cache-Control', 'no-store');
95
+ return new Response(null, { status: 307, headers });
96
+ }
97
+
98
+ if (pathname.startsWith('/api/') && (auth?.error === 'unavailable' || auth?.error === 'configuration')) {
99
+ return withClerkHeaders(Response.json({ error: 'Sign-in is temporarily unavailable. Please retry.' }, { status: 503 }), auth, context.request);
100
+ }
101
+
80
102
  // design preview links (?designPreview=1, ?designVersion=, ?preview=<token>,
81
103
  // ?preview=off) become a cookie, then the clean URL — so every link clicked
82
104
  // afterwards stays in preview mode
83
105
  const previewTo = await previewParamRedirect(context.url, context.request.method, context.cookies, async () =>
84
- Boolean(await validateSession(context.cookies.get(SESSION_COOKIE)?.value, context.locals)));
85
- if (previewTo) return context.redirect(previewTo, 302);
106
+ Boolean(await validateRequestSession(context.request, context.cookies, context.locals)));
107
+ if (previewTo) return withClerkHeaders(context.redirect(previewTo, 302), auth, context.request);
86
108
 
87
- if (pathname.startsWith('/admin') && pathname !== '/admin/login') {
88
- const token = context.cookies.get(SESSION_COOKIE)?.value;
89
- if (!(await validateSession(token, context.locals))) {
109
+ if (pathname.startsWith('/admin') && pathname !== '/admin/login' && pathname !== '/admin/logout') {
110
+ if (auth?.error === 'configuration' || auth?.error === 'unavailable') return new Response('Sign-in is temporarily unavailable. Check the authentication configuration or try again later.', { status: 503, headers: { 'Cache-Control': 'no-store' } });
111
+ if (!(await validateRequestSession(context.request, context.cookies, context.locals))) {
90
112
  // editor-app sign-in comes back here after login (see login.astro's next)
91
113
  if (pathname === '/admin/app-auth' && context.request.method === 'GET') {
92
- return context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`);
114
+ return withClerkHeaders(context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`), auth, context.request);
93
115
  }
94
- return context.redirect('/admin/login');
116
+ return withClerkHeaders(context.redirect('/admin/login'), auth, context.request);
95
117
  }
96
118
  }
97
119
 
@@ -121,12 +143,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
121
143
  const push = runPushJobs(repo, { locals: context.locals, fallbackOrigin: context.url.origin, now })
122
144
  .catch((e) => console.error('acms push: scheduled jobs failed', e));
123
145
  if (wait) wait(push);
146
+ // a purge that failed or was throttled after an earlier write
147
+ // (Node: detached — the process outlives the request)
148
+ const purge = syncCachePurge(repo, context.locals).catch((e) => console.error('acms cache: purge failed', e));
149
+ if (wait) wait(purge);
124
150
  } catch {
125
151
  // never let the publish sweep break a request
126
152
  }
127
153
  }
128
154
 
129
- const response = await next();
155
+ const response = withClerkHeaders(await next(), auth, context.request);
130
156
 
131
157
  // Database writes enqueue publication/media notifications atomically.
132
158
  // Flush after mutations (including Astro actions), once their transaction
@@ -142,6 +168,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
142
168
  .catch((e) => console.error('acms push: queue flush failed', e));
143
169
  if (wait) wait(push);
144
170
  }
171
+ // content changed → clear shared caches (the scheduler retries failures)
172
+ const purge = getRepo(context.locals).then(repo => syncCachePurge(repo, context.locals))
173
+ .catch((e) => console.error('acms cache: purge failed', e));
174
+ if (wait) wait(purge);
145
175
  }
146
176
 
147
177
  // a preview render must never be cached where a visitor could get it
@@ -169,7 +199,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
169
199
  }
170
200
 
171
201
  return response;
172
- });
202
+ }
173
203
 
174
204
  let redirectCache: { at: number; map: Map<string, { toPath: string; code: 301 | 302 }> } | null = null;
175
205
 
@@ -3,7 +3,7 @@ import ThemeSections from '../components/ThemeSections.astro';
3
3
  import Site from '../layouts/Site.astro';
4
4
  import WidgetRenderer from '../components/WidgetRenderer.astro';
5
5
  import { getRepo } from '../lib/repo';
6
- import { validateSession, SESSION_COOKIE } from '../lib/auth';
6
+ import { validateRequestSession } from '../lib/auth/request';
7
7
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
8
8
  import { highlightCodeHtml } from '../lib/highlightCode';
9
9
  import { resolveDesign } from '../lib/theme';
@@ -18,7 +18,7 @@ const rawPage = slug ? await repo.getPageBySlug(slug) : null;
18
18
  const page = rawPage ? localizeDoc(rawPage, locale) : null;
19
19
 
20
20
  // Drafts are visible only to a logged-in admin (doubles as draft preview).
21
- const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
21
+ const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
22
22
  if (!page || (page.status !== 'published' && !isAdmin)) {
23
23
  return new Response(null, { status: 404, statusText: 'Not found' });
24
24
  }
@@ -37,6 +37,7 @@ const PageTemplate = (page.template && settings.sectionPages?.[page.id]?.mode !=
37
37
  ---
38
38
 
39
39
  <Site
40
+ staffPreview={!!isAdmin && page.status !== 'published'}
40
41
  title={page.seoTitle || page.title}
41
42
  exactTitle={Boolean(page.seoTitle)}
42
43
  description={page.seoDesc || undefined}
@@ -5,10 +5,16 @@ import { sha256 } from '../../lib/email/config';
5
5
  import { dispatchEmail, kickEmail } from '../../lib/email/service';
6
6
  import { waitUntilFrom } from '../../lib/webhooks';
7
7
  import { SESSION_COOKIE } from '../../lib/auth';
8
+ import { AUTH } from '../../lib/auth/config';
8
9
  import '../../styles/admin.css';
9
10
  const action=Astro.params.action||'';
10
11
  if(!['invite','reset','forgot'].includes(action))return new Response('Not found',{status:404});
11
12
  Astro.response.headers.set('Cache-Control','no-store');
13
+ // Clerk handles invitations and recovery; local links are never redeemed.
14
+ if(AUTH.provider==='clerk'){
15
+ if(Astro.request.method!=='GET'&&Astro.request.method!=='HEAD')return new Response('Account access is managed by the sign-in provider.',{status:403,headers:{'Cache-Control':'no-store'}});
16
+ return new Response(null,{status:303,headers:{Location:'/admin/login','Cache-Control':'no-store','Referrer-Policy':'no-referrer'}});
17
+ }
12
18
  Astro.response.headers.set('Referrer-Policy','no-referrer');
13
19
  Astro.response.headers.set('X-Robots-Tag','noindex');
14
20
  const repo=await getRepo(Astro.locals);
@@ -7,7 +7,10 @@ import { roleLabels } from '../../lib/roles';
7
7
  // return address and the PKCE challenge, then returns to the app.
8
8
  import { getRepo } from '../../lib/repo';
9
9
  import { BRAND } from '../../lib/brand';
10
- import { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
10
+ import { OWNER_ENV } from '../../lib/auth';
11
+ import { AUTH } from '../../lib/auth/config';
12
+ import { getRequestAuth, requestSessionBinding } from '../../lib/auth/request';
13
+ import ClerkSession from '../../components/auth/ClerkSession.astro';
11
14
  import { EDITOR } from '../../lib/editor/config';
12
15
  import { checkAuthorize, checkConsentTicket, consentTicket, issueCode, redirectWith } from '../../lib/editor/auth';
13
16
  import { bookingsEnabled } from '../../lib/calendar/config';
@@ -26,8 +29,10 @@ if (!EDITOR.enabled) return new Response(null, { status: 404 });
26
29
 
27
30
  const leave = (to: string) => new Response(null, { status: 303, headers: { Location: to, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' } });
28
31
  const repo = await getRepo(Astro.locals);
29
- const session = Astro.cookies.get(SESSION_COOKIE)?.value ?? '';
30
- const userId = await validateSession(session, Astro.locals);
32
+ const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
33
+ const userId = auth.userId;
34
+ // consent tickets are bound to this browser session (built-in cookie or Clerk session)
35
+ const session = await requestSessionBinding(Astro.request, Astro.cookies, Astro.locals);
31
36
  if (!userId) return Astro.redirect(`/admin/login?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`);
32
37
 
33
38
  const check = checkAuthorize(Astro.url.searchParams);
@@ -41,7 +46,10 @@ const envOwner = userId === OWNER_ENV;
41
46
  const user = envOwner ? null : await repo.getUser(userId);
42
47
  const settings = await repo.getSettings();
43
48
  const appName = EDITOR.name || params?.clientId || 'An app';
44
- const switchUrl = `/admin/logout?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`;
49
+ const here = Astro.url.pathname + Astro.url.search;
50
+ const switchUrl = `/admin/logout?next=${encodeURIComponent(here)}`;
51
+ // Clerk sign-out only accepts a same-origin POST; built-in sign-out is a link
52
+ const postLogout = AUTH.provider === 'clerk';
45
53
 
46
54
  let error = '';
47
55
  if (Astro.request.method === 'POST' && params && user) {
@@ -56,7 +64,7 @@ if (Astro.request.method === 'POST' && params && user) {
56
64
  const version = await repo.email.sessionVersion(user.id);
57
65
  if (version === null) error = 'Your account changed. Sign in again.';
58
66
  else {
59
- const code = await issueCode(repo, { id: user.id, sessionVersion: version }, params, String(form.get('device_name') ?? ''));
67
+ const code = await issueCode(repo, { id: user.id, sessionVersion: version }, params, String(form.get('device_name') ?? ''), Date.now(), auth.clerk ?? undefined);
60
68
  return leave(redirectWith(params.redirectUri, { code, state: params.state, iss }));
61
69
  }
62
70
  }
@@ -85,8 +93,10 @@ const can = [
85
93
  <title>Sign in to {appName} · {settings.siteTitle}</title>
86
94
  <link rel="preconnect" href="https://fonts.googleapis.com" />
87
95
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
96
+ <ClerkSession keepAlive signedOutUrl={`/admin/login?next=${encodeURIComponent(here)}`} />
88
97
  </head>
89
98
  <body>
99
+ {postLogout && <form id="switch-account" method="post" action={switchUrl} hidden></form>}
90
100
  <main class="login-wrap consent-wrap">
91
101
  <div class="card consent-card">
92
102
  <div class="consent-site">
@@ -110,7 +120,9 @@ const can = [
110
120
  <div class="consent-body">
111
121
  <h1>Use your own account</h1>
112
122
  <p class="consent-lead"><b>{appName}</b> signs in as a person, so each phone can be signed out on its own. You’re signed in with the owner password, which has no account to connect.</p>
113
- <a class="btn primary block" href={switchUrl}>Sign in with an account</a>
123
+ {postLogout
124
+ ? <button class="btn primary block" type="submit" form="switch-account">Sign in with an account</button>
125
+ : <a class="btn primary block" href={switchUrl}>Sign in with an account</a>}
114
126
  <p class="hint consent-foot">Owners create accounts in Users.</p>
115
127
  </div>
116
128
  )}
@@ -150,7 +162,9 @@ const can = [
150
162
  <div><dt>Returns to</dt><dd><code>{returnTo}</code></dd></div>
151
163
  <div><dt>App ID</dt><dd><code>{params.clientId}</code></dd></div>
152
164
  </dl>
153
- <p class="hint consent-foot">Not you? <a href={switchUrl}>Switch account</a></p>
165
+ <p class="hint consent-foot">Not you? {postLogout
166
+ ? <button class="linklike" type="submit" form="switch-account">Switch account</button>
167
+ : <a href={switchUrl}>Switch account</a>}</p>
154
168
  </form>
155
169
  )}
156
170
  </div>
@@ -80,6 +80,7 @@ if (Astro.request.method === 'POST') {
80
80
  minNotice: int(form.get('minNoticeH'), 0, 24 * 60, 2) * 60,
81
81
  maxAdvance: int(form.get('maxAdvance'), 1, 730, 90),
82
82
  perDayCap: int(form.get('perDayCap'), 0, 100, 0),
83
+ changeCutoff: int(form.get('changeCutoff'), 0, 8760, 0),
83
84
  hours: form.get('customHours') ? hoursFromForm(form, 'lh') : null,
84
85
  approval, approvalRules,
85
86
  // legacy flag for older API readers; approval is what decides
@@ -183,6 +184,7 @@ const effective = linkApproval(view, settings).mode;
183
184
  <label class="field"><span>Max per day <em class="hint">0 = no limit</em></span><input type="number" name="perDayCap" min="0" max="100" value={view.perDayCap} /></label>
184
185
  <label class="field"><span>Book at least (hours ahead)</span><input type="number" name="minNoticeH" min="0" max="1440" value={Math.round(view.minNotice / 60)} /></label>
185
186
  <label class="field"><span>Book up to (days ahead)</span><input type="number" name="maxAdvance" min="1" max="730" value={view.maxAdvance} /></label>
187
+ <label class="field"><span>Guests can change until (hours before) <em class="hint">0 = any time</em></span><input type="number" name="changeCutoff" min="0" max="8760" value={view.changeCutoff} /></label>
186
188
  </div>
187
189
  </section>
188
190
 
@@ -3,7 +3,7 @@ import Admin from '../../layouts/Admin.astro';
3
3
  import { getRepo } from '../../lib/repo';
4
4
  import { env } from '../../lib/env';
5
5
  import DesignManager from '../../components/DesignManager';
6
- import { listThemes, FONT_PAIRINGS, getTheme } from '../../lib/theme';
6
+ import { listThemes, FONT_PAIRINGS, getTheme, isProjectTheme } from '../../lib/theme';
7
7
  import { diffDesign } from '../../lib/designDiff';
8
8
  import { mergeDesignDraft } from '../../lib/drafts';
9
9
  import { postUrl } from '../../lib/content';
@@ -31,6 +31,7 @@ const themes = listThemes().map((t) => ({
31
31
  version: t.version,
32
32
  templateCount: Number(Boolean(t.HomeTemplate)) + Number(Boolean(t.EventTemplate)) + Object.keys(t.postTemplates).length + Object.keys(t.pageTemplates).length,
33
33
  sectionCount: Object.keys(t.sections).length,
34
+ project: isProjectTheme(t.slug),
34
35
  }));
35
36
  const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
36
37
  ---
@@ -1,16 +1,18 @@
1
1
  ---
2
2
  import Admin from '../../layouts/Admin.astro';
3
3
  import ImportExport from '../../components/import/ImportExport';
4
+ import { AUTH } from '../../lib/auth/config';
4
5
  import { getRepo } from '../../lib/repo';
5
- import { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
6
+ import { OWNER_ENV } from '../../lib/auth';
7
+ import { validateRequestSession } from '../../lib/auth/request';
6
8
 
7
9
  // owner-only: an import can create accounts and change site settings
8
10
  const repo = await getRepo(Astro.locals);
9
- const sessionUserId = await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals);
11
+ const sessionUserId = await validateRequestSession(Astro.request, Astro.cookies, Astro.locals);
10
12
  const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
11
13
  if (sessionUserId !== OWNER_ENV && sessionUser?.role !== 'owner') return Astro.redirect('/admin/settings');
12
14
  const tab = Astro.url.searchParams.get('tab');
13
15
  ---
14
16
  <Admin title="Import & export" section="settings" wide>
15
- <ImportExport client:load initialTab={tab === 'export' || tab === 'history' ? tab : 'import'} />
17
+ <ImportExport client:load initialTab={tab === 'export' || tab === 'history' ? tab : 'import'} clerk={AUTH.provider === 'clerk'} />
16
18
  </Admin>
@@ -3,13 +3,14 @@ import Admin from '../../layouts/Admin.astro';
3
3
  import { getRepo } from '../../lib/repo';
4
4
  import { userConfig } from 'virtual:acms-config';
5
5
  import { CONTENT } from '../../lib/content';
6
- import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../lib/auth';
6
+ import { OWNER_ENV } from '../../lib/auth';
7
+ import { validateRequestSession } from '../../lib/auth/request';
7
8
  import { getTraffic, umamiReadEnabled } from '../../lib/umami';
8
9
  import { collectionUrl } from '../../lib/content';
9
10
 
10
11
  const coreVersion = userConfig.coreVersion;
11
12
  const repo = await getRepo(Astro.locals);
12
- const sessionUserId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
13
+ const sessionUserId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
13
14
  const [stats, pages, posts, links, settings, nav, users] = await Promise.all([
14
15
  repo.getStats(),
15
16
  repo.listPages(),
@@ -3,7 +3,8 @@ import Admin from '../../layouts/Admin.astro';
3
3
  import Modal from '../../components/Modal.astro';
4
4
  import { getRepo } from '../../lib/repo';
5
5
  import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
6
- import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../lib/auth';
6
+ import { OWNER_ENV } from '../../lib/auth';
7
+ import { validateRequestSession } from '../../lib/auth/request';
7
8
  import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
8
9
  import { issueApiKey } from '../../lib/api-keys';
9
10
  import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
@@ -15,7 +16,7 @@ import { PROVIDERS } from '../../lib/maps/providers';
15
16
  // Site-wide integrations. Owners manage keys and webhook subscriptions;
16
17
  // calendar-specific feeds and API help appear only when enabled.
17
18
  const repo = await getRepo(Astro.locals);
18
- const sessionId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
19
+ const sessionId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
19
20
  const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
20
21
  const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
21
22
  const settings = await repo.getSettings();