@acms-subir/core 0.1.0 → 0.3.0

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 (237) hide show
  1. package/CHANGELOG.md +106 -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 -315
  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/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -0,0 +1,114 @@
1
+ import { z } from 'zod';
2
+ import sanitizeHtml from 'sanitize-html';
3
+ import { renderMarkdown } from './markdown';
4
+ import { slugify, type Page, type Post } from './schema';
5
+ import { CONTENT } from './content';
6
+ import type { ContentRepository } from './repo/types';
7
+ import { ApiError } from './api';
8
+ import { EventToolInputs } from './calendar/agent-schema';
9
+ import { runEventTool, type AgentContext, type EventToolInput } from './calendar/agent';
10
+ import { BookingToolInputs, BOOKING_TOOL_NAMES, runBookingTool } from './booking/agent';
11
+
12
+ const Kind = z.enum(['posts', 'pages']);
13
+ const fields = {
14
+ title: z.string().trim().min(1).max(300).optional(),
15
+ content: z.string().max(100_000).optional(),
16
+ excerpt: z.string().max(2000).optional(),
17
+ tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
18
+ seoTitle: z.string().max(300).optional(), seoDesc: z.string().max(2000).optional(),
19
+ };
20
+ export const AgentInput = z.discriminatedUnion('tool', [
21
+ z.object({ tool: z.literal('list_content'), arguments: z.object({ kind: Kind, collection: z.string().optional(), query: z.string().max(200).optional(), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(100).default(25) }).strict() }).strict(),
22
+ z.object({ tool: z.literal('get_content'), arguments: z.object({ kind: Kind, id: z.string().min(1) }).strict() }).strict(),
23
+ z.object({ tool: z.literal('create_draft'), arguments: z.object({ ...fields, kind: Kind, title: z.string().trim().min(1).max(300), slug: z.string().max(300).optional(), collection: z.string().optional() }).strict() }).strict(),
24
+ z.object({ tool: z.literal('update_content'), arguments: z.object({ ...fields, kind: Kind, id: z.string().min(1) }).strict() }).strict(),
25
+ z.object({ tool: z.literal('publish_content'), arguments: z.object({ kind: Kind, id: z.string().min(1) }).strict() }).strict(),
26
+ ...EventToolInputs,
27
+ ...BookingToolInputs,
28
+ ]);
29
+ export function renderAgentMarkdown(markdown: string): string {
30
+ const html = sanitizeHtml(renderMarkdown(markdown), {
31
+ allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img'],
32
+ allowedAttributes: { ...sanitizeHtml.defaults.allowedAttributes, img: ['src', 'alt', 'title'], code: ['class'] },
33
+ allowedSchemes: ['http', 'https', 'mailto'], allowProtocolRelative: false,
34
+ });
35
+ // Editors/public templates fall back to legacy Markdown when bodyHtml is
36
+ // empty. Keep that fallback from reintroducing stripped HTML-only input.
37
+ return html || (markdown ? '<p></p>' : '');
38
+ }
39
+ const editUrl = (kind: 'posts' | 'pages', doc: { id: string; collection?: string }) => kind === 'pages' ? `/admin/pages/${doc.id}` : `/admin/c/${doc.collection}/${doc.id}`;
40
+
41
+ export async function runAgentTool(repo: ContentRepository, raw: unknown, allowPublish: boolean, ctx: AgentContext = { origin: '' }) {
42
+ const parsed = AgentInput.parse(raw);
43
+ if ((BOOKING_TOOL_NAMES as readonly string[]).includes(parsed.tool)) {
44
+ return runBookingTool(repo, parsed as never, allowPublish, ctx);
45
+ }
46
+ if (parsed.tool.endsWith('_event') || parsed.tool === 'list_events' || parsed.tool === 'list_calendars') {
47
+ return runEventTool(repo, parsed as EventToolInput, allowPublish, ctx);
48
+ }
49
+ const input = parsed as Extract<typeof parsed, { tool: 'list_content' | 'get_content' | 'create_draft' | 'update_content' | 'publish_content' }>;
50
+ const args = input.arguments;
51
+ const isPost = args.kind === 'posts';
52
+ if (input.tool === 'list_content') {
53
+ const a = input.arguments;
54
+ if (a.collection && !CONTENT.collections[a.collection]) throw new ApiError(400, 'Unknown collection.');
55
+ let docs: (Page | Post)[] = isPost ? await repo.listPosts(a.collection) : await repo.listPages();
56
+ if (a.query) docs = docs.filter((d) => `${d.title} ${d.slug}`.toLowerCase().includes(a.query!.toLowerCase()));
57
+ return { total: docs.length, offset: a.offset, items: docs.slice(a.offset, a.offset + a.limit).map((d) => ({ id: d.id, title: d.title, slug: d.slug, status: d.status, updatedAt: d.updatedAt, hasPendingDraft: !!d.draft, editUrl: editUrl(a.kind, d) })) };
58
+ }
59
+ if (input.tool === 'create_draft') {
60
+ const a = input.arguments;
61
+ if (isPost && !CONTENT.collections[a.collection ?? 'posts']) throw new ApiError(400, 'Unknown collection.');
62
+ if (!isPost && (a.tags !== undefined || a.excerpt !== undefined || a.collection !== undefined)) throw new ApiError(400, 'Tags, excerpts, and collections apply only to posts.');
63
+ const base = slugify(a.slug || a.title) || 'untitled';
64
+ let slug = base;
65
+ for (let n = 2; isPost ? await repo.getPostBySlug(slug, a.collection ?? 'posts') : await repo.getPageBySlug(slug); n++) slug = `${base}-${n}`;
66
+ const data = contentFields(a);
67
+ const doc = isPost ? await repo.createPost({ title: a.title, slug, collection: a.collection }) : await repo.createPage({ title: a.title, slug });
68
+ if (isPost) { await repo.updatePost(doc.id, data); if (a.tags) await repo.ensureTags(a.tags); }
69
+ else await repo.updatePage(doc.id, pageFields(data));
70
+ return { id: doc.id, slug, status: 'draft', editUrl: editUrl(a.kind, doc) };
71
+ }
72
+ const a = input.arguments;
73
+ const doc = isPost ? await repo.getPost(a.id) : await repo.getPage(a.id);
74
+ if (!doc) throw new ApiError(404, 'Content not found.');
75
+ if (input.tool === 'get_content') return { ...doc, editUrl: editUrl(a.kind, doc) };
76
+ if (input.tool === 'publish_content') {
77
+ if (!allowPublish) throw new ApiError(403, 'Agent publishing is disabled. An owner can enable it in Settings.');
78
+ // Existing editor drafts can rename content; agents never publish those
79
+ // implicitly because doing so requires redirect/homepage handling.
80
+ if (doc.draft && Object.keys(doc.draft).some((key) => !['title', 'body', 'bodyHtml', 'bodyJson', 'excerpt', 'tags', 'seoTitle', 'seoDesc', 'savedAt'].includes(key))) {
81
+ throw new ApiError(409, 'This draft contains editor layout or routing changes. Publish it from the admin editor.');
82
+ }
83
+ if (isPost) {
84
+ if (doc.draft) await repo.applyPostDraft(doc.id);
85
+ const updated = await repo.getPost(doc.id);
86
+ if (updated?.tags.length) await repo.ensureTags(updated.tags);
87
+ await repo.updatePost(doc.id, { status: 'published', scheduledAt: null });
88
+ } else {
89
+ if (doc.draft) await repo.applyPageDraft(doc.id);
90
+ await repo.updatePage(doc.id, { status: 'published' });
91
+ }
92
+ return { id: doc.id, status: 'published', editUrl: editUrl(a.kind, doc) };
93
+ }
94
+ const changes = input.arguments;
95
+ if (!isPost && (changes.tags !== undefined || changes.excerpt !== undefined)) throw new ApiError(400, 'Tags and excerpts apply only to posts.');
96
+ const data = contentFields(changes);
97
+ if (!Object.keys(data).length) throw new ApiError(400, 'Provide at least one content field to update.');
98
+ const staged = doc.status === 'published' || doc.status === 'scheduled';
99
+ if (isPost) {
100
+ if (staged) await repo.savePostDraft(doc.id, data); else await repo.updatePost(doc.id, data);
101
+ if (changes.tags) await repo.ensureTags(changes.tags);
102
+ } else {
103
+ if (staged) await repo.savePageDraft(doc.id, pageFields(data)); else await repo.updatePage(doc.id, pageFields(data));
104
+ }
105
+ return { id: doc.id, status: doc.status, staged, editUrl: editUrl(a.kind, doc) };
106
+ }
107
+ function contentFields(args: z.infer<z.ZodObject<typeof fields>>) {
108
+ const { content, ...rest } = z.object(fields).parse(args);
109
+ return { ...rest, ...(content !== undefined ? { body: content, bodyJson: '', bodyHtml: renderAgentMarkdown(content) } : {}) };
110
+ }
111
+ function pageFields(data: ReturnType<typeof contentFields>) {
112
+ const { body: _body, tags: _tags, excerpt: _excerpt, ...rest } = data;
113
+ return rest;
114
+ }
@@ -0,0 +1,58 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { env } from './env';
3
+
4
+ import type { AiConfig } from './ai-schema';
5
+
6
+ // Only owners configure this destination. Private addresses are intentional
7
+ // for Node deployments on a tailnet. Never accept per-generation URLs.
8
+ export function normalizeAiUrl(provider: AiConfig['provider'], raw: string): string {
9
+ if (provider === 'openai') return 'https://api.openai.com/v1';
10
+ let url: URL;
11
+ try { url = new URL(raw.trim()); } catch { throw new Error('Enter a full API URL starting with https:// or http://.'); }
12
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
13
+ throw new Error('API URL must use HTTP(S), without credentials, query parameters, or a fragment.');
14
+ }
15
+ let path = url.pathname.replace(/\/+$/, '');
16
+ if (/\/(management\.html|v0\/management)(\/|$)/i.test(path)) throw new Error('Use the proxy API base URL, not the CPAMC management URL.');
17
+ if (/\/(chat\/completions|responses|models)$/.test(path)) throw new Error('Enter the API base URL, not an individual endpoint.');
18
+ // Custom providers may serve at the root, /v1, /api/v4, or another prefix.
19
+ // Use the supplied base exactly; never guess a provider's API version.
20
+ url.pathname = path;
21
+ return url.href.replace(/\/+$/, '');
22
+ }
23
+ function encryptionKey(): Buffer {
24
+ const secret = env('SESSION_SECRET');
25
+ if (!secret) throw new Error('SESSION_SECRET is required to store AI credentials.');
26
+ return createHash('sha256').update(`acms:ai:v1:${secret}`).digest();
27
+ }
28
+ export function encryptApiKey(value: string): string {
29
+ if (!value) return '';
30
+ const iv = randomBytes(12);
31
+ const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv);
32
+ const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
33
+ return [iv, cipher.getAuthTag(), ciphertext].map((v) => v.toString('base64url')).join('.');
34
+ }
35
+ export function decryptApiKey(value: string): string {
36
+ if (!value) return '';
37
+ try {
38
+ const parts = value.split('.');
39
+ if (parts.length !== 3) throw new Error();
40
+ const [iv, tag, ciphertext] = parts.map((v) => Buffer.from(v, 'base64url'));
41
+ const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), iv);
42
+ decipher.setAuthTag(tag);
43
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
44
+ } catch { throw new Error('The saved AI key cannot be decrypted. Re-enter it in Settings.'); }
45
+ }
46
+ export const hashAgentKey = (key: string) => createHash('sha256').update(key).digest('hex');
47
+ export function validAgentKey(key: string, hash: string): boolean {
48
+ if (!key || !/^[a-f0-9]{64}$/.test(hash)) return false;
49
+ return timingSafeEqual(Buffer.from(hashAgentKey(key), 'hex'), Buffer.from(hash, 'hex'));
50
+ }
51
+ export function generateAgentKey() {
52
+ const token = `acms_agent_${randomBytes(32).toString('hex')}`;
53
+ return { token, keyHash: hashAgentKey(token), keyPrefix: token.slice(0, 18) };
54
+ }
55
+ export function publicAiConfig(ai: AiConfig) {
56
+ const { encryptedKey, ...config } = ai;
57
+ return { ...config, hasApiKey: !!encryptedKey };
58
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+
3
+ // Read legacy provider names as Custom without changing saved URLs or keys.
4
+ export const AiProvider = z.enum(['openai', 'custom', 'cliproxy', 'tailscale'])
5
+ .transform((provider): 'openai' | 'custom' => provider === 'openai' ? 'openai' : 'custom');
6
+
7
+ export const AiConfig = z.object({
8
+ enabled: z.boolean().default(false),
9
+ provider: AiProvider.default('openai'),
10
+ baseUrl: z.string().default('https://api.openai.com/v1'),
11
+ model: z.string().default(''),
12
+ encryptedKey: z.string().default(''),
13
+ }).default({});
14
+ export type AiConfig = z.infer<typeof AiConfig>;
15
+ export const AgentConfig = z.object({
16
+ enabled: z.boolean().default(false),
17
+ keyHash: z.string().default(''),
18
+ keyPrefix: z.string().default(''),
19
+ allowPublish: z.boolean().default(false),
20
+ }).default({});
21
+ export type AgentConfig = z.infer<typeof AgentConfig>;
22
+
package/src/lib/ai.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod';
2
+ import type { AiConfig } from './ai-schema';
3
+ import { decryptApiKey, normalizeAiUrl } from './ai-config';
4
+ import { ApiError, readResponseJson } from './api';
5
+ import { CleanupInput, cleanupInstructions } from './documents/cleanup';
6
+ import { francAll } from 'franc-min';
7
+
8
+ async function providerRequest(config: AiConfig, path: string, payload?: unknown): Promise<unknown> {
9
+ const baseUrl = normalizeAiUrl(config.provider, config.baseUrl);
10
+ let key: string;
11
+ try { key = decryptApiKey(config.encryptedKey); }
12
+ catch { throw new ApiError(409, 'The saved AI key cannot be decrypted. Re-enter it in Settings.'); }
13
+ if (config.provider === 'openai' && !key) throw new ApiError(400, 'Add your OpenAI API key in Settings.');
14
+ let response: Response;
15
+ try {
16
+ response = await fetch(`${baseUrl}/${path}`, {
17
+ method: payload ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(payload ? 90_000 : 15_000),
18
+ headers: { Accept: 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}), ...(payload ? { 'Content-Type': 'application/json' } : {}) },
19
+ ...(payload ? { body: JSON.stringify(payload) } : {}),
20
+ });
21
+ } catch {
22
+ throw new ApiError(502, new URL(baseUrl).hostname.endsWith('.ts.net')
23
+ ? 'Cannot reach the Tailscale proxy. The Node server must be on your tailnet; Cloudflare needs a publicly reachable endpoint. Check the URL, Tailscale access, and proxy service.'
24
+ : 'Cannot reach the AI provider. Check the API URL and network connection.');
25
+ }
26
+ if (!response.ok) {
27
+ await response.body?.cancel();
28
+ const detail = response.status === 401 || response.status === 403 ? 'Check the client API key and provider access.'
29
+ : response.status === 429 ? 'Provider quota or rate limit reached. Try again later.'
30
+ : response.status === 404 ? 'Check the API base URL and selected model.' : 'Check the provider service and model.';
31
+ throw new ApiError(502, `AI provider returned HTTP ${response.status}. ${detail}`);
32
+ }
33
+ try { return await readResponseJson(response, 2_000_000); }
34
+ catch { throw new ApiError(502, 'Provider returned an invalid or oversized JSON response. Check that the URL serves the AI API.'); }
35
+ }
36
+ export async function listAiModels(config: AiConfig) {
37
+ const result = z.object({ data: z.array(z.object({ id: z.string() })) }).safeParse(await providerRequest(config, 'models'));
38
+ if (!result.success) throw new ApiError(502, 'Provider did not return an OpenAI-compatible model list.');
39
+ return [...new Set(result.data.data.map((m) => m.id))].sort();
40
+ }
41
+ async function requestCleanupText(config: AiConfig, instruction: string, input: string) {
42
+ if (!config.enabled) throw new ApiError(409, 'AI is disabled. Enable it in Settings → AI & agents.');
43
+ if (!config.model) throw new ApiError(400, 'Choose an AI model in Settings.');
44
+ if (config.provider === 'openai') {
45
+ const raw = await providerRequest(config, 'responses', { model: config.model, instructions: instruction, input, store: false, max_output_tokens: 16384 });
46
+ const result = z.object({ status: z.string().optional(), output: z.array(z.object({ content: z.array(z.object({ type: z.string(), text: z.string().optional() })).optional() })).optional() }).safeParse(raw);
47
+ if (!result.success || result.data.status === 'incomplete' || result.data.status === 'failed') throw new ApiError(502, 'The provider could not complete the cleanup. Try a smaller document or a model with a larger output limit.');
48
+ const text = result.data.output?.flatMap((o) => o.content ?? []).filter((c) => c.type === 'output_text').map((c) => c.text ?? '').join('\n').trim();
49
+ if (!text) throw new ApiError(502, 'The provider returned no cleaned text. Try another model.');
50
+ return text;
51
+ }
52
+ const raw = await providerRequest(config, 'chat/completions', { model: config.model, messages: [{ role: 'system', content: instruction }, { role: 'user', content: input }], stream: false, max_tokens: 16384 });
53
+ const result = z.object({ choices: z.array(z.object({ message: z.object({ content: z.string().nullable() }), finish_reason: z.string().nullish() })) }).safeParse(raw);
54
+ const choice = result.success ? result.data.choices[0] : null;
55
+ if (!choice?.message.content?.trim() || choice.finish_reason === 'length') throw new ApiError(502, 'The provider returned empty or incomplete text. Try another model or a smaller document.');
56
+ return choice.message.content.trim();
57
+ }
58
+
59
+
60
+ // This is a conservative guard, not a language guarantee: short/ambiguous or
61
+ // mixed-language documents still need review. Reject clear full-document
62
+ // language switches rather than silently accepting a translated result.
63
+ function languageChanged(source: string, cleaned: string): boolean {
64
+ if (source.length < 160 || cleaned.length < 160) return false;
65
+ const before = francAll(source, { minLength: 160 });
66
+ const after = francAll(cleaned, { minLength: 160 });
67
+ if (before[0]?.[0] === 'und' || after[0]?.[0] === 'und' || before[0]?.[0] === after[0]?.[0]) return false;
68
+ const beforeInAfter = after.find(([lang]) => lang === before[0]?.[0])?.[1] ?? 0;
69
+ const afterInBefore = before.find(([lang]) => lang === after[0]?.[0])?.[1] ?? 0;
70
+ return beforeInAfter < 0.7 && afterInBefore < 0.7;
71
+ }
72
+
73
+ export async function cleanDocument(config: AiConfig, raw: unknown) {
74
+ const input = CleanupInput.parse(raw);
75
+ const response = await requestCleanupText(config, cleanupInstructions(input), JSON.stringify({
76
+ cleanupNotes: input.notes, sourceDocument: input.source,
77
+ }));
78
+ let parsed: unknown;
79
+ try { parsed = JSON.parse(response.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim()); }
80
+ catch { throw new ApiError(502, 'The provider returned an unreadable cleanup result. Your original document is unchanged. Try again.'); }
81
+ const result = z.object({ text: z.string().trim().min(1).max(100_000), warnings: z.array(z.string().max(1000)).max(30).default([]) }).safeParse(parsed);
82
+ if (!result.success) throw new ApiError(502, 'The provider returned an invalid cleanup result. Your original document is unchanged.');
83
+ if (!input.translate && languageChanged(input.source, result.data.text)) {
84
+ throw new ApiError(502, 'The cleanup appears to change the document language without permission. The result was rejected; retry or use another model.');
85
+ }
86
+ if (!input.translate && result.data.text.replace(/\s+/g, ' ').length < input.source.replace(/\s+/g, ' ').length * 0.55) {
87
+ throw new ApiError(502, 'The cleanup appears to omit too much source content. The result was rejected. Try a smaller document or another model.');
88
+ }
89
+ return { ...result.data, model: config.model };
90
+ }
@@ -0,0 +1,121 @@
1
+ // Analytics providers (Umami, Meta Pixel, TikTok Pixel): builds the script
2
+ // markup Site.astro renders into the public <head> via set:html. Because the
3
+ // output bypasses Astro's escaping, nothing is emitted unless the stored
4
+ // ID/URL passes its validator — a bad value renders nothing, never broken or
5
+ // injected markup. The admin form rejects invalid values with the same rules.
6
+ import type { Settings } from './schema';
7
+
8
+ export const UMAMI_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
9
+ export const META_PIXEL_ID_RE = /^\d{5,20}$/;
10
+ export const TIKTOK_PIXEL_ID_RE = /^[A-Z0-9_-]{8,40}$/i;
11
+
12
+ // https only (self-hosted instances included); returns the normalized href,
13
+ // whose serialization percent-encodes quotes/angle brackets, so the value is
14
+ // attribute-safe. Belt and braces: reject anything still risky post-encode.
15
+ export function normalizeScriptUrl(url: string): string {
16
+ try {
17
+ const href = new URL(url).href;
18
+ if (new URL(url).protocol !== 'https:' || /["'<>\s]/.test(href)) return '';
19
+ return href;
20
+ } catch {
21
+ return '';
22
+ }
23
+ }
24
+
25
+ type Analytics = Settings['analytics'];
26
+
27
+ function umamiTag(a: Analytics['umami']): string {
28
+ const src = normalizeScriptUrl(a.scriptUrl);
29
+ if (!a.enabled || !UMAMI_ID_RE.test(a.websiteId) || !src) return '';
30
+ return `<script defer src="${src}" data-website-id="${a.websiteId.toLowerCase()}"></script>`;
31
+ }
32
+
33
+ // The replay recorder lives next to the tracker on the same instance:
34
+ // swap the URL's last path segment for recorder.js and re-validate.
35
+ export function umamiRecorderUrl(scriptUrl: string): string {
36
+ const src = normalizeScriptUrl(scriptUrl);
37
+ if (!src) return '';
38
+ const url = new URL(src);
39
+ url.pathname = url.pathname.replace(/[^/]*$/, 'recorder.js');
40
+ return normalizeScriptUrl(url.href);
41
+ }
42
+
43
+ // Session replay (Umami v3.1+): a second tag mirroring the dashboard-generated
44
+ // snippet. Attributes are re-clamped here because, like the rest of this file,
45
+ // the output bypasses Astro escaping — schema-invalid values render defaults,
46
+ // never broken markup.
47
+ function umamiReplayTag(a: Analytics['umami']): string {
48
+ const src = umamiRecorderUrl(a.scriptUrl);
49
+ if (!a.enabled || !a.replay.enabled || !UMAMI_ID_RE.test(a.websiteId) || !src) return '';
50
+ const rate =
51
+ Number.isFinite(a.replay.sampleRate) && a.replay.sampleRate >= 0.01 && a.replay.sampleRate <= 1
52
+ ? a.replay.sampleRate
53
+ : 0.15;
54
+ const mask = a.replay.maskLevel === 'strict' ? 'strict' : 'moderate';
55
+ const duration =
56
+ Number.isInteger(a.replay.maxDuration) && a.replay.maxDuration > 0 ? a.replay.maxDuration : 300000;
57
+ return `<script defer src="${src}" data-website-id="${a.websiteId.toLowerCase()}" data-sample-rate="${rate}" data-mask-level="${mask}" data-max-duration="${duration}"></script>`;
58
+ }
59
+
60
+ // Meta's standard base code: fbq loader + PageView, with the no-JS pixel
61
+ // image fallback. The ID is digits-only by validation, so safe to inline.
62
+ function metaPixelTag(a: Analytics['metaPixel']): string {
63
+ if (!a.enabled || !META_PIXEL_ID_RE.test(a.pixelId)) return '';
64
+ return `<script>
65
+ !function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
66
+ n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
67
+ n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
68
+ t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
69
+ document,'script','https://connect.facebook.net/en_US/fbevents.js');
70
+ fbq('init', '${a.pixelId}');
71
+ fbq('track', 'PageView');
72
+ </script>
73
+ <noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=${a.pixelId}&ev=PageView&noscript=1"/></noscript>`;
74
+ }
75
+
76
+ // TikTok's published base code (experimental — untested live): ttq loader +
77
+ // page(). ID is restricted to [A-Z0-9_-] by validation.
78
+ function tiktokPixelTag(a: Analytics['tiktokPixel']): string {
79
+ if (!a.enabled || !TIKTOK_PIXEL_ID_RE.test(a.pixelId)) return '';
80
+ return `<script>
81
+ !function (w, d, t) {
82
+ w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<e.length;n++)ttq.setAndDefer(e[n],ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
83
+ ttq.load('${a.pixelId}');
84
+ ttq.page();
85
+ }(window, document, 'ttq');
86
+ </script>`;
87
+ }
88
+
89
+ export function analyticsHead(settings: Settings): string {
90
+ const a = settings.analytics;
91
+ return [umamiTag(a.umami), umamiReplayTag(a.umami), metaPixelTag(a.metaPixel), tiktokPixelTag(a.tiktokPixel)]
92
+ .filter(Boolean)
93
+ .join('\n');
94
+ }
95
+
96
+ // Event names are author-typed; collapse whitespace so "Sign up " and
97
+ // "Sign up" count as one event in the dashboards.
98
+ export function cleanEventName(name: string): string {
99
+ return name.replace(/\s+/g, ' ').trim();
100
+ }
101
+
102
+ // Click-event bridge: elements carrying data-umami-event (editor cards, the
103
+ // links page) are counted by Umami natively — this dispatcher only exists to
104
+ // forward the same clicks to the ad pixels, so it is emitted solely when one
105
+ // of them is actually enabled. Umami-only sites ship zero extra JS.
106
+ export function analyticsBody(settings: Settings): string {
107
+ const a = settings.analytics;
108
+ const meta = a.metaPixel.enabled && META_PIXEL_ID_RE.test(a.metaPixel.pixelId);
109
+ const tiktok = a.tiktokPixel.enabled && TIKTOK_PIXEL_ID_RE.test(a.tiktokPixel.pixelId);
110
+ if (!meta && !tiktok) return '';
111
+ return `<script>
112
+ document.addEventListener('click', function (e) {
113
+ var el = e.target && e.target.closest && e.target.closest('[data-umami-event]');
114
+ if (!el) return;
115
+ var name = el.getAttribute('data-umami-event');
116
+ if (!name) return;
117
+ if (window.fbq) window.fbq('trackCustom', name);
118
+ if (window.ttq) window.ttq.track(name);
119
+ });
120
+ </script>`;
121
+ }
@@ -0,0 +1,65 @@
1
+ // Scoped API keys for the integration APIs (/api/calendar/*, and bookings
2
+ // later). A key is shown once at creation; only its SHA-256 hash is stored.
3
+ // The agent key from Settings → AI & agents also authenticates here and
4
+ // counts as every scope, so existing agent setups keep working.
5
+ import type { ContentRepository } from './repo/types';
6
+ import type { ApiScope } from './schema';
7
+ import { ApiError } from './api';
8
+ import { validAgentKey } from './ai-config';
9
+
10
+ export const API_KEY_PREFIX = 'acms_key_';
11
+
12
+ const hex = (bytes: ArrayBuffer | Uint8Array) =>
13
+ [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');
14
+
15
+ export async function sha256Hex(value: string): Promise<string> {
16
+ return hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
17
+ }
18
+
19
+ export function generateApiKey(): string {
20
+ return API_KEY_PREFIX + hex(crypto.getRandomValues(new Uint8Array(24)));
21
+ }
22
+
23
+ // creates the record and returns the plaintext key — the only time it exists
24
+ export async function issueApiKey(repo: ContentRepository, name: string, scopes: ApiScope[]) {
25
+ const key = generateApiKey();
26
+ const record = await repo.createApiKey({
27
+ name: name.trim() || 'API key',
28
+ prefix: key.slice(0, API_KEY_PREFIX.length + 6),
29
+ keyHash: await sha256Hex(key),
30
+ scopes: [...new Set(scopes)],
31
+ });
32
+ return { key, record };
33
+ }
34
+
35
+ export interface ApiPrincipal {
36
+ via: 'key' | 'agent';
37
+ name: string;
38
+ scopes: Set<ApiScope> | 'all';
39
+ }
40
+
41
+ export const hasScope = (p: ApiPrincipal, scope: ApiScope) => p.scopes === 'all' || p.scopes.has(scope);
42
+
43
+ // null = no (or an unknown) bearer token; callers decide whether anonymous
44
+ // access is fine (public calendars) or a 401
45
+ export async function authenticate(request: Request, repo: ContentRepository): Promise<ApiPrincipal | null> {
46
+ const auth = request.headers.get('authorization') ?? '';
47
+ const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
48
+ if (!token) return null;
49
+ if (token.startsWith(API_KEY_PREFIX)) {
50
+ const key = await repo.findApiKeyByHash(await sha256Hex(token));
51
+ return key ? { via: 'key', name: key.name, scopes: new Set(key.scopes) } : null;
52
+ }
53
+ const { agent } = await repo.getSettings();
54
+ if (agent.enabled && validAgentKey(token, agent.keyHash)) return { via: 'agent', name: 'Agent key', scopes: 'all' };
55
+ return null;
56
+ }
57
+
58
+ export async function requireScope(request: Request, repo: ContentRepository, scope: ApiScope): Promise<ApiPrincipal> {
59
+ const principal = await authenticate(request, repo);
60
+ if (!principal) {
61
+ throw new ApiError(401, 'Send an API key: Authorization: Bearer <key>.');
62
+ }
63
+ if (!hasScope(principal, scope)) throw new ApiError(403, `This key is missing the ${scope} scope.`);
64
+ return principal;
65
+ }
package/src/lib/api.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import { validateSession, OWNER_ENV, SESSION_COOKIE } from './auth';
3
+ import type { ContentRepository } from './repo/types';
4
+
5
+ export class ApiError extends Error {
6
+ constructor(public status: number, message: string) { super(message); }
7
+ }
8
+ export const json = (body: unknown, status = 200) => Response.json(body, {
9
+ status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' },
10
+ });
11
+ export function apiError(error: unknown): Response {
12
+ if (error instanceof ApiError) return json({ error: error.message }, error.status);
13
+ if (error instanceof z.ZodError) return json({ error: 'Invalid request', issues: error.issues.map(({ path, message }) => ({ path, message })) }, 400);
14
+ return json({ error: 'Request failed. Please try again.' }, 500);
15
+ }
16
+ export async function readJson(request: Request, maxBytes = 300_000): Promise<unknown> {
17
+ if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new ApiError(415, 'Use Content-Type: application/json.');
18
+ return readResponseJson(request, maxBytes);
19
+ }
20
+ export async function readResponseJson(source: Request | Response, maxBytes: number): Promise<unknown> {
21
+ if (Number(source.headers.get('content-length')) > maxBytes) throw new ApiError(413, 'Payload too large.');
22
+ const reader = source.body?.getReader();
23
+ if (!reader) throw new ApiError(400, 'JSON body is required.');
24
+ const chunks: Uint8Array[] = [];
25
+ let length = 0;
26
+ try {
27
+ while (true) {
28
+ const { done, value } = await reader.read();
29
+ if (done) break;
30
+ length += value.byteLength;
31
+ if (length > maxBytes) { await reader.cancel(); throw new ApiError(413, 'Payload too large.'); }
32
+ chunks.push(value);
33
+ }
34
+ } finally { reader.releaseLock(); }
35
+ const bytes = new Uint8Array(length);
36
+ let offset = 0;
37
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
38
+ try { return JSON.parse(new TextDecoder().decode(bytes)); }
39
+ catch { throw new ApiError(400, 'Body must be valid JSON.'); }
40
+ }
41
+ export async function requireSession(
42
+ request: Request, cookies: { get(name: string): { value: string } | undefined }, repo: ContentRepository, ownerOnly = false,
43
+ ) {
44
+ const id = (await validateSession(cookies.get(SESSION_COOKIE)?.value, repo));
45
+ if (!id) throw new ApiError(401, 'Sign in to continue.');
46
+ const user = id === OWNER_ENV ? null : await repo.getUser(id);
47
+ if (id !== OWNER_ENV && (!user || (ownerOnly && user.role !== 'owner'))) throw new ApiError(403, 'Owner access is required.');
48
+ const origin = request.headers.get('origin');
49
+ if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') {
50
+ throw new ApiError(403, 'Cross-origin requests are not allowed.');
51
+ }
52
+ }
package/src/lib/auth.ts CHANGED
@@ -15,7 +15,8 @@ function hmac(payload: string): string {
15
15
  }
16
16
 
17
17
  function safeEqual(a: string, b: string): boolean {
18
- return a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
18
+ const left = Buffer.from(a), right = Buffer.from(b);
19
+ return left.length === right.length && timingSafeEqual(left, right);
19
20
  }
20
21
 
21
22
  // ---- sessions -------------------------------------------------------------
@@ -27,26 +28,33 @@ function safeEqual(a: string, b: string): boolean {
27
28
 
28
29
  export const OWNER_ENV = '__owner_env__';
29
30
 
30
- export function createSessionToken(userId: string = OWNER_ENV): string {
31
+ export function createSessionToken(userId: string = OWNER_ENV, version = 0): string {
31
32
  const exp = Date.now() + WEEK_MS;
32
33
  if (userId === OWNER_ENV) return `${exp}.${hmac(String(exp))}`;
33
- return `u.${userId}.${exp}.${hmac(`${userId}.${exp}`)}`;
34
+ return `v.${userId}.${version}.${exp}.${hmac(`${userId}.${version}.${exp}`)}`;
34
35
  }
35
36
 
36
37
  // Returns the session's user id (OWNER_ENV for env-password sessions) or
37
38
  // null. Truthy/falsy compatible with the old boolean signature.
38
39
  export function verifySessionToken(token: string | undefined): string | null {
39
40
  if (!token) return null;
41
+ if (token.startsWith('v.')) {
42
+ const parts = token.split('.');
43
+ if (parts.length !== 5) return null;
44
+ const [, id, version, exp, sig] = parts;
45
+ if (!id || !/^\d+$/.test(version) || !Number.isFinite(Number(exp)) || Number(exp) < Date.now()) return null;
46
+ return safeEqual(sig, hmac(`${id}.${version}.${exp}`)) ? id : null;
47
+ }
40
48
  if (token.startsWith('u.')) {
41
49
  const parts = token.split('.');
42
50
  if (parts.length !== 4) return null;
43
51
  const [, userId, expStr, sig] = parts;
44
- if (!userId || Number(expStr) < Date.now()) return null;
52
+ if (!userId || !Number.isFinite(Number(expStr)) || Number(expStr) < Date.now()) return null;
45
53
  return safeEqual(sig, hmac(`${userId}.${expStr}`)) ? userId : null;
46
54
  }
47
55
  const [expStr, sig] = token.split('.');
48
56
  if (!expStr || !sig) return null;
49
- if (Number(expStr) < Date.now()) return null;
57
+ if (!Number.isFinite(Number(expStr)) || Number(expStr) < Date.now()) return null;
50
58
  return safeEqual(sig, hmac(expStr)) ? OWNER_ENV : null;
51
59
  }
52
60
 
@@ -92,3 +100,15 @@ export async function verifyPassword(password: string, stored: string): Promise<
92
100
  }
93
101
 
94
102
  export const SESSION_COOKIE = COOKIE;
103
+
104
+ // All authorization paths must consult the database, not just the signature.
105
+ export async function validateSession(token: string | undefined, localsOrRepo?: unknown): Promise<string | null> {
106
+ const id = verifySessionToken(token);
107
+ if (!id || id === OWNER_ENV) return id;
108
+ const { getRepo } = await import('./repo');
109
+ const repo = localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo
110
+ ? localsOrRepo as import('./repo/types').ContentRepository : await getRepo(localsOrRepo);
111
+ const version = await repo.email.sessionVersion(id);
112
+ const expected = token!.startsWith('v.') ? Number(token!.split('.')[2]) : 0;
113
+ return version !== null && version === expected ? id : null;
114
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking } from '../schema';
3
+ import type { ContentRepository } from '../repo/types';
4
+
5
+ export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
6
+ export type BookingRequest = z.infer<typeof BookingRequest>;
7
+ export const BackupContent = z.object({
8
+ format: z.literal('acms-backup'), version: z.number().int().min(1).max(5), exportedAt: z.string().optional(),
9
+ settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
10
+ tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
11
+ calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
12
+ bookingLinks: z.array(BookingLink).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
13
+ }).superRefine((data, ctx) => {
14
+ if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
15
+ const keys = new Set<string>();
16
+ for (const r of data.bookingRequests ?? []) {
17
+ if (keys.has(r.keyHash)) ctx.addIssue({ code: 'custom', message: 'Duplicate booking retry key.' });
18
+ keys.add(r.keyHash);
19
+ }
20
+ for (const m of data.media) for (const key of [m.key, ...m.variants.map(v => v.key)]) {
21
+ if (!key || key.startsWith('/') || key.includes('..') || key.includes('\\') || /[\x00-\x1f]/.test(key)) ctx.addIssue({ code: 'custom', message: 'Unsafe media key.' });
22
+ }
23
+ });
24
+ export async function exportContent(repo: ContentRepository, now = Date.now()) {
25
+ const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState] = await Promise.all([
26
+ repo.getSettings(), repo.listPages(), repo.listPosts(), repo.listMedia(), repo.listLinks(), repo.listTags(), repo.listNavItems(),
27
+ repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
28
+ ]);
29
+ return {
30
+ format: 'acms-backup' as const, version: 5, exportedAt: new Date(now).toISOString(),
31
+ settings: { ...settings, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
32
+ pages, posts, media, links, tags, navItems, users, redirects,
33
+ calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
34
+ resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState,
35
+ };
36
+ }