@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3

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 (251) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +55 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +4 -1
  117. package/src/components/import/runner.ts +1 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +31 -2
  151. package/src/lib/import/bundle.ts +4 -0
  152. package/src/lib/import/export.ts +1 -0
  153. package/src/lib/import/ghost.ts +1 -0
  154. package/src/lib/media/upload.ts +83 -0
  155. package/src/lib/mobile/associations.ts +25 -0
  156. package/src/lib/mobile/calendar.ts +19 -11
  157. package/src/lib/mobile/config.mjs +59 -0
  158. package/src/lib/mobile/content.ts +18 -13
  159. package/src/lib/mobile/embed.ts +83 -0
  160. package/src/lib/mobile/http.ts +8 -6
  161. package/src/lib/mobile/locale.ts +18 -0
  162. package/src/lib/mobile/openapi.ts +111 -0
  163. package/src/lib/mobile/pages.ts +71 -0
  164. package/src/lib/mobile/route.ts +3 -3
  165. package/src/lib/mobile/schemas.ts +60 -0
  166. package/src/lib/mobile/site.ts +41 -0
  167. package/src/lib/push/api.ts +158 -0
  168. package/src/lib/push/config.ts +20 -0
  169. package/src/lib/push/expo.ts +76 -0
  170. package/src/lib/push/migration.ts +60 -0
  171. package/src/lib/push/service.ts +302 -0
  172. package/src/lib/push/store.ts +144 -0
  173. package/src/lib/push/types.ts +43 -0
  174. package/src/lib/repo/migrations.ts +11 -0
  175. package/src/lib/repo/sql.ts +40 -7
  176. package/src/lib/repo/types.ts +9 -3
  177. package/src/lib/roles.ts +5 -0
  178. package/src/lib/scheduler.ts +3 -0
  179. package/src/lib/schema.ts +27 -1
  180. package/src/lib/section-ai.ts +55 -0
  181. package/src/lib/section-targets.ts +62 -0
  182. package/src/lib/sections-agent.ts +146 -0
  183. package/src/lib/sections.ts +217 -0
  184. package/src/lib/theme.ts +16 -1
  185. package/src/lib/translate.ts +97 -0
  186. package/src/lib/translations.ts +39 -0
  187. package/src/middleware.ts +32 -0
  188. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  189. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  190. package/src/pages/[...slug].astro +8 -3
  191. package/src/pages/admin/app-auth.astro +159 -0
  192. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  193. package/src/pages/admin/calendar/events/[id].astro +9 -0
  194. package/src/pages/admin/login.astro +6 -2
  195. package/src/pages/admin/logout.ts +5 -2
  196. package/src/pages/admin/mobile.astro +18 -0
  197. package/src/pages/admin/pages/[id].astro +62 -0
  198. package/src/pages/admin/section-preview.astro +61 -0
  199. package/src/pages/admin/users.astro +107 -11
  200. package/src/pages/api/agent/docs.ts +6 -2
  201. package/src/pages/api/agent/index.ts +1 -1
  202. package/src/pages/api/ai/settings.ts +7 -4
  203. package/src/pages/api/design/ai.ts +15 -0
  204. package/src/pages/api/design/index.ts +12 -42
  205. package/src/pages/api/design/translate.ts +15 -0
  206. package/src/pages/api/live/thumb.ts +1 -1
  207. package/src/pages/api/mcp.ts +2 -2
  208. package/src/pages/api/media/upload.ts +10 -78
  209. package/src/pages/api/push/admin.ts +27 -0
  210. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  211. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  212. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  213. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  214. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  215. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  216. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  217. package/src/pages/api/v1/admin/me.ts +6 -0
  218. package/src/pages/api/v1/admin/media/index.ts +32 -0
  219. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  220. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  221. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  222. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  223. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  224. package/src/pages/api/v1/auth/revoke.ts +5 -0
  225. package/src/pages/api/v1/auth/token.ts +5 -0
  226. package/src/pages/api/v1/devices/[token].ts +20 -0
  227. package/src/pages/api/v1/devices/index.ts +17 -0
  228. package/src/pages/api/v1/openapi.json.ts +7 -0
  229. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  230. package/src/pages/api/v1/pages/home.ts +7 -0
  231. package/src/pages/api/v1/push/topics.ts +7 -0
  232. package/src/pages/api/v1/site.ts +7 -0
  233. package/src/pages/api/v1/theme.ts +7 -0
  234. package/src/pages/blog/[slug].astro +8 -2
  235. package/src/pages/blog/index.astro +11 -4
  236. package/src/pages/embed/section/[...path].astro +42 -0
  237. package/src/pages/events/[slug].astro +7 -2
  238. package/src/pages/index.astro +11 -5
  239. package/src/pages/sitemap.xml.ts +8 -0
  240. package/src/styles/admin.css +69 -0
  241. package/src/styles/editor.css +21 -0
  242. package/src/styles/mobile-app.css +151 -0
  243. package/src/styles/sections.css +7 -1
  244. package/src/styles/theme-editor.css +255 -0
  245. package/src/theme-sdk/contract.d.mts +2193 -89
  246. package/src/theme-sdk/contract.mjs +77 -20
  247. package/src/theme-sdk/fields.d.mts +88 -0
  248. package/src/theme-sdk/fields.mjs +326 -0
  249. package/src/theme-sdk/index.ts +10 -2
  250. package/src/theme-sdk/tokens.d.mts +11 -0
  251. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,264 @@
1
+ import { Role } from '../schema';
2
+ import { createHmac } from 'node:crypto';
3
+ import { env } from '../env';
4
+ import { sha256Hex } from '../api-keys';
5
+ import { ApiError } from '../api';
6
+ import { redirectUriProblem } from '../mobile/config.mjs';
7
+ import type { ContentRepository } from '../repo/types';
8
+ import { EDITOR } from './config';
9
+
10
+ // Editor-app sign-in: OAuth 2.0 authorization code + PKCE (RFC 6749, 7636)
11
+ // for native apps (RFC 8252). The app opens /admin/app-auth in the system
12
+ // browser, the signed-in person approves, the browser returns to the app's
13
+ // allowlisted redirect URI with a one-time code, and the app trades code +
14
+ // verifier for per-device tokens. The app never sees a password or cookie.
15
+ //
16
+ // Secrets: opaque random tokens with recognizable prefixes, stored only as
17
+ // SHA-256 hashes. Access tokens are short-lived; refresh tokens rotate on
18
+ // every use, and presenting a rotated one revokes the whole device (theft) —
19
+ // unless it is a retry within a short grace window whose previous answer was
20
+ // never used (a lost network response), see tokenGrant.
21
+ // The role is read live from the user on every request, never frozen.
22
+
23
+ export const ACCESS_PREFIX = 'acms_dev_a_';
24
+ export const REFRESH_PREFIX = 'acms_dev_r_';
25
+ export const CODE_PREFIX = 'acms_code_';
26
+ const TOKEN_BODY = /^[0-9a-f]{64}$/;
27
+
28
+ const hex = (bytes: Uint8Array) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
29
+ export const randomToken = (prefix: string) => prefix + hex(crypto.getRandomValues(new Uint8Array(32)));
30
+ const wellFormed = (token: string, prefix: string) => token.startsWith(prefix) && TOKEN_BODY.test(token.slice(prefix.length));
31
+
32
+ /** constant-time string comparison (length leaks, content doesn't) */
33
+ export function safeEqual(a: string, b: string): boolean {
34
+ const len = Math.max(a.length, b.length);
35
+ let diff = a.length ^ b.length;
36
+ for (let i = 0; i < len; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
37
+ return diff === 0;
38
+ }
39
+
40
+ const base64url = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
41
+ /** PKCE S256: BASE64URL(SHA256(ASCII(verifier))) */
42
+ export async function s256(verifier: string): Promise<string> {
43
+ return base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
44
+ }
45
+
46
+ // ---- redirect URIs ---------------------------------------------------------
47
+
48
+ const LOOPBACK = new Set(['127.0.0.1', '[::1]', 'localhost']);
49
+ /**
50
+ * Exact match against acms({ mobile: { editor: { redirectUris } } }). The one
51
+ * exception (RFC 8252 §7.3): an http loopback entry matches any port, since
52
+ * the OS picks the app's listening port at run time.
53
+ */
54
+ export function allowedRedirect(uri: string): boolean {
55
+ if (!uri || redirectUriProblem(uri)) return false;
56
+ return EDITOR.redirectUris.some((allowed) => {
57
+ if (allowed === uri) return true;
58
+ try {
59
+ const a = new URL(allowed), b = new URL(uri);
60
+ return a.protocol === 'http:' && b.protocol === 'http:' && LOOPBACK.has(a.hostname) && a.hostname === b.hostname
61
+ && a.pathname === b.pathname && a.search === b.search;
62
+ } catch { return false; }
63
+ });
64
+ }
65
+
66
+ export function redirectWith(uri: string, params: Record<string, string>): string {
67
+ const url = new URL(uri);
68
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
69
+ return url.href;
70
+ }
71
+
72
+ // ---- the authorization request ---------------------------------------------
73
+
74
+ export interface AuthorizeParams { clientId: string; redirectUri: string; codeChallenge: string; state: string; deviceName: string }
75
+ export type AuthorizeCheck =
76
+ | { ok: true; params: AuthorizeParams }
77
+ /** redirect=false: the client or return address can't be trusted, so the error is shown in the browser */
78
+ | { ok: false; error: string; message: string; redirect: false }
79
+ | { ok: false; error: string; message: string; redirect: true; redirectUri: string; state: string };
80
+
81
+ const CLIENT_ID = /^[A-Za-z0-9._:-]{1,100}$/;
82
+ const CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
83
+ const STATE = /^[\x21-\x7e]{1,500}$/;
84
+ const VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
85
+ const KNOWN = ['response_type', 'client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'state', 'device_name', 'scope'];
86
+
87
+ export function cleanDeviceName(raw: string): string {
88
+ return raw.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60);
89
+ }
90
+
91
+ export function checkAuthorize(search: URLSearchParams): AuthorizeCheck {
92
+ const one = (k: string) => search.getAll(k).length > 1 ? null : (search.get(k) ?? '');
93
+ const clientId = one('client_id'), redirectUri = one('redirect_uri');
94
+ if (clientId === null || !CLIENT_ID.test(clientId)) return { ok: false, error: 'invalid_client', message: 'The app didn’t say who it is (client_id).', redirect: false };
95
+ if (redirectUri === null || !allowedRedirect(redirectUri)) {
96
+ return { ok: false, error: 'invalid_redirect_uri', message: 'This app’s return address isn’t on this site’s list of allowed editor apps. The site owner adds it in acms({ mobile: { editor: { redirectUris } } }).', redirect: false };
97
+ }
98
+ const state = one('state');
99
+ const fail = (error: string, message: string): AuthorizeCheck => ({ ok: false, error, message, redirect: true, redirectUri, state: state && STATE.test(state) ? state : '' });
100
+ if (KNOWN.some((k) => search.getAll(k).length > 1)) return fail('invalid_request', 'A sign-in parameter was repeated.');
101
+ if (one('response_type') !== 'code') return fail('unsupported_response_type', 'Only response_type=code is supported.');
102
+ if (one('code_challenge_method') !== 'S256') return fail('invalid_request', 'PKCE with code_challenge_method=S256 is required.');
103
+ const codeChallenge = one('code_challenge') ?? '';
104
+ if (!CHALLENGE.test(codeChallenge)) return fail('invalid_request', 'code_challenge must be a 43-character base64url S256 hash.');
105
+ if (!state || !STATE.test(state)) return fail('invalid_request', 'state is required (1–500 visible ASCII characters).');
106
+ return { ok: true, params: { clientId, redirectUri, codeChallenge, state, deviceName: cleanDeviceName(one('device_name') ?? '') } };
107
+ }
108
+
109
+ // ---- consent form ticket (CSRF) ----------------------------------------------
110
+ // The approve form carries an HMAC bound to this browser's session cookie and
111
+ // to the exact request (app, return address, challenge, state), valid ten
112
+ // minutes. A forged or replayed-elsewhere POST can't produce one.
113
+
114
+ const TICKET_MS = 10 * 60_000;
115
+ function ticketSig(session: string, p: AuthorizeParams, exp: number) {
116
+ const secret = env('SESSION_SECRET');
117
+ if (!secret) throw new Error('SESSION_SECRET is not set — add it to .env');
118
+ return createHmac('sha256', secret).update(JSON.stringify(['acms-app-auth-v1', session, p.clientId, p.redirectUri, p.codeChallenge, p.state, exp])).digest('hex');
119
+ }
120
+ export function consentTicket(session: string, p: AuthorizeParams, now = Date.now()): string {
121
+ const exp = now + TICKET_MS;
122
+ return `${exp}.${ticketSig(session, p, exp)}`;
123
+ }
124
+ export function checkConsentTicket(ticket: string, session: string, p: AuthorizeParams, now = Date.now()): boolean {
125
+ const [expRaw, sig] = ticket.split('.');
126
+ const exp = Number(expRaw);
127
+ if (!session || !sig || !/^\d{10,16}$/.test(expRaw ?? '') || exp < now || exp > now + TICKET_MS) return false;
128
+ return safeEqual(sig, ticketSig(session, p, exp));
129
+ }
130
+
131
+ /** approve: a one-time code bound to the user, app, return address and challenge */
132
+ export async function issueCode(repo: ContentRepository, user: { id: string; sessionVersion: number }, p: AuthorizeParams, deviceName: string, now = Date.now()): Promise<string> {
133
+ const code = randomToken(CODE_PREFIX);
134
+ await repo.editor.createCode({ hash: await sha256Hex(code), userId: user.id, clientId: p.clientId, redirectUri: p.redirectUri,
135
+ challenge: p.codeChallenge, deviceName: cleanDeviceName(deviceName) || 'Phone', sessionVersion: user.sessionVersion, expiresAt: now + EDITOR.codeMs }, now);
136
+ return code;
137
+ }
138
+
139
+ // ---- token endpoint --------------------------------------------------------
140
+
141
+ export class OAuthError extends Error {
142
+ constructor(public status: number, public error: string, message: string) { super(message); }
143
+ }
144
+ const invalidGrant = (why: string) => new OAuthError(400, 'invalid_grant', why);
145
+
146
+ export interface TokenResponse {
147
+ access_token: string; token_type: 'Bearer'; expires_in: number;
148
+ refresh_token: string; refresh_token_expires_in: number;
149
+ user: { id: string; name: string; email: string; role: Role };
150
+ device: { id: string; name: string };
151
+ }
152
+
153
+ async function pair(now: number) {
154
+ const access = randomToken(ACCESS_PREFIX), refresh = randomToken(REFRESH_PREFIX);
155
+ return { plain: { access, refresh }, hashed: { access: await sha256Hex(access), accessExpires: now + EDITOR.accessTokenMs, refresh: await sha256Hex(refresh), refreshExpires: now + EDITOR.refreshTokenMs } };
156
+ }
157
+ async function liveUser(repo: ContentRepository, id: string, sessionVersion: number) {
158
+ const [user, version] = await Promise.all([repo.getUser(id), repo.email.sessionVersion(id)]);
159
+ return user && version === sessionVersion ? user : null;
160
+ }
161
+ function tokenResponse(t: { access: string; refresh: string }, user: { id: string; name: string; email: string; role: string }, device: { id: string; name: string }): TokenResponse {
162
+ return { access_token: t.access, token_type: 'Bearer', expires_in: Math.floor(EDITOR.accessTokenMs / 1000), refresh_token: t.refresh,
163
+ refresh_token_expires_in: Math.floor(EDITOR.refreshTokenMs / 1000),
164
+ user: { id: user.id, name: user.name, email: user.email, role: Role.parse(user.role) }, device };
165
+ }
166
+
167
+ export async function tokenGrant(repo: ContentRepository, form: Record<string, string>, now = Date.now()): Promise<TokenResponse> {
168
+ const grant = form.grant_type;
169
+ if (grant === 'authorization_code') {
170
+ const { code = '', code_verifier: verifier = '', redirect_uri: redirectUri = '', client_id: clientId = '' } = form;
171
+ if (!code || !verifier || !redirectUri || !clientId) throw new OAuthError(400, 'invalid_request', 'code, code_verifier, redirect_uri and client_id are required.');
172
+ if (!wellFormed(code, CODE_PREFIX)) throw invalidGrant('The sign-in code is invalid or expired.');
173
+ const spent = await repo.editor.consumeCode(await sha256Hex(code), now);
174
+ if (!spent) throw invalidGrant('The sign-in code is invalid or expired.');
175
+ const c = spent.code;
176
+ if (spent.reused) {
177
+ // a replayed code means it leaked: whatever it produced is signed out
178
+ if (c.deviceId) await repo.editor.revokeDevice(c.deviceId, 'sign-in code reused', now);
179
+ throw invalidGrant('The sign-in code was already used.');
180
+ }
181
+ if (c.expiresAt <= now) throw invalidGrant('The sign-in code expired. Sign in again.');
182
+ // every check runs; the code is already burned whatever the outcome
183
+ const checks = [safeEqual(clientId, c.clientId), safeEqual(redirectUri, c.redirectUri), VERIFIER.test(verifier) && safeEqual(await s256(verifier), c.challenge), allowedRedirect(c.redirectUri)];
184
+ if (checks.includes(false)) throw invalidGrant('The sign-in code doesn’t match this app, return address or verifier.');
185
+ const user = await liveUser(repo, c.userId, c.sessionVersion);
186
+ if (!user) throw invalidGrant('This account changed since sign-in. Sign in again.');
187
+ const device = { id: crypto.randomUUID(), name: c.deviceName };
188
+ const t = await pair(now);
189
+ await repo.editor.createDevice({ id: device.id, userId: user.id, name: device.name, clientId: c.clientId, sessionVersion: c.sessionVersion }, t.hashed, now);
190
+ await repo.editor.bindCode(c.hash, device.id);
191
+ await repo.editor.prune(now).catch(() => {});
192
+ return tokenResponse(t.plain, user, device);
193
+ }
194
+ if (grant === 'refresh_token') {
195
+ const token = form.refresh_token ?? '';
196
+ if (!token) throw new OAuthError(400, 'invalid_request', 'refresh_token is required.');
197
+ if (!wellFormed(token, REFRESH_PREFIX)) throw invalidGrant('The refresh token is invalid or expired.');
198
+ const hash = await sha256Hex(token);
199
+ const spent = await repo.editor.spendRefresh(hash, now);
200
+ if (!spent) throw invalidGrant('The refresh token is invalid or expired.');
201
+ // A rotated token presented again is theft, except for a short grace: the
202
+ // app retrying a refresh whose response it lost (the pair it produced was
203
+ // never used). That retry gets a new pair; the lost one is retired.
204
+ if (spent.reused && (spent.expired || !await repo.editor.retireUnusedSuccessor(hash, spent.usedAt ?? 0, now, EDITOR.refreshGraceMs))) {
205
+ await repo.editor.revokeDevice(spent.deviceId, 'refresh token reused', now);
206
+ throw invalidGrant('This refresh token was already used, so the device was signed out. Sign in again.');
207
+ }
208
+ if (spent.expired) throw invalidGrant('The session expired. Sign in again.');
209
+ const device = await repo.editor.getDevice(spent.deviceId);
210
+ if (!device || device.revokedAt !== null) throw invalidGrant('This device was signed out. Sign in again.');
211
+ if (form.client_id && !safeEqual(form.client_id, device.clientId)) throw invalidGrant('The refresh token belongs to another app.');
212
+ const user = await liveUser(repo, device.userId, device.sessionVersion);
213
+ if (!user) {
214
+ await repo.editor.revokeDevice(device.id, 'account changed', now);
215
+ throw invalidGrant('This account changed. Sign in again.');
216
+ }
217
+ const t = await pair(now);
218
+ await repo.editor.rotate(device.id, t.hashed, now, hash);
219
+ return tokenResponse(t.plain, user, { id: device.id, name: device.name });
220
+ }
221
+ throw new OAuthError(400, 'unsupported_grant_type', 'Use grant_type=authorization_code or refresh_token.');
222
+ }
223
+
224
+ /** sign out (RFC 7009): either token revokes its device; unknown tokens succeed silently */
225
+ export async function revokeToken(repo: ContentRepository, token: string, now = Date.now()) {
226
+ if (!wellFormed(token, ACCESS_PREFIX) && !wellFormed(token, REFRESH_PREFIX)) return;
227
+ const deviceId = await repo.editor.deviceForToken(await sha256Hex(token));
228
+ if (deviceId) await repo.editor.revokeDevice(deviceId, 'signed out in the app', now);
229
+ }
230
+
231
+ // ---- bearer authentication for /api/v1/admin/* -----------------------------
232
+
233
+ export interface EditorPrincipal {
234
+ user: { id: string; name: string; email: string; role: Role };
235
+ device: { id: string; name: string; clientId: string };
236
+ /** how writes are attributed: "editor:<id> via <device>" */
237
+ actor: string;
238
+ }
239
+
240
+ export function bearerToken(request: Request): string {
241
+ const auth = request.headers.get('authorization') ?? '';
242
+ const m = /^Bearer[ ]+([^\s]+)\s*$/i.exec(auth);
243
+ return m ? m[1] : '';
244
+ }
245
+
246
+ /** Device access tokens only: cookie sessions never authenticate here (no CSRF surface). */
247
+ export async function authenticateDevice(request: Request, repo: ContentRepository, now = Date.now()): Promise<EditorPrincipal> {
248
+ const token = bearerToken(request);
249
+ if (!token) throw new ApiError(401, 'Sign in through the app: send Authorization: Bearer <access token>.');
250
+ if (!wellFormed(token, ACCESS_PREFIX)) throw new ApiError(401, 'The access token is invalid or expired.');
251
+ const access = await repo.editor.access(await sha256Hex(token), now);
252
+ if (!access) throw new ApiError(401, 'The access token is invalid, expired or signed out.');
253
+ if (access.user.sessionVersion !== access.device.sessionVersion) {
254
+ await repo.editor.revokeDevice(access.device.id, 'password changed', now);
255
+ throw new ApiError(401, 'This account changed. Sign in again.');
256
+ }
257
+ await repo.editor.touch(access.device.id, now);
258
+ const { user, device } = access;
259
+ return {
260
+ user: { id: user.id, name: user.name, email: user.email, role: user.role },
261
+ device: { id: device.id, name: device.name, clientId: device.clientId },
262
+ actor: `${user.role}:${user.id} via ${device.name}`.slice(0, 200),
263
+ };
264
+ }
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { Booking, BookingStatus } from '../schema';
4
+ import { ApiError } from '../api';
5
+ import { bookingsEnabled } from '../calendar/config';
6
+ import { bookingJson, setBookingStatus } from '../booking/service';
7
+ import { siteOrigin } from '../seo';
8
+ import { waitUntilFrom } from '../webhooks';
9
+ import { pageQuery } from '../mobile/http';
10
+
11
+ // Bookings inbox for editor apps: the same status changes as Admin →
12
+ // Bookings (confirm, decline, cancel; the guest is emailed, webhooks fire),
13
+ // available to every signed-in owner or editor exactly like the web page.
14
+
15
+ export const InboxQuery = z.object({
16
+ /** active = pending + confirmed (default); pending requests always sort first */
17
+ status: z.enum(['active', 'pending', 'confirmed', 'declined', 'cancelled', 'all']).default('active'),
18
+ from: z.string().datetime({ offset: true }).optional(),
19
+ ...pageQuery,
20
+ }).strict();
21
+ export const BookingActionInput = z.object({ message: z.string().max(1000).optional() }).strict();
22
+
23
+ export function requireBookings() {
24
+ if (!bookingsEnabled()) throw new ApiError(404, 'Bookings are turned off on this site.');
25
+ }
26
+
27
+ export async function inbox(repo: ContentRepository, q: z.infer<typeof InboxQuery>, now = Date.now()) {
28
+ requireBookings();
29
+ const statuses: BookingStatus[] | undefined = q.status === 'all' ? undefined : q.status === 'active' ? ['pending', 'confirmed'] : [q.status];
30
+ const [list, resources, events] = await Promise.all([
31
+ repo.listBookings({ from: q.from ? Date.parse(q.from) : now - 86_400_000, statuses, kinds: ['booking', 'rsvp'] }),
32
+ repo.listResources(), repo.listEvents(),
33
+ ]);
34
+ const res = new Map(resources.map((r) => [r.id, r.name])), ev = new Map(events.map((e) => [e.id, e.title]));
35
+ list.sort((a, b) => Number(b.status === 'pending') - Number(a.status === 'pending') || a.start - b.start);
36
+ const start = (q.page - 1) * q.limit;
37
+ return {
38
+ pending: list.filter((b) => b.status === 'pending').length,
39
+ items: list.slice(start, start + q.limit).map((b) => bookingView(b, res, ev)),
40
+ pagination: { page: q.page, limit: q.limit, nextPage: start + q.limit < list.length ? q.page + 1 : null },
41
+ };
42
+ }
43
+
44
+ function bookingView(b: Booking, res: Map<string, string>, ev: Map<string, string>) {
45
+ return { ...bookingJson(b), title: b.kind === 'rsvp' ? ev.get(b.eventId) ?? 'Event' : res.get(b.resourceId) ?? 'Booking', notes: b.notes, updatedAt: new Date(b.updatedAt).toISOString() };
46
+ }
47
+
48
+ const TO: Record<string, BookingStatus> = { approve: 'confirmed', decline: 'declined', cancel: 'cancelled' };
49
+
50
+ export async function bookingAction(repo: ContentRepository, id: string, action: string, body: z.infer<typeof BookingActionInput>, ctx: { url: URL; locals: unknown }) {
51
+ requireBookings();
52
+ const to = TO[action];
53
+ if (!to) throw new ApiError(404, 'Unknown action.');
54
+ const b = await repo.getBooking(id);
55
+ if (!b || b.kind === 'block') throw new ApiError(404, 'Booking not found.');
56
+ const flow = { origin: siteOrigin(await repo.getSettings(), ctx.url), locals: ctx.locals, waitUntil: waitUntilFrom(ctx.locals) };
57
+ const next = await setBookingStatus(repo, b, to, flow, body.message?.trim() || undefined);
58
+ const [resources, events] = await Promise.all([repo.listResources(), repo.listEvents()]);
59
+ return bookingView(next, new Map(resources.map((r) => [r.id, r.name])), new Map(events.map((e) => [e.id, e.title])));
60
+ }
@@ -0,0 +1,25 @@
1
+ // Editor apps: staff sign in on a phone through the browser (OAuth 2.0 +
2
+ // PKCE) and edit through /api/v1/admin/*. Configured per project:
3
+ // acms({ mobile: { editor: { name: 'Grace Staff', redirectUris: ['org.grace.staff://auth'] } } })
4
+ // Off unless redirect URIs are listed: the sign-in page, token endpoints and
5
+ // /api/v1/admin/* are then not registered at all, and nothing issues codes.
6
+ import { userConfig } from 'virtual:acms-config';
7
+ import { ApiError } from '../api';
8
+
9
+ const cfg = userConfig.mobile?.editor;
10
+
11
+ // mutable so tests can exercise both states
12
+ export const EDITOR = {
13
+ enabled: cfg?.enabled === true && (cfg?.redirectUris?.length ?? 0) > 0,
14
+ name: cfg?.name ?? '',
15
+ redirectUris: [...(cfg?.redirectUris ?? [])] as string[],
16
+ accessTokenMs: (cfg?.accessTokenMinutes ?? 60) * 60_000,
17
+ refreshTokenMs: (cfg?.refreshTokenDays ?? 90) * 86_400_000,
18
+ codeMs: 120_000,
19
+ /** a retried refresh within this window, whose answer was never used, isn't treated as theft */
20
+ refreshGraceMs: 60_000,
21
+ };
22
+
23
+ export function requireEditorApps() {
24
+ if (!EDITOR.enabled) throw new ApiError(404, 'This feature is disabled.');
25
+ }
@@ -0,0 +1,227 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { CalEvent, DocTranslations, Page, Post } from '../schema';
4
+ import { slugify } from '../schema';
5
+ import { ApiError } from '../api';
6
+ import { CONTENT, collectionUrl } from '../content';
7
+ import { CALENDAR, eventUrl } from '../calendar/config';
8
+ import { normalizeEventTimes } from '../calendar/events';
9
+ import { draftSavedAt, mergeEventDraft, mergePageDraft, mergePostDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
10
+ import { localeHomeUrl } from '../i18n';
11
+ import { pageQuery } from '../mobile/http';
12
+ import { applyEventUpdate, applyPageUpdate, applyPostUpdate, saveEvent, savePage, savePost, type SaveHooks } from '../content-service';
13
+
14
+ // Posts, pages and events for editor apps. Reads show the document as it
15
+ // will look after Update (staged edits merged); writes go through the same
16
+ // content service as the admin editor, so live documents stage their edits
17
+ // and every validation, redirect and side effect is identical. Staff have
18
+ // the same document rights here as in the web admin.
19
+
20
+ export type DocKind = 'post' | 'page' | 'event';
21
+ type AnyDoc = Post | Page | CalEvent;
22
+ export const DocKindParam = z.enum(['post', 'page', 'event']);
23
+
24
+ export function kindEnabled(kind: DocKind) {
25
+ if (kind === 'event' && !CALENDAR.enabled) throw new ApiError(404, 'The calendar is turned off on this site.');
26
+ }
27
+ export async function loadDoc(repo: ContentRepository, kind: DocKind, id: string): Promise<AnyDoc> {
28
+ kindEnabled(kind);
29
+ const doc = kind === 'post' ? await repo.getPost(id) : kind === 'page' ? await repo.getPage(id) : await repo.getEvent(id);
30
+ if (!doc) throw new ApiError(404, `${kind === 'post' ? 'Post' : kind === 'page' ? 'Page' : 'Event'} not found.`);
31
+ return doc;
32
+ }
33
+ /** changes whenever the live row or its staged draft changes */
34
+ export const revisionOf = (doc: AnyDoc) => `${doc.updatedAt}.${draftSavedAt(doc) ?? 0}`;
35
+ export function checkRevision(doc: AnyDoc, revision: string) {
36
+ if (revision !== revisionOf(doc)) throw new ApiError(409, 'This document changed since you loaded it. Reload it and try again.');
37
+ }
38
+ /**
39
+ * Run a revision-checked write under the document's save lock: the revision
40
+ * check and the write happen as one step, so of two concurrent saves from
41
+ * the same revision exactly one wins and the other gets 409.
42
+ */
43
+ export async function withDocLock<T>(repo: ContentRepository, kind: DocKind, id: string, work: () => Promise<T>): Promise<T> {
44
+ const key = `doc:${kind}:${id}`;
45
+ if (!await repo.editor.claim(key, Date.now())) throw new ApiError(409, 'Someone else is saving this document right now. Reload it and try again.');
46
+ try { return await work(); } finally { await repo.editor.release(key).catch(() => {}); }
47
+ }
48
+ export function mergedDoc(kind: DocKind, doc: AnyDoc): AnyDoc {
49
+ return kind === 'post' ? mergePostDraft(doc as Post) : kind === 'page' ? mergePageDraft(doc as Page) : mergeEventDraft(doc as CalEvent);
50
+ }
51
+ const iso = (ms: number | null | undefined) => (ms ? new Date(ms).toISOString() : null);
52
+ const urlOf = (kind: DocKind, d: AnyDoc) => kind === 'post' ? collectionUrl((d as Post).collection, d.slug)
53
+ : kind === 'page' ? ((d as Page).isHome ? localeHomeUrl((d as Page).locale) : `/${d.slug}`) : eventUrl(d.slug);
54
+ const imageOf = (kind: DocKind, d: AnyDoc) => (kind === 'page' ? '' : (d as Post | CalEvent).cover ?? '');
55
+
56
+ export function summaryJson(kind: DocKind, raw: AnyDoc) {
57
+ const d = mergedDoc(kind, raw);
58
+ return {
59
+ id: raw.id, kind, collection: kind === 'post' ? (d as Post).collection : null, title: d.title, slug: d.slug, status: raw.status,
60
+ hasChanges: Boolean(raw.draft), url: urlOf(kind, raw), image: imageOf(kind, d), updatedAt: iso(raw.updatedAt)!, revision: revisionOf(raw),
61
+ };
62
+ }
63
+
64
+ export function docJson(kind: DocKind, raw: AnyDoc) {
65
+ const d = mergedDoc(kind, raw) as AnyDoc & Partial<Post & Page & CalEvent>;
66
+ return {
67
+ ...summaryJson(kind, raw),
68
+ excerpt: kind === 'page' ? '' : d.excerpt ?? '', bodyHtml: d.bodyHtml ?? '', tags: d.tags ?? [], cover: d.cover ?? '', coverAlt: d.coverAlt ?? '',
69
+ seoTitle: d.seoTitle ?? '', seoDesc: d.seoDesc ?? '', featured: Boolean(d.featured), template: d.template ?? '',
70
+ authorIds: kind === 'post' ? (d as Post).authorIds : [], locale: kind === 'page' ? (d as Page).locale || null : null, isHome: kind === 'page' ? (d as Page).isHome : false,
71
+ publishedAt: iso((raw as Post | CalEvent).publishedAt), scheduledAt: iso((raw as Post | CalEvent).scheduledAt), createdAt: iso(raw.createdAt)!,
72
+ event: kind === 'event' ? {
73
+ start: d.start!, end: d.end!, allDay: Boolean(d.allDay), timezone: d.timezone ?? '', rrule: d.rrule ?? '', exdates: d.exdates ?? [], cancelled: Boolean(d.cancelled),
74
+ location: d.location!, cta: d.cta!, calendarIds: d.calendarIds ?? [], fields: d.fields ?? {}, rsvp: d.rsvp!,
75
+ } : null,
76
+ languages: Object.keys((d.translations ?? {}) as DocTranslations),
77
+ };
78
+ }
79
+
80
+ // ---- list ----------------------------------------------------------------
81
+
82
+ export const listQuery = z.object({
83
+ kind: DocKindParam.default('post'),
84
+ collection: z.string().max(100).optional(),
85
+ status: z.enum(['draft', 'scheduled', 'published', 'archived', 'changed']).optional(),
86
+ q: z.string().max(200).optional(),
87
+ ...pageQuery,
88
+ }).strict();
89
+
90
+ export async function listDocs(repo: ContentRepository, query: z.infer<typeof listQuery>) {
91
+ kindEnabled(query.kind);
92
+ if (query.collection && (query.kind !== 'post' || !CONTENT.collections[query.collection])) throw new ApiError(400, 'Unknown collection.');
93
+ let docs: AnyDoc[] = query.kind === 'post' ? await repo.listPosts(query.collection) : query.kind === 'page' ? await repo.listPages() : await repo.listEvents();
94
+ if (query.status === 'changed') docs = docs.filter((d) => d.draft);
95
+ else if (query.status) docs = docs.filter((d) => d.status === query.status);
96
+ const q = query.q?.trim().toLowerCase();
97
+ if (q) docs = docs.filter((d) => { const m = mergedDoc(query.kind, d); return `${m.title} ${m.slug}`.toLowerCase().includes(q); });
98
+ docs.sort((a, b) => b.updatedAt - a.updatedAt);
99
+ const start = (query.page - 1) * query.limit;
100
+ return {
101
+ items: docs.slice(start, start + query.limit).map((d) => summaryJson(query.kind, d)),
102
+ total: docs.length,
103
+ pagination: { page: query.page, limit: query.limit, nextPage: start + query.limit < docs.length ? query.page + 1 : null },
104
+ };
105
+ }
106
+
107
+ // ---- create --------------------------------------------------------------
108
+
109
+ export const CreateInput = z.discriminatedUnion('kind', [
110
+ z.object({ kind: z.literal('post'), title: z.string().trim().min(1).max(300), collection: z.string().max(100).default('posts') }).strict(),
111
+ z.object({ kind: z.literal('page'), title: z.string().trim().min(1).max(300) }).strict(),
112
+ z.object({ kind: z.literal('event'), title: z.string().trim().min(1).max(300), start: z.string().max(40), allDay: z.boolean().optional() }).strict(),
113
+ ]);
114
+
115
+ export async function createDoc(repo: ContentRepository, input: z.infer<typeof CreateInput>, authorId: string) {
116
+ kindEnabled(input.kind);
117
+ const base = slugify(input.title) || 'untitled';
118
+ if (input.kind === 'post') {
119
+ if (!CONTENT.collections[input.collection]) throw new ApiError(400, `Unknown collection "${input.collection}".`);
120
+ let slug = base;
121
+ for (let n = 2; await repo.getPostBySlug(slug, input.collection); n++) slug = `${base}-${n}`;
122
+ const post = await repo.createPost({ title: input.title, slug, collection: input.collection });
123
+ // the person who started it is its author (editable afterwards)
124
+ await repo.updatePost(post.id, { authorIds: [authorId] });
125
+ return (await repo.getPost(post.id))!;
126
+ }
127
+ if (input.kind === 'page') {
128
+ let slug = base;
129
+ for (let n = 2; await repo.getPageBySlug(slug); n++) slug = `${base}-${n}`;
130
+ return repo.createPage({ title: input.title, slug });
131
+ }
132
+ let times: { start: string; end: string };
133
+ try { times = normalizeEventTimes({ start: input.start, end: '', allDay: input.allDay ?? !input.start.includes('T') }); }
134
+ catch (err) { throw new ApiError(400, err instanceof Error ? err.message : 'Invalid start time.'); }
135
+ let slug = base;
136
+ for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
137
+ return repo.createEvent({ title: input.title, slug, start: times.start, end: times.end, allDay: input.allDay ?? !input.start.includes('T') });
138
+ }
139
+
140
+ // ---- save ----------------------------------------------------------------
141
+ // Field rules mirror the admin save actions; status and schedule changes go
142
+ // through the publish/unpublish/schedule actions instead.
143
+
144
+ const text = (max: number) => z.string().max(max);
145
+ const common = { title: text(1000), slug: text(300), bodyHtml: text(2_000_000), seoTitle: text(1000), seoDesc: text(5000), template: text(100) };
146
+ export const PostFields = z.object({ ...common, excerpt: text(5000), tags: z.array(text(100)).max(100), cover: text(2000), coverAlt: text(1000),
147
+ featured: z.boolean(), collection: text(100), authorIds: z.array(text(100)).max(20) }).partial().strict();
148
+ export const PageFields = z.object({ ...common, isHome: z.boolean(), locale: text(20) }).partial().strict();
149
+ export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.array(text(100)).max(100), cover: text(2000), coverAlt: text(1000), featured: z.boolean(),
150
+ start: text(40), end: text(40), allDay: z.boolean(), timezone: text(100), rrule: text(500), exdates: z.array(text(40)).max(500), cancelled: z.boolean(),
151
+ location: z.object({ name: text(300), address: text(500), url: text(1000) }).strict(),
152
+ cta: z.object({ label: text(120), href: text(1000), style: z.enum(['primary', 'outline']) }).strict(),
153
+ calendarIds: z.array(text(100)).max(50), fields: z.record(text(2000)),
154
+ rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50) }).strict(),
155
+ }).partial().strict();
156
+ export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
157
+
158
+ export async function saveDoc(repo: ContentRepository, kind: DocKind, id: string, body: z.infer<typeof SaveInput>, hooks: SaveHooks) {
159
+ return withDocLock(repo, kind, id, () => saveDocLocked(repo, kind, id, body, hooks));
160
+ }
161
+ async function saveDocLocked(repo: ContentRepository, kind: DocKind, id: string, body: z.infer<typeof SaveInput>, hooks: SaveHooks) {
162
+ const before = await loadDoc(repo, kind, id);
163
+ checkRevision(before, body.revision);
164
+ if (kind === 'post') {
165
+ const fields = PostFields.parse(body.fields);
166
+ if (fields.authorIds) {
167
+ const known = new Set((await repo.listUsers()).map((u) => u.id));
168
+ fields.authorIds = [...new Set(fields.authorIds)].filter((a) => known.has(a));
169
+ }
170
+ // HTML written here replaces the editor's structured copy (it reloads from HTML)
171
+ return savePost(repo, id, { ...fields, ...(fields.bodyHtml !== undefined ? { bodyJson: '' } : {}) }, hooks);
172
+ }
173
+ if (kind === 'page') {
174
+ const fields = PageFields.parse(body.fields);
175
+ return savePage(repo, id, { ...fields, ...(fields.bodyHtml !== undefined ? { bodyJson: '' } : {}) }, undefined, hooks);
176
+ }
177
+ const fields = EventFields.parse(body.fields);
178
+ return saveEvent(repo, id, { ...fields, ...(fields.bodyHtml !== undefined ? { bodyJson: '' } : {}) }, hooks);
179
+ }
180
+
181
+ /** save through the right service with whatever fields (translations, status…) */
182
+ export async function saveRaw(repo: ContentRepository, kind: DocKind, id: string, data: Record<string, unknown>, hooks: SaveHooks): Promise<AnyDoc> {
183
+ if (kind === 'post') return savePost(repo, id, data, hooks);
184
+ if (kind === 'page') return savePage(repo, id, data, undefined, hooks);
185
+ return saveEvent(repo, id, data, hooks);
186
+ }
187
+
188
+ // ---- publish / unpublish / schedule / discard ------------------------------
189
+
190
+ export const ActionInput = z.object({ revision: z.string().max(60), at: z.string().datetime({ offset: true }).optional() }).strict();
191
+ const live = (s: string) => s === 'published' || s === 'scheduled';
192
+ const staged = (kind: DocKind, doc: AnyDoc) => !doc.draft ? {} : kind === 'post' ? pickPostDraft(doc.draft) : kind === 'page' ? pickPageDraft(doc.draft) : pickEventDraft(doc.draft);
193
+
194
+ export async function docAction(repo: ContentRepository, kind: DocKind, id: string, action: string, body: z.infer<typeof ActionInput>, hooks: SaveHooks): Promise<AnyDoc> {
195
+ return withDocLock(repo, kind, id, () => docActionLocked(repo, kind, id, action, body, hooks));
196
+ }
197
+ async function docActionLocked(repo: ContentRepository, kind: DocKind, id: string, action: string, body: z.infer<typeof ActionInput>, hooks: SaveHooks): Promise<AnyDoc> {
198
+ const doc = await loadDoc(repo, kind, id);
199
+ checkRevision(doc, body.revision);
200
+ if (action === 'publish') {
201
+ // live + staged edits → Update; otherwise the status change (the editor's Publish)
202
+ let current = doc;
203
+ if (live(doc.status) && doc.draft) {
204
+ current = kind === 'post' ? await applyPostUpdate(repo, id, hooks) : kind === 'page' ? await applyPageUpdate(repo, id, hooks) : await applyEventUpdate(repo, id, hooks);
205
+ }
206
+ if (current.status === 'published') return current;
207
+ return saveRaw(repo, kind, id, { status: 'published', ...(kind === 'page' ? {} : { scheduledAt: null }) }, hooks);
208
+ }
209
+ if (action === 'unpublish') {
210
+ if (!live(doc.status)) throw new ApiError(409, 'This isn’t published.');
211
+ // staged edits become the draft's content, as the web editor sends them
212
+ return saveRaw(repo, kind, id, { ...staged(kind, doc), status: 'draft', ...(kind === 'page' ? {} : { scheduledAt: null }) }, hooks);
213
+ }
214
+ if (action === 'schedule') {
215
+ if (kind === 'page') throw new ApiError(400, 'Pages publish immediately; they can’t be scheduled.');
216
+ const at = body.at ? Date.parse(body.at) : NaN;
217
+ if (!Number.isFinite(at) || at <= Date.now()) throw new ApiError(400, 'Pick a future publish time to schedule.');
218
+ if (doc.status === 'published') throw new ApiError(409, 'This is already published. Unpublish it first.');
219
+ return saveRaw(repo, kind, id, { ...staged(kind, doc), status: 'scheduled', scheduledAt: at }, hooks);
220
+ }
221
+ if (action === 'discard') {
222
+ if (!doc.draft) return doc;
223
+ if (kind === 'post') await repo.discardPostDraft(id); else if (kind === 'page') await repo.discardPageDraft(id); else await repo.discardEventDraft(id);
224
+ return loadDoc(repo, kind, id);
225
+ }
226
+ throw new ApiError(404, 'Unknown action.');
227
+ }
@@ -0,0 +1,48 @@
1
+ import type { APIContext, APIRoute } from 'astro';
2
+ import { getRepo } from '../repo';
3
+ import type { ContentRepository } from '../repo/types';
4
+ import { ApiError } from '../api';
5
+ import { sha256Hex } from '../api-keys';
6
+ import { failure, response } from '../mobile/http';
7
+ import { authenticateDevice, type EditorPrincipal } from './auth';
8
+ import { requireEditorApps } from './config';
9
+
10
+ export interface EditorContext { context: APIContext; repo: ContentRepository; who: EditorPrincipal; now: number }
11
+
12
+ const address = (context: APIContext) => { try { return context.clientAddress || 'unknown'; } catch { return 'unknown'; } };
13
+
14
+ /**
15
+ * One /api/v1/admin/* handler: editor apps must be on, a device access token
16
+ * (never a cookie) identifies the person, requests are rate limited per
17
+ * device, and results use the v1 envelope. Handlers may return a Response.
18
+ */
19
+ export function editorRoute(handler: (c: EditorContext) => Promise<unknown>, opts: { write?: boolean; status?: number; limit?: number } = {}): APIRoute {
20
+ return async (context) => {
21
+ try {
22
+ requireEditorApps();
23
+ const repo = await getRepo(context.locals);
24
+ const now = Date.now();
25
+ let who: EditorPrincipal;
26
+ try { who = await authenticateDevice(context.request, repo, now); }
27
+ catch (error) {
28
+ // failed bearer attempts are throttled per client address
29
+ if (!await repo.email.rateLimit(`editor:fail:${await sha256Hex(address(context))}`, 30, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
30
+ throw error;
31
+ }
32
+ const max = opts.limit ?? (opts.write ? 60 : 600);
33
+ if (!await repo.email.rateLimit(`editor:${opts.write ? 'write' : 'read'}:${opts.limit ? `${opts.limit}:` : ''}${who.device.id}`, max, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
34
+ const data = await handler({ context, repo, who, now });
35
+ if (data instanceof Response) return data;
36
+ return response(data, opts.status ?? 200);
37
+ } catch (error) {
38
+ // expected failures carry a status (ApiError, BookingError) or zod issues; only surprises are logged (never request data)
39
+ if (typeof (error as { status?: unknown })?.status !== 'number' && !(error instanceof Error && 'issues' in error)) console.error('acms editor api: request failed:', error instanceof Error ? error.message : error);
40
+ return failure(error);
41
+ }
42
+ };
43
+ }
44
+
45
+ /** audit trail for a write: who, through which device */
46
+ export async function logWrite(c: EditorContext, action: string, target: string) {
47
+ await c.repo.editor.log({ userId: c.who.user.id, deviceId: c.who.device.id, action, target }, Date.now()).catch(() => {});
48
+ }