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
@@ -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,16 +316,21 @@ 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
  ];
312
325
 
313
326
  /**
314
- * The variables M5b Decision 5 names: the media pipeline's and Mux's. Only these are
315
- * taken from the site's env files (copyMediaEnv); everything else in `.env` (the
316
- * GitHub App, live mode) is left to the process's own environment, as before.
327
+ * The variables M5b Decision 5 names: the media pipeline's and Mux's, and the cloud key,
328
+ * which is both on a cloud site. Only these are taken from the site's env files
329
+ * (copyMediaEnv); everything else in `.env` (the GitHub App, live mode) is left to the
330
+ * process's own environment, as before.
317
331
  */
318
332
  export const MEDIA_ENV_NAMES = [
333
+ 'CARACMS_CLOUD_KEY',
319
334
  'CARACMS_MEDIA',
320
335
  'CARACMS_MEDIA_URL',
321
336
  'IMGPROXY_KEY',
@@ -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
  }