caracms 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +65 -20
  6. package/bin/templates/catch-all.astro.txt +3 -2
  7. package/package.json +1 -1
  8. package/skills/caracms-adopt/SKILL.md +17 -3
  9. package/skills/caracms-adopt/look-same.mjs +24 -11
  10. package/skills/caracms-blocks/SKILL.md +69 -4
  11. package/skills/caracms-content/SKILL.md +2 -1
  12. package/skills/caracms-tina/SKILL.md +431 -0
  13. package/skills/caracms-tina/field-kinds.md +88 -0
  14. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  15. package/src/block-runs.ts +84 -0
  16. package/src/cli/validate.ts +10 -5
  17. package/src/components/BlockRunTree.astro +45 -0
  18. package/src/components/Blocks.astro +20 -1
  19. package/src/components/Form.astro +76 -10
  20. package/src/components/Head.astro +4 -2
  21. package/src/components/Media.astro +27 -1
  22. package/src/components/RichText.astro +12 -4
  23. package/src/components/Text.astro +32 -9
  24. package/src/components/form-client.ts +7 -4
  25. package/src/components/sitemap.xml.ts +2 -1
  26. package/src/config.ts +32 -1
  27. package/src/content-schema.ts +7 -4
  28. package/src/content.ts +28 -0
  29. package/src/document.ts +15 -2
  30. package/src/editor/client/block-list/summary.ts +7 -1
  31. package/src/editor/client/editor.ts +24 -6
  32. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  33. package/src/editor/client/fields/LinkField.tsx +17 -12
  34. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  35. package/src/editor/client/fields/RichTextField.tsx +38 -32
  36. package/src/editor/client/fields/host.tsx +5 -0
  37. package/src/editor/client/fields/parts.tsx +2 -1
  38. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  39. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  40. package/src/editor/client/live/publish.tsx +20 -10
  41. package/src/editor/client/nav/DocRow.tsx +6 -0
  42. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  43. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  44. package/src/editor/client/pane/parts.tsx +34 -2
  45. package/src/editor/client/preview/driver.ts +5 -5
  46. package/src/editor/client/preview/field-text.ts +40 -2
  47. package/src/editor/client/preview/page-edit.ts +2 -2
  48. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  49. package/src/editor/client/preview/page-editor.ts +3 -3
  50. package/src/editor/client/state/document-store.ts +20 -2
  51. package/src/editor/client/state/keeper.ts +51 -6
  52. package/src/editor/client/state/live.ts +45 -6
  53. package/src/editor/middleware.ts +3 -2
  54. package/src/editor/routes/try-again.ts +2 -1
  55. package/src/editor/server/admin-props.ts +2 -1
  56. package/src/editor/server/mode.ts +13 -3
  57. package/src/editor/styles/editor.css +2 -0
  58. package/src/editor/styles/levels/live.css +1 -1
  59. package/src/editor/types.ts +27 -6
  60. package/src/fields.ts +63 -15
  61. package/src/files-route.ts +9 -4
  62. package/src/forms/endpoint.ts +11 -16
  63. package/src/forms/rules.ts +9 -1
  64. package/src/head.ts +17 -4
  65. package/src/index.ts +53 -7
  66. package/src/links.ts +35 -0
  67. package/src/live/README.md +5 -2
  68. package/src/live/auth/auth.ts +4 -1
  69. package/src/live/auth/guard.ts +3 -2
  70. package/src/live/auth/index.ts +6 -7
  71. package/src/live/auth/login.ts +17 -8
  72. package/src/live/auth/middleware.ts +7 -3
  73. package/src/live/auth/request.ts +92 -11
  74. package/src/live/auth/views.ts +9 -4
  75. package/src/live/autosaves.ts +59 -16
  76. package/src/live/backend.ts +54 -24
  77. package/src/live/branches.ts +19 -0
  78. package/src/live/drafts-store.ts +4 -3
  79. package/src/live/editor.ts +92 -67
  80. package/src/live/git.ts +112 -34
  81. package/src/live/github.ts +128 -13
  82. package/src/live/locks.ts +92 -26
  83. package/src/live/mode.ts +177 -18
  84. package/src/live/notices.ts +100 -0
  85. package/src/live/redis.ts +161 -39
  86. package/src/live/runtime.ts +15 -4
  87. package/src/live/status-cache.ts +219 -0
  88. package/src/live/status.ts +14 -2
  89. package/src/live/timings.ts +24 -0
  90. package/src/media.ts +40 -7
  91. package/src/read.ts +2 -1
  92. package/src/rich-text.ts +30 -5
  93. package/src/serialize.ts +10 -1
@@ -6,20 +6,53 @@
6
6
  const first = (value: string | null) => (value ?? '').split(',')[0]!.trim();
7
7
 
8
8
  /**
9
- * The client's IP, which keys the per-IP rate limits (the template's `clientIp()`,
10
- * `docs/admin-login.md` → Rate limits and the client IP). In order:
9
+ * The header `caracms start`'s gate sets on every request, replacing any copy the request
10
+ * brought (runs-anywhere Decision 8, the spec's Contracts): the visitor's IP, decided from
11
+ * the one source `CARACMS_CLIENT_IP_HEADER` names.
12
+ */
13
+ export const CLIENT_IP_HEADER = 'x-caracms-client-ip';
14
+
15
+ /**
16
+ * The mark `caracms start` puts on its own process before importing the built site, which
17
+ * runs in that same process (`bin/gate.mjs` → `GATED_MARK`, `markProcessGated()`):
18
+ * `globalThis[Symbol.for('caracms.gated')] === true`. It's what makes the header above
19
+ * trustworthy: with the mark there, every request reached the site through the gate, which
20
+ * replaced any copy the visitor sent. Without it (`node dist/server/entry.mjs`), nothing
21
+ * replaced it, so the header is the visitor's to choose and is never read.
22
+ */
23
+ export const GATED_MARK = Symbol.for('caracms.gated');
24
+
25
+ /** Whether this process was started by `caracms start` (the mark above). */
26
+ export const isGatedProcess = (): boolean => (globalThis as Record<symbol, unknown>)[GATED_MARK] === true;
27
+
28
+ /** The 503 on sign-in, forms and downloads of a production server with no gate (Decision 8; acceptance check 17). */
29
+ export const NO_GATE_MESSAGE =
30
+ "This site wasn't started with `caracms start`, so it can't tell its visitors apart for the limits on signing in, forms and downloads. A developer can start it with `caracms start` (the site's start script).";
31
+
32
+ /**
33
+ * The client's IP, which keys the per-IP rate limits (sign-in, forms, downloads).
11
34
  *
12
- * 1. **`X-Real-IP`.** Railway's edge overwrites it with the address that connected, so
13
- * behind Railway it's the one value the client can't choose.
14
- * 2. The socket's address as the adapter gives it (`clientAddress`).
15
- * 3. The first `X-Forwarded-For` hop, only when there's nothing else. Never ahead of
16
- * the socket: the client writes the leftmost hop, so keyed on it an attacker could
17
- * rotate past every per-IP limit.
35
+ * **In production, only `x-caracms-client-ip`**, which `caracms start`'s gate sets on
36
+ * every request from the one trusted source, and only in a process the gate marked
37
+ * (`GATED_MARK`). Null when the mark is missing, whatever the request carries: the server
38
+ * wasn't started by `caracms start`, so a header there is one the visitor sent. Null too
39
+ * when the header is absent. Either way the caller answers 503 with `NO_GATE_MESSAGE`.
40
+ * Nothing else on the request is trusted there: Astro's Node adapter reports the first
41
+ * `X-Forwarded-For` entry as `clientAddress` whenever the Host is trusted, which caracms
42
+ * always arranges for its own domain, so the adapter's address is the visitor's to choose
43
+ * (`node_modules/astro/dist/core/app/node.js`), and `X-Real-IP` is trustworthy only behind
44
+ * a proxy that overwrites it.
18
45
  *
19
- * Off Railway (dev, tests) nothing proxies, so a spoofed `X-Real-IP` only rate-limits
20
- * the spoofer's own requests: accepted, as in the template.
46
+ * **Under `astro dev`** (no gate, and nothing to protect), as before: `X-Real-IP`, then
47
+ * the socket's address as the adapter gives it, then the first `X-Forwarded-For` hop.
48
+ * A spoofed one only rate-limits the spoofer's own requests.
21
49
  */
22
- export function clientIp(request: Request, clientAddress: () => string | undefined): string {
50
+ export function clientIp(request: Request, clientAddress: () => string | undefined, production = false): string | null {
51
+ if (production) {
52
+ if (!isGatedProcess()) return null;
53
+ const gated = request.headers.get(CLIENT_IP_HEADER)?.trim();
54
+ return gated ? gated.slice(0, 64) : null;
55
+ }
23
56
  const real = first(request.headers.get('x-real-ip'));
24
57
  if (real) return real.slice(0, 64);
25
58
  let socket: string | undefined;
@@ -53,4 +86,52 @@ export function isSameOrigin(request: Request): boolean {
53
86
  }
54
87
  }
55
88
 
89
+ /**
90
+ * Runs-anywhere Decision 7: the sentence for a site reached at an address that isn't its
91
+ * own, naming SITE_URL. The likeliest setup mistake: SITE_URL at www while people use the
92
+ * apex (or the other way round), or set differently at build and at run time.
93
+ */
94
+ export function addressMismatchMessage(reached: string, address: string | null): string {
95
+ return address
96
+ ? `This site's address is set to ${address} (SITE_URL), but it was opened at ${reached}, so signing in and saving are refused here. Open it at ${address}, or set SITE_URL to ${reached} where the site is built and where it runs.`
97
+ : `This site's address isn't set, so signing in and saving are refused. Set SITE_URL to ${reached} where the site is built and where it runs.`;
98
+ }
99
+
100
+ /** The address the visitor typed (the proxy's forwarded host, else Host), when it isn't the one Astro read the request as. */
101
+ export function reachedElsewhere(request: Request): string | null {
102
+ const host = first(request.headers.get('x-forwarded-host')) || first(request.headers.get('host'));
103
+ if (!host) return null;
104
+ const url = new URL(request.url);
105
+ if (host === url.host) return null;
106
+ const proto = first(request.headers.get('x-forwarded-proto')) || url.protocol.replace(':', '');
107
+ return `${proto}://${host}`;
108
+ }
109
+
110
+ /**
111
+ * For a write refused by `isSameOrigin`: the mismatch sentence when the browser was on
112
+ * this site all along (it says `Sec-Fetch-Site: same-origin`, or its `Origin` is the
113
+ * request's own Host) but Astro, trusting only the site's address, read the request as
114
+ * another host. Null for a real cross-site request.
115
+ */
116
+ export function addressMismatch(request: Request, address: string | null): string | null {
117
+ let origin: URL;
118
+ try {
119
+ origin = new URL(request.headers.get('origin') ?? '');
120
+ } catch {
121
+ return null;
122
+ }
123
+ if (origin.host === new URL(request.url).host) return null;
124
+ const fetchSite = request.headers.get('sec-fetch-site');
125
+ const host = first(request.headers.get('x-forwarded-host')) || first(request.headers.get('host'));
126
+ if (fetchSite !== 'same-origin' && !(!fetchSite && origin.host === host)) return null;
127
+ return addressMismatchMessage(origin.origin, address);
128
+ }
129
+
130
+ /** On a page with no `Origin` (the sign-in form's GET): the sentence when the site is opened at an address that isn't its own. */
131
+ export function addressNotice(request: Request, address: string | null): string | null {
132
+ const reached = reachedElsewhere(request);
133
+ if (!reached || (address && new URL(address).host === new URL(reached).host)) return null;
134
+ return addressMismatchMessage(reached, address);
135
+ }
136
+
56
137
  export const isRead = (method: string) => method === 'GET' || method === 'HEAD';
@@ -301,9 +301,9 @@ const FINE = html`<p class="fine">Not you? Close this tab.</p>`;
301
301
  const backupLink = (next: string) => html`<p class="si-alt"><a class="linkbtn" href="${loginHref({ backup: '1' }, next)}">Use the backup account</a></p>`;
302
302
 
303
303
  /** The email form: plain, signed out, or with the malformed-address error. */
304
- export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean }): SafeHtml {
304
+ export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean; notice?: string | null }): SafeHtml {
305
305
  const bad = !!input.invalid;
306
- return html`<section class="si-card">${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
306
+ return html`<section class="si-card">${addressNoticeHtml(input.notice)}${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
307
307
  bad ? html`<p class="field-err" id="si-err">${ic('warn', 'i-sm')}<span>${input.invalid}</span></p>` : ''
308
308
  }</div><button class="btn accent block" type="submit">Send sign-in link</button></form></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
309
309
  }
@@ -317,9 +317,14 @@ export function sentView(input: { email: string; next: string; again: boolean; b
317
317
  return html`<section class="si-card">${input.again ? html`<p class="notice ok" role="status">${ic('check', 'i-sm')}Sent again.</p>` : ''}<h1>Check your email</h1><p>If <strong>${input.email}</strong> can sign in, a link is on its way. It expires in 15 minutes.</p><p class="fine">Not there? Check spam, or send again.</p><div class="row-acts"><form method="post" action="${LOGIN_PATH}"><input type="hidden" name="email" value="${input.email}"><input type="hidden" name="next" value="${input.next}"><input type="hidden" name="again" value="1"><button class="btn" type="submit">Send again</button></form><a class="linkbtn" href="${loginHref({}, input.next)}">Use a different address</a></div></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
318
318
  }
319
319
 
320
+ /** Runs-anywhere Decision 7: the site was opened at an address that isn't its own (request.ts → `addressNotice`). */
321
+ function addressNoticeHtml(notice: string | null | undefined): SafeHtml | string {
322
+ return notice ? html`<p class="notice fail" role="alert" data-notice="address">${ic('warn', 'i-sm')}${notice}</p>` : '';
323
+ }
324
+
320
325
  /** The backup form. Posting it without script only shows it again. */
321
- export function backupView(input: { next: string }): SafeHtml {
322
- return html`<section class="si-card"><p class="notice fail" id="signin-error" role="alert" hidden></p><h1>Backup account</h1><p class="sub">Details are in the shared password manager.</p>${NOSCRIPT}<form method="post" action="${loginHref({ backup: '1' }, input.next)}" data-signin="backup" data-next="${input.next}"><div class="field"><label class="field-label" for="si-user">Username</label><input class="input" id="si-user" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" autofocus required></div><div class="field"><label class="field-label" for="si-pass">Password</label><input class="input" id="si-pass" name="password" type="password" autocomplete="current-password" required></div><button class="btn accent block" type="submit">Sign in</button></form></section><p class="si-alt"><a class="linkbtn" href="${loginHref({}, input.next)}">Use email sign-in instead</a></p>`;
326
+ export function backupView(input: { next: string; notice?: string | null }): SafeHtml {
327
+ return html`<section class="si-card">${addressNoticeHtml(input.notice)}<p class="notice fail" id="signin-error" role="alert" hidden></p><h1>Backup account</h1><p class="sub">Details are in the shared password manager.</p>${NOSCRIPT}<form method="post" action="${loginHref({ backup: '1' }, input.next)}" data-signin="backup" data-next="${input.next}"><div class="field"><label class="field-label" for="si-user">Username</label><input class="input" id="si-user" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" autofocus required></div><div class="field"><label class="field-label" for="si-pass">Password</label><input class="input" id="si-pass" name="password" type="password" autocomplete="current-password" required></div><button class="btn accent block" type="submit">Sign in</button></form></section><p class="si-alt"><a class="linkbtn" href="${loginHref({}, input.next)}">Use email sign-in instead</a></p>`;
323
328
  }
324
329
 
325
330
  /**
@@ -41,10 +41,14 @@
41
41
  * counts while its old file is gone and its new file is there (`movesIn`, git.ts).
42
42
  *
43
43
  * Paths are site-relative (`content/pages/index.json`). Values are JSON text.
44
+ *
45
+ * **The status cache** (status-cache.ts, Redis diet Decision 2): every write here of a
46
+ * key the status poll reads (autosaves, change records, moves) tells it what landed.
44
47
  */
45
48
  import type { AnyDocument } from '../document.ts';
46
49
  import type { Person } from './messages.ts';
47
50
  import type { RedisClient } from './redis.ts';
51
+ import { landed, type StatusCache } from './status-cache.ts';
48
52
  import type { LiveTimings } from './timings.ts';
49
53
 
50
54
  export interface Autosave {
@@ -131,17 +135,34 @@ export class Autosaves {
131
135
  readonly timings: LiveTimings;
132
136
  readonly now: () => number;
133
137
  readonly prefix: string;
138
+ private readonly cache: StatusCache | null;
134
139
 
135
- constructor(redis: RedisClient, timings: LiveTimings, now: () => number, prefix = 'caracms:') {
140
+ constructor(redis: RedisClient, timings: LiveTimings, now: () => number, prefix = 'caracms:', cache: StatusCache | null = null) {
136
141
  this.redis = redis;
137
142
  this.timings = timings;
138
143
  this.now = now;
139
144
  this.prefix = prefix;
145
+ this.cache = cache;
140
146
  }
141
147
 
142
148
  key = (file: string) => `${this.prefix}autosave:${file}`;
149
+ /** What every autosave key starts with (the status cache scans it). */
150
+ get keyPrefix(): string {
151
+ return this.key('');
152
+ }
143
153
  private committedKey = (file: string) => `${this.prefix}committed:${file}`;
144
- private changedKey = (file: string) => `${this.prefix}changed:${file}`;
154
+ readonly changedKey = (file: string) => `${this.prefix}changed:${file}`;
155
+ readonly movesKey = () => movesKey(this.prefix);
156
+
157
+ /** A compare-and-swap (or delete) on an autosave key, and the status cache told what came of it. */
158
+ private async swap(key: string, expected: string | null, raw: string | null): Promise<boolean> {
159
+ const ok = await landed(this.cache, key, () =>
160
+ raw === null ? this.redis.compareAndDelete(key, expected!) : this.redis.compareAndSet(key, expected, raw, { px: this.timings.autosaveTtlMs }),
161
+ );
162
+ if (ok) this.cache?.string(key, raw);
163
+ else this.cache?.drop(key);
164
+ return ok;
165
+ }
145
166
 
146
167
  async get(file: string): Promise<Read<Autosave> | null> {
147
168
  const raw = await this.redis.get(this.key(file));
@@ -151,30 +172,34 @@ export class Autosaves {
151
172
 
152
173
  /** Every autosave: one SCAN and one MGET. */
153
174
  async all(): Promise<Read<Autosave>[]> {
154
- const keys = await this.redis.scan(`${this.prefix}autosave:*`);
175
+ const keys = await this.redis.scan(`${this.keyPrefix}*`);
155
176
  if (keys.length === 0) return [];
156
- const raws = await this.redis.mget(keys);
177
+ return Autosaves.allFrom(await this.redis.mget(keys));
178
+ }
179
+
180
+ /** The autosaves among these values (a scan of `keyPrefix`'s, read). */
181
+ static allFrom(raws: Iterable<string | null>): Read<Autosave>[] {
157
182
  const out: Read<Autosave>[] = [];
158
- raws.forEach((raw) => {
183
+ for (const raw of raws) {
159
184
  const value = parse<Autosave>(raw);
160
185
  if (raw !== null && value) out.push({ raw, value });
161
- });
186
+ }
162
187
  return out;
163
188
  }
164
189
 
165
190
  /** Store it only if the autosave is still what was read (`null`: only if there's none), so a write that read an older one never replaces a newer. */
166
191
  putIf(read: Read<Autosave> | null, autosave: Autosave): Promise<boolean> {
167
- return this.redis.compareAndSet(this.key(autosave.file), read?.raw ?? null, JSON.stringify(autosave), { px: this.timings.autosaveTtlMs });
192
+ return this.swap(this.key(autosave.file), read?.raw ?? null, JSON.stringify(autosave));
168
193
  }
169
194
 
170
195
  /** Delete it only if it's still what was read (a newer write wins). */
171
196
  removeIf(read: Read<Autosave>): Promise<boolean> {
172
- return this.redis.compareAndDelete(this.key(read.value.file), read.raw);
197
+ return this.swap(this.key(read.value.file), read.raw, null);
173
198
  }
174
199
 
175
200
  /** Replace it only if it's still what was read. */
176
201
  replaceIf(read: Read<Autosave>, next: Autosave): Promise<boolean> {
177
- return this.redis.compareAndSet(this.key(next.file), read.raw, JSON.stringify(next), { px: this.timings.autosaveTtlMs });
202
+ return this.swap(this.key(next.file), read.raw, JSON.stringify(next));
178
203
  }
179
204
 
180
205
  async committed(file: string): Promise<Committed | null> {
@@ -186,7 +211,12 @@ export class Autosaves {
186
211
  }
187
212
 
188
213
  async changed(file: string): Promise<ChangeRecord | null> {
189
- return parse<ChangeRecord>(await this.redis.get(this.changedKey(file)));
214
+ return Autosaves.changedFrom(await this.redis.get(this.changedKey(file)));
215
+ }
216
+
217
+ /** A change record from its text as read. */
218
+ static changedFrom(raw: string | null): ChangeRecord | null {
219
+ return parse<ChangeRecord>(raw);
190
220
  }
191
221
 
192
222
  /** Several files' records in one MGET (the documents list's "Last edited"), in the order asked; none: null. */
@@ -197,7 +227,9 @@ export class Autosaves {
197
227
 
198
228
  /** No expiry: one small key per file, overwritten on each change, so "who changed it" is never forgotten while Redis keeps its data. */
199
229
  async setChanged(file: string, record: ChangeRecord): Promise<void> {
200
- await this.redis.set(this.changedKey(file), JSON.stringify(record));
230
+ const raw = JSON.stringify(record);
231
+ await landed(this.cache, this.changedKey(file), () => this.redis.set(this.changedKey(file), raw));
232
+ this.cache?.string(this.changedKey(file), raw);
201
233
  }
202
234
 
203
235
  /**
@@ -205,7 +237,8 @@ export class Autosaves {
205
237
  * from `main` leaves `{ by: null }` ("only merges"), and never overwrites an editor's.
206
238
  */
207
239
  async setChangedIfAbsent(file: string, record: Omit<ChangeRecord, 'by'> & { by: Person | null }): Promise<void> {
208
- await this.redis.set(this.changedKey(file), JSON.stringify(record), { nx: true });
240
+ const raw = JSON.stringify(record);
241
+ if (await landed(this.cache, this.changedKey(file), () => this.redis.set(this.changedKey(file), raw, { nx: true }))) this.cache?.string(this.changedKey(file), raw);
209
242
  }
210
243
 
211
244
  /** Pre-M7 item 8: the moves waiting to publish, old file → new file. */
@@ -213,6 +246,16 @@ export class Autosaves {
213
246
  return new Map(Object.entries(await this.redis.hgetall(movesKey(this.prefix))));
214
247
  }
215
248
 
249
+ private async setMove(from: string, to: string): Promise<void> {
250
+ await landed(this.cache, movesKey(this.prefix), () => this.redis.hset(movesKey(this.prefix), { [from]: to }));
251
+ this.cache?.hash(movesKey(this.prefix), { [from]: to });
252
+ }
253
+
254
+ private async dropMoves(...from: string[]): Promise<void> {
255
+ await landed(this.cache, movesKey(this.prefix), () => this.redis.hdel(movesKey(this.prefix), ...from));
256
+ this.cache?.hash(movesKey(this.prefix), {}, from);
257
+ }
258
+
216
259
  /**
217
260
  * A document moved from `from` to `to`. Moved since the last Publish already (a record
218
261
  * ends at `from`): that record now ends at `to`, or goes when `to` is where it started
@@ -223,16 +266,16 @@ export class Autosaves {
223
266
  const moves = await this.moves();
224
267
  const earlier = [...moves].find(([, t]) => t === from)?.[0];
225
268
  if (earlier !== undefined) {
226
- if (earlier === to) await this.redis.hdel(movesKey(this.prefix), earlier);
227
- else await this.redis.hset(movesKey(this.prefix), { [earlier]: to });
228
- } else if (wasLive) await this.redis.hset(movesKey(this.prefix), { [from]: to });
269
+ if (earlier === to) await this.dropMoves(earlier);
270
+ else await this.setMove(earlier, to);
271
+ } else if (wasLive) await this.setMove(from, to);
229
272
  }
230
273
 
231
274
  /** A deleted document's move, if it was moved, is no move any more: it's a deletion. */
232
275
  async forgetMoveTo(file: string): Promise<void> {
233
276
  const moves = await this.moves();
234
277
  const olds = [...moves].filter(([, t]) => t === file).map(([o]) => o);
235
- if (olds.length) await this.redis.hdel(movesKey(this.prefix), ...olds);
278
+ if (olds.length) await this.dropMoves(...olds);
236
279
  }
237
280
 
238
281
  /** The merge's ancestor for an autosave (Decision 6). See `ancestorFor`. */
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { createHash } from 'node:crypto';
17
17
  import { gunzipSync } from 'node:zlib';
18
- import { GitHubClient, GraphQLError, HttpError, Tracker } from './github.ts';
18
+ import { GitHubClient, GraphQLError, HttpError, Tracker, type GitHubAccessProblem } from './github.ts';
19
19
 
20
20
  export interface Head {
21
21
  commit: string;
@@ -26,7 +26,10 @@ export interface Heads {
26
26
  drafts: Head;
27
27
  }
28
28
 
29
- export type Branch = 'main' | 'drafts';
29
+ import type { Branch, BranchNames } from './branches.ts';
30
+ import { DEFAULT_BRANCHES } from './branches.ts';
31
+
32
+ export { DEFAULT_BRANCHES, type Branch, type BranchNames };
30
33
 
31
34
  export interface CommitSummary {
32
35
  sha: string;
@@ -111,6 +114,8 @@ export class MissingBranchError extends Error {
111
114
  export interface GitBackend {
112
115
  readonly tracker: Tracker;
113
116
  readonly name: 'github' | 'memory';
117
+ /** The ref name behind each role. */
118
+ readonly branches: Readonly<BranchNames>;
114
119
  /** Both heads, in one request. Throws MissingBranchError. */
115
120
  readHeads(): Promise<Heads>;
116
121
  createBranch(branch: Branch, sha: string): Promise<void>;
@@ -134,6 +139,8 @@ export interface GitBackend {
134
139
  listDeployments(count: number): Promise<Deployment[]>;
135
140
  /** A deployment's newest status, or null when it has none yet. */
136
141
  deploymentStatus(id: number): Promise<DeploymentStatus | null>;
142
+ /** What's wrong with GitHub access now, from the responses so far (runs-anywhere Decision 5); null when nothing is. */
143
+ accessProblem(): GitHubAccessProblem | null;
137
144
  }
138
145
 
139
146
  /** Is this GraphQL error a stale head (someone moved the ref)? M1 → G6 (a): often it can't tell. */
@@ -144,11 +151,11 @@ export function isStaleGraphQL(errors: { type?: string; message?: string }[]): b
144
151
 
145
152
  // ── GitHub ────────────────────────────────────────────────────────────────────
146
153
 
147
- const HEADS_QUERY = `query($owner: String!, $name: String!) {
154
+ const HEADS_QUERY = `query($owner: String!, $name: String!, $main: String!, $drafts: String!) {
148
155
  repository(owner: $owner, name: $name) {
149
156
  id
150
- main: ref(qualifiedName: "refs/heads/main") { target { oid ... on Commit { tree { oid } } } }
151
- drafts: ref(qualifiedName: "refs/heads/drafts") { target { oid ... on Commit { tree { oid } } } }
157
+ main: ref(qualifiedName: $main) { target { oid ... on Commit { tree { oid } } } }
158
+ drafts: ref(qualifiedName: $drafts) { target { oid ... on Commit { tree { oid } } } }
152
159
  }
153
160
  }`;
154
161
 
@@ -168,19 +175,29 @@ const COMPARE_FILE_CAP = 300;
168
175
  export class GitHubBackend implements GitBackend {
169
176
  readonly name = 'github';
170
177
  readonly gh: GitHubClient;
178
+ readonly branches: Readonly<BranchNames>;
171
179
  private repoId = '';
172
180
 
173
- constructor(gh: GitHubClient) {
181
+ constructor(gh: GitHubClient, branches: Readonly<BranchNames> = DEFAULT_BRANCHES) {
174
182
  this.gh = gh;
183
+ this.branches = branches;
184
+ }
185
+ /** `refs/heads/<name>` for a role. */
186
+ private qualified(branch: Branch) {
187
+ return `refs/heads/${this.branches[branch]}`;
175
188
  }
176
189
  get tracker() {
177
190
  return this.gh.tracker;
178
191
  }
179
192
 
193
+ accessProblem(): GitHubAccessProblem | null {
194
+ return this.gh.accessProblem();
195
+ }
196
+
180
197
  async readHeads(): Promise<Heads> {
181
198
  const d = await this.gh.graphql<{ repository: { id: string; main: RefNode; drafts: RefNode } }>(
182
199
  HEADS_QUERY,
183
- { owner: this.gh.owner, name: this.gh.repo },
200
+ { owner: this.gh.owner, name: this.gh.repo, main: this.qualified('main'), drafts: this.qualified('drafts') },
184
201
  'readHeads',
185
202
  );
186
203
  this.repoId = d.repository.id;
@@ -192,7 +209,7 @@ export class GitHubBackend implements GitBackend {
192
209
  }
193
210
 
194
211
  async createBranch(branch: Branch, sha: string) {
195
- await this.gh.rest('POST', this.gh.repoPath('/git/refs'), { ref: `refs/heads/${branch}`, sha });
212
+ await this.gh.rest('POST', this.gh.repoPath('/git/refs'), { ref: this.qualified(branch), sha });
196
213
  }
197
214
 
198
215
  async readArchive(commit: string, keep: (path: string) => boolean) {
@@ -253,7 +270,7 @@ export class GitHubBackend implements GitBackend {
253
270
  COMMIT_ON_BRANCH,
254
271
  {
255
272
  input: {
256
- branch: { repositoryNameWithOwner: this.gh.nameWithOwner, branchName: branch },
273
+ branch: { repositoryNameWithOwner: this.gh.nameWithOwner, branchName: this.branches[branch] },
257
274
  expectedHeadOid: expectedHead,
258
275
  message: { headline, body },
259
276
  fileChanges: {
@@ -295,7 +312,7 @@ export class GitHubBackend implements GitBackend {
295
312
  {
296
313
  input: {
297
314
  repositoryId: this.repoId,
298
- refUpdates: updates.map((u) => ({ name: `refs/heads/${u.branch}`, beforeOid: u.before, afterOid: u.after, force: u.force })),
315
+ refUpdates: updates.map((u) => ({ name: this.qualified(u.branch), beforeOid: u.before, afterOid: u.after, force: u.force })),
299
316
  },
300
317
  },
301
318
  'updateRefs',
@@ -309,7 +326,7 @@ export class GitHubBackend implements GitBackend {
309
326
  async listCommits(branch: Branch, count: number) {
310
327
  const list = await this.gh.rest<{ sha: string; parents: { sha: string }[]; commit: { message: string; committer: { date: string } } }[]>(
311
328
  'GET',
312
- this.gh.repoPath(`/commits?sha=${branch}&per_page=${Math.min(100, count)}`),
329
+ this.gh.repoPath(`/commits?sha=${encodeURIComponent(this.branches[branch])}&per_page=${Math.min(100, count)}`),
313
330
  );
314
331
  return list.map((k) => ({ sha: k.sha, parents: k.parents.map((p) => p.sha), message: k.commit.message, date: k.commit.committer.date }));
315
332
  }
@@ -395,21 +412,30 @@ export const blobSha = (contents: string) => {
395
412
  export class MemoryBackend implements GitBackend {
396
413
  readonly name = 'memory';
397
414
  readonly tracker: Tracker;
415
+ readonly branches: Readonly<BranchNames>;
398
416
  /** Deployments, newest last; tests push to it. */
399
417
  deployments: MemoryDeployment[] = [];
400
418
  /** Called before each recorded request, with its endpoint: tests inject races here. */
401
419
  beforeRequest: ((endpoint: string) => Promise<void> | void) | null = null;
420
+ /** The GitHub access problem the status reports; tests set it (the GitHub client's own is tested with faked responses). */
421
+ access: GitHubAccessProblem | null = null;
402
422
  private readonly now: () => number;
403
423
  private inHook = false;
404
424
  private blobs = new Map<string, string>();
405
425
  private trees = new Map<string, MemTree>();
406
426
  private commits = new Map<string, MemCommit>();
407
- refs = new Map<Branch, string>();
427
+ /** Every ref, by its name (not its role): a test can seed a branch caracms mustn't touch. */
428
+ refs = new Map<string, string>();
408
429
  private n = 0;
409
430
 
410
- constructor(tracker = new Tracker(), options: { now?: () => number } = {}) {
431
+ constructor(tracker = new Tracker(), options: { now?: () => number; branches?: Readonly<BranchNames> } = {}) {
411
432
  this.tracker = tracker;
412
433
  this.now = options.now ?? Date.now;
434
+ this.branches = options.branches ?? DEFAULT_BRANCHES;
435
+ }
436
+ /** The ref name behind a role. */
437
+ private ref(branch: Branch) {
438
+ return this.branches[branch];
413
439
  }
414
440
 
415
441
  private async rec(endpoint: string, creating: boolean, status = 200) {
@@ -464,7 +490,7 @@ export class MemoryBackend implements GitBackend {
464
490
  return c;
465
491
  }
466
492
  private head(branch: Branch): Head {
467
- const c = this.refs.get(branch);
493
+ const c = this.refs.get(this.ref(branch));
468
494
  if (!c) throw new MissingBranchError(branch);
469
495
  return { commit: c, tree: this.commit(c).tree };
470
496
  }
@@ -479,12 +505,12 @@ export class MemoryBackend implements GitBackend {
479
505
 
480
506
  // Test helpers (not part of GitBackend; record no requests).
481
507
 
482
- /** Start the repo: one parentless commit with these files on `main` (and `drafts`, if asked). */
508
+ /** Start the repo: one parentless commit with these files on the main branch (and the drafts branch, if asked). */
483
509
  seed(files: FileWrite[], options: { drafts?: boolean } = {}): string {
484
510
  const tree = this.putTree(new Map(files.map((f) => [f.path, { sha: this.blob(f.contents), mode: '100644' }])));
485
511
  const seed = this.putCommit(tree, [], 'Seed');
486
- this.refs.set('main', seed);
487
- if (options.drafts) this.refs.set('drafts', seed);
512
+ this.refs.set(this.ref('main'), seed);
513
+ if (options.drafts) this.refs.set(this.ref('drafts'), seed);
488
514
  return seed;
489
515
  }
490
516
 
@@ -497,7 +523,7 @@ export class MemoryBackend implements GitBackend {
497
523
  else t.set(path, { sha: this.blob(contents), mode: '100644' });
498
524
  }
499
525
  const c = this.putCommit(this.putTree(t), [head.commit], message);
500
- this.refs.set(branch, c);
526
+ this.refs.set(this.ref(branch), c);
501
527
  return c;
502
528
  }
503
529
 
@@ -523,6 +549,10 @@ export class MemoryBackend implements GitBackend {
523
549
 
524
550
  // GitBackend.
525
551
 
552
+ accessProblem(): GitHubAccessProblem | null {
553
+ return this.access;
554
+ }
555
+
526
556
  async readHeads() {
527
557
  await this.rec('graphql readHeads', false);
528
558
  return { main: this.head('main'), drafts: this.head('drafts') };
@@ -530,9 +560,9 @@ export class MemoryBackend implements GitBackend {
530
560
 
531
561
  async createBranch(branch: Branch, sha: string) {
532
562
  await this.rec('POST /git/refs', true);
533
- if (this.refs.has(branch)) throw new HttpError(422, 'Reference already exists', null);
563
+ if (this.refs.has(this.ref(branch))) throw new HttpError(422, 'Reference already exists', null);
534
564
  this.commit(sha);
535
- this.refs.set(branch, sha);
565
+ this.refs.set(this.ref(branch), sha);
536
566
  }
537
567
 
538
568
  async readArchive(commit: string, keep: (path: string) => boolean) {
@@ -574,14 +604,14 @@ export class MemoryBackend implements GitBackend {
574
604
 
575
605
  async commitOnBranch(branch: Branch, expectedHead: string, additions: FileWrite[], deletions: string[], headline: string, body: string) {
576
606
  await this.rec('graphql createCommitOnBranch', true);
577
- const cur = this.refs.get(branch);
607
+ const cur = this.refs.get(this.ref(branch));
578
608
  if (cur !== expectedHead) throw new StaleHeadError(`Expected branch to point to "${expectedHead}" but it did not. Pull and try again.`);
579
609
  const t = new Map(this.trees.get(this.commit(cur).tree)!);
580
610
  for (const f of additions) t.set(f.path, { sha: this.blob(f.contents), mode: '100644' });
581
611
  for (const p of deletions) t.delete(p);
582
612
  const tree = this.putTree(t);
583
613
  const c = this.putCommit(tree, [cur], body ? `${headline}\n\n${body}` : headline);
584
- this.refs.set(branch, c);
614
+ this.refs.set(this.ref(branch), c);
585
615
  return { commit: c, tree };
586
616
  }
587
617
 
@@ -606,10 +636,10 @@ export class MemoryBackend implements GitBackend {
606
636
  await this.rec('graphql updateRefs', true);
607
637
  for (const u of updates) {
608
638
  // GitHub's answer here is a generic error, not a typed one (M1 → G6 (a)).
609
- if (this.refs.get(u.branch) !== u.before) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
639
+ if (this.refs.get(this.ref(u.branch)) !== u.before) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
610
640
  if (!u.force && !this.isAncestor(u.before, u.after)) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
611
641
  }
612
- for (const u of updates) this.refs.set(u.branch, u.after); // all or nothing
642
+ for (const u of updates) this.refs.set(this.ref(u.branch), u.after); // all or nothing
613
643
  }
614
644
 
615
645
  async listCommits(branch: Branch, count: number) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The ref names behind each branch role (M9 Decision 6). `main` is what's published
3
+ * (and deployed); `drafts` is where autosaves land. `CARACMS_MAIN_BRANCH` and
4
+ * `CARACMS_DRAFTS_BRANCH` name them (mode.ts), defaulting to `main` and `drafts`.
5
+ * With both named, a branch called `main` is never read or moved.
6
+ *
7
+ * Only the backends (backend.ts) turn a role into a name; everything above them speaks
8
+ * in roles. No imports here, so mode.ts can read it without the backends.
9
+ */
10
+
11
+ /** A branch's role, not its name. */
12
+ export type Branch = 'main' | 'drafts';
13
+
14
+ export interface BranchNames {
15
+ main: string;
16
+ drafts: string;
17
+ }
18
+
19
+ export const DEFAULT_BRANCHES: Readonly<BranchNames> = Object.freeze({ main: 'main', drafts: 'drafts' });
@@ -41,7 +41,7 @@ import { parseAnyDocument } from '../document.ts';
41
41
  import type { DraftsStore } from '../editor/server/drafts-store.ts';
42
42
  import { confinePath, fileVersion, WriteRefused, writeText, type WriteContext, type WriteRequest, type WriteResponse } from '../editor/server/write.ts';
43
43
  import type { CreateResponse, DeleteResponse, LastChange, LockRefusal, MoveResponse, ReplaceFileResponse } from '../editor/types.ts';
44
- import type { Autosave, Autosaves, Read } from './autosaves.ts';
44
+ import type { Autosave, Autosaves, ChangeRecord, Read } from './autosaves.ts';
45
45
  import { titleOf, type LiveGit } from './git.ts';
46
46
  import type { LockRecord, Locks } from './locks.ts';
47
47
  import { displayName, type Person } from './messages.ts';
@@ -79,8 +79,9 @@ export function unavailable(error: unknown): WriteRefused {
79
79
  * expire, so a file with none was never changed in the editor or Redis lost its data, and
80
80
  * the server can't tell which: the keeper holds edits on unknown rather than re-send them.
81
81
  */
82
- export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string): Promise<LastChange> {
83
- const record = await parts.autosaves.changed(file);
82
+ /** `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here. */
83
+ export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string, known?: ChangeRecord | null): Promise<LastChange> {
84
+ const record = known !== undefined ? known : await parts.autosaves.changed(file);
84
85
  if (record) return { by: record.by, version: record.version, at: record.at };
85
86
  const editor = parts.git.waiting().find((w) => w.file === file)?.editors[0];
86
87
  return editor ? { by: editor.person, version: null, at: editor.at } : { by: null, version: null, at: null, known: false };