caracms 0.7.0 → 0.8.1

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 (75) hide show
  1. package/README.md +108 -19
  2. package/bin/caracms.mjs +11 -3
  3. package/bin/init.mjs +332 -34
  4. package/bin/media.mjs +60 -6
  5. package/bin/templates/railway-cloud.ts.txt +73 -0
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +30 -13
  8. package/skills/caracms-hosting/SKILL.md +240 -0
  9. package/skills/caracms-tina/SKILL.md +45 -15
  10. package/src/body.ts +55 -0
  11. package/src/cli/validate.ts +1 -1
  12. package/src/cloud-middleware.ts +51 -5
  13. package/src/config.ts +20 -1
  14. package/src/content.ts +15 -6
  15. package/src/document-problems.ts +48 -0
  16. package/src/editor/client/collections/CollectionGrid.tsx +2 -2
  17. package/src/editor/client/collections/CollectionTable.tsx +3 -3
  18. package/src/editor/client/documents/DocMenu.tsx +56 -1
  19. package/src/editor/client/documents/actions.ts +30 -1
  20. package/src/editor/client/documents/dialogs.tsx +61 -3
  21. package/src/editor/client/editor.ts +98 -2
  22. package/src/editor/client/fields/LockupField.tsx +4 -4
  23. package/src/editor/client/inbox/InboxStage.tsx +25 -1
  24. package/src/editor/client/live/notices.tsx +19 -0
  25. package/src/editor/client/live/publish.tsx +58 -2
  26. package/src/editor/client/nav/DocRow.tsx +4 -4
  27. package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
  28. package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
  29. package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
  30. package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
  31. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
  32. package/src/editor/client/pane/parts.tsx +67 -4
  33. package/src/editor/client/pane/switcher.tsx +4 -4
  34. package/src/editor/client/shell/Bar.tsx +11 -0
  35. package/src/editor/client/state/keeper.ts +28 -4
  36. package/src/editor/client/state/live.ts +18 -1
  37. package/src/editor/routes/_live.ts +1 -1
  38. package/src/editor/routes/admin.astro +9 -0
  39. package/src/editor/routes/discard.ts +11 -0
  40. package/src/editor/server/admin-props.ts +18 -2
  41. package/src/editor/server/arrange.ts +4 -4
  42. package/src/editor/server/create.ts +4 -4
  43. package/src/editor/server/drafts-store.ts +2 -2
  44. package/src/editor/server/empty-site.ts +23 -0
  45. package/src/editor/server/media/cloud-mux.ts +4 -1
  46. package/src/editor/server/media/config.ts +12 -5
  47. package/src/editor/server/media/upload.ts +2 -41
  48. package/src/editor/server/mode.ts +3 -1
  49. package/src/editor/server/move.ts +32 -4
  50. package/src/editor/server/write.ts +4 -4
  51. package/src/editor/styles/levels/live.css +2 -0
  52. package/src/editor/types.ts +92 -4
  53. package/src/email.ts +4 -1
  54. package/src/forms/cloud-store.ts +4 -1
  55. package/src/forms/inbox.ts +10 -5
  56. package/src/forms/store.ts +21 -4
  57. package/src/index.ts +21 -6
  58. package/src/live/auth/login.ts +4 -3
  59. package/src/live/auth/views.ts +8 -1
  60. package/src/live/autosaves.ts +94 -6
  61. package/src/live/cloud.ts +64 -20
  62. package/src/live/drafts-store.ts +17 -6
  63. package/src/live/editor.ts +232 -16
  64. package/src/live/git.ts +492 -22
  65. package/src/live/github.ts +84 -17
  66. package/src/live/locks.ts +22 -0
  67. package/src/live/messages.ts +49 -2
  68. package/src/live/mode.ts +19 -8
  69. package/src/live/notices.ts +5 -1
  70. package/src/live/runtime.ts +2 -1
  71. package/src/live/services.ts +114 -0
  72. package/src/live/status.ts +5 -0
  73. package/src/live/sweep.ts +44 -1
  74. package/src/live/webhook.ts +8 -3
  75. package/src/media.ts +9 -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
  }
package/src/live/mode.ts CHANGED
@@ -17,12 +17,20 @@
17
17
  * browser runs, never against the site's own repo. It's live mode with no App and
18
18
  * no repo, and it's **refused in production**.
19
19
  *
20
+ * - **The config's `services`** (caracms-cloud M5, live/services.ts, baked in as `Baked.services`):
21
+ * with it, the other mode's variables are removed before anything here reads them, so the
22
+ * "not both" sentences below only ever apply to a config without the field (Decision 3);
23
+ * `'cloud'` without the key is `CLOUD_KEY_MISSING_PROBLEM` on a production server (Decision 4).
24
+ *
20
25
  * This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
21
26
  * Phase 3 guards with it).
22
27
  */
23
28
  import { CLOUD_KEY_SHAPE, DEFAULT_CLOUD_URL } from './cloud.ts';
24
29
  import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
25
30
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
31
+ import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES, servicesEnv, type Services } from './services.ts';
32
+
33
+ export { OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES };
26
34
 
27
35
  export interface LiveSettings {
28
36
  /** `memory`: the in-memory GitHub backend (Decision 2). `token`: CARACMS_GITHUB_TOKEN (runs-anywhere Decision 3). */
@@ -81,6 +89,8 @@ export interface Baked {
81
89
  site?: string | null;
82
90
  /** The commit the build was made from (runs-anywhere Decision 9). */
83
91
  commit?: string | null;
92
+ /** The config's `services` (caracms-cloud M5 Decision 1; live/services.ts). Null or missing: the key decides (Decision 3). */
93
+ services?: Services | null;
84
94
  }
85
95
 
86
96
  // The sentences the configuration problems print, kept here so the docs can quote them.
@@ -97,15 +107,9 @@ export const CLOUD_KEY_PROBLEM =
97
107
  "CARACMS_CLOUD_KEY isn't a CaraCMS Cloud site key: a key is cck_ followed by 43 letters, digits, - and _. Copy it again from app.caracms.com.";
98
108
  export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
99
109
  `Both CARACMS_CLOUD_KEY and ${other} are set: a site uses one GitHub credential, so remove ${other}.`;
100
- /** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
101
- export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
102
110
  const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
103
111
  export const CLOUD_AND_MEDIA_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove ${listed(names)}.`;
104
- /** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
105
- export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
106
112
  export const CLOUD_AND_MUX_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove ${listed(names)}.`;
107
- /** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
108
- export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
109
113
  export const CLOUD_AND_FORMS_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove ${listed(names)}.`;
110
114
 
111
115
  /** The key beside any of the site's own forms and email variables: Decision 22's sentence, or null. */
@@ -121,7 +125,8 @@ export function cloudFormsConflict(env: Env): string | null {
121
125
  * email variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site. Whether
122
126
  * the cloud is *used* is the caller's (only on a production server; the Core framing).
123
127
  */
124
- export function cloudForms(env: Env): { connection: { key: string; url: string }; problems: string[] } | null {
128
+ export function cloudForms(env: Env, services: Services | null = bakedServices()): { connection: { key: string; url: string }; problems: string[] } | null {
129
+ env = servicesEnv(env, services);
125
130
  const get = (name: string) => env[name]?.trim() || null;
126
131
  const key = get('CARACMS_CLOUD_KEY');
127
132
  if (!key) return null;
@@ -192,6 +197,9 @@ const isLocalhost = (host: string) => host === 'localhost' || host.endsWith('.lo
192
197
  * `baked` is what the build baked in (the address and the commit).
193
198
  */
194
199
  export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production', baked: Baked = {}): Mode {
200
+ // caracms-cloud M5 Decision 2: with the config's `services`, the other mode's variables are never read.
201
+ const services = baked.services ?? null;
202
+ env = servicesEnv(env, services);
195
203
  const get = (name: string) => {
196
204
  const v = env[name]?.trim();
197
205
  return v ? v : null;
@@ -215,7 +223,10 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
215
223
  if (other && other !== 'memory') problems.push(`CARACMS_GITHUB must be "memory" or unset (got "${other}").`);
216
224
 
217
225
  let github: LiveSettings['github'] = { kind: 'memory' };
218
- if (cloudKey && (!memory || production)) {
226
+ if (services === 'cloud' && !cloudKey && (!memory || production)) {
227
+ // M5 Decision 4: never the self-hosted variables instead (they were removed above).
228
+ problems.push(CLOUD_KEY_MISSING_PROBLEM);
229
+ } else if (cloudKey && (!memory || production)) {
219
230
  // The cloud names the repo (Decision 13), so GITHUB_REPO is a mistake and Railway's own variables are ignored.
220
231
  if (!CLOUD_KEY_SHAPE.test(cloudKey)) problems.push(CLOUD_KEY_PROBLEM);
221
232
  if (ghToken) problems.push(CLOUD_AND_GITHUB_PROBLEM('CARACMS_GITHUB_TOKEN'));
@@ -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 {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Where the site's services come from (caracms-cloud M5): the config's `services` field,
3
+ * not whichever variables happen to be set.
4
+ *
5
+ * - **`'cloud'`**: `CARACMS_CLOUD_KEY` is the one credential. The self-hosted variables
6
+ * (`REPLACED_VARIABLES`) are **removed from the environment the readers see**, so none is
7
+ * ever read, not even as a fallback (Decision 2, check 5), and each is named once per
8
+ * process as ignored.
9
+ * - **`'self-hosted'`**: the site's own variables; `CARACMS_CLOUD_KEY` is removed and named
10
+ * as ignored the same way.
11
+ * - **Unset**: the environment as it is, so the key decides and the "not both" refusals
12
+ * stand (Decision 3).
13
+ *
14
+ * Every key-or-own reader (live/mode.ts `readMode` and `cloudForms`, media.ts, the editor's
15
+ * media config, forms/store.ts, cloud-middleware.ts, and `media import` in bin/media.mjs)
16
+ * starts from `servicesEnv`. The running site's value is baked in at build
17
+ * (`__CARACMS_SERVICES__`, a Vite define from the config: `bakedServices`), since the readers
18
+ * are synchronous and have no config in hand.
19
+ */
20
+ import type { Services } from '../config.ts';
21
+
22
+ export type { Services };
23
+ type Env = Record<string, string | undefined>;
24
+
25
+ /** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
26
+ export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
27
+ /** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
28
+ export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
29
+ /** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
30
+ export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
31
+
32
+ /** The site's own GitHub variables, which the cloud key replaces (caracms-cloud M2 Decision 11). `GITHUB_WEBHOOK_SECRET` is harmless and not here. */
33
+ export const OWN_GITHUB_VARIABLES = ['CARACMS_GITHUB_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY', 'GITHUB_REPO'] as const;
34
+
35
+ /** Every variable the cloud replaces, by group, in the move list's order of retiring (M5 Contracts). */
36
+ export const REPLACED_VARIABLES = {
37
+ media: OWN_MEDIA_VARIABLES,
38
+ video: OWN_MUX_VARIABLES,
39
+ forms: OWN_FORMS_VARIABLES,
40
+ github: OWN_GITHUB_VARIABLES,
41
+ } as const;
42
+
43
+ /** What each group's ignored line ends with: when it can go. */
44
+ const RETIRE: Record<keyof typeof REPLACED_VARIABLES, string> = {
45
+ media: 'Remove them once the old bucket and imgproxy are retired.',
46
+ video: 'Remove them once every clip is uploaded again in the editor.',
47
+ forms: 'Remove them once the old submissions are exported or no longer wanted.',
48
+ github: 'Remove them: the cloud gives the site its repo and access.',
49
+ };
50
+
51
+ /** The sentence Decision 4 fails a production build and `/admin` with. */
52
+ export const CLOUD_KEY_MISSING_PROBLEM = "This site runs on CaraCMS Cloud (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in app.caracms.com.";
53
+
54
+ const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
55
+
56
+ /** The ignored line for some of one group's variables (cloud), or for the key (self-hosted). */
57
+ export function ignoredMessage(names: readonly string[], services: Services, group?: keyof typeof REPLACED_VARIABLES): string {
58
+ const one = names.length === 1;
59
+ const set = `${listed(names)} ${one ? 'is' : 'are'} set`;
60
+ if (services === 'self-hosted') return `${set}, but this site is self-hosted (caracms.config.ts): ${one ? "it's" : "they're"} ignored. Remove ${one ? 'it' : 'them'}, or run \`caracms init --cloud\` to move the site to CaraCMS Cloud.`;
61
+ const tail = group ? RETIRE[group] : 'Remove them once the move is done.';
62
+ return `${set}, but this site runs on CaraCMS Cloud (caracms.config.ts): ${one ? "it's" : "they're"} ignored. ${one ? tail.replace(/them/g, 'it') : tail}`;
63
+ }
64
+
65
+ const WARNED = Symbol.for('caracms.ignoredVariables');
66
+ type Shared = { [WARNED]?: Set<string> };
67
+ const warned = () => ((globalThis as Shared)[WARNED] ??= new Set());
68
+
69
+ /** Forget which variables were named (tests). */
70
+ export function resetIgnoredWarnings(): void {
71
+ delete (globalThis as Shared)[WARNED];
72
+ }
73
+
74
+ const set = (env: Env, name: string) => Boolean(env[name]?.trim());
75
+
76
+ /**
77
+ * The environment the readers see for this `services` (null: the environment as it is),
78
+ * with the other mode's variables removed. Each removed variable is named once per process
79
+ * (shared on globalThis, so the build's own module and its bundled chunks warn once between them).
80
+ */
81
+ export function servicesEnv(env: Env, services: Services | null | undefined, log: (line: string) => void = (line) => console.warn(line)): Env {
82
+ if (!services) return env;
83
+ const seen = warned();
84
+ const out: Env = { ...env };
85
+ if (services === 'self-hosted') {
86
+ if (!set(env, 'CARACMS_CLOUD_KEY')) return env;
87
+ delete out.CARACMS_CLOUD_KEY;
88
+ if (!seen.has('CARACMS_CLOUD_KEY')) log(`[caracms] ${ignoredMessage(['CARACMS_CLOUD_KEY'], services)}`);
89
+ seen.add('CARACMS_CLOUD_KEY');
90
+ return out;
91
+ }
92
+ for (const [group, names] of Object.entries(REPLACED_VARIABLES) as [keyof typeof REPLACED_VARIABLES, readonly string[]][]) {
93
+ const found = names.filter((name) => set(env, name));
94
+ for (const name of found) delete out[name];
95
+ const fresh = found.filter((name) => !seen.has(name));
96
+ if (fresh.length) log(`[caracms] ${ignoredMessage(fresh, services, group)}`);
97
+ for (const name of fresh) seen.add(name);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** The names `servicesEnv` would ignore in `env` for `services` (init's move list, tests). */
103
+ export function ignoredVariables(env: Env, services: Services | null | undefined): string[] {
104
+ if (!services) return [];
105
+ if (services === 'self-hosted') return set(env, 'CARACMS_CLOUD_KEY') ? ['CARACMS_CLOUD_KEY'] : [];
106
+ return Object.values(REPLACED_VARIABLES).flatMap((names) => names.filter((name) => set(env, name)));
107
+ }
108
+
109
+ declare const __CARACMS_SERVICES__: Services | null | undefined;
110
+
111
+ /** The config's `services` as the build baked it in; null in plain Node (tests) and for a config without one. */
112
+ export function bakedServices(): Services | null {
113
+ return typeof __CARACMS_SERVICES__ === 'string' ? __CARACMS_SERVICES__ : null;
114
+ }
@@ -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);