caracms 0.8.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 (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 +18 -5
  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
@@ -26,6 +26,16 @@ export interface DocRef {
26
26
  /** `pages/services/branding`: a document's id, for keys, storage and the Recent list. */
27
27
  export const docId = (ref: DocRef): string => `${ref.collection}/${ref.path}`;
28
28
 
29
+ /**
30
+ * Whether the editor opens a listed document. One whose only trouble is a missing title
31
+ * does (caracms-cloud adversarial review, finding 15): it's kept off the site and marked,
32
+ * and opening it is how the title goes back. Invalid JSON and a nested index.json don't.
33
+ */
34
+ export const opensInEditor = (doc: Pick<DocumentEntry, 'warning'>): boolean => !doc.warning || doc.warning.code === 'no-title';
35
+
36
+ /** The screen reader's words for a listed document's warning mark. */
37
+ export const warnedWords = (doc: Pick<DocumentEntry, 'warning'>): string => (doc.warning?.code === 'no-title' ? ', needs a title' : ", can't be opened");
38
+
29
39
  export const sameDoc = (a: DocRef | null | undefined, b: DocRef | null | undefined): boolean =>
30
40
  !!a && !!b && a.collection === b.collection && a.path === b.path;
31
41
 
@@ -297,6 +307,8 @@ export const ENDPOINTS = {
297
307
  history: '/_caracms/history',
298
308
  tryAgain: '/_caracms/try-again',
299
309
  notice: '/_caracms/notice',
310
+ // Discard changes (live mode only): one document back to what's live.
311
+ discard: '/_caracms/discard',
300
312
  // M6 Decision 9: the inbox, both modes (forms/inbox.ts has each route).
301
313
  submissions: '/_caracms/submissions',
302
314
  } as const;
@@ -342,6 +354,42 @@ export interface LastChange {
342
354
  version: string | null;
343
355
  /** ISO. */
344
356
  at: string | null;
357
+ /**
358
+ * Discard changes: someone put the document back to what's live (within the last week),
359
+ * and when. Edits held in a browser from before then are dropped, not re-sent (keeper.ts → verdict).
360
+ */
361
+ discarded?: { by: Person; at: string } | null;
362
+ }
363
+
364
+ /**
365
+ * POST /_caracms/discard `{ file }` (live mode only): put one waiting document back to what's
366
+ * live. A changed one reads as `main` has it, a new one is removed, a deleted one comes back,
367
+ * a moved one goes back to its old address (its file `movedBack`). `{ file, seen: true }`: this
368
+ * tab has let go of its copy of a document someone discarded (`open.discarded`).
369
+ */
370
+ export interface DiscardRequest {
371
+ file: string;
372
+ seen?: true;
373
+ }
374
+
375
+ export interface DiscardResponse {
376
+ ok: true;
377
+ file: string;
378
+ /** What it was: `changed`, `new` (now removed) or `deleted` (now back). */
379
+ change: WaitingChange;
380
+ /** Every file put back as `main` has it. */
381
+ files: string[];
382
+ /** A moved document: where it is again (its collection and path), else null. */
383
+ movedBack: { collection: string; path: string; file: string } | null;
384
+ }
385
+
386
+ /**
387
+ * Discard's refusals: `not-waiting` (404: nothing to discard), `locked` (409: someone is
388
+ * editing it now), `publishing` (409), `busy` (409: the sweep is busy on another server),
389
+ * `held-move` (409: it moved with the page that holds it), `bad-request`/`bad-path` (400).
390
+ */
391
+ export interface DiscardRefusal extends ApiError {
392
+ code: 'not-waiting' | 'locked' | 'publishing' | 'busy' | 'held-move' | 'swap' | 'bad-request' | 'bad-path' | 'no-tab' | 'signed-out' | 'unavailable';
345
393
  }
346
394
 
347
395
  /** 401 from any editor API in live mode with no session (Phase 3's guard, Decision 11). */
@@ -414,6 +462,11 @@ export interface WaitingView {
414
462
  movedFrom?: string;
415
463
  /** With `movedFrom`: Site Settings' redirects send visitors from the old address here (the editor may have taken it out). */
416
464
  movedRedirect?: boolean;
465
+ /**
466
+ * caracms-cloud adversarial review, finding 15: why Publish will leave it waiting ("its title
467
+ * is empty"), read from the document as it will publish; absent when it will go out.
468
+ */
469
+ heldBack?: string;
417
470
  }
418
471
 
419
472
  /**
@@ -444,7 +497,9 @@ export type NoticeView =
444
497
  /** "Home was also changed outside the editor. Your draft was kept; check it before publishing" (Decision 7). */
445
498
  | { id: string; kind: 'draft-kept'; file: string; title: string }
446
499
  /** "Aoife published Home as it stood at 14:20" (Decision 10), for the people who had it open. */
447
- | { id: string; kind: 'published-as-it-stood'; file: string; title: string; by: Person; at: string };
500
+ | { id: string; kind: 'published-as-it-stood'; file: string; title: string; by: Person; at: string }
501
+ /** "Aoife discarded the unpublished changes to About" (Discard changes), for the people who had edits in it or had it open. */
502
+ | { id: string; kind: 'discarded'; file: string; title: string; by: Person; at: string };
448
503
 
449
504
  export interface PublishRecordView {
450
505
  commit: string;
@@ -493,6 +548,12 @@ export interface StatusResponse {
493
548
  * who deleted it and when, when that's known (`by` null: not known). Null while it exists.
494
549
  */
495
550
  deleted: { by: Person | null; at: string | null } | null;
551
+ /**
552
+ * Discard changes: someone put the document back to what's live while this tab had it
553
+ * (open, or edits in it), so this tab's copy is stale: it lets go (`POST discard { file,
554
+ * seen }`) and reads it again. Its writes are refused (409 `discarded`) until then.
555
+ */
556
+ discarded?: { by: Person; at: string } | null;
496
557
  } | null;
497
558
  waiting: WaitingView[];
498
559
  locks: LockView[];
@@ -505,9 +566,33 @@ export interface StatusResponse {
505
566
  * missing permission, or a token expiring within 14 days. `message` names the fix,
506
567
  * for the developer it's forwarded to. Null when nothing is wrong. `cloud` (caracms-cloud
507
568
  * M2 Decision 14): CaraCMS Cloud refused a token; `message` is the cloud's sentence and
508
- * `link` the install link it names, if any, which the note shows as a link.
569
+ * `link` the install link it names, if any, which the note shows as a link. `renamed`
570
+ * (caracms-cloud adversarial review, finding 7) is information, not a problem: the repo
571
+ * was renamed or transferred on GitHub and work carries on under the new name, shown
572
+ * until the server restarts.
573
+ */
574
+ githubAccess: { kind: 'refused' | 'permission' | 'expiring' | 'cloud' | 'renamed'; title: string; message: string; link?: string | null } | null;
575
+ /**
576
+ * caracms-cloud adversarial review, finding 4: edits are waiting in Redis and the draft
577
+ * commits that should carry them to GitHub have failed several times in a row (git.ts →
578
+ * `COMMIT_TROUBLE_AFTER`). The save bar stops saying Kept and says so; `message` is the
579
+ * whole sentence. Null otherwise, and again once a commit goes through. (Absent from an
580
+ * older server: read it as null.)
581
+ */
582
+ commitTrouble?: { since: string; failures: number; message: string } | null;
583
+ /**
584
+ * Finding 5: a start found the `drafts` branch gone and could only make it again from
585
+ * `main`, so what it held since the last Publish is gone. `commit` is its last recorded
586
+ * head (null: none was recorded). Shown to everyone for a week, or until the next Publish.
587
+ */
588
+ draftsLost?: { title: string; message: string; commit: string | null; at: string } | null;
589
+ /**
590
+ * caracms-cloud adversarial review, force-push note: `main` no longer has the last
591
+ * Publish's commit (`publish`), so its history was rewritten, and what was rolled back is
592
+ * waiting again. Shown to everyone until the next Publish, or once nothing is waiting.
593
+ * (Absent from an older server: read it as null.)
509
594
  */
510
- githubAccess: { kind: 'refused' | 'permission' | 'expiring' | 'cloud'; title: string; message: string; link?: string | null } | null;
595
+ mainRolledBack?: { title: string; message: string; publish: string; main: string; at: string } | null;
511
596
  /**
512
597
  * The brand as committed in Site Settings (M5a Decision 10: read from `drafts`, never
513
598
  * an autosave), so the chrome picks up a change on the next poll with no reload.
@@ -534,8 +619,11 @@ export interface LockResponse {
534
619
 
535
620
  /** POST /_caracms/publish. */
536
621
  export interface PublishResponse {
537
- result: 'published' | 'nothing-waiting' | 'joined';
622
+ /** `held-back` (finding 15): everything waiting would break the reader, so nothing was published. */
623
+ result: 'published' | 'nothing-waiting' | 'joined' | 'held-back';
538
624
  publish: PublishView;
625
+ /** Finding 15: what was left waiting, and why ("its title is empty"); absent or empty when nothing was. */
626
+ held?: { file: string; title: string; reason: string }[];
539
627
  }
540
628
 
541
629
  export interface HistoryEntryView {
@@ -39,7 +39,10 @@ function submissionOf(value: unknown, what: string): StoredSubmission {
39
39
  if (!s || typeof s !== 'object' || !isSubmissionId(s.id) || typeof s.form !== 'string' || typeof s.receivedAt !== 'string' || !s.fields || typeof s.fields !== 'object') {
40
40
  throw malformed(what);
41
41
  }
42
- return { ...s, v: 1, readAt: typeof s.readAt === 'string' ? s.readAt : null };
42
+ const { filesNote, ...rest } = s;
43
+ // The attachments-not-kept sentence (caracms-cloud's attachment limit): kept only as a non-empty string.
44
+ const note = typeof filesNote === 'string' && filesNote.trim() ? { filesNote: filesNote.trim() } : {};
45
+ return { ...rest, v: 1, readAt: typeof s.readAt === 'string' ? s.readAt : null, ...note };
43
46
  }
44
47
 
45
48
  export function cloudStore(connection: CloudConnection): SubmissionStore {
@@ -24,7 +24,8 @@
24
24
  import type { ResolvedSite } from '../config.ts';
25
25
  import { checkOrigin, WriteRefused } from '../editor/server/write.ts';
26
26
  import { FILE_TYPES, isSubmissionId, senderName, shownFileName, submissionSnippet, type ResolvedFormField } from './rules.ts';
27
- import { fileExtension, readSubmissionsConfig, submissionStore, type StoredSubmission, type SubmissionStore } from './store.ts';
27
+ import { fileExtension, readSubmissionsConfig, submissionStore, type EmailOutcome, type StoredSubmission, type SubmissionStore } from './store.ts';
28
+ import { readCappedText } from '../body.ts';
28
29
 
29
30
  type Env = Record<string, string | undefined>;
30
31
 
@@ -48,8 +49,10 @@ export interface SubmissionRow {
48
49
  readAt: string | null;
49
50
  /** How many files came with it. */
50
51
  files: number;
51
- /** The email's outcome, or null when it's unknown (the server died between storing and recording). */
52
- emailStatus: 'sent' | 'failed' | 'skipped' | null;
52
+ /** The email's outcome (`held`: the cloud's, behind the site's daily limit), or null when it's unknown (the server died between storing and recording). */
53
+ emailStatus: EmailOutcome['status'] | null;
54
+ /** True when the cloud didn't keep its attachments (`StoredSubmission.filesNote`); absent otherwise. */
55
+ filesNotKept?: true;
53
56
  }
54
57
 
55
58
  export interface SubmissionsListResponse {
@@ -103,6 +106,7 @@ export function rowOf(site: Pick<ResolvedSite, 'forms'>, s: StoredSubmission): S
103
106
  readAt: s.readAt ?? null,
104
107
  files: s.files?.length ?? 0,
105
108
  emailStatus: s.email?.status ?? null,
109
+ ...(s.filesNote ? { filesNotKept: true as const } : {}),
106
110
  };
107
111
  }
108
112
 
@@ -125,8 +129,9 @@ export function attachmentHeader(name: string): string {
125
129
  export const downloadType = (key: string): string => FILE_TYPES[fileExtension(key)]?.[0] ?? 'application/octet-stream';
126
130
 
127
131
  async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
128
- const text = await request.text().catch(() => '');
129
- if (text.length > 4096) return {};
132
+ // caracms-cloud adversarial review, finding 10: 4 KB counted as it's read, never the whole body first.
133
+ const text = await readCappedText(request, 4096).catch(() => '');
134
+ if (text === null) return {};
130
135
  try {
131
136
  const value = JSON.parse(text || '{}') as unknown;
132
137
  return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
@@ -53,10 +53,16 @@ export interface StoredFile {
53
53
  }
54
54
 
55
55
  export interface EmailOutcome {
56
- status: 'sent' | 'failed' | 'skipped';
56
+ /**
57
+ * `held` is only ever the cloud's answer (caracms-cloud adversarial review, finding 20): the
58
+ * email waits behind the site's daily limit and goes out when it's raised or the next UTC day.
59
+ * It's never stored by the package's own stores.
60
+ */
61
+ status: 'sent' | 'failed' | 'skipped' | 'held';
62
+ /** When it was sent, failed or was skipped; for `held`, when it was held. */
57
63
  at: string;
58
64
  to: string[];
59
- /** Resend's message on failure, or why it was skipped. */
65
+ /** Resend's message on failure, why it was skipped, or the cloud's sentence for a held one. */
60
66
  error?: string;
61
67
  }
62
68
 
@@ -76,6 +82,12 @@ export interface StoredSubmission {
76
82
  readAt: string | null;
77
83
  /** Absent: not attempted yet, or the server died mid-way (the inbox says "unknown"). */
78
84
  email?: EmailOutcome;
85
+ /**
86
+ * Only ever the cloud's: why the attachments that came with it weren't kept (the site
87
+ * reached its daily attachment limit in CaraCMS Cloud), as a sentence the inbox shows in
88
+ * place of "No files". Absent otherwise.
89
+ */
90
+ filesNote?: string;
79
91
  }
80
92
 
81
93
  export interface NewFile {
package/src/index.ts CHANGED
@@ -139,6 +139,7 @@ export const EDITOR_ROUTES = [
139
139
  { pattern: '/_caracms/history', entrypoint: 'editor/routes/history.ts' },
140
140
  { pattern: '/_caracms/try-again', entrypoint: 'editor/routes/try-again.ts' },
141
141
  { pattern: '/_caracms/notice', entrypoint: 'editor/routes/notice.ts' },
142
+ { pattern: '/_caracms/discard', entrypoint: 'editor/routes/discard.ts' },
142
143
  // M6 Decision 9: the inbox, both modes (the guard's prefix rule covers it in live mode).
143
144
  { pattern: '/_caracms/submissions/[...path]', entrypoint: 'editor/routes/submissions.ts' },
144
145
  ] as const;
@@ -281,18 +282,27 @@ export function trustedDomains(
281
282
  return domains;
282
283
  }
283
284
 
284
- /**
285
- * The browser side of the editor imports React and idiomorph from the package.
286
- * Pre-bundled up front (dev only), so Vite doesn't discover them on the first
287
- * visit to /admin and reload the page to optimise them (a flicker, M2 Decision 9).
288
- */
289
285
  /** Vite's cache for every command but `astro dev`, apart from dev's `node_modules/.vite` (see its use). Relative to the site's root. */
290
286
  export const BUILD_VITE_CACHE = 'node_modules/.vite-caracms-build';
291
287
 
288
+ /**
289
+ * Every bare import the package's browser code makes (the editor, and the site's own
290
+ * components such as the video player), pre-bundled up front in dev. Two reasons:
291
+ * - Vite doesn't discover them on the first visit to /admin and reload the page to
292
+ * optimise them (a flicker, M2 Decision 9).
293
+ * - Installed from the registry (npm or pnpm), the package sits in the site's node_modules,
294
+ * and Vite serves a bare import from it that isn't listed here as the dependency's raw
295
+ * file. For a CommonJS one (react-dom) the browser refuses the module and the editor never
296
+ * starts (caracms-cloud adversarial review, finding 12). The workspace's linked copy hides
297
+ * this, so any new bare import in browser code goes here; test/pack-install.release.test.ts
298
+ * checks it in a real browser.
299
+ */
292
300
  export const EDITOR_CLIENT_DEPS = [
293
301
  'caracms > react',
294
302
  'caracms > react/jsx-runtime',
295
303
  'caracms > react/jsx-dev-runtime',
304
+ // Arrange pages' createPortal (arrange/ArrangeStage.tsx). react-dom's root is CommonJS.
305
+ 'caracms > react-dom',
296
306
  'caracms > react-dom/client',
297
307
  'caracms > idiomorph',
298
308
  // M5a: the rich-text field's editor (Tiptap on ProseMirror), browser only.
@@ -306,6 +316,9 @@ export const EDITOR_CLIENT_DEPS = [
306
316
  'caracms > @tiptap/pm/view',
307
317
  // M5b: a clip's upload straight to Mux, imported on the first one (client/media/store.ts).
308
318
  'caracms > @mux/upchunk',
319
+ // The site's video player, imported on the first click on Play (components/media-player.ts).
320
+ // ESM, so served raw it still loaded, but unbundled, and one CommonJS file in its tree would break it.
321
+ 'caracms > @mux/mux-player',
309
322
  // The date field's calendar (pre-M7 polish).
310
323
  'caracms > react-day-picker',
311
324
  ];
@@ -54,6 +54,7 @@ import {
54
54
  type PageAssets,
55
55
  type SafeHtml,
56
56
  } from './views.ts';
57
+ import { readCappedText } from '../../body.ts';
57
58
 
58
59
  export const CONFIRM_PATH = `${LOGIN_PATH}/confirm`;
59
60
  export const SIGNOUT_PATH = `${LOGIN_PATH}/signout`;
@@ -80,9 +81,9 @@ const BASE_HEADERS = {
80
81
  class BodyTooLarge extends Error {}
81
82
 
82
83
  async function readBody(request: Request): Promise<string> {
83
- if (Number(request.headers.get('content-length') ?? 0) > MAX_BODY_BYTES) throw new BodyTooLarge();
84
- const text = await request.text();
85
- if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new BodyTooLarge();
84
+ // caracms-cloud adversarial review, finding 10: counted as it's read, so a chunked body stops at the cap too.
85
+ const text = await readCappedText(request, MAX_BODY_BYTES);
86
+ if (text === null) throw new BodyTooLarge();
86
87
  return text;
87
88
  }
88
89
 
@@ -313,8 +313,15 @@ export function emailView(input: { next: string; value?: string; invalid?: strin
313
313
  * rate-limited, send failed). It repeats the address typed, which the person already
314
314
  * knows, and says nothing about it.
315
315
  */
316
+ /**
317
+ * caracms-cloud adversarial review, editor wording note (B3): the same answer for every
318
+ * address, so it never says whether one has access; this line tells someone who mistyped
319
+ * theirs, or wasn't invited, why nothing may come.
320
+ */
321
+ export const SENT_BY_INVITATION = 'Sign-in is by invitation. If nothing arrives in a few minutes, check the address or ask whoever invited you.';
322
+
316
323
  export function sentView(input: { email: string; next: string; again: boolean; backupEnabled: boolean }): SafeHtml {
317
- return html`<section class="si-card">${input.again ? html`<p class="notice ok" role="status">${ic('check', 'i-sm')}Sent again.</p>` : ''}<h1>Check your email</h1><p>If <strong>${input.email}</strong> can sign in, a link is on its way. It expires in 15 minutes.</p><p class="fine">Not there? Check spam, or send again.</p><div class="row-acts"><form method="post" action="${LOGIN_PATH}"><input type="hidden" name="email" value="${input.email}"><input type="hidden" name="next" value="${input.next}"><input type="hidden" name="again" value="1"><button class="btn" type="submit">Send again</button></form><a class="linkbtn" href="${loginHref({}, input.next)}">Use a different address</a></div></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
324
+ return html`<section class="si-card">${input.again ? html`<p class="notice ok" role="status">${ic('check', 'i-sm')}Sent again.</p>` : ''}<h1>Check your email</h1><p>If <strong>${input.email}</strong> can sign in, a link is on its way. It expires in 15 minutes.</p><p class="fine">${SENT_BY_INVITATION}</p><p class="fine">Not there? Check spam, or send again.</p><div class="row-acts"><form method="post" action="${LOGIN_PATH}"><input type="hidden" name="email" value="${input.email}"><input type="hidden" name="next" value="${input.next}"><input type="hidden" name="again" value="1"><button class="btn" type="submit">Send again</button></form><a class="linkbtn" href="${loginHref({}, input.next)}">Use a different address</a></div></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
318
325
  }
319
326
 
320
327
  /** Runs-anywhere Decision 7: the site was opened at an address that isn't its own (request.ts → `addressNotice`). */
@@ -4,8 +4,11 @@
4
4
  * - **`caracms:autosave:<file>`**: the whole document as the tab last sent it, with the
5
5
  * base it was edited from, its text (`applyEdits(base, document)`), its editor and
6
6
  * tab, and two times for the sweep: the first write since the last commit (the
7
- * five-minute cap) and the newest write (the idle time). Expires after a week, as a
8
- * backstop (`autosaveTtlMs`).
7
+ * five-minute cap) and the newest write (the idle time). **It never expires**: it's an
8
+ * edit that hasn't reached GitHub, so while commits fail (a lost token, an uninstalled
9
+ * App) it waits for the sweep however long that takes (caracms-cloud adversarial review,
10
+ * finding 4). Once committed it's deleted, and what's left of it, the committed record
11
+ * below, expires a week later as before.
9
12
  * - **`caracms:committed:<file>`**: what the sweep last committed from a tab's editing
10
13
  * session (its tab, its base, and the text it committed). The next commit from the
11
14
  * same session merges against that text, not the session's first base, so the
@@ -40,6 +43,13 @@
40
43
  * moving it back to where it was drops the record, as does deleting it. A record only
41
44
  * counts while its old file is gone and its new file is there (`movesIn`, git.ts).
42
45
  *
46
+ * **Discards** (Discard changes) ride on **`caracms:changed:<file>`** (`ChangeRecord.discard`),
47
+ * which every poll on the document and every write to it already read, so a discard costs
48
+ * them no command of their own: who put the document back to what's live, when, and the tabs
49
+ * that had it open or were editing it then. Those tabs' writes are refused until each says it
50
+ * has let go of its copy (`ackDiscard`), so a tab that hasn't heard yet can't bring the
51
+ * discarded edits back. A write carries the discard forward for a week (`carryDiscard`).
52
+ *
43
53
  * Paths are site-relative (`content/pages/index.json`). Values are JSON text.
44
54
  *
45
55
  * **The status cache** (status-cache.ts, Redis diet Decision 2): every write here of a
@@ -110,6 +120,16 @@ export interface ChangeRecord {
110
120
  at: string;
111
121
  /** M5a: this change deleted the document (`by` deleted it). */
112
122
  gone?: boolean;
123
+ /** Discard changes: the last time someone put the document back to what's live (see the header). */
124
+ discard?: DiscardRecord;
125
+ }
126
+
127
+ /** A document put back to what's live (Discard changes): `tabs` are the tabs whose copies are stale until they let go. */
128
+ export interface DiscardRecord {
129
+ by: Person;
130
+ /** ISO. */
131
+ at: string;
132
+ tabs: string[];
113
133
  }
114
134
 
115
135
  /** A value with the raw text it was read as, for compare-and-swap. */
@@ -169,11 +189,9 @@ export class Autosaves {
169
189
  readonly changedKey = (file: string) => `${this.prefix}changed:${file}`;
170
190
  readonly movesKey = () => movesKey(this.prefix);
171
191
 
172
- /** A compare-and-swap (or delete) on an autosave key, and the status cache told what came of it. */
192
+ /** A compare-and-swap (or delete) on an autosave key, and the status cache told what came of it. No expiry: a SET without PX also clears one an older version left. */
173
193
  private async swap(key: string, expected: string | null, raw: string | null): Promise<boolean> {
174
- const ok = await landed(this.cache, key, () =>
175
- raw === null ? this.redis.compareAndDelete(key, expected!) : this.redis.compareAndSet(key, expected, raw, { px: this.timings.autosaveTtlMs }),
176
- );
194
+ const ok = await landed(this.cache, key, () => (raw === null ? this.redis.compareAndDelete(key, expected!) : this.redis.compareAndSet(key, expected, raw)));
177
195
  if (ok) this.cache?.string(key, raw);
178
196
  else this.cache?.drop(key);
179
197
  return ok;
@@ -212,11 +230,81 @@ export class Autosaves {
212
230
  return this.swap(this.key(read.value.file), read.raw, null);
213
231
  }
214
232
 
233
+ /**
234
+ * An autosave written by caracms 0.8.0 or before, with the week's expiry it set: written
235
+ * again as it is, with none (the sweep's startup check). Only if it's still what was read.
236
+ */
237
+ async keepForever(read: Read<Autosave>): Promise<boolean> {
238
+ if ((await this.redis.pttl(this.key(read.value.file))) < 0) return false;
239
+ return this.swap(this.key(read.value.file), read.raw, read.raw);
240
+ }
241
+
215
242
  /** Replace it only if it's still what was read. */
216
243
  replaceIf(read: Read<Autosave>, next: Autosave): Promise<boolean> {
217
244
  return this.swap(this.key(next.file), read.raw, JSON.stringify(next));
218
245
  }
219
246
 
247
+ /**
248
+ * Discard changes: delete the autosave whatever it holds (a newer write is deleted too),
249
+ * returning each one deleted, so the caller knows whose tabs had it.
250
+ */
251
+ async removeAll(file: string): Promise<Autosave[]> {
252
+ const gone: Autosave[] = [];
253
+ for (let attempt = 0; attempt < 8; attempt++) {
254
+ const read = await this.get(file);
255
+ if (!read) return gone;
256
+ if (await this.removeIf(read)) gone.push(read.value);
257
+ }
258
+ throw new Error(`caracms: the autosave for ${file} kept changing under the discard.`);
259
+ }
260
+
261
+ /** Discard changes: the session's committed record goes with the edit it recorded. */
262
+ async forgetCommitted(file: string): Promise<void> {
263
+ await this.redis.del(this.committedKey(file));
264
+ }
265
+
266
+ /** Discard changes: every move record from or to these files, and their live addresses, go. */
267
+ async forgetMovesOf(files: readonly string[]): Promise<void> {
268
+ const key = movesKey(this.prefix);
269
+ const all = Object.entries(await this.redis.hgetall(key));
270
+ const drop = all
271
+ .filter(([k, v]) => (k.startsWith(ADDRESS_FIELD) ? files.includes(k.slice(ADDRESS_FIELD.length)) : files.includes(k) || files.includes(v)))
272
+ .map(([k]) => k);
273
+ if (drop.length === 0) return;
274
+ await landed(this.cache, key, () => this.redis.hdel(key, ...drop));
275
+ this.cache?.hash(key, {}, drop);
276
+ }
277
+
278
+ /** The autosave and the change record in one MGET (the write route reads both: Discard changes costs it no command). */
279
+ async getWithChange(file: string): Promise<[Read<Autosave> | null, ChangeRecord | null]> {
280
+ const [raw, changed] = await this.redis.mget([this.key(file), this.changedKey(file)]);
281
+ const value = parse<Autosave>(raw ?? null);
282
+ return [raw !== null && raw !== undefined && value ? { raw, value } : null, Autosaves.changedFrom(changed ?? null)];
283
+ }
284
+
285
+ /** The discard a write should keep on the change record it makes: the one there, while under a week old. */
286
+ carryDiscard(record: ChangeRecord | null): Pick<ChangeRecord, 'discard'> {
287
+ const d = record?.discard;
288
+ return d && this.now() - Date.parse(d.at) < this.timings.autosaveTtlMs ? { discard: d } : {};
289
+ }
290
+
291
+ /** A tab has let go of its copy of a discarded document: its writes are taken again. */
292
+ async ackDiscard(file: string, tab: string): Promise<void> {
293
+ const key = this.changedKey(file);
294
+ for (let attempt = 0; attempt < 5; attempt++) {
295
+ const raw = await this.redis.get(key);
296
+ const record = Autosaves.changedFrom(raw);
297
+ if (!record?.discard || !record.discard.tabs.includes(tab)) return;
298
+ const next = JSON.stringify({ ...record, discard: { ...record.discard, tabs: record.discard.tabs.filter((t) => t !== tab) } });
299
+ const ok = await landed(this.cache, key, () => this.redis.compareAndSet(key, raw, next));
300
+ if (ok) {
301
+ this.cache?.string(key, next);
302
+ return;
303
+ }
304
+ this.cache?.drop(key);
305
+ }
306
+ }
307
+
220
308
  async committed(file: string): Promise<Committed | null> {
221
309
  return parse<Committed>(await this.redis.get(this.committedKey(file)));
222
310
  }
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();