caracms 0.8.0 → 0.8.2

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 (62) hide show
  1. package/README.md +23 -2
  2. package/package.json +1 -1
  3. package/skills/caracms-hosting/SKILL.md +1 -1
  4. package/src/body.ts +55 -0
  5. package/src/cli/validate.ts +1 -1
  6. package/src/cloud-middleware.ts +39 -3
  7. package/src/content.ts +15 -6
  8. package/src/document-problems.ts +48 -0
  9. package/src/editor/client/collections/CollectionGrid.tsx +2 -2
  10. package/src/editor/client/collections/CollectionTable.tsx +3 -3
  11. package/src/editor/client/documents/DocMenu.tsx +56 -1
  12. package/src/editor/client/documents/actions.ts +30 -1
  13. package/src/editor/client/documents/dialogs.tsx +61 -3
  14. package/src/editor/client/editor.ts +98 -2
  15. package/src/editor/client/fields/LockupField.tsx +4 -4
  16. package/src/editor/client/inbox/InboxStage.tsx +25 -1
  17. package/src/editor/client/live/notices.tsx +19 -0
  18. package/src/editor/client/live/publish.tsx +58 -2
  19. package/src/editor/client/nav/DocRow.tsx +4 -4
  20. package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
  21. package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
  22. package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
  23. package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
  24. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
  25. package/src/editor/client/pane/parts.tsx +67 -4
  26. package/src/editor/client/pane/switcher.tsx +4 -4
  27. package/src/editor/client/shell/Bar.tsx +11 -0
  28. package/src/editor/client/state/keeper.ts +28 -4
  29. package/src/editor/client/state/live.ts +18 -1
  30. package/src/editor/routes/_live.ts +1 -1
  31. package/src/editor/routes/admin.astro +9 -0
  32. package/src/editor/routes/discard.ts +11 -0
  33. package/src/editor/server/admin-props.ts +18 -2
  34. package/src/editor/server/arrange.ts +4 -4
  35. package/src/editor/server/create.ts +4 -4
  36. package/src/editor/server/drafts-store.ts +2 -2
  37. package/src/editor/server/empty-site.ts +23 -0
  38. package/src/editor/server/media/cloud-mux.ts +4 -1
  39. package/src/editor/server/media/upload.ts +2 -41
  40. package/src/editor/server/move.ts +32 -4
  41. package/src/editor/server/write.ts +4 -4
  42. package/src/editor/styles/levels/live.css +2 -0
  43. package/src/editor/types.ts +92 -4
  44. package/src/forms/cloud-store.ts +4 -1
  45. package/src/forms/inbox.ts +10 -5
  46. package/src/forms/store.ts +14 -2
  47. package/src/index.ts +23 -8
  48. package/src/live/auth/login.ts +4 -3
  49. package/src/live/auth/views.ts +8 -1
  50. package/src/live/autosaves.ts +94 -6
  51. package/src/live/cloud.ts +64 -20
  52. package/src/live/drafts-store.ts +17 -6
  53. package/src/live/editor.ts +232 -16
  54. package/src/live/git.ts +492 -22
  55. package/src/live/github.ts +84 -17
  56. package/src/live/locks.ts +22 -0
  57. package/src/live/messages.ts +49 -2
  58. package/src/live/notices.ts +5 -1
  59. package/src/live/runtime.ts +2 -1
  60. package/src/live/status.ts +5 -0
  61. package/src/live/sweep.ts +44 -1
  62. package/src/live/webhook.ts +8 -3
@@ -15,7 +15,8 @@
15
15
  *
16
16
  * - **Every request times out** after `timeoutMs` (20 s), its body included: a GitHub
17
17
  * that stops answering fails the operation instead of holding it (and Publish's or
18
- * the check's callers) forever.
18
+ * the check's callers) forever. The cloud's token call is the one exception: 35 s
19
+ * unless `timeoutMs` is given (cloud.ts, `CLOUD_TOKEN_TIMEOUT_MS`).
19
20
  *
20
21
  * - **Access problems** (Decision 5): every response is watched for a refused credential
21
22
  * (401), a write refused for lack of a permission (403 "not accessible"), and, for a
@@ -25,13 +26,21 @@
25
26
  * - **CaraCMS Cloud** (`CARACMS_CLOUD_KEY`; caracms-cloud M2 Decisions 12–14) mints the
26
27
  * installation token instead: `POST /v1/github/token` (cloud.ts), refreshed five minutes
27
28
  * early like the App's. A refusal from the cloud is an access problem of kind `cloud`
28
- * carrying the cloud's sentence (and its install link), cleared by the next token; so is
29
- * a token answer for another repo than the one the server started on.
29
+ * carrying the cloud's sentence (and its install link), cleared by the next token.
30
+ * - **A rename or transfer on GitHub** (caracms-cloud adversarial review, finding 7; this
31
+ * retires M2 Decision 13's refusal of a token answer for another repo, whose only way out
32
+ * was a developer restarting the server): the settings are read again, and when they name
33
+ * the new repo too, the client takes that name for the rest of the process. Every path,
34
+ * GraphQL variable and commit asks for it from then on (the backend reads `owner`, `repo`
35
+ * and `nameWithOwner` on each call), a call that was waiting on the token is moved to it,
36
+ * one line is logged and the editor gets an information note (`renamed`) until the next
37
+ * restart. Settings that don't name it yet, or can't be read, fail that refresh as an
38
+ * access problem of kind `cloud`; the next token asks again.
30
39
  *
31
40
  * The private key, the site key and the tokens are never logged.
32
41
  */
33
42
  import { createSign } from 'node:crypto';
34
- import { CLOUD_REPO_CHANGED, CloudRefusal, isCloudRefusal, cloudToken } from './cloud.ts';
43
+ import { CLOUD_REPO_CHANGED, CloudRefusal, confirmCloudRepo, isCloudRefusal, cloudToken } from './cloud.ts';
35
44
 
36
45
  const API = 'https://api.github.com';
37
46
 
@@ -61,6 +70,8 @@ export interface TokenConnection extends ConnectionBase {
61
70
  export interface CloudConnection extends ConnectionBase {
62
71
  cloudKey: string;
63
72
  cloudUrl: string;
73
+ /** Finding 7's one line when the repo's new name is taken. Default: the console, as `[caracms] …`. */
74
+ log?: (line: string) => void;
64
75
  }
65
76
 
66
77
  export type GitHubConnection = AppConnection | TokenConnection | CloudConnection;
@@ -77,7 +88,9 @@ export type GitHubAccessProblem =
77
88
  | { kind: 'permission'; credential: GitHubCredential; permission: string }
78
89
  | { kind: 'expiring'; credential: 'token'; expires: string }
79
90
  /** CaraCMS Cloud refused a token (caracms-cloud M2 Decision 14): its sentence as it came, and its install link. */
80
- | { kind: 'cloud'; credential: 'cloud'; message: string; installUrl: string | null };
91
+ | { kind: 'cloud'; credential: 'cloud'; message: string; installUrl: string | null }
92
+ /** Not a problem: information (finding 7). The repo was renamed or transferred, and work carries on under `repo`. */
93
+ | { kind: 'renamed'; credential: 'cloud'; repo: string; was: string };
81
94
 
82
95
  /** A token expiring within this long shows in the editor (Decision 5). */
83
96
  export const TOKEN_EXPIRY_WARNING_MS = 14 * 24 * 60 * 60_000;
@@ -115,6 +128,8 @@ export function accessProblemMessage(problem: GitHubAccessProblem): string {
115
128
  return `GitHub refused a change because this site's ${what} lacks the "${problem.permission}" permission. A developer can add it in the ${problem.credential === 'token' ? "token's settings" : "App's settings"}.`;
116
129
  case 'cloud':
117
130
  return problem.message;
131
+ case 'renamed':
132
+ return `This site's GitHub repo is now ${problem.repo} (it was ${problem.was}). Changes save and publish to it as before; nothing needs doing.`;
118
133
  case 'expiring': {
119
134
  const date = new Date(problem.expires).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
120
135
  return `This site's ${what} expires on ${date}. After that, changes can't be saved to GitHub: a developer can regenerate it, or make a new one, and set it before then.`;
@@ -229,8 +244,6 @@ function shorten(p: string): string {
229
244
 
230
245
  export class GitHubClient {
231
246
  readonly tracker: Tracker;
232
- readonly owner: string;
233
- readonly repo: string;
234
247
  /** Which credential this client signs in with. */
235
248
  readonly credential: GitHubCredential;
236
249
  private readonly appId: string;
@@ -239,6 +252,8 @@ export class GitHubClient {
239
252
  private readonly fetcher: typeof fetch;
240
253
  private readonly now: () => number;
241
254
  private readonly timeoutMs: number;
255
+ /** The connection's own `timeoutMs` (tests), else undefined: the token call then waits its 35 s (cloud.ts). */
256
+ private readonly givenTimeoutMs: number | undefined;
242
257
  private token = '';
243
258
  private tokenExpires = 0;
244
259
  private signingIn: Promise<void> | null = null;
@@ -248,6 +263,11 @@ export class GitHubClient {
248
263
  private personalTokenExpires: number | null = null;
249
264
  /** The cloud's last refusal, until a token comes. */
250
265
  private cloudProblem: { message: string; installUrl: string | null } | null = null;
266
+ /** The repo, as the server started on it or as a rename made it since (finding 7). */
267
+ private name: { owner: string; repo: string };
268
+ /** The name before the last rename taken, for the editor's note until a restart. */
269
+ private renamedFrom: string | null = null;
270
+ private readonly log: (line: string) => void = (line) => console.log(`[caracms] ${line}`);
251
271
 
252
272
  constructor(connection: GitHubConnection, tracker: Tracker) {
253
273
  if ('token' in connection) {
@@ -261,16 +281,17 @@ export class GitHubClient {
261
281
  this.appId = '';
262
282
  this.key = '';
263
283
  this.cloud = { key: connection.cloudKey, url: connection.cloudUrl };
284
+ if (connection.log) this.log = connection.log;
264
285
  } else {
265
286
  this.credential = 'app';
266
287
  this.appId = connection.appId;
267
288
  this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
268
289
  }
269
- this.owner = connection.owner;
270
- this.repo = connection.repo;
290
+ this.name = { owner: connection.owner, repo: connection.repo };
271
291
  this.fetcher = connection.fetch ?? fetch;
272
292
  this.now = connection.now ?? Date.now;
273
293
  this.timeoutMs = connection.timeoutMs ?? GITHUB_TIMEOUT_MS;
294
+ this.givenTimeoutMs = connection.timeoutMs;
274
295
  this.tracker = tracker;
275
296
  }
276
297
 
@@ -283,6 +304,7 @@ export class GitHubClient {
283
304
  if (this.credential === 'token' && expires !== null && expires - this.now() < TOKEN_EXPIRY_WARNING_MS) {
284
305
  return { kind: 'expiring', credential: 'token', expires: new Date(expires).toISOString() };
285
306
  }
307
+ if (this.renamedFrom) return { kind: 'renamed', credential: 'cloud', repo: this.nameWithOwner, was: this.renamedFrom };
286
308
  return null;
287
309
  }
288
310
 
@@ -306,6 +328,14 @@ export class GitHubClient {
306
328
  if (notPermitted && write && !/\/deployments/.test(path)) this.missingPermission = CONTENTS_PERMISSION;
307
329
  }
308
330
 
331
+ get owner() {
332
+ return this.name.owner;
333
+ }
334
+
335
+ get repo() {
336
+ return this.name.repo;
337
+ }
338
+
309
339
  get nameWithOwner() {
310
340
  return `${this.owner}/${this.repo}`;
311
341
  }
@@ -339,15 +369,25 @@ export class GitHubClient {
339
369
  private async cloudSignIn(cloud: { key: string; url: string }): Promise<void> {
340
370
  let answer;
341
371
  try {
342
- answer = await cloudToken({ ...cloud, fetch: this.fetcher, timeoutMs: this.timeoutMs });
372
+ // Seam note (caracms-cloud adversarial review): the token route's worst case passes 20 s, so
373
+ // it gets cloud.ts's 35 s; the settings read below keeps the 20 s every other call has.
374
+ answer = await cloudToken({ ...cloud, fetch: this.fetcher, timeoutMs: this.givenTimeoutMs });
343
375
  } catch (error) {
344
376
  if (isCloudRefusal(error) && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
345
377
  throw error;
346
378
  }
347
379
  if (answer.repo.toLowerCase() !== this.nameWithOwner.toLowerCase()) {
348
- const refusal = new CloudRefusal('repo_changed', CLOUD_REPO_CHANGED(answer.repo));
349
- this.cloudProblem = { message: refusal.message, installUrl: null };
350
- throw refusal;
380
+ // Finding 7: a rename or transfer. Taken only when the settings, read again, name it too.
381
+ const repo = await confirmCloudRepo({ ...cloud, fetch: this.fetcher, timeoutMs: this.timeoutMs }, answer.repo);
382
+ if (!repo) {
383
+ const refusal = new CloudRefusal('repo_changed', CLOUD_REPO_CHANGED(answer.repo));
384
+ this.cloudProblem = { message: refusal.message, installUrl: null };
385
+ throw refusal;
386
+ }
387
+ const was = this.nameWithOwner;
388
+ this.name = repo;
389
+ this.renamedFrom = was;
390
+ this.log(`the site's repo is now ${this.nameWithOwner} (was ${was})`);
351
391
  }
352
392
  this.cloudProblem = null;
353
393
  this.token = answer.token;
@@ -372,18 +412,43 @@ export class GitHubClient {
372
412
  }
373
413
 
374
414
  async rest<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
375
- return (await this.request(method, path, body, await this.auth())) as T;
415
+ const was = this.nameWithOwner;
416
+ const bearer = await this.auth();
417
+ return (await this.request(method, this.movedPath(path, was), body, bearer)) as T;
376
418
  }
377
419
 
378
420
  /** A GET whose body is bytes (the tarball). Follows GitHub's redirect to codeload. */
379
421
  async bytes(path: string): Promise<Buffer> {
380
- const res = await this.fetcher(`${API}${path}`, { headers: this.headers(await this.auth()), redirect: 'follow', signal: this.signal() });
422
+ const was = this.nameWithOwner;
423
+ const bearer = await this.auth();
424
+ path = this.movedPath(path, was);
425
+ const res = await this.fetcher(`${API}${path}`, { headers: this.headers(bearer), redirect: 'follow', signal: this.signal() });
381
426
  this.tracker.record({ endpoint: `GET ${shorten(path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, ''))}`, status: res.status, creating: false });
382
427
  this.observe(res, path, false, false);
383
428
  if (!res.ok) throw new HttpError(res.status, `GET ${path} -> ${res.status}`, await res.text().catch(() => ''));
384
429
  return Buffer.from(await res.arrayBuffer());
385
430
  }
386
431
 
432
+ /**
433
+ * Finding 7: a call's path was made on `was` (its caller read `repoPath()` before the
434
+ * token), and the token it waited for took a new name. It goes to the new name, so no
435
+ * call after a rename leans on GitHub's redirect from the old one.
436
+ */
437
+ private movedPath(path: string, was: string): string {
438
+ const from = `/repos/${was}`;
439
+ return was !== this.nameWithOwner && (path === from || path.startsWith(`${from}/`) || path.startsWith(`${from}?`)) ? `/repos/${this.nameWithOwner}${path.slice(from.length)}` : path;
440
+ }
441
+
442
+ /** The same for GraphQL's variables: an `owner` and `name` pair, or a `repositoryNameWithOwner`, on `was`. */
443
+ private movedVariables(value: unknown, was: string): unknown {
444
+ if (was === this.nameWithOwner || !value || typeof value !== 'object') return value;
445
+ if (Array.isArray(value)) return value.map((v) => this.movedVariables(v, was));
446
+ const o = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.movedVariables(v, was)]));
447
+ if (o.repositoryNameWithOwner === was) o.repositoryNameWithOwner = this.nameWithOwner;
448
+ if (`${o.owner}/${o.name}` === was) Object.assign(o, { owner: this.owner, name: this.repo });
449
+ return o;
450
+ }
451
+
387
452
  /** Aborts the request (and the reading of its body) after `timeoutMs`. */
388
453
  private signal(): AbortSignal {
389
454
  return AbortSignal.timeout(this.timeoutMs);
@@ -427,11 +492,13 @@ export class GitHubClient {
427
492
  /** GraphQL as the installation (or the token). Throws GraphQLError when the answer has `errors`. */
428
493
  async graphql<T = unknown>(query: string, variables: Record<string, unknown>, name: string): Promise<T> {
429
494
  const isMutation = /^\s*mutation\b/.test(query);
495
+ const was = this.nameWithOwner;
496
+ const bearer = await this.auth();
430
497
  const res = await this.fetcher(`${API}/graphql`, {
431
498
  method: 'POST',
432
499
  // updateRefs worked with this preview header in M1 (G2); whether it still needs it is untested (G6 (f)).
433
- headers: { ...this.headers(await this.auth(), true), accept: 'application/vnd.github.update-refs-preview+json, application/json' },
434
- body: JSON.stringify({ query, variables }),
500
+ headers: { ...this.headers(bearer, true), accept: 'application/vnd.github.update-refs-preview+json, application/json' },
501
+ body: JSON.stringify({ query, variables: this.movedVariables(variables, was) }),
435
502
  signal: this.signal(),
436
503
  });
437
504
  this.tracker.record({ endpoint: `graphql ${name}`, status: res.status, creating: isMutation });
package/src/live/locks.ts CHANGED
@@ -302,6 +302,28 @@ export class Locks {
302
302
  this.cache?.hash(this.presenceKey(), fields);
303
303
  }
304
304
 
305
+ /** The tabs on this document now (polled with it within their record's hold), with their people (Discard changes). */
306
+ async tabsOn(file: string): Promise<{ tab: string; person: Person }[]> {
307
+ const now = this.now();
308
+ const out: { tab: string; person: Person }[] = [];
309
+ for (const [tab, raw] of Object.entries(await this.redis.hgetall(this.presenceKey()))) {
310
+ try {
311
+ const r = JSON.parse(raw) as PresenceRecord;
312
+ const hold = typeof r.hold === 'number' ? r.hold : this.timings.presenceMs;
313
+ if (r.file === file && now - r.at <= hold) out.push({ tab, person: r.person });
314
+ } catch {
315
+ /* skip */
316
+ }
317
+ }
318
+ return out;
319
+ }
320
+
321
+ /** Discard changes: let go of a lock whoever holds it (only while it's still what was read). */
322
+ async drop(read: ReadLock, file: string): Promise<void> {
323
+ if (await landed(this.cache, this.key(file), () => this.redis.compareAndDelete(this.key(file), read.raw))) this.cache?.string(this.key(file), null);
324
+ else this.cache?.drop(this.key(file));
325
+ }
326
+
305
327
  /** Who is on which document now (polled with it within the record's hold). Long-gone tabs are pruned. */
306
328
  async presence(): Promise<PresenceView[]> {
307
329
  return this.presenceFrom(Object.entries(await this.redis.hgetall(this.presenceKey())));
@@ -31,6 +31,25 @@
31
31
  *
32
32
  * Published-by: Aoife <aoife@fernhill.ie>
33
33
  * Published-files: content/pages/index.json, content/pages/about.json
34
+ *
35
+ * Discarding a document's unpublished changes (its files put back as `main` has them):
36
+ *
37
+ * Discarded: About — by Aoife
38
+ *
39
+ * Discarded-by: Aoife <aoife@fernhill.ie>
40
+ * Discarded-files: content/pages/about.json
41
+ *
42
+ * Publish holding documents back (caracms-cloud adversarial review, finding 15): the one
43
+ * commit on `drafts`, on top of the publish, putting back what `main` didn't get. It says
44
+ * again who changed each file, so they're still waiting with who changed them:
45
+ *
46
+ * Held back: About
47
+ *
48
+ * Held-back-files: content/pages/about.json
49
+ * Edited-by: Ciarán <ciaran@fernhill.ie>: content/pages/about.json
50
+ *
51
+ * Reading back, a discard ends what the commits before it said about those files: who
52
+ * changed them and any `Draft-kept` (status.ts → `waitingFromCommits`).
34
53
  */
35
54
 
36
55
  export interface Person {
@@ -86,6 +105,28 @@ export function mergeMessage(fromMain: string[], keptDraft: string[]): string {
86
105
  return `Merge main into drafts${lines.length ? `\n\n${lines.join('\n')}` : ''}`;
87
106
  }
88
107
 
108
+ /** A discard's commit on `drafts`: the document's files put back as `main` has them. */
109
+ export function discardMessage(titles: string[], files: string[], by: Person): string {
110
+ return `Discarded: ${titleList(titles)} — by ${displayName(by)}\n\nDiscarded-by: ${ident(by)}\nDiscarded-files: ${files.join(', ')}`;
111
+ }
112
+
113
+ /** Finding 15: Publish's commit on `drafts` putting back the files it held back. `waiting`: who changed each, newest first (status.ts → waitingFromCommits). */
114
+ export function heldMessage(titles: string[], files: string[], waiting: { file: string; editors: { person: Person }[]; keptDraft: boolean }[]): string {
115
+ const byEditor = new Map<string, { person: Person; files: string[] }>();
116
+ // Oldest first, so reading back puts the newest editor in front again.
117
+ for (const w of waiting)
118
+ for (const e of [...w.editors].reverse()) {
119
+ const key = e.person.email.toLowerCase();
120
+ const entry = byEditor.get(key) ?? { person: e.person, files: [] };
121
+ entry.files.push(w.file);
122
+ byEditor.set(key, entry);
123
+ }
124
+ const lines = [`Held-back-files: ${files.join(', ')}`, ...[...byEditor.values()].map(({ person, files }) => `Edited-by: ${ident(person)}: ${files.join(', ')}`)];
125
+ const kept = waiting.filter((w) => w.keptDraft).map((w) => w.file);
126
+ if (kept.length) lines.push(`Draft-kept: ${kept.join(', ')}`);
127
+ return `Held back: ${titleList(titles)}\n\n${lines.join('\n')}`;
128
+ }
129
+
89
130
  export function publishMessage(titles: string[], files: string[], by: Person): string {
90
131
  return `Published: ${titleList(titles)} — by ${displayName(by)}\n\nPublished-by: ${ident(by)}\nPublished-files: ${files.join(', ')}`;
91
132
  }
@@ -93,7 +134,7 @@ export function publishMessage(titles: string[], files: string[], by: Person): s
93
134
  // ── Reading them back ─────────────────────────────────────────────────────────
94
135
 
95
136
  export interface ParsedMessage {
96
- kind: 'draft' | 'merge' | 'publish' | 'other';
137
+ kind: 'draft' | 'merge' | 'publish' | 'discard' | 'other';
97
138
  /** Edited-by trailers: who changed which files. */
98
139
  edits: { person: Person; files: string[] }[];
99
140
  /** Draft-kept: files where the draft won a conflict. */
@@ -104,6 +145,8 @@ export interface ParsedMessage {
104
145
  publishedFiles: string[];
105
146
  /** The titles in a Draft or Published headline (without "and N more"). */
106
147
  titles: string[];
148
+ /** Discarded-files, for a discard: what came before about these files no longer counts. */
149
+ discardedFiles: string[];
107
150
  }
108
151
 
109
152
  const list = (value: string) =>
@@ -123,7 +166,7 @@ const parsePerson = (value: string): Person | null => {
123
166
  /** Read a message written by the functions above. Anything else parses as `other` with no data. */
124
167
  export function parseMessage(message: string): ParsedMessage {
125
168
  const [headline = '', ...rest] = message.split('\n');
126
- const out: ParsedMessage = { kind: 'other', edits: [], keptDraft: [], publishedBy: null, publishedFiles: [], titles: [] };
169
+ const out: ParsedMessage = { kind: 'other', edits: [], keptDraft: [], publishedBy: null, publishedFiles: [], titles: [], discardedFiles: [] };
127
170
  const titles = (s: string) => list(s.replace(/ and \d+ more$/, ''));
128
171
  if (headline.startsWith('Draft: ')) {
129
172
  out.kind = 'draft';
@@ -132,6 +175,9 @@ export function parseMessage(message: string): ParsedMessage {
132
175
  else if (headline.startsWith('Published: ')) {
133
176
  out.kind = 'publish';
134
177
  out.titles = titles(headline.slice('Published: '.length).replace(/ — by .*$/, ''));
178
+ } else if (headline.startsWith('Discarded: ')) {
179
+ out.kind = 'discard';
180
+ out.titles = titles(headline.slice('Discarded: '.length).replace(/ — by .*$/, ''));
135
181
  }
136
182
  for (const line of rest) {
137
183
  const m = /^([A-Za-z-]+): (.*)$/.exec(line.trim());
@@ -146,6 +192,7 @@ export function parseMessage(message: string): ParsedMessage {
146
192
  } else if (key === 'Draft-kept') out.keptDraft.push(...list(value!));
147
193
  else if (key === 'Published-by') out.publishedBy = parsePerson(value!);
148
194
  else if (key === 'Published-files') out.publishedFiles = list(value!);
195
+ else if (key === 'Discarded-files' && out.kind === 'discard') out.discardedFiles = list(value!);
149
196
  }
150
197
  return out;
151
198
  }
@@ -4,6 +4,8 @@
4
4
  *
5
5
  * - **"Published as it stood"** (`caracms:notices`, a hash by notice id): Publish leaves
6
6
  * one for each person who had a published document open; dismissing it deletes it.
7
+ * - **"Discarded"** (Discard changes), in the same hash: one for each person who had edits in
8
+ * the document or had it open, but didn't discard it; dismissing it deletes it.
7
9
  * - **Dismissed "draft kept" notices** (`caracms:dismissed`, a set of ids): those are
8
10
  * shared, worked out from git (`git.keptDraftNotices()`), so a dismissal is remembered
9
11
  * here; a new conflict has a new id and shows again.
@@ -25,6 +27,8 @@ export interface StoodNotice {
25
27
  title: string;
26
28
  by: Person;
27
29
  at: string;
30
+ /** Absent: "published as it stood". `discarded`: "Aoife discarded the unpublished changes to About". */
31
+ kind?: 'discarded';
28
32
  }
29
33
 
30
34
  export class Notices {
@@ -90,7 +94,7 @@ export class Notices {
90
94
  for (const raw of stood) {
91
95
  try {
92
96
  const n = JSON.parse(raw) as StoodNotice;
93
- if (n.for === email) out.push({ id: n.id, kind: 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
97
+ if (n.for === email) out.push({ id: n.id, kind: n.kind === 'discarded' ? 'discarded' : 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
94
98
  } catch {
95
99
  /* skip */
96
100
  }
@@ -93,8 +93,9 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
93
93
  if (gh.kind === 'cloud') {
94
94
  // caracms-cloud M2 Decision 13: the repo from the cloud's settings, then the first
95
95
  // token, so a refusal of either stops the start (the sign-in guard shows its sentence).
96
+ // A rename on GitHub after this is taken by the client (adversarial review, finding 7).
96
97
  const { owner, repo } = await cloudRepoForStart({ key: gh.key, url: gh.url, fetch });
97
- client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch }, tracker);
98
+ client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch, log }, tracker);
98
99
  await client.ready();
99
100
  log(`live mode on ${owner}/${repo} through CaraCMS Cloud`);
100
101
  } else {
@@ -29,6 +29,11 @@ export function waitingFromCommits(files: Iterable<string>, commits: CommitSumma
29
29
  for (const file of files) out.set(file, { file, editors: [], changedAt: null, keptDraft: false });
30
30
  for (const commit of commits) {
31
31
  const parsed = parseMessage(commit.message);
32
+ // A discard: what came before about its files no longer counts (they matched `main` after it).
33
+ for (const file of parsed.discardedFiles) {
34
+ const w = out.get(file);
35
+ if (w) Object.assign(w, { editors: [], changedAt: null, keptDraft: false });
36
+ }
32
37
  for (const edit of parsed.edits) {
33
38
  for (const file of edit.files) {
34
39
  const w = out.get(file);
package/src/live/sweep.ts CHANGED
@@ -25,6 +25,10 @@
25
25
  * → `ancestorFor`), a "gone" one as a removed file (git.ts → `commitDrafts`). A newer
26
26
  * write that came in while a new document was being committed is no longer a new
27
27
  * document: its `created` mark is dropped (retried if a write beats the swap), so its ancestor is that commit.
28
+ * - **`exclusively`** runs other work under the sweep's own locks (this process's queue and
29
+ * the Redis lock): Discard changes, so no tick commits the edit being thrown away while
30
+ * it's being thrown away. It waits a few seconds for another server's tick, then gives
31
+ * up (`SweepBusyError`) rather than go ahead without the lock.
28
32
  */
29
33
  import { randomBytes } from 'node:crypto';
30
34
  import type { DraftEntry, LiveGit } from './git.ts';
@@ -64,6 +68,14 @@ export interface SweepResult {
64
68
 
65
69
  type Select = 'ready' | 'all' | { files: string[] };
66
70
 
71
+ /** `exclusively` couldn't get the sweep's Redis lock: another server's tick held it throughout. */
72
+ export class SweepBusyError extends Error {
73
+ constructor() {
74
+ super('the sweep is busy on another server');
75
+ this.name = 'SweepBusyError';
76
+ }
77
+ }
78
+
67
79
  /** Is an autosave ready for a tick: marked to go at once, idle long enough, or past the cap? */
68
80
  export function ready(a: Autosave, now: number, timings: Pick<LiveTimings, 'sweepIdleMs' | 'sweepCapMs'>): boolean {
69
81
  return a.sweepAtOnce === true || now - a.updatedAt >= timings.sweepIdleMs || now - a.firstAt >= timings.sweepCapMs;
@@ -90,11 +102,16 @@ export class Sweep {
90
102
  this.lockKey = `${options.prefix ?? 'caracms:'}sweep:lock`;
91
103
  }
92
104
 
93
- /** The startup check: anything a previous server left behind starts the ticking. */
105
+ /**
106
+ * The startup check: anything a previous server left behind starts the ticking. An
107
+ * autosave that still carries the week's expiry older versions gave it loses it
108
+ * (caracms-cloud adversarial review, finding 4: an uncommitted edit never expires).
109
+ */
94
110
  async start(): Promise<void> {
95
111
  const pending = await this.o.autosaves.all();
96
112
  if (pending.length) {
97
113
  this.log(`sweep: ${pending.length} autosave(s) waiting from before this server started`);
114
+ for (const a of pending) await this.o.autosaves.keepForever(a);
98
115
  this.poke();
99
116
  }
100
117
  }
@@ -151,6 +168,32 @@ export class Sweep {
151
168
  return (await this.run({ files })).result;
152
169
  }
153
170
 
171
+ /**
172
+ * Run `fn` holding the sweep's locks: after any run of this process's in flight, and with
173
+ * the Redis lock (waiting up to about 5 s for another server's tick, else SweepBusyError).
174
+ */
175
+ exclusively<T>(fn: () => Promise<T>): Promise<T> {
176
+ const work = async (): Promise<T> => {
177
+ const { redis, timings } = this.o;
178
+ const token = randomBytes(9).toString('base64url');
179
+ const lockMs = Math.max(30_000, timings.sweepTickMs);
180
+ let locked = await redis.set(this.lockKey, token, { nx: true, px: lockMs });
181
+ for (let i = 0; i < 20 && !locked; i++) {
182
+ await this.sleep(250);
183
+ locked = await redis.set(this.lockKey, token, { nx: true, px: lockMs });
184
+ }
185
+ if (!locked) throw new SweepBusyError();
186
+ try {
187
+ return await fn();
188
+ } finally {
189
+ await redis.compareAndDelete(this.lockKey, token).catch(() => false);
190
+ }
191
+ };
192
+ const next = this.running.then(work, work);
193
+ this.running = next.catch(() => {});
194
+ return next;
195
+ }
196
+
154
197
  /** Runs one at a time in this process; the Redis lock keeps two servers apart. */
155
198
  private run(select: Select): Promise<{ result: SweepResult; left: boolean }> {
156
199
  const next = this.running.then(
@@ -8,8 +8,11 @@
8
8
  * compared in constant time, or it's 401 and nothing happens.
9
9
  * - **Nothing in the body is trusted or even read**: a good signature only prompts
10
10
  * Decision 8's check, which asks GitHub itself what moved.
11
+ * - **At most 25 MB is read**, counted as it comes (body.ts): a chunked body with no
12
+ * `Content-Length`, or one that lies, is refused at the cap, not after (finding 10).
11
13
  */
12
14
  import { createHmac, timingSafeEqual } from 'node:crypto';
15
+ import { readCapped } from '../body.ts';
13
16
 
14
17
  /** Largest body read. GitHub caps payloads at 25 MB; a push is far smaller, and it's never parsed. */
15
18
  const MAX_BODY_BYTES = 25 * 1024 * 1024;
@@ -30,9 +33,11 @@ const text = (status: number, body: string) => new Response(body, { status, head
30
33
  export async function handleGithubWebhook(request: Request, options: { secret: string | null; trigger: () => Promise<unknown> }): Promise<Response> {
31
34
  if (!options.secret) return text(404, 'Not found');
32
35
  if (request.method !== 'POST') return new Response('The webhook only takes POST.', { status: 405, headers: { Allow: 'POST' } });
33
- if (Number(request.headers.get('content-length') ?? 0) > MAX_BODY_BYTES) return text(413, 'Too large.');
34
- const body = Buffer.from(await request.arrayBuffer());
35
- if (body.length > MAX_BODY_BYTES) return text(413, 'Too large.');
36
+ // caracms-cloud adversarial review, finding 10: the bytes are counted as they're read and the read
37
+ // stops at the cap, with or without a Content-Length, before the signature is checked.
38
+ const read = await readCapped(request, MAX_BODY_BYTES);
39
+ if (!read.ok) return text(413, 'Too large.');
40
+ const body = Buffer.from(read.bytes.buffer, read.bytes.byteOffset, read.bytes.byteLength);
36
41
  if (!signatureMatches(options.secret, body, request.headers.get('x-hub-signature-256'))) return text(401, 'Bad signature.');
37
42
  void options.trigger().catch((error: unknown) => console.error('[caracms] the check after a webhook failed:', error));
38
43
  return text(202, 'Checking.');