caracms 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * GitHub as the site's App (M4 Phase 1; lifted from M1's spike, `spikes/git/src/github.ts`).
3
+ *
4
+ * - **The App's JWT**: RS256, signed with the App's private key, `iat` backdated a
5
+ * minute for clock drift, nine minutes long (GitHub's limit is ten).
6
+ * - **An installation token scoped to the one repo**: the installation is found with
7
+ * `GET /repos/{owner}/{repo}/installation`, and the token is minted for that repo
8
+ * only. It's refreshed five minutes before it expires (they last an hour).
9
+ * - Every response is recorded by a Tracker, tagged with the operation it belongs to,
10
+ * so a run can be checked against the content-creating cap (80 a minute, 500 an
11
+ * hour; M1 → G4).
12
+ *
13
+ * - **Every request times out** after `timeoutMs` (20 s), its body included: a GitHub
14
+ * that stops answering fails the operation instead of holding it (and Publish's or
15
+ * the check's callers) forever.
16
+ *
17
+ * The private key is never logged.
18
+ */
19
+ import { createSign } from 'node:crypto';
20
+
21
+ const API = 'https://api.github.com';
22
+
23
+ export interface AppConnection {
24
+ appId: string;
25
+ /** The PEM. Railway variables often carry it with literal `\n`s; those are turned back into newlines. */
26
+ privateKey: string;
27
+ owner: string;
28
+ repo: string;
29
+ /** For tests. Default: global fetch. */
30
+ fetch?: typeof fetch;
31
+ /** Default: Date.now. */
32
+ now?: () => number;
33
+ /** How long one request may take, response body included, before it fails (default 20 s). */
34
+ timeoutMs?: number;
35
+ }
36
+
37
+ /** A GitHub request that takes longer than this fails (its caller treats it as any failure). */
38
+ export const GITHUB_TIMEOUT_MS = 20_000;
39
+
40
+ const b64url = (b: Buffer | string) => Buffer.from(b).toString('base64url');
41
+
42
+ /** The App's JWT. */
43
+ export function appJwt(appId: string, key: string, nowSeconds = Math.floor(Date.now() / 1000)): string {
44
+ const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
45
+ const payload = b64url(JSON.stringify({ iat: nowSeconds - 60, exp: nowSeconds + 540, iss: appId }));
46
+ const signer = createSign('RSA-SHA256');
47
+ signer.update(`${header}.${payload}`);
48
+ return `${header}.${payload}.${b64url(signer.sign(key))}`;
49
+ }
50
+
51
+ export class HttpError extends Error {
52
+ readonly status: number;
53
+ readonly body: unknown;
54
+ constructor(status: number, message: string, body: unknown) {
55
+ super(message);
56
+ this.name = 'HttpError';
57
+ this.status = status;
58
+ this.body = body;
59
+ }
60
+ }
61
+
62
+ export class GraphQLError extends Error {
63
+ readonly errors: { type?: string; message?: string }[];
64
+ constructor(errors: { type?: string; message?: string }[]) {
65
+ super(`GraphQL: ${errors.map((e) => `${e.type ?? '?'}: ${e.message}`).join(' | ')}`);
66
+ this.name = 'GraphQLError';
67
+ this.errors = errors;
68
+ }
69
+ }
70
+
71
+ /** One request, as the Tracker keeps it. */
72
+ export interface RequestRecord {
73
+ /** The operation instance: `draft #2`, `publish #1`. */
74
+ op: string;
75
+ /** Its kind: `draft`, `publish`, `merge`, `start`, `check`, `status`… */
76
+ kind: string;
77
+ /** `POST /git/commits`, `graphql createCommitOnBranch`. */
78
+ endpoint: string;
79
+ status: number;
80
+ /** Counts against GitHub's content-creating cap. */
81
+ creating: boolean;
82
+ at: number;
83
+ }
84
+
85
+ /** Is this request content-creating in GitHub's sense? */
86
+ export function isContentCreating(method: string, path: string, graphqlMutation: boolean): boolean {
87
+ if (graphqlMutation) return true;
88
+ if (method === 'GET' || method === 'HEAD') return false;
89
+ return /\/git\/(blobs|trees|commits|refs)|\/contents\/|\/merges$|\/deployments/.test(path);
90
+ }
91
+
92
+ /** Records every request against the operation running at the time. */
93
+ export class Tracker {
94
+ records: RequestRecord[] = [];
95
+ private op = 'other';
96
+ private kind = 'other';
97
+ private counters = new Map<string, number>();
98
+ private readonly now: () => number;
99
+
100
+ constructor(now: () => number = Date.now) {
101
+ this.now = now;
102
+ }
103
+
104
+ /** Run `fn` with its requests tagged as one instance of `kind` (`draft` → `draft #2`). */
105
+ async within<T>(kind: string, fn: () => Promise<T>): Promise<T> {
106
+ const prev = [this.op, this.kind] as const;
107
+ const n = (this.counters.get(kind) ?? 0) + 1;
108
+ this.counters.set(kind, n);
109
+ this.op = `${kind} #${n}`;
110
+ this.kind = kind;
111
+ try {
112
+ return await fn();
113
+ } finally {
114
+ [this.op, this.kind] = prev;
115
+ }
116
+ }
117
+
118
+ record(r: { endpoint: string; status: number; creating: boolean }) {
119
+ this.records.push({ ...r, op: this.op, kind: this.kind, at: this.now() });
120
+ if (this.records.length > 2000) this.records.splice(0, this.records.length - 2000);
121
+ }
122
+
123
+ /** Content-creating requests in the last `ms` (the cap is 80 a minute, 500 an hour). */
124
+ creatingWithin(ms: number): number {
125
+ const since = this.now() - ms;
126
+ return this.records.filter((r) => r.creating && r.at >= since).length;
127
+ }
128
+ }
129
+
130
+ /** `/git/commits/abc…` → `/git/commits/:sha`, so a table groups by endpoint. */
131
+ function shorten(p: string): string {
132
+ return p
133
+ .replace(/\b[0-9a-f]{40}\b/g, ':sha')
134
+ .replace(/\/compare\/.*$/, '/compare/:range')
135
+ .replace(/\/git\/refs\/heads\/.*$/, '/git/refs/heads/:branch')
136
+ .replace(/\/git\/ref\/heads\/.*$/, '/git/ref/heads/:branch')
137
+ .replace(/\/tarball\/.*$/, '/tarball/:ref')
138
+ .replace(/\/deployments\/\d+/, '/deployments/:id')
139
+ .replace(/\/contents\/.*$/, '/contents/:path');
140
+ }
141
+
142
+ export class GitHubClient {
143
+ readonly tracker: Tracker;
144
+ readonly owner: string;
145
+ readonly repo: string;
146
+ private readonly appId: string;
147
+ private readonly key: string;
148
+ private readonly fetcher: typeof fetch;
149
+ private readonly now: () => number;
150
+ private readonly timeoutMs: number;
151
+ private token = '';
152
+ private tokenExpires = 0;
153
+ private signingIn: Promise<void> | null = null;
154
+
155
+ constructor(connection: AppConnection, tracker: Tracker) {
156
+ this.appId = connection.appId;
157
+ this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
158
+ this.owner = connection.owner;
159
+ this.repo = connection.repo;
160
+ this.fetcher = connection.fetch ?? fetch;
161
+ this.now = connection.now ?? Date.now;
162
+ this.timeoutMs = connection.timeoutMs ?? GITHUB_TIMEOUT_MS;
163
+ this.tracker = tracker;
164
+ }
165
+
166
+ get nameWithOwner() {
167
+ return `${this.owner}/${this.repo}`;
168
+ }
169
+
170
+ repoPath(p = ''): string {
171
+ return `/repos/${this.owner}/${this.repo}${p}`;
172
+ }
173
+
174
+ /** Find the App's installation on the repo and mint a token for that repo only. */
175
+ private async signIn(): Promise<void> {
176
+ const jwt = appJwt(this.appId, this.key, Math.floor(this.now() / 1000));
177
+ const install = (await this.request('GET', this.repoPath('/installation'), undefined, jwt).catch((error: unknown) => {
178
+ if (error instanceof HttpError && error.status === 404) {
179
+ throw new Error(`The GitHub App (id ${this.appId}) isn't installed on ${this.nameWithOwner}. Install it on that repo.`);
180
+ }
181
+ throw error;
182
+ })) as { id: number };
183
+ const tok = (await this.request('POST', `/app/installations/${install.id}/access_tokens`, { repositories: [this.repo] }, jwt, true)) as {
184
+ token: string;
185
+ expires_at: string;
186
+ };
187
+ this.token = tok.token;
188
+ this.tokenExpires = Date.parse(tok.expires_at);
189
+ }
190
+
191
+ /** The installation token, refreshed five minutes before it expires. */
192
+ private async auth(): Promise<string> {
193
+ if (!this.token || this.now() > this.tokenExpires - 5 * 60_000) {
194
+ this.signingIn ??= this.signIn().finally(() => {
195
+ this.signingIn = null;
196
+ });
197
+ await this.signingIn;
198
+ }
199
+ return this.token;
200
+ }
201
+
202
+ async rest<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
203
+ return (await this.request(method, path, body, await this.auth())) as T;
204
+ }
205
+
206
+ /** A GET whose body is bytes (the tarball). Follows GitHub's redirect to codeload. */
207
+ async bytes(path: string): Promise<Buffer> {
208
+ const res = await this.fetcher(`${API}${path}`, { headers: this.headers(await this.auth()), redirect: 'follow', signal: this.signal() });
209
+ this.tracker.record({ endpoint: `GET ${shorten(path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, ''))}`, status: res.status, creating: false });
210
+ if (!res.ok) throw new HttpError(res.status, `GET ${path} -> ${res.status}`, await res.text().catch(() => ''));
211
+ return Buffer.from(await res.arrayBuffer());
212
+ }
213
+
214
+ /** Aborts the request (and the reading of its body) after `timeoutMs`. */
215
+ private signal(): AbortSignal {
216
+ return AbortSignal.timeout(this.timeoutMs);
217
+ }
218
+
219
+ private headers(bearer: string, json = false): Record<string, string> {
220
+ return {
221
+ authorization: `Bearer ${bearer}`,
222
+ accept: 'application/vnd.github+json',
223
+ 'x-github-api-version': '2022-11-28',
224
+ 'user-agent': 'caracms',
225
+ ...(json ? { 'content-type': 'application/json' } : {}),
226
+ };
227
+ }
228
+
229
+ private async request(method: string, path: string, body: unknown, bearer: string, notCreating = false): Promise<unknown> {
230
+ const res = await this.fetcher(`${API}${path}`, {
231
+ method,
232
+ headers: this.headers(bearer, body !== undefined),
233
+ body: body !== undefined ? JSON.stringify(body) : undefined,
234
+ signal: this.signal(),
235
+ });
236
+ const endpointPath = path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, '');
237
+ this.tracker.record({ endpoint: `${method} ${shorten(endpointPath)}`, status: res.status, creating: !notCreating && isContentCreating(method, path, false) });
238
+ const text = await res.text();
239
+ let json: unknown = null;
240
+ try {
241
+ json = text ? JSON.parse(text) : null;
242
+ } catch {
243
+ json = text;
244
+ }
245
+ if (!res.ok) {
246
+ const message = (json as { message?: string } | null)?.message ?? text.slice(0, 200);
247
+ throw new HttpError(res.status, `${method} ${endpointPath} -> ${res.status}: ${message}`, json);
248
+ }
249
+ return json;
250
+ }
251
+
252
+ /** GraphQL as the installation. Throws GraphQLError when the answer has `errors`. */
253
+ async graphql<T = unknown>(query: string, variables: Record<string, unknown>, name: string): Promise<T> {
254
+ const isMutation = /^\s*mutation\b/.test(query);
255
+ const res = await this.fetcher(`${API}/graphql`, {
256
+ method: 'POST',
257
+ // updateRefs worked with this preview header in M1 (G2); whether it still needs it is untested (G6 (f)).
258
+ headers: { ...this.headers(await this.auth(), true), accept: 'application/vnd.github.update-refs-preview+json, application/json' },
259
+ body: JSON.stringify({ query, variables }),
260
+ signal: this.signal(),
261
+ });
262
+ this.tracker.record({ endpoint: `graphql ${name}`, status: res.status, creating: isMutation });
263
+ const json = (await res.json().catch((error: unknown) => {
264
+ if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) throw error; // timed out mid-body
265
+ return {};
266
+ })) as { data?: T; errors?: { type?: string; message?: string }[] };
267
+ if (!res.ok && !json.errors) throw new HttpError(res.status, `graphql ${name} -> ${res.status}`, json);
268
+ if (json.errors?.length) throw new GraphQLError(json.errors);
269
+ return json.data as T;
270
+ }
271
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The signed-in person, handed from the sign-in guard to the editor's routes
3
+ * (M4 Phases 2 and 3). The guard (Phase 3's middleware) checks the session on every
4
+ * `/admin` and `/_caracms/` request in live mode and, when it passes, records the
5
+ * person here; a route reads it back. In local mode nothing is recorded: there's no
6
+ * sign-in (Decision 1).
7
+ *
8
+ * It rides on Astro's `context.locals`, under one key, so the package needs no
9
+ * global `App.Locals` declaration in the site.
10
+ */
11
+ import type { Person } from './messages.ts';
12
+
13
+ const KEY = '__caracms_person';
14
+
15
+ type Locals = object;
16
+
17
+ export function setPerson(locals: Locals, person: Person): void {
18
+ (locals as Record<string, unknown>)[KEY] = person;
19
+ }
20
+
21
+ /** The person the guard let through, or null (local mode, or no guard ran). */
22
+ export function personOf(locals: Locals): Person | null {
23
+ return ((locals as Record<string, unknown>)[KEY] as Person | undefined) ?? null;
24
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Locks and presence (M4 Decision 9).
3
+ *
4
+ * **A lock belongs to a tab**, held for a person: `caracms:lock:<file>` →
5
+ * `{ tab, person, since, beat, releasing }`. Every change to it is a compare-and-swap
6
+ * on the text read, so two servers (a deploy) and two tabs can't both win.
7
+ *
8
+ * - **The first write takes it, atomically** (`acquire`); browsing never locks. A write
9
+ * from a tab that doesn't hold it is refused (the route's 409).
10
+ * - **The heartbeat** rides on the status poll (`beat`), and on every write. A tab back
11
+ * from hidden re-takes its own lapsed lock on its first poll, if nobody took it.
12
+ * - **The lapse comes only after the autosave is committed**: a lock whose heartbeat
13
+ * is older than `lockLapseMs` is still held while its document has an autosave.
14
+ * The sweep commits first; the lock is then free.
15
+ * - **Releasing** (`X-Caracms-Release: 1` on the last write, or a lone release): the lock
16
+ * still accepts that tab's writes, and drops once the document has no autosave. A
17
+ * short grace keeps it while no autosave has arrived yet, because a closing tab's
18
+ * two keepalive requests come in no set order (a release can overtake the last write).
19
+ * - **Edit here** (`take`) moves your own lock to this tab at once.
20
+ * - **Take over** (`takeOver`) moves anyone's lock to this tab at once. The route then
21
+ * commits the old holder's autosave under their name; their tab goes read-only.
22
+ * - **Idle** tabs stop renewing: the status route beats only for a tab someone typed,
23
+ * clicked or scrolled in within `lockIdleMs`, so a forgotten tab lapses by itself.
24
+ *
25
+ * Nothing here drops a lock key on a timer: whether a lock still holds is worked out
26
+ * when it's read (`holds`), so no process has to be running for a lock to lapse,
27
+ * and Redis gets no traffic for it. The sweep deletes the keys it can.
28
+ *
29
+ * **Presence** (`caracms:presence`, a hash by tab): each poll records the person and the
30
+ * document their tab has open; someone is "on" a document while a tab of theirs
31
+ * polled with it in the last `presenceMs`.
32
+ */
33
+ import type { LockState, LockView, PresenceView } from '../editor/types.ts';
34
+ import type { Person } from './messages.ts';
35
+ import type { RedisClient } from './redis.ts';
36
+ import type { LiveTimings } from './timings.ts';
37
+
38
+ export interface LockRecord {
39
+ tab: string;
40
+ person: Person;
41
+ /** ms: when it was taken. */
42
+ since: number;
43
+ /** ms: the last heartbeat (a poll or a write). */
44
+ beat: number;
45
+ /** ms: when the tab said it was closing; null while it isn't. */
46
+ releasing: number | null;
47
+ /** The document was deleted (M5a): the tab's own polls don't clear `releasing`, so the lock drops once the removal is committed. */
48
+ deleted?: true;
49
+ }
50
+
51
+ interface ReadLock {
52
+ raw: string;
53
+ lock: LockRecord;
54
+ }
55
+
56
+ interface PresenceRecord {
57
+ person: Person;
58
+ file: string | null;
59
+ at: number;
60
+ }
61
+
62
+ /** How long a releasing lock with no autosave yet waits for the tab's last write. */
63
+ export const RELEASE_GRACE_MS = 5_000;
64
+
65
+ const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.toLowerCase();
66
+
67
+ export type AcquireResult = { ok: true; lock: LockRecord } | { ok: false; lock: LockRecord };
68
+
69
+ export class Locks {
70
+ readonly redis: RedisClient;
71
+ readonly timings: LiveTimings;
72
+ readonly now: () => number;
73
+ readonly prefix: string;
74
+ /** Whether the document has an autosave not yet committed. */
75
+ private readonly pending: (file: string) => Promise<boolean>;
76
+
77
+ constructor(redis: RedisClient, timings: LiveTimings, now: () => number, pending: (file: string) => Promise<boolean>, prefix = 'caracms:') {
78
+ this.redis = redis;
79
+ this.timings = timings;
80
+ this.now = now;
81
+ this.pending = pending;
82
+ this.prefix = prefix;
83
+ }
84
+
85
+ key = (file: string) => `${this.prefix}lock:${file}`;
86
+ private presenceKey = () => `${this.prefix}presence`;
87
+
88
+ private parse(raw: string | null): ReadLock | null {
89
+ if (raw === null) return null;
90
+ try {
91
+ const lock = JSON.parse(raw) as LockRecord;
92
+ return lock && typeof lock.tab === 'string' && lock.person ? { raw, lock } : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ async read(file: string): Promise<ReadLock | null> {
99
+ return this.parse(await this.redis.get(this.key(file)));
100
+ }
101
+
102
+ /** Every lock key: one SCAN and one MGET. */
103
+ async all(): Promise<Map<string, ReadLock>> {
104
+ const keys = await this.redis.scan(`${this.prefix}lock:*`);
105
+ const out = new Map<string, ReadLock>();
106
+ if (keys.length === 0) return out;
107
+ const raws = await this.redis.mget(keys);
108
+ keys.forEach((k, i) => {
109
+ const read = this.parse(raws[i] ?? null);
110
+ if (read) out.set(k.slice(this.key('').length), read);
111
+ });
112
+ return out;
113
+ }
114
+
115
+ /** Does this lock still hold? `pending`: the document has an uncommitted autosave. */
116
+ holds(lock: LockRecord, pending: boolean): boolean {
117
+ if (pending) return true; // commit first, then release
118
+ const now = this.now();
119
+ if (lock.releasing !== null) return now - lock.releasing < Math.min(RELEASE_GRACE_MS, this.timings.lockLapseMs);
120
+ return now - lock.beat < this.timings.lockLapseMs;
121
+ }
122
+
123
+ private async live(read: ReadLock | null, file: string): Promise<boolean> {
124
+ return read !== null && this.holds(read.lock, await this.pending(file));
125
+ }
126
+
127
+ private put(file: string, expected: string | null, lock: LockRecord): Promise<boolean> {
128
+ return this.redis.compareAndSet(this.key(file), expected, JSON.stringify(lock), { px: this.timings.autosaveTtlMs });
129
+ }
130
+
131
+ private static mine(lock: LockRecord, tab: string, person: Person) {
132
+ return lock.tab === tab && sameEmail(lock.person, person);
133
+ }
134
+
135
+ /**
136
+ * A write's lock (Decision 9): the tab's own lock is renewed (a releasing one stays
137
+ * releasing); a free or lapsed one is taken, atomically; anyone else's refuses.
138
+ */
139
+ async acquire(file: string, tab: string, person: Person): Promise<AcquireResult> {
140
+ for (let attempt = 0; attempt < 5; attempt++) {
141
+ const cur = await this.read(file);
142
+ const now = this.now();
143
+ if (cur && Locks.mine(cur.lock, tab, person)) {
144
+ // A tab that deleted the document and now writes to it again (a re-create) is back: no longer releasing.
145
+ const { deleted: _deleted, ...kept } = cur.lock;
146
+ const next: LockRecord = { ...kept, person, beat: now, ...(cur.lock.deleted ? { releasing: null } : {}) };
147
+ if (await this.put(file, cur.raw, next)) return { ok: true, lock: next };
148
+ continue;
149
+ }
150
+ if (cur && (await this.live(cur, file))) return { ok: false, lock: cur.lock };
151
+ const next: LockRecord = { tab, person, since: now, beat: now, releasing: null };
152
+ if (await this.put(file, cur?.raw ?? null, next)) return { ok: true, lock: next };
153
+ }
154
+ const cur = await this.read(file);
155
+ if (cur) return { ok: false, lock: cur.lock };
156
+ throw new Error('caracms: the lock kept changing under the write.');
157
+ }
158
+
159
+ /**
160
+ * The status poll's heartbeat: renew the tab's own lock and clear "releasing" (the tab
161
+ * is still here), except for a lock the tab's own delete released. A lapsed lock that still names this tab is re-taken (a tab back
162
+ * from hidden). Nothing is taken that the tab never held.
163
+ */
164
+ async beat(file: string, tab: string, person: Person): Promise<ReadLock | null> {
165
+ for (let attempt = 0; attempt < 3; attempt++) {
166
+ const cur = await this.read(file);
167
+ if (!cur || !Locks.mine(cur.lock, tab, person)) return cur;
168
+ // A poll in flight when the tab deleted the document must not un-release its lock (M5a).
169
+ const next = { ...cur.lock, beat: this.now(), releasing: cur.lock.deleted ? cur.lock.releasing : null };
170
+ const raw = JSON.stringify(next);
171
+ if (await this.redis.compareAndSet(this.key(file), cur.raw, raw, { px: this.timings.autosaveTtlMs })) return { raw, lock: next };
172
+ }
173
+ return this.read(file);
174
+ }
175
+
176
+ /** Edit here: move your own lock to this tab at once, or take a free one. Someone else's refuses. */
177
+ async take(file: string, tab: string, person: Person): Promise<AcquireResult> {
178
+ for (let attempt = 0; attempt < 5; attempt++) {
179
+ const cur = await this.read(file);
180
+ if (cur && !sameEmail(cur.lock.person, person) && (await this.live(cur, file))) return { ok: false, lock: cur.lock };
181
+ const now = this.now();
182
+ const next: LockRecord = { tab, person, since: cur && Locks.mine(cur.lock, tab, person) ? cur.lock.since : now, beat: now, releasing: null };
183
+ if (await this.put(file, cur?.raw ?? null, next)) return { ok: true, lock: next };
184
+ }
185
+ throw new Error('caracms: the lock kept changing under Edit here.');
186
+ }
187
+
188
+ /**
189
+ * Take over: move the lock to this tab whoever holds it, at once. Returns the lock
190
+ * it replaced (null if it was free or lapsed), so the route can commit their autosave.
191
+ */
192
+ async takeOver(file: string, tab: string, person: Person): Promise<{ lock: LockRecord; from: LockRecord | null }> {
193
+ for (let attempt = 0; attempt < 5; attempt++) {
194
+ const cur = await this.read(file);
195
+ const now = this.now();
196
+ const next: LockRecord = { tab, person, since: now, beat: now, releasing: null };
197
+ if (await this.put(file, cur?.raw ?? null, next)) return { lock: next, from: cur && (await this.live(cur, file)) ? cur.lock : null };
198
+ }
199
+ throw new Error('caracms: the lock kept changing under Take over.');
200
+ }
201
+
202
+ /**
203
+ * The tab is closing: mark its lock releasing. Another tab's lock is left alone.
204
+ * `deleted`: the tab deleted the document, so its later polls leave the lock releasing (`beat`).
205
+ */
206
+ async release(file: string, tab: string, person: Person, options: { deleted?: boolean } = {}): Promise<ReadLock | null> {
207
+ for (let attempt = 0; attempt < 3; attempt++) {
208
+ const cur = await this.read(file);
209
+ if (!cur || !Locks.mine(cur.lock, tab, person)) return cur;
210
+ if (cur.lock.releasing !== null && (!options.deleted || cur.lock.deleted)) return cur;
211
+ const next: LockRecord = { ...cur.lock, releasing: cur.lock.releasing ?? this.now(), ...(options.deleted ? { deleted: true as const } : {}) };
212
+ const raw = JSON.stringify(next);
213
+ if (await this.put(file, cur.raw, next)) return { raw, lock: next };
214
+ }
215
+ return this.read(file);
216
+ }
217
+
218
+ /** Delete a lock that no longer holds (the sweep, after a commit). */
219
+ async dropIfFree(file: string): Promise<void> {
220
+ const cur = await this.read(file);
221
+ if (cur && !(await this.live(cur, file))) await this.redis.compareAndDelete(this.key(file), cur.raw);
222
+ }
223
+
224
+ view(file: string, lock: LockRecord, tab: string | null, person: Person): LockView {
225
+ return {
226
+ file,
227
+ person: lock.person,
228
+ since: new Date(lock.since).toISOString(),
229
+ releasing: lock.releasing !== null,
230
+ mine: !sameEmail(lock.person, person) ? null : lock.tab === tab ? 'this-tab' : 'other-tab',
231
+ };
232
+ }
233
+
234
+ static state(view: LockView | null): LockState {
235
+ if (!view) return 'free';
236
+ return view.mine === 'this-tab' ? 'yours' : view.mine === 'other-tab' ? 'yours-elsewhere' : 'theirs';
237
+ }
238
+
239
+ // ── Presence ────────────────────────────────────────────────────────────────
240
+
241
+ /** Record a poll: this tab, this person, this document (or none). */
242
+ async present(tab: string, person: Person, file: string | null): Promise<void> {
243
+ const record: PresenceRecord = { person, file, at: this.now() };
244
+ await this.redis.hset(this.presenceKey(), { [tab]: JSON.stringify(record) });
245
+ }
246
+
247
+ /** Who is on which document now (polled with it in the last `presenceMs`). Long-gone tabs are pruned. */
248
+ async presence(): Promise<PresenceView[]> {
249
+ const all = await this.redis.hgetall(this.presenceKey());
250
+ const now = this.now();
251
+ const byFile = new Map<string, Person[]>();
252
+ const gone: string[] = [];
253
+ for (const [tab, raw] of Object.entries(all)) {
254
+ let r: PresenceRecord;
255
+ try {
256
+ r = JSON.parse(raw) as PresenceRecord;
257
+ } catch {
258
+ gone.push(tab);
259
+ continue;
260
+ }
261
+ if (now - r.at > this.timings.presenceMs) {
262
+ if (now - r.at > this.timings.lockLapseMs) gone.push(tab);
263
+ continue;
264
+ }
265
+ if (!r.file) continue;
266
+ const people = byFile.get(r.file) ?? [];
267
+ if (!people.some((p) => sameEmail(p, r.person))) people.push(r.person);
268
+ byFile.set(r.file, people);
269
+ }
270
+ if (gone.length) await this.redis.hdel(this.presenceKey(), ...gone);
271
+ return [...byFile].map(([file, people]) => ({ file, people }));
272
+ }
273
+ }