@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,30 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import { CONTENT } from '../content';
3
+ import { CALENDAR, bookingsEnabled } from '../calendar/config';
4
+ import { I18N, extraLocales, localeName } from '../i18n';
5
+ import { translationEngine } from '../translate';
6
+ import { siteOrigin } from '../seo';
7
+ import type { EditorPrincipal } from './auth';
8
+
9
+ /** who is signed in on this device, and what this site lets them do */
10
+ export async function me(repo: ContentRepository, who: EditorPrincipal, url: URL, locals: unknown) {
11
+ const settings = await repo.getSettings();
12
+ const owner = who.user.role === 'owner';
13
+ return {
14
+ user: who.user,
15
+ device: { id: who.device.id, name: who.device.name, clientId: who.device.clientId },
16
+ site: { name: settings.siteTitle, url: siteOrigin(settings, url) },
17
+ features: {
18
+ collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, singular: c.singular, plural: c.plural })),
19
+ calendar: CALENDAR.enabled, bookings: bookingsEnabled(),
20
+ languages: { default: I18N.enabled ? I18N.defaultLocale : null, extra: extraLocales().map((code) => ({ code, name: localeName(code, true) })) },
21
+ aiTranslation: translationEngine(settings.ai, locals) !== null,
22
+ },
23
+ // Non-owner roles retain the existing editor capabilities.
24
+ permissions: {
25
+ documents: { edit: true, publish: true },
26
+ sections: { editContent: true, editLocked: owner, publishSection: true, changeLayout: false },
27
+ bookings: bookingsEnabled(),
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,57 @@
1
+ // v38: editor apps (staff sign in on their phone through the browser, OAuth
2
+ // 2.0 authorization code + PKCE). Every secret is stored as a SHA-256 hash.
3
+ //
4
+ // editor_devices one row per signed-in app install; revoking it kills every
5
+ // token. session_version pins the user's password generation.
6
+ // editor_tokens access (short) and refresh (rotating) tokens. Rotated refresh
7
+ // tokens keep their row with used_at set: presenting one again
8
+ // is token theft, and revokes the whole device.
9
+ // editor_codes one-time authorization codes (about two minutes, single use)
10
+ // editor_activity what each device changed (actor + device), for the audit trail
11
+ //
12
+ // Triggers make every path that deletes a user or changes their password
13
+ // (Users page, password reset email, restore) sign their devices out.
14
+ const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
15
+ const revoke = (reason: string) => `UPDATE editor_devices SET revoked_at=${now}, revoked_reason='${reason}' WHERE user_id=OLD.id AND revoked_at IS NULL;
16
+ DELETE FROM editor_tokens WHERE device_id IN (SELECT id FROM editor_devices WHERE user_id=OLD.id);`;
17
+
18
+ export const EDITOR_MIGRATION = { version: 38, statements: [
19
+ `CREATE TABLE editor_devices (
20
+ id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, client_id TEXT NOT NULL,
21
+ session_version INTEGER NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL,
22
+ revoked_at INTEGER, revoked_reason TEXT NOT NULL DEFAULT ''
23
+ )`,
24
+ `CREATE INDEX editor_devices_user ON editor_devices (user_id, revoked_at)`,
25
+ `CREATE TABLE editor_tokens (
26
+ hash TEXT PRIMARY KEY, device_id TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('access','refresh')),
27
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER
28
+ )`,
29
+ `CREATE INDEX editor_tokens_device ON editor_tokens (device_id)`,
30
+ `CREATE TABLE editor_codes (
31
+ hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, client_id TEXT NOT NULL, redirect_uri TEXT NOT NULL,
32
+ challenge TEXT NOT NULL, device_name TEXT NOT NULL, session_version INTEGER NOT NULL,
33
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER, device_id TEXT
34
+ )`,
35
+ `CREATE TABLE editor_activity (
36
+ id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL,
37
+ action TEXT NOT NULL, target TEXT NOT NULL DEFAULT ''
38
+ )`,
39
+ `CREATE INDEX editor_activity_device ON editor_activity (device_id, created_at)`,
40
+ `CREATE TRIGGER editor_user_delete AFTER DELETE ON users BEGIN ${revoke('user removed')} END`,
41
+ `CREATE TRIGGER editor_user_password AFTER UPDATE OF session_version ON users
42
+ WHEN NEW.session_version IS NOT OLD.session_version BEGIN ${revoke('password changed')} END`,
43
+ ] };
44
+
45
+ // v39: a refresh-token grace window and atomic document saves.
46
+ // editor_tokens.parent the refresh token this pair replaced, so a retried
47
+ // refresh (the app lost the response) can be told apart
48
+ // from a stolen, replayed one.
49
+ // editor_tokens.used_at on access tokens: set on first use (proves the app
50
+ // received that pair).
51
+ // editor_claims a per-document lock held while a save checks the
52
+ // revision and writes, so two saves can't both win.
53
+ export const EDITOR_GRACE_MIGRATION = { version: 39, statements: [
54
+ `ALTER TABLE editor_tokens ADD COLUMN parent TEXT`,
55
+ `CREATE INDEX editor_tokens_parent ON editor_tokens (parent)`,
56
+ `CREATE TABLE editor_claims (key TEXT PRIMARY KEY, created_at INTEGER NOT NULL)`,
57
+ ] };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A post-login destination: only the editor-app sign-in page on this origin
3
+ * (with its query), so ?next= can never become an open redirect.
4
+ */
5
+ export function safeNext(next: string | null | undefined, base: URL): string | null {
6
+ if (!next || !next.startsWith('/') || next.startsWith('//') || next.includes('\\') || /[\u0000-\u001f]/.test(next)) return null;
7
+ let url: URL;
8
+ try { url = new URL(next, base.origin); } catch { return null; }
9
+ if (url.origin !== base.origin || url.pathname !== '/admin/app-auth' || url.hash) return null;
10
+ return url.pathname + url.search;
11
+ }
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod';
2
+ import * as schemas from './schemas';
3
+ import { ActionInput, CreateInput, EventFields, PageFields, PostFields, listQuery } from './documents';
4
+ import { SectionPatch, SectionsQuery, RevisionInput } from './sections';
5
+ import { AiDocInput, AiSectionInput, ReviewDocInput, ReviewSectionInput, SetDocInput, SetSectionInput } from './translations';
6
+ import { BookingActionInput, InboxQuery } from './bookings';
7
+ import { pageQuery } from '../mobile/http';
8
+
9
+ // OpenAPI for editor apps: device sign-in (/api/v1/auth/*) and the bearer-only
10
+ // admin API (/api/v1/admin/*). Present in the document for every site; the
11
+ // routes exist only when acms({ mobile: { editor } }) is configured (404 otherwise).
12
+
13
+ type Json = (schema: z.ZodTypeAny) => unknown;
14
+ const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` });
15
+ const media = (schema: unknown) => ({ 'application/json': { schema } });
16
+ const envelope = (schema: unknown) => ({ type: 'object', required: ['version', 'data'], properties: { version: { const: 1 }, data: schema } });
17
+ const noStore = { 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } };
18
+ const tags = ['Editor'];
19
+ const security = [{ deviceToken: [] }];
20
+ const preflight = { summary: 'CORS preflight', tags, responses: { 204: { description: 'No body.' } } };
21
+
22
+ export const editorSchemas = schemas;
23
+
24
+ export function editorPaths(jsonSchema: Json) {
25
+ const error = (description: string) => ({ description, content: media(ref('Error')) });
26
+ const errors = {
27
+ 400: error('Invalid request.'), 401: { ...error('Missing, expired or signed-out access token: refresh once, then sign in again.'), headers: { 'WWW-Authenticate': { schema: { type: 'string' } } } },
28
+ 403: error('The signed-in role may not do this (same rules as the web admin).'), 404: error('Not found, or the feature is off.'),
29
+ 429: { ...error('Rate limited per device (600 reads, 60 writes a minute; AI drafts 10, uploads 30).'), headers: { 'Retry-After': { schema: { type: 'string' } } } },
30
+ 500: error('Request failed.'),
31
+ };
32
+ const writeErrors = { ...errors, 409: error('Revision conflict or a state that forbids it: reload and try again.'), 413: error('Payload too large.'), 415: error('Unsupported media type.') };
33
+ const params = (query: z.AnyZodObject) => Object.entries(query.shape).map(([name, s]) => ({ name, in: 'query', required: !(s as z.ZodTypeAny).isOptional(), schema: jsonSchema(s as z.ZodTypeAny) }));
34
+ const pathParams = (path: string) => [...path.matchAll(/\{(\w+)\}/g)].map((m) => ({
35
+ name: m[1], in: 'path', required: true,
36
+ schema: m[1] === 'kind' ? { type: 'string', enum: ['post', 'page', 'event'] } : { type: 'string' },
37
+ }));
38
+ const op = (path: string, summary: string, response: string, opts: { query?: z.AnyZodObject; body?: z.ZodTypeAny; write?: boolean; status?: number; description?: string; form?: boolean } = {}) => ({
39
+ summary, tags, security, ...(opts.description ? { description: opts.description } : {}),
40
+ parameters: [...pathParams(path), ...(opts.query ? params(opts.query) : [])],
41
+ ...(opts.body ? { requestBody: { required: true, content: opts.form ? { 'multipart/form-data': { schema: jsonSchema(opts.body) } } : media(jsonSchema(opts.body)) } } : {}),
42
+ responses: { ...(opts.write ? writeErrors : errors), [opts.status ?? 200]: { description: 'Success.', headers: noStore, content: media(envelope(ref(response))) } },
43
+ });
44
+ const P = '/api/v1/admin';
45
+ const doc = `${P}/documents/{kind}/{id}`, docTr = `${doc}/translations/{locale}`;
46
+ const sec = `${P}/sections/{pageId}/{sectionId}`, secTr = `${sec}/translations/{locale}`;
47
+ const SaveBody = z.object({ revision: z.string().max(60), fields: z.union([PostFields, PageFields, EventFields]) }).strict();
48
+ const Upload = z.object({ file: z.string().describe('The file (binary). Images should be compressed on the device first.'), width: z.string().optional(), height: z.string().optional(), alt: z.string().optional() });
49
+ const oauth = (summary: string, description: string, body: z.ZodTypeAny, ok: string) => ({
50
+ summary, description, tags, security: [],
51
+ requestBody: { required: true, content: { 'application/x-www-form-urlencoded': { schema: jsonSchema(body) }, 'application/json': { schema: jsonSchema(body) } } },
52
+ responses: {
53
+ 200: { description: 'Success (OAuth shape, no v1 envelope).', headers: noStore, content: media(ref(ok)) },
54
+ ...Object.fromEntries([400, 404, 415, 429].map((code) => [code, { description: code === 429 ? 'Rate limited (30/minute/client address).' : 'OAuth error (error, error_description).', content: media(ref('EditorOAuthError')) }])),
55
+ },
56
+ });
57
+ const paths: Record<string, unknown> = {
58
+ '/api/v1/auth/token': {
59
+ post: oauth('Exchange a sign-in code (+ PKCE verifier) or a refresh token for device tokens',
60
+ 'authorization_code: the one-time code from /admin/app-auth (single use, about two minutes) with the PKCE code_verifier, the same redirect_uri and client_id. refresh_token: rotates — the response carries a new refresh token and the old one stops working; presenting a used refresh token signs the whole device out.',
61
+ schemas.EditorTokenRequest, 'EditorToken'),
62
+ options: preflight,
63
+ },
64
+ '/api/v1/auth/revoke': {
65
+ post: oauth('Sign this device out (RFC 7009)', 'Either token revokes the device. Unknown tokens also succeed.', schemas.EditorRevokeRequest, 'EditorRevoked'),
66
+ options: preflight,
67
+ },
68
+ [`${P}/me`]: { get: op(`${P}/me`, 'The signed-in person, device, site features and permissions', 'EditorMe'), options: preflight },
69
+ [`${P}/documents`]: {
70
+ get: op(`${P}/documents`, 'Posts, pages or events (staged edits merged), newest change first', 'EditorDocumentList', { query: listQuery }),
71
+ post: op(`${P}/documents`, 'Start a draft post, page or event', 'EditorDocument', { body: CreateInput, write: true, status: 201 }),
72
+ options: preflight,
73
+ },
74
+ [doc]: {
75
+ get: op(doc, 'One document as the editor shows it (staged edits merged)', 'EditorDocument'),
76
+ put: op(doc, 'Save fields (live documents stage them until publish)', 'EditorDocument', { body: SaveBody, write: true,
77
+ description: 'Same validation as the admin editor. Fields per kind: post, page or event. bodyHtml replaces the body. Status changes use the publish/unpublish/schedule actions.' }),
78
+ options: preflight,
79
+ },
80
+ ...Object.fromEntries(([['publish', 'Publish, or put staged edits live'], ['unpublish', 'Unpublish (back to draft, keeping staged edits)'], ['schedule', 'Schedule publishing (posts and events; body.at)'], ['discard', 'Discard staged edits']] as const)
81
+ .map(([action, summary]) => [`${doc}/${action}`, { post: op(`${doc}/${action}`, summary, 'EditorDocument', { body: ActionInput, write: true }), options: preflight }])),
82
+ [docTr]: {
83
+ get: op(docTr, 'Translation units and review state for one extra language', 'EditorDocumentTranslation'),
84
+ put: op(docTr, 'Set translated texts (a person’s words are stored as reviewed)', 'EditorDocumentTranslation', { body: SetDocInput, write: true }),
85
+ options: preflight,
86
+ },
87
+ [`${docTr}/review`]: { post: op(`${docTr}/review`, 'Mark translated units reviewed', 'EditorDocumentTranslation', { body: ReviewDocInput, write: true }), options: preflight },
88
+ [`${docTr}/ai-draft`]: { post: op(`${docTr}/ai-draft`, 'AI drafts for missing (or changed) units; 409 when AI is off', 'EditorDocumentTranslation', { body: AiDocInput, write: true }), options: preflight },
89
+ [`${P}/sections`]: { get: op(`${P}/sections`, 'Section layouts (staged or live) and the theme’s section definitions', 'EditorSections', { query: SectionsQuery }), options: preflight },
90
+ [sec]: {
91
+ patch: op(sec, 'Stage new field values for one section', 'EditorSectionResult', { body: SectionPatch, write: true,
92
+ description: 'Editors change content only: locked sections and locked fields are owner-only (403). Uses the design revision (409 when stale).' }),
93
+ options: preflight,
94
+ },
95
+ [`${sec}/publish`]: { post: op(`${sec}/publish`, 'Publish just this section’s staged copy', 'EditorRevision', { body: RevisionInput, write: true }), options: preflight },
96
+ [secTr]: {
97
+ get: op(secTr, 'Section translation units and review state', 'EditorSectionTranslation'),
98
+ put: op(secTr, 'Set translated section texts (stored as reviewed)', 'EditorSectionTranslation', { body: SetSectionInput, write: true }),
99
+ options: preflight,
100
+ },
101
+ [`${secTr}/review`]: { post: op(`${secTr}/review`, 'Mark section translation units reviewed', 'EditorSectionTranslation', { body: ReviewSectionInput, write: true }), options: preflight },
102
+ [`${secTr}/ai-draft`]: { post: op(`${secTr}/ai-draft`, 'AI drafts for a section’s missing units', 'EditorSectionTranslation', { body: AiSectionInput, write: true }), options: preflight },
103
+ [`${P}/media`]: {
104
+ get: op(`${P}/media`, 'Media library, newest first', 'EditorMediaList', { query: z.object({ q: z.string().max(200).optional(), type: z.enum(['image', 'document']).optional(), ...pageQuery }).strict() }),
105
+ post: op(`${P}/media`, 'Upload an image or document (multipart, same types and limits as the admin)', 'EditorMedia', { body: Upload, form: true, write: true, status: 201,
106
+ description: 'Images up to 30 MB, documents up to 25 MB; optional variant_<width> WebP fields (max 8). Compress images on the device first.' }),
107
+ options: preflight,
108
+ },
109
+ [`${P}/bookings`]: { get: op(`${P}/bookings`, 'Bookings inbox (pending first); requires bookings', 'EditorBookingList', { query: InboxQuery }), options: preflight },
110
+ ...Object.fromEntries((['approve', 'decline', 'cancel'] as const).map((action) => [`${P}/bookings/{id}/${action}`, {
111
+ post: op(`${P}/bookings/{id}/${action}`, `${action[0].toUpperCase()}${action.slice(1)} a booking (the guest is emailed)`, 'EditorBooking', { body: BookingActionInput, write: true }),
112
+ options: preflight,
113
+ }])),
114
+ };
115
+ return paths;
116
+ }
117
+
118
+ export const editorSecuritySchemes = {
119
+ deviceToken: { type: 'http', scheme: 'bearer', bearerFormat: 'acms_dev_a_…', description: 'Device access token from POST /api/v1/auth/token. Cookies never authenticate the admin API.' },
120
+ editorSignIn: {
121
+ type: 'oauth2', description: 'Browser sign-in for editor apps: authorization code + PKCE (S256). The page is /admin/app-auth; redirect_uri must be listed in acms({ mobile: { editor: { redirectUris } } }).',
122
+ flows: { authorizationCode: { authorizationUrl: '/admin/app-auth', tokenUrl: '/api/v1/auth/token', refreshUrl: '/api/v1/auth/token', scopes: {} } },
123
+ },
124
+ };
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import { Booking } from '../schema';
3
+
4
+ // Response shapes of the editor-app API (OpenAPI components, client types).
5
+ const str = z.string(), bool = z.boolean(), num = z.number(), date = str.datetime();
6
+ const role = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
7
+ const Pagination = z.object({ page: num.int(), limit: num.int(), nextPage: num.int().nullable() });
8
+ const User = z.object({ id: str, name: str, email: str, role });
9
+
10
+ export const EditorToken = z.object({
11
+ access_token: str, token_type: z.literal('Bearer'), expires_in: num.int(), refresh_token: str, refresh_token_expires_in: num.int(),
12
+ user: User, device: z.object({ id: str, name: str }),
13
+ });
14
+ export const EditorOAuthError = z.object({ error: str, error_description: str });
15
+ export const EditorRevoked = z.object({ revoked: z.literal(true) });
16
+
17
+ export const EditorMe = z.object({
18
+ user: User, device: z.object({ id: str, name: str, clientId: str }), site: z.object({ name: str, url: str }),
19
+ features: z.object({
20
+ collections: z.array(z.object({ key: str, singular: str, plural: str })), calendar: bool, bookings: bool,
21
+ languages: z.object({ default: str.nullable(), extra: z.array(z.object({ code: str, name: str })) }), aiTranslation: bool,
22
+ }),
23
+ permissions: z.object({
24
+ documents: z.object({ edit: bool, publish: bool }),
25
+ sections: z.object({ editContent: bool, editLocked: bool, publishSection: bool, changeLayout: bool }),
26
+ bookings: bool,
27
+ }),
28
+ });
29
+
30
+ const kind = z.enum(['post', 'page', 'event']);
31
+ const status = z.enum(['draft', 'scheduled', 'published', 'archived']);
32
+ export const EditorDocumentSummary = z.object({
33
+ id: str, kind, collection: str.nullable(), title: str, slug: str, status, hasChanges: bool, url: str, image: str, updatedAt: date,
34
+ /** send back unchanged with every write; 409 when the document changed meanwhile */
35
+ revision: str,
36
+ });
37
+ export const EditorDocumentList = z.object({ items: z.array(EditorDocumentSummary), total: num.int(), pagination: Pagination });
38
+ export const EditorDocument = EditorDocumentSummary.extend({
39
+ excerpt: str, bodyHtml: str, tags: z.array(str), cover: str, coverAlt: str, seoTitle: str, seoDesc: str, featured: bool, template: str,
40
+ authorIds: z.array(str), locale: str.nullable(), isHome: bool, publishedAt: date.nullable(), scheduledAt: date.nullable(), createdAt: date,
41
+ event: z.object({
42
+ start: str, end: str, allDay: bool, timezone: str, rrule: str, exdates: z.array(str), cancelled: bool,
43
+ location: z.object({ name: str, address: str, url: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
44
+ calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num }),
45
+ }).nullable(),
46
+ languages: z.array(str),
47
+ });
48
+
49
+ const SectionTranslation = z.object({ data: z.record(z.unknown()), meta: z.record(z.object({ state: z.enum(['ai', 'reviewed']), src: str })) });
50
+ const SectionInstance = z.object({
51
+ id: str, type: str, version: num.int(), variant: str, hidden: bool, locked: bool.optional(), showFrom: str.optional(), showUntil: str.optional(),
52
+ translations: z.record(SectionTranslation).optional(), data: z.record(z.unknown()),
53
+ });
54
+ export const EditorSections = z.object({
55
+ revision: num.int(), stage: z.enum(['draft', 'live']), hasDraft: bool, role, theme: z.object({ slug: str, name: str }),
56
+ definitions: z.record(z.object({ name: str, version: num.int(), category: str.nullable(), description: str, variants: z.array(str), fields: z.record(z.unknown()) })),
57
+ pages: z.array(z.object({
58
+ pageId: str, title: str, slug: str, isHome: bool, locale: str.nullable(), mode: z.enum(['content', 'sections']),
59
+ sections: z.array(z.object({
60
+ id: str, type: str, version: num.int(), variant: str, hidden: bool, locked: bool, showFrom: str.nullable(), showUntil: str.nullable(),
61
+ data: z.record(z.unknown()), translations: z.record(SectionTranslation), name: str, isNew: bool, changed: bool, editable: bool,
62
+ })),
63
+ })),
64
+ });
65
+ export const EditorSectionResult = z.object({ revision: num.int(), section: SectionInstance });
66
+ export const EditorRevision = z.object({ revision: num.int() });
67
+
68
+ const Unit = z.object({
69
+ path: str, label: str, kind: z.enum(['text', 'textarea', 'richtext', 'link', 'select']), source: str, value: str,
70
+ status: z.enum(['missing', 'ai', 'reviewed', 'outdated']),
71
+ });
72
+ const Summary = z.object({ total: num.int(), missing: num.int(), ai: num.int(), reviewed: num.int(), outdated: num.int() });
73
+ export const EditorDocumentTranslation = z.object({ locale: str, revision: str, units: z.array(Unit), summary: Summary, changed: num.int().optional() });
74
+ export const EditorSectionTranslation = z.object({ locale: str, revision: num.int(), units: z.array(Unit), summary: Summary, changed: num.int().optional() });
75
+
76
+ export const EditorMedia = z.object({
77
+ id: str, url: str, filename: str, mime: str, width: num, height: num, size: num, alt: str,
78
+ variants: z.array(z.object({ width: num, url: str })), createdAt: date,
79
+ });
80
+ export const EditorMediaList = z.object({ items: z.array(EditorMedia), pagination: Pagination });
81
+
82
+ export const EditorBooking = Booking.pick({ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, source: true, notes: true })
83
+ .extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date, updatedAt: date, title: str });
84
+ export const EditorBookingList = z.object({ pending: num.int(), items: z.array(EditorBooking), pagination: Pagination });
85
+
86
+ /** documented request body for POST /api/v1/auth/token (form-encoded or JSON) */
87
+ export const EditorTokenRequest = z.discriminatedUnion('grant_type', [
88
+ z.object({ grant_type: z.literal('authorization_code'), code: str, code_verifier: str, redirect_uri: str, client_id: str }).strict(),
89
+ z.object({ grant_type: z.literal('refresh_token'), refresh_token: str, client_id: str.optional() }).strict(),
90
+ ]);
91
+ export const EditorRevokeRequest = z.object({ token: str }).strict();
@@ -0,0 +1,96 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { Settings } from '../schema';
4
+ import { ApiError } from '../api';
5
+ import { DesignRequest, runDesignOperation } from '../design-service';
6
+ import type { EditorPrincipal } from './auth';
7
+
8
+ // Section layouts for editor apps. Every write is a design operation run by
9
+ // the same code as the Design screen (lib/design-service runDesignOperation)
10
+ // with the person's live role: editors change content only (assertContentOnly
11
+ // — no structure, style, visibility or lock changes, nothing locked) and may
12
+ // publish single sections; owners may also edit locked sections. The design
13
+ // revision guards every write (409 when someone else saved first).
14
+
15
+ type Pages = Settings['sectionPages'];
16
+ type Instance = Pages[string]['sections'][number];
17
+
18
+ export const SectionsQuery = z.object({ stage: z.enum(['draft', 'live']).default('draft'), pageId: z.string().max(100).optional() }).strict();
19
+ export const SectionPatch = z.object({
20
+ revision: z.number().int().nonnegative(),
21
+ /** top-level field values to replace; other fields keep their values */
22
+ data: z.record(z.string().max(100), z.unknown()),
23
+ }).strict();
24
+ export const RevisionInput = z.object({ revision: z.number().int().nonnegative() }).strict();
25
+
26
+ export const stagedPages = (s: Settings) => (s.draftDesign?.sectionPages ?? s.sectionPages) as Pages;
27
+ // lazy: the theme registry pulls in Astro templates (keeps the OpenAPI/schema imports light)
28
+ const getTheme = async (slug: string) => (await import('../theme')).getTheme(slug);
29
+ export const stagedTheme = (s: Settings) => getTheme(String(s.draftDesign?.activeTheme ?? s.activeTheme));
30
+ export const designIdentityFor = (who: EditorPrincipal) => ({ actor: who.actor, role: who.user.role === 'owner' ? 'owner' as const : 'editor' as const });
31
+
32
+ export async function listSections(repo: ContentRepository, query: z.infer<typeof SectionsQuery>, who: EditorPrincipal) {
33
+ const [settings, pages] = await Promise.all([repo.getSettings(), repo.listPages()]);
34
+ const draft = query.stage === 'draft';
35
+ const layouts = draft ? stagedPages(settings) : settings.sectionPages;
36
+ const theme = draft ? await stagedTheme(settings) : await getTheme(settings.activeTheme);
37
+ const byId = new Map(pages.map((p) => [p.id, p]));
38
+ const out = Object.entries(layouts)
39
+ .filter(([id, layout]) => layout && byId.has(id) && (!query.pageId || query.pageId === id))
40
+ .map(([id, layout]) => {
41
+ const p = byId.get(id)!;
42
+ const liveLayout = settings.sectionPages[id];
43
+ return {
44
+ pageId: id, title: p.title, slug: p.slug, isHome: p.isHome, locale: p.locale || null, mode: layout.mode,
45
+ sections: layout.sections.map((s) => ({
46
+ id: s.id, type: s.type, version: s.version, variant: s.variant, hidden: s.hidden, locked: Boolean(s.locked),
47
+ showFrom: s.showFrom ?? null, showUntil: s.showUntil ?? null, data: s.data, translations: s.translations ?? {},
48
+ name: theme.sections[s.type]?.name ?? s.type,
49
+ /** new on the page (not live yet): only a full design publish adds it */
50
+ isNew: !liveLayout?.sections.some((x) => x.id === s.id),
51
+ changed: draft && JSON.stringify(liveLayout?.sections.find((x) => x.id === s.id) ?? null) !== JSON.stringify(s),
52
+ editable: who.user.role === 'owner' || !s.locked,
53
+ })),
54
+ };
55
+ });
56
+ if (query.pageId && !out.length) throw new ApiError(404, 'That page has no section layout.');
57
+ return {
58
+ revision: settings.designRevision, stage: query.stage, hasDraft: Boolean(settings.draftDesign?.sectionPages),
59
+ role: who.user.role, theme: { slug: theme.slug, name: theme.name },
60
+ definitions: Object.fromEntries(Object.entries(theme.sections).map(([type, d]) => [type, {
61
+ name: d.name, version: d.version, category: d.category ?? null, description: d.description ?? '', variants: d.variants, fields: d.fields,
62
+ }])),
63
+ pages: out,
64
+ };
65
+ }
66
+
67
+ export function findSection(pages: Pages, pageId: string, sectionId: string): Instance {
68
+ const section = pages[pageId]?.sections.find((s) => s.id === sectionId);
69
+ if (!section) throw new ApiError(404, 'Section not found.');
70
+ return section;
71
+ }
72
+
73
+ /** stage one section's changed copy through the shared design rules */
74
+ export async function stageSection(repo: ContentRepository, who: EditorPrincipal, revision: number, pageId: string, sectionId: string, change: (s: Instance) => Instance, origin: string) {
75
+ const settings = await repo.getSettings();
76
+ if (settings.designRevision !== revision) throw new ApiError(409, 'Design changed. Pull the latest revision before saving.');
77
+ const pages = structuredClone(stagedPages(settings));
78
+ const layout = pages[pageId];
79
+ const index = layout?.sections.findIndex((s) => s.id === sectionId) ?? -1;
80
+ if (!layout || index < 0) throw new ApiError(404, 'Section not found.');
81
+ layout.sections[index] = change(layout.sections[index]);
82
+ const input = DesignRequest.parse({ operation: 'stage', revision, patch: { sectionPages: pages } });
83
+ const result = await runDesignOperation(repo, designIdentityFor(who), input, origin);
84
+ return { revision: result.revision, section: layout.sections[index] };
85
+ }
86
+
87
+ export async function patchSection(repo: ContentRepository, who: EditorPrincipal, pageId: string, sectionId: string, body: z.infer<typeof SectionPatch>, origin: string) {
88
+ if (!Object.keys(body.data).length) throw new ApiError(400, 'Send at least one field in data.');
89
+ return stageSection(repo, who, body.revision, pageId, sectionId, (s) => ({ ...s, data: { ...s.data, ...body.data } as Instance['data'] }), origin);
90
+ }
91
+
92
+ export async function publishSection(repo: ContentRepository, who: EditorPrincipal, pageId: string, sectionId: string, revision: number, origin: string) {
93
+ const input = DesignRequest.parse({ operation: 'publishSection', revision, pageId, sectionId });
94
+ const result = await runDesignOperation(repo, designIdentityFor(who), input, origin);
95
+ return { revision: result.revision };
96
+ }
@@ -0,0 +1,184 @@
1
+ import { Role } from '../schema';
2
+ import type { SqlDriver } from '../repo/sql';
3
+
4
+ type Row = Record<string, unknown>;
5
+
6
+ export interface EditorDevice {
7
+ id: string; userId: string; name: string; clientId: string; sessionVersion: number;
8
+ createdAt: number; lastUsedAt: number; revokedAt: number | null; revokedReason: string;
9
+ }
10
+ export interface EditorCode {
11
+ hash: string; userId: string; clientId: string; redirectUri: string; challenge: string;
12
+ deviceName: string; sessionVersion: number; createdAt: number; expiresAt: number; usedAt: number | null; deviceId: string | null;
13
+ }
14
+ /** an access token joined with its device and the user's live row */
15
+ export interface EditorAccess {
16
+ device: EditorDevice;
17
+ user: { id: string; name: string; email: string; role: Role; sessionVersion: number };
18
+ }
19
+ export interface EditorActivity { id: string; createdAt: number; userId: string; deviceId: string; action: string; target: string }
20
+
21
+ function device(r: Row): EditorDevice {
22
+ return { id: String(r.id), userId: String(r.user_id), name: String(r.name), clientId: String(r.client_id), sessionVersion: Number(r.session_version),
23
+ createdAt: Number(r.created_at), lastUsedAt: Number(r.last_used_at), revokedAt: r.revoked_at == null ? null : Number(r.revoked_at), revokedReason: String(r.revoked_reason ?? '') };
24
+ }
25
+ function code(r: Row): EditorCode {
26
+ return { hash: String(r.hash), userId: String(r.user_id), clientId: String(r.client_id), redirectUri: String(r.redirect_uri), challenge: String(r.challenge),
27
+ deviceName: String(r.device_name), sessionVersion: Number(r.session_version), createdAt: Number(r.created_at), expiresAt: Number(r.expires_at),
28
+ usedAt: r.used_at == null ? null : Number(r.used_at), deviceId: r.device_id == null ? null : String(r.device_id) };
29
+ }
30
+
31
+ // Storage for editor-app devices, tokens, one-time codes and the activity log.
32
+ // Shared by the D1 and libSQL adapters (plain SQLite). Callers pass hashes
33
+ // only; plaintext secrets never reach this layer.
34
+ export class EditorStore {
35
+ constructor(private driver: () => Promise<SqlDriver>) {}
36
+ private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
37
+ private async run(sql: string, args: unknown[] = []) { return (await this.driver()).run(sql, args); }
38
+
39
+ // ---- authorization codes ----
40
+ async createCode(c: Omit<EditorCode, 'usedAt' | 'deviceId' | 'createdAt'>, now: number) {
41
+ await this.run(`INSERT INTO editor_codes (hash,user_id,client_id,redirect_uri,challenge,device_name,session_version,created_at,expires_at,used_at,device_id)
42
+ VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)`, [c.hash, c.userId, c.clientId, c.redirectUri, c.challenge, c.deviceName, c.sessionVersion, now, c.expiresAt]);
43
+ }
44
+ /**
45
+ * Burn a code (single use, even when the rest of the exchange fails).
46
+ * `reused` = it was already spent: the caller revokes whatever it produced.
47
+ */
48
+ async consumeCode(hash: string, now: number): Promise<{ code: EditorCode; reused: boolean } | null> {
49
+ const rows = await this.all('UPDATE editor_codes SET used_at=? WHERE hash=? AND used_at IS NULL RETURNING *', [now, hash]);
50
+ if (rows[0]) return { code: code(rows[0]), reused: false };
51
+ const spent = await this.all('SELECT * FROM editor_codes WHERE hash=?', [hash]);
52
+ return spent[0] ? { code: code(spent[0]), reused: true } : null;
53
+ }
54
+ async bindCode(hash: string, deviceId: string) { await this.run('UPDATE editor_codes SET device_id=? WHERE hash=?', [deviceId, hash]); }
55
+
56
+ // ---- devices + tokens ----
57
+ /** a new device and its first token pair, atomically */
58
+ async createDevice(d: Pick<EditorDevice, 'id' | 'userId' | 'name' | 'clientId' | 'sessionVersion'>, tokens: { access: string; accessExpires: number; refresh: string; refreshExpires: number }, now: number) {
59
+ await (await this.driver()).batch([
60
+ { sql: `INSERT INTO editor_devices (id,user_id,name,client_id,session_version,created_at,last_used_at,revoked_at,revoked_reason) VALUES (?,?,?,?,?,?,?,NULL,'')`,
61
+ args: [d.id, d.userId, d.name, d.clientId, d.sessionVersion, now, now] },
62
+ ...this.tokenRows(d.id, tokens, now),
63
+ ]);
64
+ }
65
+ private tokenRows(deviceId: string, t: { access: string; accessExpires: number; refresh: string; refreshExpires: number }, now: number, parent: string | null = null) {
66
+ return [
67
+ { sql: `INSERT INTO editor_tokens (hash,device_id,kind,created_at,expires_at,used_at,parent) VALUES (?,?,'access',?,?,NULL,?)`, args: [t.access, deviceId, now, t.accessExpires, parent] },
68
+ { sql: `INSERT INTO editor_tokens (hash,device_id,kind,created_at,expires_at,used_at,parent) VALUES (?,?,'refresh',?,?,NULL,?)`, args: [t.refresh, deviceId, now, t.refreshExpires, parent] },
69
+ ];
70
+ }
71
+ /**
72
+ * Spend a refresh token. `reused` = presented after it was rotated (or two
73
+ * requests raced for it): the caller revokes the device, unless `retry`
74
+ * says it's the app retrying a refresh whose response it lost.
75
+ */
76
+ async spendRefresh(hash: string, now: number): Promise<{ deviceId: string; reused: boolean; expired: boolean; usedAt?: number } | null> {
77
+ const rows = await this.all(`UPDATE editor_tokens SET used_at=? WHERE hash=? AND kind='refresh' AND used_at IS NULL RETURNING device_id, expires_at`, [now, hash]);
78
+ if (rows[0]) return { deviceId: String(rows[0].device_id), reused: false, expired: Number(rows[0].expires_at) <= now };
79
+ const spent = await this.all(`SELECT device_id, used_at, expires_at FROM editor_tokens WHERE hash=? AND kind='refresh'`, [hash]);
80
+ return spent[0] ? { deviceId: String(spent[0].device_id), reused: true, expired: Number(spent[0].expires_at) <= now, usedAt: Number(spent[0].used_at) } : null;
81
+ }
82
+ /**
83
+ * Grace for a lost response: `hash` was rotated at most `graceMs` ago and
84
+ * the pair it produced was never used (its refresh token unspent, its
85
+ * access token never presented), so the app most likely never received
86
+ * it. Atomically retire that pair: the refresh token is marked spent (if
87
+ * it ever shows up later, that is reuse and signs the device out) and the
88
+ * access token is deleted. True when the caller may issue a new pair.
89
+ */
90
+ async retireUnusedSuccessor(hash: string, usedAt: number, now: number, graceMs: number): Promise<boolean> {
91
+ if (!(usedAt > 0) || now - usedAt > graceMs) return false;
92
+ const usedAccess = await this.all(`SELECT 1 FROM editor_tokens WHERE parent=? AND kind='access' AND used_at IS NOT NULL LIMIT 1`, [hash]);
93
+ if (usedAccess.length) return false;
94
+ const claimed = await this.all(`UPDATE editor_tokens SET used_at=? WHERE parent=? AND kind='refresh' AND used_at IS NULL RETURNING hash`, [now, hash]);
95
+ if (!claimed.length) return false;
96
+ await this.run(`DELETE FROM editor_tokens WHERE parent=? AND kind='access'`, [hash]);
97
+ return true;
98
+ }
99
+ /** rotation: a fresh pair for the device; earlier access tokens stop working */
100
+ async rotate(deviceId: string, tokens: { access: string; accessExpires: number; refresh: string; refreshExpires: number }, now: number, parent: string | null = null) {
101
+ await (await this.driver()).batch([
102
+ { sql: `DELETE FROM editor_tokens WHERE device_id=? AND kind='access'`, args: [deviceId] },
103
+ ...this.tokenRows(deviceId, tokens, now, parent),
104
+ { sql: 'UPDATE editor_devices SET last_used_at=? WHERE id=?', args: [now, deviceId] },
105
+ ]);
106
+ }
107
+ async access(hash: string, now: number): Promise<EditorAccess | null> {
108
+ const rows = await this.all(`SELECT d.*, t.used_at AS t_used, u.name AS u_name, u.email AS u_email, u.role AS u_role, u.session_version AS u_sv
109
+ FROM editor_tokens t JOIN editor_devices d ON d.id=t.device_id JOIN users u ON u.id=d.user_id
110
+ WHERE t.hash=? AND t.kind='access' AND t.expires_at>? AND d.revoked_at IS NULL`, [hash, now]);
111
+ const r = rows[0];
112
+ if (!r) return null;
113
+ // first use proves the app received this pair (ends the refresh grace)
114
+ if (r.t_used == null) await this.run('UPDATE editor_tokens SET used_at=? WHERE hash=? AND used_at IS NULL', [now, hash]);
115
+ return { device: device(r), user: { id: String(r.user_id), name: String(r.u_name), email: String(r.u_email), role: Role.parse(r.u_role), sessionVersion: Number(r.u_sv) } };
116
+ }
117
+ /** the device a token (access or refresh) belongs to, for revocation */
118
+ async deviceForToken(hash: string): Promise<string | null> {
119
+ const rows = await this.all('SELECT device_id FROM editor_tokens WHERE hash=?', [hash]);
120
+ return rows[0] ? String(rows[0].device_id) : null;
121
+ }
122
+ async getDevice(id: string): Promise<EditorDevice | null> {
123
+ const rows = await this.all('SELECT * FROM editor_devices WHERE id=?', [id]);
124
+ return rows[0] ? device(rows[0]) : null;
125
+ }
126
+ /** signed-in (not revoked) devices, newest use first; one user or everyone */
127
+ async listDevices(userId?: string): Promise<EditorDevice[]> {
128
+ const rows = userId
129
+ ? await this.all('SELECT * FROM editor_devices WHERE user_id=? AND revoked_at IS NULL ORDER BY last_used_at DESC', [userId])
130
+ : await this.all('SELECT * FROM editor_devices WHERE revoked_at IS NULL ORDER BY last_used_at DESC');
131
+ return rows.map(device);
132
+ }
133
+ async revokeDevice(id: string, reason: string, now: number) {
134
+ await (await this.driver()).batch([
135
+ { sql: 'UPDATE editor_devices SET revoked_at=?, revoked_reason=? WHERE id=? AND revoked_at IS NULL', args: [now, reason.slice(0, 100), id] },
136
+ { sql: 'DELETE FROM editor_tokens WHERE device_id=?', args: [id] },
137
+ ]);
138
+ }
139
+ async revokeUserDevices(userId: string, reason: string, now: number) {
140
+ await (await this.driver()).batch([
141
+ { sql: 'UPDATE editor_devices SET revoked_at=?, revoked_reason=? WHERE user_id=? AND revoked_at IS NULL', args: [now, reason.slice(0, 100), userId] },
142
+ { sql: 'DELETE FROM editor_tokens WHERE device_id IN (SELECT id FROM editor_devices WHERE user_id=?)', args: [userId] },
143
+ ]);
144
+ }
145
+ /** last-used bookkeeping, at most once a minute per device */
146
+ async touch(id: string, now: number) {
147
+ await this.run('UPDATE editor_devices SET last_used_at=? WHERE id=? AND last_used_at<?', [now, id, now - 60_000]);
148
+ }
149
+
150
+ // ---- save locks ----
151
+ /**
152
+ * A short per-document lock so "check the revision, then write" is atomic:
153
+ * a second save of the same document while one is in flight gets false
154
+ * (the caller answers 409). Held only during the save; a lock left by a
155
+ * crashed request expires after `ttlMs`.
156
+ */
157
+ async claim(key: string, now: number, ttlMs = 30_000): Promise<boolean> {
158
+ const rows = await this.all(`INSERT INTO editor_claims (key, created_at) VALUES (?, ?)
159
+ ON CONFLICT(key) DO UPDATE SET created_at=excluded.created_at WHERE editor_claims.created_at < ? RETURNING key`, [key.slice(0, 300), now, now - ttlMs]);
160
+ return rows.length > 0;
161
+ }
162
+ async release(key: string) { await this.run('DELETE FROM editor_claims WHERE key=?', [key.slice(0, 300)]); }
163
+
164
+ // ---- activity ----
165
+ async log(a: Omit<EditorActivity, 'id' | 'createdAt'>, now: number) {
166
+ await this.run('INSERT INTO editor_activity (id,created_at,user_id,device_id,action,target) VALUES (?,?,?,?,?,?)',
167
+ [crypto.randomUUID(), now, a.userId, a.deviceId, a.action.slice(0, 60), a.target.slice(0, 300)]);
168
+ }
169
+ async activity(deviceId: string, limit = 20): Promise<EditorActivity[]> {
170
+ const rows = await this.all('SELECT * FROM editor_activity WHERE device_id=? ORDER BY created_at DESC LIMIT ?', [deviceId, limit]);
171
+ return rows.map((r) => ({ id: String(r.id), createdAt: Number(r.created_at), userId: String(r.user_id), deviceId: String(r.device_id), action: String(r.action), target: String(r.target) }));
172
+ }
173
+
174
+ /** expired codes and tokens go; revoked devices are kept 30 days for the record */
175
+ async prune(now: number) {
176
+ await (await this.driver()).batch([
177
+ { sql: 'DELETE FROM editor_codes WHERE expires_at < ?', args: [now - 86_400_000] },
178
+ { sql: `DELETE FROM editor_tokens WHERE expires_at < ? OR (used_at IS NOT NULL AND used_at < ?)`, args: [now, now - 30 * 86_400_000] },
179
+ { sql: 'DELETE FROM editor_devices WHERE revoked_at IS NOT NULL AND revoked_at < ?', args: [now - 30 * 86_400_000] },
180
+ { sql: 'DELETE FROM editor_activity WHERE created_at < ?', args: [now - 365 * 86_400_000] },
181
+ { sql: 'DELETE FROM editor_claims WHERE created_at < ?', args: [now - 86_400_000] },
182
+ ]);
183
+ }
184
+ }