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
package/src/live/cloud.ts CHANGED
@@ -12,11 +12,18 @@
12
12
  * - `cloudSettings()`: the site's repo from those settings (`github.repo`; null is
13
13
  * `no_repo`, with the cloud's own sentence). Only the editor needs a repo.
14
14
  * - `cloudToken()`: `POST /v1/github/token`, a fresh installation token on the site's repo,
15
- * about an hour long. `GitHubClient` calls it and refreshes five minutes early.
15
+ * about an hour long. `GitHubClient` calls it and refreshes five minutes early. It waits
16
+ * 35 s (`CLOUD_TOKEN_TIMEOUT_MS`), every other call 20 s (`CLOUD_TIMEOUT_MS`).
17
+ * - `confirmCloudRepo()`: a token answer naming another repo is a rename or transfer on
18
+ * GitHub (the cloud's M2b: "A `full_name` change GitHub reports updates the site's repo").
19
+ * The settings are read again; when they name it too, the held copy takes the new repo
20
+ * and `GitHubClient` carries on under it.
16
21
  * - **A refusal** (any answer that isn't 2xx, carrying `{ error, message }`) throws a
17
22
  * `CloudRefusal` with the cloud's `message` as it came, and `installUrl` when there is
18
23
  * one. A cloud that can't be reached, or answers with something else, throws a
19
- * `CloudRefusal` whose `error` is `unreachable`, with the package's own sentence.
24
+ * `CloudRefusal` whose `error` is `unreachable`, with the package's own sentence and a
25
+ * `detail` for logs (timed out, the connection failed, or the status of an answer that
26
+ * wasn't the API's).
20
27
  *
21
28
  * - `cloudUnavailable()`: the sign-in guard's 503 for a refusal at start (Decision 14),
22
29
  * the sentence escaped and the install link a link.
@@ -33,19 +40,37 @@ export const CLOUD_KEY_SHAPE = /^cck_[A-Za-z0-9_-]{43}$/;
33
40
  export const CLOUD_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so the editor can't start. Try again in a minute.";
34
41
  /** M3 Decision 20: the build's own sentence for a cloud it can't reach. */
35
42
  export const CLOUD_UNREACHABLE_BUILD = "CaraCMS Cloud couldn't be reached, so the site can't be built. Try again in a minute.";
36
- /** The cloud's own `no_repo` sentence (Contracts), for `github.repo: null` in the settings. */
37
- export const CLOUD_NO_REPO = 'This site has no GitHub repo in CaraCMS Cloud yet. Ask CaraCMS to set one.';
38
- /** Decision 13: a token answer named another repo than the one the server started with. */
39
- export const CLOUD_REPO_CHANGED = (repo: string) => `This site's repo was changed in CaraCMS Cloud to ${repo}. Restart the server to use it.`;
43
+ /**
44
+ * The cloud's own `no_repo` sentence, word for word (caracms-cloud `convex/http.ts`, the token
45
+ * route), for `github.repo: null` in the settings, which carry no sentence of their own. The
46
+ * token route's `no_repo` refusal is shown as it came (`cloudCall`); this is only the copy for
47
+ * the settings (caracms-cloud adversarial review, seam note: it was M1's "Ask CaraCMS to set one").
48
+ */
49
+ export const CLOUD_NO_REPO = 'This site has no GitHub repo in CaraCMS Cloud yet. A team owner can pick one in app.caracms.com.';
50
+ /**
51
+ * A token answer named another repo than the one the server is on, and the settings, read
52
+ * again, don't say so yet (or couldn't be read): this refresh fails, and the next one asks
53
+ * again (caracms-cloud adversarial review, finding 7; once the settings agree, the new name
54
+ * is taken, github.ts).
55
+ */
56
+ export const CLOUD_REPO_CHANGED = (repo: string) =>
57
+ `This site's repo is now ${repo} on GitHub, but CaraCMS Cloud's settings don't say so yet, so changes can't be saved to GitHub for now. The editor tries again on its own; if this stays, ask CaraCMS to check the site.`;
40
58
 
41
59
  /** How long a call to the cloud may take before it counts as unreachable. */
42
60
  export const CLOUD_TIMEOUT_MS = 20_000;
61
+ /**
62
+ * The token call's own, longer timeout (caracms-cloud adversarial review, seam note): the
63
+ * cloud's worst case, an id mint refused and then the installation found and a token minted
64
+ * by name (10 s to GitHub each), can pass 20 s, which would read as `unreachable`.
65
+ */
66
+ export const CLOUD_TOKEN_TIMEOUT_MS = 35_000;
43
67
 
44
68
  export interface CloudConnection {
45
69
  key: string;
46
70
  url: string;
47
71
  /** For tests. Default: global fetch. */
48
72
  fetch?: typeof fetch;
73
+ /** Default: `CLOUD_TIMEOUT_MS`, or `CLOUD_TOKEN_TIMEOUT_MS` for `cloudToken()`. */
49
74
  timeoutMs?: number;
50
75
  }
51
76
 
@@ -66,27 +91,30 @@ export function isCloudRefusal(value: unknown): value is CloudRefusal {
66
91
  /** The cloud said no (or couldn't be reached). `message` is safe to show and to log. */
67
92
  export class CloudRefusal extends Error {
68
93
  readonly [REFUSAL_BRAND] = true;
69
- /** The cloud's `error` (`no_repo`, `app_not_installed`, `unknown_key`…), or the package's `unreachable` / `repo_changed`. */
94
+ /** The cloud's `error` (`no_repo`, `app_not_installed`, `unknown_key`…), or the package's `unreachable` / `repo_changed` (finding 7: a rename the settings don't confirm yet). */
70
95
  readonly error: string;
71
96
  /** An `https://` link the message names (the App's install page), or null. */
72
97
  readonly installUrl: string | null;
73
- constructor(error: string, message: string, installUrl: string | null = null) {
98
+ /** For `unreachable`, what happened, for a log line (never a secret): `timed out`, `answered HTTP 502`… */
99
+ readonly detail: string | null;
100
+ constructor(error: string, message: string, installUrl: string | null = null, detail: string | null = null) {
74
101
  super(message);
75
102
  this.name = 'CloudRefusal';
76
103
  this.error = error;
77
104
  this.installUrl = installUrl;
105
+ this.detail = detail;
78
106
  }
79
107
  }
80
108
 
81
- const unreachable = () => new CloudRefusal('unreachable', CLOUD_UNREACHABLE);
109
+ const unreachable = (detail: string | null = null) => new CloudRefusal('unreachable', CLOUD_UNREACHABLE, null, detail);
82
110
 
83
111
  /**
84
112
  * One call to the API: `path` with its query, a JSON `body` for a POST. The answer's JSON
85
113
  * object, or a `CloudRefusal` (the cloud's own, or `unreachable`). The media bucket
86
114
  * (editor/server/media/cloud-bucket.ts) calls it too.
87
115
  */
88
- export async function cloudCall(connection: CloudConnection, method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
89
- let res: Response;
116
+ export async function cloudCall(connection: CloudConnection, method: 'GET' | 'POST', path: string, body?: unknown, defaultTimeoutMs = CLOUD_TIMEOUT_MS): Promise<Record<string, unknown>> {
117
+ let res: Response | undefined;
90
118
  let answer: unknown;
91
119
  try {
92
120
  res = await (connection.fetch ?? fetch)(`${connection.url}${path}`, {
@@ -98,17 +126,18 @@ export async function cloudCall(connection: CloudConnection, method: 'GET' | 'PO
98
126
  ...(body === undefined ? {} : { 'content-type': 'application/json' }),
99
127
  },
100
128
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
101
- signal: AbortSignal.timeout(connection.timeoutMs ?? CLOUD_TIMEOUT_MS),
129
+ signal: AbortSignal.timeout(connection.timeoutMs ?? defaultTimeoutMs),
102
130
  });
103
131
  answer = await res.json();
104
- } catch {
105
- throw unreachable();
132
+ } catch (error) {
133
+ const timedOut = (error as { name?: unknown } | null)?.name === 'TimeoutError';
134
+ throw unreachable(timedOut ? 'timed out' : res ? `answered HTTP ${res.status}, not with JSON` : 'the connection failed');
106
135
  }
107
136
  const object = answer && typeof answer === 'object' && !Array.isArray(answer) ? (answer as Record<string, unknown>) : null;
108
- if (!object) throw unreachable();
137
+ if (!object) throw unreachable(`answered HTTP ${res.status}, not with an object`);
109
138
  if (!res.ok) {
110
139
  const { error, message, installUrl } = object;
111
- if (typeof error !== 'string' || typeof message !== 'string' || !message.trim()) throw unreachable();
140
+ if (typeof error !== 'string' || typeof message !== 'string' || !message.trim()) throw unreachable(`answered HTTP ${res.status} without a reason`);
112
141
  throw new CloudRefusal(error, message, typeof installUrl === 'string' && /^https:\/\//i.test(installUrl) ? installUrl : null);
113
142
  }
114
143
  return object;
@@ -147,9 +176,9 @@ export async function fetchCloudSettings(connection: CloudConnection): Promise<C
147
176
  const github = answer.github as { repo?: unknown } | undefined;
148
177
  const media = answer.media as { bucket?: unknown; imageHost?: unknown } | undefined;
149
178
  const pair = answer.imgproxy as { key?: unknown; salt?: unknown } | undefined;
150
- if (!github || typeof github !== 'object') throw unreachable();
179
+ if (!github || typeof github !== 'object') throw unreachable('answered settings without github');
151
180
  const m = typeof github.repo === 'string' ? REPO.exec(github.repo) : null;
152
- if (github.repo !== null && !m) throw unreachable();
181
+ if (github.repo !== null && !m) throw unreachable('answered settings with a malformed repo');
153
182
  const folder = str(answer.folder);
154
183
  const bucket = str(media && typeof media === 'object' ? media.bucket : null);
155
184
  const imageHost = str(media && typeof media === 'object' ? media.imageHost : null);
@@ -221,14 +250,29 @@ export function heldCloudSettings(connection: Pick<CloudConnection, 'key' | 'url
221
250
  return h.failure ? { kind: 'failed', refusal: h.failure } : null;
222
251
  }
223
252
 
253
+ /**
254
+ * caracms-cloud adversarial review, finding 7: a token answer named `fullName`, not the repo
255
+ * the server is on. `GET /v1/settings` afresh: when it names `fullName` too, that repo is
256
+ * returned and the held copy takes it (a start later in this process begins on it; the media
257
+ * are left as held). Null when the settings name another repo, or none, or can't be read.
258
+ */
259
+ export async function confirmCloudRepo(connection: CloudConnection, fullName: string): Promise<{ owner: string; repo: string } | null> {
260
+ const settings = await fetchCloudSettings(connection).catch(() => null);
261
+ const repo = settings?.repo ?? null;
262
+ if (!repo || `${repo.owner}/${repo.repo}`.toLowerCase() !== fullName.toLowerCase()) return null;
263
+ const h = held()[HELD];
264
+ if (h?.value && h.key === connection.key && h.url === connection.url) h.value = { ...h.value, repo };
265
+ return repo;
266
+ }
267
+
224
268
  /** Forget the held settings (tests). */
225
269
  export function resetCloudSettings(): void {
226
270
  delete held()[HELD];
227
271
  }
228
272
 
229
- /** A fresh installation token on the site's repo, from `POST /v1/github/token`. */
273
+ /** A fresh installation token on the site's repo, from `POST /v1/github/token` (35 s unless `timeoutMs` says otherwise). */
230
274
  export async function cloudToken(connection: CloudConnection): Promise<{ token: string; expiresAt: number; repo: string }> {
231
- const answer = await cloudCall(connection, 'POST', '/v1/github/token');
275
+ const answer = await cloudCall(connection, 'POST', '/v1/github/token', undefined, CLOUD_TOKEN_TIMEOUT_MS);
232
276
  const { token, expiresAt, repo } = answer;
233
277
  const at = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
234
278
  if (typeof token !== 'string' || !token || Number.isNaN(at) || typeof repo !== 'string') throw unreachable();
@@ -80,12 +80,16 @@ export function unavailable(error: unknown): WriteRefused {
80
80
  * expire, so a file with none was never changed in the editor or Redis lost its data, and
81
81
  * the server can't tell which: the keeper holds edits on unknown rather than re-send them.
82
82
  */
83
- /** `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here. */
83
+ /**
84
+ * `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here.
85
+ * Discard changes: its `discard` is `discarded`, so a browser drops edits held from before it.
86
+ */
84
87
  export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string, known?: ChangeRecord | null): Promise<LastChange> {
85
88
  const record = known !== undefined ? known : await parts.autosaves.changed(file);
86
- if (record) return { by: record.by, version: record.version, at: record.at };
89
+ const discarded = record?.discard ? { discarded: { by: record.discard.by, at: record.discard.at } } : {};
90
+ if (record) return { by: record.by, version: record.version, at: record.at, ...discarded };
87
91
  const editor = parts.git.waiting().find((w) => w.file === file)?.editors[0];
88
- return editor ? { by: editor.person, version: null, at: editor.at } : { by: null, version: null, at: null, known: false };
92
+ return editor ? { by: editor.person, version: null, at: editor.at, ...discarded } : { by: null, version: null, at: null, known: false, ...discarded };
89
93
  }
90
94
 
91
95
  export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
@@ -400,7 +404,7 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
400
404
  guard(async () => {
401
405
  const file = posix(relative(site.root, resolveDocumentFile(site, collection, path)));
402
406
  const save = (await autosaves.get(file))?.value ?? null;
403
- const read = await readDocument(site, collection, path, overlay(save ? [save] : []));
407
+ const read = await readDocument(site, collection, path, overlay(save ? [save] : []), { untitled: true });
404
408
  return { ...read, mtimeMs: save?.updatedAt ?? 0, version: fileVersion(read.text), lastChange: await lastChangeOf(parts, file) };
405
409
  }),
406
410
 
@@ -415,8 +419,15 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
415
419
  const current = git.read(request.file);
416
420
  // It exists: in the copy, or as a new document's autosave (M5a); a "gone" autosave is a deletion.
417
421
  const missing = () => new WriteRefused(404, `${site.collections[target.collection]!.label}: no document "${target.path}".`);
418
- const saved = await autosaves.get(request.file);
422
+ const [saved, changed] = await autosaves.getWithChange(request.file);
419
423
  if (saved?.value.gone || (current === null && !saved)) throw missing();
424
+ // Discard changes: a tab that had the document when someone put it back to what's live
425
+ // can't write until it has let go of its copy (it's stale: its edits would come back).
426
+ const discard = changed?.discard;
427
+ if (discard && discard.tabs.includes(tab)) {
428
+ const title = saved?.value.text ? titleOf(request.file, saved.value.text) : titleOf(request.file, current);
429
+ throw new WriteRefused(409, `${displayName(discard.by)} discarded the unpublished changes to ${title}, so these edits weren't kept.`, { code: 'discarded', by: discard.by, at: discard.at });
430
+ }
420
431
 
421
432
  const text = writeText(site, target.collection, request);
422
433
 
@@ -494,7 +505,7 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
494
505
  if (await autosaves.putIf(existing, next)) break;
495
506
  }
496
507
  const version = fileVersion(text);
497
- await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString() });
508
+ await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString(), ...autosaves.carryDiscard(changed) });
498
509
  if (context?.release) await locks.release(request.file, tab, person);
499
510
  sweep.poke();
500
511
  return { ok: true, file: request.file, changed: text !== (existing?.value.text ?? current), mtimeMs: t, version };
@@ -1,7 +1,17 @@
1
1
  /**
2
2
  * Live mode's editor server (M4 Phase 2): the live drafts store, the sweep, locks and
3
3
  * presence, and the handlers behind `/_caracms/status`, `lock`, `publish`, `history`,
4
- * `try-again` and `notice` (the routes in ../editor/routes/ are one line each).
4
+ * `try-again`, `notice` and `discard` (the routes in ../editor/routes/ are one line each).
5
+ *
6
+ * **Discard changes** (`discard`): one waiting document back to what's live. Its autosave,
7
+ * committed record and move records go and its files on `drafts` are committed as `main` has
8
+ * them (git.ts → `restoreDrafts`), all under the sweep's locks (sweep.ts → `exclusively`), so
9
+ * no tick commits the edit meanwhile. A moved document first moves back through the move
10
+ * engine (its links and redirects too), unredirected. Every other tab that had it open or
11
+ * was editing it is listed in its discard record (autosaves.ts): their writes are refused
12
+ * until they let go (`{ file, seen: true }`), and each other person who edited it or had it
13
+ * open gets a "discarded" notice. Refused while someone else is editing it now (a fresh
14
+ * lock), while a Publish runs, and for nothing waiting.
5
15
  *
6
16
  * One per process, beside the runtime (`liveEditor`), or made by tests
7
17
  * (`createLiveEditor`) on a runtime from `createLiveRuntime` with the in-memory
@@ -32,8 +42,11 @@ import type { DraftsStore } from '../editor/server/drafts-store.ts';
32
42
  import { handleCreate, handleDelete } from '../editor/server/create.ts';
33
43
  import { checkOrigin, confinePath, fileVersion, handleWrite, tabOf, WriteRefused } from '../editor/server/write.ts';
34
44
  import { missingAltIn } from '../editor/server/media/alt.ts';
45
+ import { settingsWithRedirectsBack } from '../editor/server/move.ts';
46
+ import { parseAnyDocument } from '../document.ts';
35
47
  import type {
36
48
  ApiError,
49
+ DiscardResponse,
37
50
  HistoryResponse,
38
51
  LockRefusal,
39
52
  LockResponse,
@@ -47,7 +60,7 @@ import type {
47
60
  } from '../editor/types.ts';
48
61
  import { Autosaves, splitMoves } from './autosaves.ts';
49
62
  import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
50
- import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf } from './git.ts';
63
+ import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf, type DraftsLost } from './git.ts';
51
64
  import { accessProblemMessage, type GitHubAccessProblem } from './github.ts';
52
65
  import { parseRedirects, type Redirect } from '../redirects.ts';
53
66
  import { Locks } from './locks.ts';
@@ -56,7 +69,9 @@ import type { LiveSettings } from './mode.ts';
56
69
  import { Notices, type StoodNotice } from './notices.ts';
57
70
  import { RedisUnavailableError } from './redis.ts';
58
71
  import { liveRuntime, type LiveRuntime } from './runtime.ts';
59
- import { Sweep, type Schedule } from './sweep.ts';
72
+ import { Sweep, SweepBusyError, type Schedule } from './sweep.ts';
73
+ import { readCappedText } from '../body.ts';
74
+ import { textProblems } from '../document-problems.ts';
60
75
 
61
76
  type Handler = (request: Request, person: Person | null) => Promise<Response>;
62
77
 
@@ -67,8 +82,33 @@ const ACCESS_TITLE: Record<GitHubAccessProblem['kind'], string> = {
67
82
  permission: 'GitHub refused a change',
68
83
  expiring: "This site's GitHub token expires soon",
69
84
  cloud: 'No GitHub access through CaraCMS Cloud',
85
+ // caracms-cloud adversarial review, finding 7: information, not a problem.
86
+ renamed: "This site's repo has a new name",
70
87
  };
71
88
 
89
+ /** Finding 4: the save bar's sentence while draft commits keep failing. */
90
+ export const COMMIT_TROUBLE_MESSAGE = "Saved here, but not reaching GitHub. Ask your developer to check the site's GitHub access.";
91
+
92
+ /** Finding 5: the note's sentence once `drafts` had to be made again from `main`. */
93
+ export function draftsLostMessage(lost: DraftsLost): string {
94
+ const lastCommit = lost.commit ? `its last commit (${lost.commit.slice(0, 7)}) couldn't be found` : 'no record of its last commit was kept';
95
+ return `The drafts branch went missing on GitHub and ${lastCommit}, so it was made again from the live site. Changes saved since the last Publish are gone from the editor. Ask your developer to protect the drafts branch from deletion.`;
96
+ }
97
+
98
+ /** Force-push note (caracms-cloud adversarial review): `main` was rolled back past the last Publish. */
99
+ export const MAIN_ROLLED_BACK_TITLE = 'The live site was rolled back on GitHub';
100
+ export const MAIN_ROLLED_BACK_MESSAGE =
101
+ "The changes waiting here include what was rolled back, and publishing will put it back. Discard what you don't want first: each waiting document has Discard… in the Publish list.";
102
+
103
+ /**
104
+ * A Redis outage before a Publish's refs move (finding 6): nothing went out, and nothing is
105
+ * held in the browser either (that's the write route's sentence, `unavailable`).
106
+ */
107
+ export function publishUnavailable(error: unknown): WriteRefused {
108
+ const what = error instanceof Error ? error.message : String(error);
109
+ return new WriteRefused(503, `The server can't reach its store right now, so it couldn't publish. Your changes are still waiting: try again in a minute (${what}).`, { code: 'unavailable' });
110
+ }
111
+
72
112
  export interface LiveEditor {
73
113
  readonly runtime: Pick<LiveRuntime, 'site' | 'settings' | 'git' | 'redis' | 'timings' | 'now'>;
74
114
  readonly autosaves: Autosaves;
@@ -85,6 +125,8 @@ export interface LiveEditor {
85
125
  history: Handler;
86
126
  tryAgain: Handler;
87
127
  notice: Handler;
128
+ /** Discard changes: POST /_caracms/discard `{ file }` (types.ts → DiscardRequest). */
129
+ discard: Handler;
88
130
  }
89
131
 
90
132
  export interface LiveEditorOptions {
@@ -99,8 +141,8 @@ const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Cont
99
141
  const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: HEADERS });
100
142
  const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.toLowerCase();
101
143
 
102
- /** A notice id: `kept:<file>:<commit>` or `stood:<commit>:<file>:<email>`, no spaces or controls, capped. */
103
- const NOTICE_ID = /^(kept|stood):[^\s\p{C}]+$/u;
144
+ /** A notice id: `kept:<file>:<commit>`, `stood:<commit>:<file>:<email>` or `discarded:<at>:<file>:<email>`, no spaces or controls, capped. */
145
+ const NOTICE_ID = /^(kept|stood|discarded):[^\s\p{C}]+$/u;
104
146
  const NOTICE_ID_MAX = 512;
105
147
 
106
148
  /** Map a site file to its document (collection and path), or nulls. */
@@ -152,9 +194,9 @@ export async function createLiveEditor(
152
194
  // hook above in place, so a merge from main commits every pending autosave first.
153
195
  await git.check({ force: true }).catch((error: unknown) => log(`check at startup: ${error instanceof Error ? error.message : String(error)}`));
154
196
 
155
- /** Common gate: a person, the same origin for anything but GET, Redis outages as 503. */
197
+ /** Common gate: a person, the same origin for anything but GET, Redis outages as 503 (`outage`: that route's sentence for one). */
156
198
  const handler =
157
- (methods: string[], fn: (request: Request, person: Person) => Promise<Response>): Handler =>
199
+ (methods: string[], fn: (request: Request, person: Person) => Promise<Response>, outage: (error: unknown) => WriteRefused = unavailable): Handler =>
158
200
  async (request, person) => {
159
201
  try {
160
202
  if (!methods.includes(request.method)) {
@@ -167,7 +209,7 @@ export async function createLiveEditor(
167
209
  if (request.method !== 'GET') checkOrigin(request);
168
210
  return await fn(request, person);
169
211
  } catch (error) {
170
- const refused = error instanceof RedisUnavailableError ? unavailable(error) : error;
212
+ const refused = error instanceof RedisUnavailableError ? outage(error) : error;
171
213
  if (refused instanceof WriteRefused) return json(refused.status, { error: refused.message, ...refused.body });
172
214
  log(`${new URL(request.url).pathname} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
173
215
  return json(502, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` } satisfies ApiError);
@@ -175,8 +217,9 @@ export async function createLiveEditor(
175
217
  };
176
218
 
177
219
  const readBody = async (request: Request): Promise<Record<string, unknown>> => {
178
- const raw = await request.text();
179
- if (raw.length > 16_384) throw new WriteRefused(413, 'That body is too large.');
220
+ // caracms-cloud adversarial review, finding 10: 16 KB counted as it's read, never the whole body first.
221
+ const raw = await readCappedText(request, 16_384);
222
+ if (raw === null) throw new WriteRefused(413, 'That body is too large.');
180
223
  try {
181
224
  const body = JSON.parse(raw || '{}') as unknown;
182
225
  if (body && typeof body === 'object' && !Array.isArray(body)) return body as Record<string, unknown>;
@@ -320,7 +363,11 @@ export async function createLiveEditor(
320
363
  // autosave's text, else the copy's). A deleted document has none.
321
364
  const textNow = new Map(saves.map((a) => [a.file, a.text]));
322
365
  for (const w of waiting.values()) {
323
- w.missingAlt = w.change === 'deleted' ? [] : missingAltOf(w.file, w.collection, textNow.has(w.file) ? (textNow.get(w.file) ?? null) : git.read(w.file));
366
+ const text = textNow.has(w.file) ? (textNow.get(w.file) ?? null) : git.read(w.file);
367
+ w.missingAlt = w.change === 'deleted' ? [] : missingAltOf(w.file, w.collection, text);
368
+ // caracms-cloud adversarial review, finding 15: what Publish will leave waiting, by the same rule (git.ts → heldBack).
369
+ const [problem] = w.change !== 'deleted' && w.collection !== null && text !== null ? textProblems(text) : [];
370
+ if (problem) w.heldBack = problem.reason;
324
371
  }
325
372
  // Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
326
373
  // Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
@@ -359,9 +406,16 @@ export async function createLiveEditor(
359
406
  if (save ? save.gone : git.read(file) === null) {
360
407
  deleted = record?.gone ? { by: record.by, at: record.at } : { by: null, at: null };
361
408
  }
362
- open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file, record), deleted };
409
+ // Discard changes: this tab had it when someone put it back to what's live (its copy is stale).
410
+ const discard = record?.discard;
411
+ const discarded = discard && tab && discard.tabs.includes(tab) ? { by: discard.by, at: discard.at } : null;
412
+ open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file, record), deleted, discarded };
363
413
  }
364
414
  const access = git.backend.accessProblem();
415
+ // Finding 4: only while something is waiting to reach GitHub.
416
+ const trouble = saves.length ? git.commitTrouble() : null;
417
+ const lost = git.draftsLost();
418
+ const rolledBack = git.mainRolledBack();
365
419
  const body: StatusResponse = {
366
420
  mode: 'live',
367
421
  you: person,
@@ -373,11 +427,14 @@ export async function createLiveEditor(
373
427
  locks: lockViews,
374
428
  presence,
375
429
  notices: Notices.forPerson(person, git.keptDraftNotices(), titleFor, snap.set(noticeStore.dismissedKey), snap.hash(noticeStore.noticesKey).values()),
376
- publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: LiveGit.publishRecordFrom(snap.string(git.publishRecordKey)) }),
430
+ publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: git.publishRecordOr(LiveGit.publishRecordFrom(snap.string(git.publishRecordKey))) }),
377
431
  // Runs-anywhere Decision 5: while it holds, not dismissable.
378
432
  githubAccess: access
379
433
  ? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access), link: access.kind === 'cloud' ? access.installUrl : null }
380
434
  : null,
435
+ commitTrouble: trouble ? { since: new Date(trouble.since).toISOString(), failures: trouble.failures, message: COMMIT_TROUBLE_MESSAGE } : null,
436
+ draftsLost: lost ? { title: 'Unpublished changes were lost', message: draftsLostMessage(lost), commit: lost.commit, at: lost.at } : null,
437
+ mainRolledBack: rolledBack ? { title: MAIN_ROLLED_BACK_TITLE, message: MAIN_ROLLED_BACK_MESSAGE, publish: rolledBack.publish, main: rolledBack.main, at: rolledBack.at } : null,
381
438
  // M5a Decision 10: from the committed copy, never an autosave.
382
439
  brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
383
440
  // Arrange pages (Decision 18): from the autosaves already in the snapshot, else the copy; no Redis command of its own.
@@ -428,7 +485,10 @@ export async function createLiveEditor(
428
485
  const publish = handler(['POST'], async (_request, person) => {
429
486
  const before = await locks.presence();
430
487
  const work = git.publish(person).then(async (result) => {
431
- await stoodNotices(result, before, person);
488
+ // Finding 6: the notices are kept in Redis, so with Redis gone they can't be sent; the Publish happened all the same.
489
+ await stoodNotices(result, before, person).catch((error: unknown) => {
490
+ log(`publish: the "published as it stood" notices weren't sent (${error instanceof Error ? error.message : String(error)})`);
491
+ });
432
492
  return result;
433
493
  });
434
494
  // Runs-anywhere (Watch-outs): Cloudflare gives up on an answer after 100 s, and a
@@ -444,8 +504,17 @@ export async function createLiveEditor(
444
504
  work.catch((error: unknown) => log(`publish (answered early): ${error instanceof Error ? error.message : String(error)}`));
445
505
  return json(200, { result: 'joined', publish: await publishView() } satisfies PublishResponse);
446
506
  }
507
+ if (result.kind === 'published') {
508
+ // Finding 6: the record as made, not as Redis has it (it may not have taken it); the lock read is the only Redis call, and its failure isn't the Publish's.
509
+ const running = await redis.get(git.publishLockKey).then(
510
+ (v) => v !== null,
511
+ () => false,
512
+ );
513
+ return json(200, { result: result.kind, publish: await publishView({ running, record: result.record }), held: result.held } satisfies PublishResponse);
514
+ }
515
+ if (result.kind === 'held-back') return json(200, { result: result.kind, publish: await publishView(), held: result.held } satisfies PublishResponse);
447
516
  return json(200, { result: result.kind, publish: await publishView() } satisfies PublishResponse);
448
- });
517
+ }, publishUnavailable);
449
518
 
450
519
  /** "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1). */
451
520
  const stoodNotices = async (result: Awaited<ReturnType<typeof git.publish>>, before: Awaited<ReturnType<typeof locks.presence>>, person: Person) => {
@@ -493,7 +562,154 @@ export async function createLiveEditor(
493
562
  return json(200, { ok: true });
494
563
  });
495
564
 
496
- return { runtime, autosaves, locks, sweep, store, write, create, delete: remove, status, lock, publish, history, tryAgain, notice };
565
+ /**
566
+ * Discard changes (see the header). `{ file }`: the document's file now (a moved one's new
567
+ * file). `{ file, seen: true }`: this tab has let go of its copy of a discarded document.
568
+ */
569
+ const discard = handler(['POST'], async (request, person) => {
570
+ const tab = tabOf(request);
571
+ if (!tab) throw new WriteRefused(400, 'Discard requests name their tab (X-Caracms-Tab).', { code: 'no-tab' });
572
+ const body = await readBody(request);
573
+ const extra = Object.keys(body).filter((k) => k !== 'file' && k !== 'seen');
574
+ if (extra.length || typeof body.file !== 'string' || (body.seen !== undefined && body.seen !== true)) {
575
+ throw new WriteRefused(400, 'Malformed body: send { file } (or { file, seen: true }).', { code: 'bad-request' });
576
+ }
577
+ const file = body.file;
578
+ confinePath(site, file);
579
+ if (body.seen === true) {
580
+ await autosaves.ackDiscard(file, tab);
581
+ return json(200, { ok: true });
582
+ }
583
+ if ((await redis.get(git.publishLockKey)) !== null) throw new WriteRefused(409, "A Publish is running. Try again once it's done.", { code: 'publishing' });
584
+
585
+ // What's waiting for it: an autosave, a commit on drafts that main hasn't got, or a move to it.
586
+ const save = (await autosaves.get(file))?.value ?? null;
587
+ const textNow = save ? save.text : git.read(file);
588
+ const fromGit = git.waiting().find((w) => w.file === file) ?? null;
589
+ const moves = await autosaves.moves();
590
+ const from = [...moves].find(([, to]) => to === file)?.[0] ?? null;
591
+ const change = git.changeOf(file, textNow);
592
+ const title = save?.gone ? save.gone.title : textNow !== null ? titleOf(file, textNow) : git.titleFor(file);
593
+ if (!change || (!save && !fromGit && !from)) {
594
+ if (!from && (await autosaves.addresses()).has(file)) {
595
+ throw new WriteRefused(409, `${title} moved with the page that holds it. Discard that page's changes to put it back.`, { code: 'held-move' });
596
+ }
597
+ throw new WriteRefused(404, `${title} has no unpublished changes.`, { code: 'not-waiting' });
598
+ }
599
+
600
+ // Someone else editing it right now (a fresh lock, not one held only until their autosave is committed).
601
+ const own = [file, ...(from ? [from] : [])];
602
+ for (const f of own) {
603
+ const held = await locks.read(f);
604
+ if (held && !sameEmail(held.lock.person, person) && held.lock.releasing === null && now() - held.lock.beat < timings.lockLapseMs) {
605
+ throw new WriteRefused(409, `${displayName(held.lock.person)} is editing ${title} right now, so its changes can't be discarded yet.`, { code: 'locked' });
606
+ }
607
+ }
608
+
609
+ const t0 = now();
610
+ let movedBack: DiscardResponse['movedBack'] = null;
611
+ if (from && git.mainHas(from)) {
612
+ // Back to its old address first, through the move engine (its links, the redirects, what
613
+ // moved with it), with no redirect. A swap (it took a live document's place) isn't undone here.
614
+ const there = docOf(site, file);
615
+ const back = docOf(site, from);
616
+ if (git.mainHas(file)) throw new WriteRefused(409, `${title} took the place of a page that's live, so it can't be discarded on its own.`, { code: 'swap' });
617
+ if (!there.collection || !there.path || !back.path || back.collection !== there.collection) {
618
+ throw new WriteRefused(409, `${title} can't go back to its old address from here.`, { code: 'swap' });
619
+ }
620
+ // Any lock someone left on it (only held until their autosave is committed) goes, so the move can take it.
621
+ for (const f of own) {
622
+ const held = await locks.read(f);
623
+ if (held && held.lock.tab !== tab) await locks.drop(held, f);
624
+ }
625
+ const context = { person, tab, release: false, seq: null };
626
+ const moved = await store.move({ collection: there.collection, path: there.path, to: back.path, redirect: false }, context);
627
+ // A redirect the move took out of Site Settings (from the address it took) comes back, as it is live.
628
+ const vacated = moved.moved.filter((m) => m.fromUrl !== m.toUrl).map((m) => m.fromUrl);
629
+ const current = (await autosaves.get(SITE_SETTINGS_FILE))?.value.text ?? git.read(SITE_SETTINGS_FILE);
630
+ const restored = settingsWithRedirectsBack(site, current, (await git.readMain([SITE_SETTINGS_FILE])).get(SITE_SETTINGS_FILE) ?? null, vacated);
631
+ if (restored !== null && current !== null) {
632
+ const settings = site.collections.settings ?? Object.values(site.collections).find((c) => c.kind === 'settings');
633
+ if (settings) {
634
+ await store.write({ file: SITE_SETTINGS_FILE, base: current, document: parseAnyDocument(JSON.parse(restored), site, settings) }, context);
635
+ await locks.release(SITE_SETTINGS_FILE, tab, person);
636
+ }
637
+ }
638
+ await locks.release(from, tab, person);
639
+ movedBack = { collection: back.collection, path: back.path, file: from };
640
+ }
641
+
642
+ const result = await sweep
643
+ .exclusively(async () => {
644
+ // The document's files, and any file the move back left exactly as main has it (a link put back).
645
+ const files = new Set(own);
646
+ if (movedBack) {
647
+ const mine = (await autosaves.all()).map((r) => r.value).filter((a) => a.tab === tab && sameEmail(a.editor, person) && a.updatedAt >= t0 && !files.has(a.file));
648
+ const main = await git.readMain(mine.map((a) => a.file));
649
+ for (const a of mine) if (a.text === (main.get(a.file) ?? null)) files.add(a.file);
650
+ }
651
+ const list = [...files];
652
+ // Every tab that has it or had edits in it, but this one: their copies are stale from now.
653
+ const tabs = new Set<string>();
654
+ const people = new Map<string, Person>();
655
+ const note = (p: Person) => {
656
+ if (!sameEmail(p, person)) people.set(p.email.toLowerCase(), p);
657
+ };
658
+ for (const e of fromGit?.editors ?? []) note(e.person);
659
+ for (const f of list) {
660
+ const a = (await autosaves.get(f))?.value;
661
+ if (a) {
662
+ tabs.add(a.tab);
663
+ note(a.editor);
664
+ }
665
+ const c = await autosaves.committed(f);
666
+ if (c) tabs.add(c.tab);
667
+ const l = await locks.read(f);
668
+ if (l) tabs.add(l.lock.tab);
669
+ for (const p of await locks.tabsOn(f)) {
670
+ tabs.add(p.tab);
671
+ note(p.person);
672
+ }
673
+ }
674
+ tabs.delete(tab);
675
+ const at = new Date(now()).toISOString();
676
+ const titles = [title];
677
+ const restored = await git.restoreDrafts(list, titles, person);
678
+ // Who changed it last, as it reads now, with the discard and the tabs that had it.
679
+ const changeOf = (f: string) => {
680
+ const text = restored.texts.get(f) ?? null;
681
+ const discard = { by: person, at, tabs: [...tabs] };
682
+ return text === null ? { by: person, version: '', at, gone: true, discard } : { by: person, version: fileVersion(text), at, discard };
683
+ };
684
+ for (const f of list) await autosaves.setChanged(f, changeOf(f));
685
+ for (const f of list) {
686
+ for (const gone of await autosaves.removeAll(f)) if (gone.tab !== tab) tabs.add(gone.tab);
687
+ await autosaves.forgetCommitted(f);
688
+ }
689
+ await autosaves.forgetMovesOf(list);
690
+ for (const f of list) {
691
+ await autosaves.setChanged(f, changeOf(f)); // any tab whose autosave was removed just now too
692
+ // This tab's lock lets go; one left by a tab that's gone is dropped; a live one (your other tab) stays, and that tab is told.
693
+ const l = await locks.read(f);
694
+ const fresh = !!l && l.lock.releasing === null && now() - l.lock.beat < timings.lockLapseMs;
695
+ if (l && l.lock.tab === tab) await locks.release(f, tab, person);
696
+ else if (l && !fresh) await locks.drop(l, f);
697
+ }
698
+ // "Aoife discarded the unpublished changes to About", for everyone else who had it.
699
+ await noticeStore
700
+ .addStood([...people.values()].map((p) => ({ id: `discarded:${at}:${file}:${p.email.toLowerCase()}`, for: p.email.toLowerCase(), file: movedBack?.file ?? file, title, by: person, at, kind: 'discarded' as const })))
701
+ .catch((error: unknown) => log(`discard: the notices weren't left (${error instanceof Error ? error.message : String(error)})`));
702
+ log(`${person.email} discarded the unpublished changes to ${list.join(', ')}`);
703
+ return { files: list };
704
+ })
705
+ .catch((error: unknown) => {
706
+ if (error instanceof SweepBusyError) throw new WriteRefused(409, 'The server is busy saving changes. Try again in a moment.', { code: 'busy' });
707
+ throw error;
708
+ });
709
+ return json(200, { ok: true, file, change, files: result.files, movedBack } satisfies DiscardResponse);
710
+ });
711
+
712
+ return { runtime, autosaves, locks, sweep, store, write, create, delete: remove, status, lock, publish, history, tryAgain, notice, discard };
497
713
  }
498
714
 
499
715
  const SHARED = Symbol.for('caracms.liveEditor');