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
@@ -13,7 +13,7 @@
13
13
  import type { ReactNode } from 'react';
14
14
  import { useEditor, useStore } from '../context.tsx';
15
15
  import { Icon } from '../shell/Icons.tsx';
16
- import { docId } from '../../types.ts';
16
+ import { docId, opensInEditor } from '../../types.ts';
17
17
  import { useTooLong } from '../nav/DocRow.tsx';
18
18
  import { DocMarksView } from '../live/marks.tsx';
19
19
  import { OfflineExtras } from '../live/session.tsx';
@@ -87,6 +87,9 @@ export function GlobalNotes() {
87
87
  return (
88
88
  <>
89
89
  <GitHubAccessNote />
90
+ <DraftsLostNote />
91
+ <RolledBackNote />
92
+ <CommitTroubleNote />
90
93
  <KeepNotes />
91
94
  </>
92
95
  );
@@ -101,10 +104,17 @@ function GitHubAccessNote() {
101
104
  const editor = useEditor();
102
105
  const access = useStore(editor.liveState, (s) => s.status?.githubAccess ?? null);
103
106
  if (editor.mode !== 'live' || !access) return null;
107
+ // caracms-cloud adversarial review, finding 7: a rename taken in its stride is information, in the plain note.
108
+ const renamed = access.kind === 'renamed';
104
109
  const expiring = access.kind === 'expiring';
105
110
  return (
106
- <div className={`note ${expiring ? 'warn' : 'danger'}`} role={expiring ? 'status' : 'alert'} data-note="github-access" data-kind={access.kind}>
107
- <Icon name="warn" />
111
+ <div
112
+ className={renamed ? 'note' : `note ${expiring ? 'warn' : 'danger'}`}
113
+ role={renamed || expiring ? 'status' : 'alert'}
114
+ data-note="github-access"
115
+ data-kind={access.kind}
116
+ >
117
+ <Icon name={renamed ? 'check' : 'warn'} />
108
118
  <div>
109
119
  <b>{access.title}</b>
110
120
  <p>{withLink(access.message, access.link ?? null)}</p>
@@ -113,6 +123,59 @@ function GitHubAccessNote() {
113
123
  );
114
124
  }
115
125
 
126
+ /**
127
+ * caracms-cloud adversarial review, finding 4: edits are kept on the server but its commits
128
+ * to GitHub keep failing (the bar says so too). Left out while the access note shows a
129
+ * problem: that one already says what's wrong and who fixes it (finding 7's rename says neither).
130
+ */
131
+ function CommitTroubleNote() {
132
+ const editor = useEditor();
133
+ const trouble = useStore(editor.liveState, (s) => s.status?.commitTrouble ?? null);
134
+ const access = useStore(editor.liveState, (s) => s.status?.githubAccess ?? null);
135
+ if (editor.mode !== 'live' || !trouble || (access && access.kind !== 'renamed')) return null;
136
+ return (
137
+ <div className="note danger" role="alert" data-note="commit-trouble">
138
+ <Icon name="warn" />
139
+ <div>
140
+ <b>Changes aren't reaching GitHub</b>
141
+ <p>{trouble.message}</p>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ /** Finding 5: the `drafts` branch went missing and was made again from `main`; the sentence is the server's. */
148
+ function DraftsLostNote() {
149
+ const editor = useEditor();
150
+ const lost = useStore(editor.liveState, (s) => s.status?.draftsLost ?? null);
151
+ if (editor.mode !== 'live' || !lost) return null;
152
+ return (
153
+ <div className="note danger" role="alert" data-note="drafts-lost">
154
+ <Icon name="warn" />
155
+ <div>
156
+ <b>{lost.title}</b>
157
+ <p>{lost.message}</p>
158
+ </div>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ /** caracms-cloud adversarial review, force-push note: `main` was rolled back past the last Publish; the sentence is the server's. */
164
+ function RolledBackNote() {
165
+ const editor = useEditor();
166
+ const rolled = useStore(editor.liveState, (s) => s.status?.mainRolledBack ?? null);
167
+ if (editor.mode !== 'live' || !rolled) return null;
168
+ return (
169
+ <div className="note warn" role="alert" data-note="main-rolled-back">
170
+ <Icon name="warn" />
171
+ <div>
172
+ <b>{rolled.title}</b>
173
+ <p>{rolled.message}</p>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
178
+
116
179
  /** The sentence as it came, with `link` as a link where it names it (or after it); the words are never changed. */
117
180
  function withLink(message: string, link: string | null) {
118
181
  if (!link) return message;
@@ -187,7 +250,7 @@ export function RecentStrip() {
187
250
  const current = useStore(editor.doc.state, (s) => docId(s.ref));
188
251
  const items = recent
189
252
  .map((id) => documents.find((d) => docId(d) === id))
190
- .filter((d): d is NonNullable<typeof d> => !!d && !d.warning)
253
+ .filter((d): d is NonNullable<typeof d> => !!d && opensInEditor(d))
191
254
  .slice(0, 5);
192
255
  return (
193
256
  <div className="side-foot">
@@ -14,7 +14,7 @@ import { useEditor, useStore } from '../context.tsx';
14
14
  import { Icon } from '../shell/Icons.tsx';
15
15
  import { DocRow } from '../nav/DocRow.tsx';
16
16
  import { parentLabel, searchDocuments } from '../nav/search.ts';
17
- import { docId, type ListedDocument } from '../../types.ts';
17
+ import { docId, opensInEditor, type ListedDocument } from '../../types.ts';
18
18
 
19
19
  export const SWITCHER_READY = true;
20
20
 
@@ -51,7 +51,7 @@ export function Switcher() {
51
51
  const out: Section[] = [];
52
52
  const recentDocs = recent
53
53
  .map((id) => documents.find((d) => docId(d) === id))
54
- .filter((d): d is ListedDocument => !!d && !d.warning)
54
+ .filter((d): d is ListedDocument => !!d && opensInEditor(d))
55
55
  .slice(0, 3);
56
56
  if (recentDocs.length) out.push({ key: 'recent', heading: 'Recent', items: recentDocs.map((d) => ({ doc: d, crumb: crumbOf(d) })), more: null });
57
57
  for (const c of editor.site.collections) {
@@ -102,8 +102,8 @@ export function Switcher() {
102
102
  event.preventDefault();
103
103
  const hit = flat[activeIndex];
104
104
  if (!hit) return;
105
- if (hit.doc.warning) {
106
- editor.announce(`${hit.doc.label} can't be opened. ${hit.doc.warning.message}`);
105
+ if (!opensInEditor(hit.doc)) {
106
+ editor.announce(`${hit.doc.label} can't be opened. ${hit.doc.warning?.message ?? ''}`);
107
107
  return;
108
108
  }
109
109
  editor.setPop(null);
@@ -132,6 +132,8 @@ function Keep() {
132
132
  const error = useStore(editor.keep, (k) => k.error);
133
133
  const unsent = useStore(editor.keep, (k) => k.unsent ?? 0);
134
134
  const signedOut = useStore(editor.liveState, (s) => !!s.signedOut);
135
+ // caracms-cloud adversarial review, finding 4: the server has the edits, but its commits to GitHub keep failing.
136
+ const trouble = useStore(editor.liveState, (s) => s.status?.commitTrouble ?? null);
135
137
  if (state === 'signedout' || (signedOut && state !== 'offline')) {
136
138
  return (
137
139
  <span className="keep loud" role="status" data-keep="signedout">
@@ -174,6 +176,15 @@ function Keep() {
174
176
  </span>
175
177
  );
176
178
  }
179
+ if (trouble && editor.mode === 'live') {
180
+ // Not Kept: kept in Redis, but not reaching GitHub. The pane's note says who can fix it.
181
+ return (
182
+ <span className="keep loud" role="status" data-keep="not-reaching" title={trouble.message}>
183
+ <Icon name="warn" size="sm" />
184
+ Saved here, not reaching GitHub
185
+ </span>
186
+ );
187
+ }
177
188
  return (
178
189
  <span className="keep" data-keep="kept">
179
190
  <Icon name="check" size="sm" />
@@ -49,7 +49,12 @@
49
49
  * **Another editor changed it since, or the server can't tell who did (`known:
50
50
  * false`): not re-sent**, held as a `conflict` until Use mine (`useMine`) or
51
51
  * Discard mine (`discard`).
52
- * Versions are compared for equality, never as times, so no clock is involved.
52
+ * Discard changes: when the server says someone put the document back to what's live
53
+ * (`lastChange.discarded`), edits stored from before then (`at`, when they were last changed)
54
+ * are dropped, not re-sent, so a discard doesn't come undone from a browser that missed it.
55
+ * A write the server refuses as `discarded` (this tab had the document when it was put back)
56
+ * is dropped the same way, and `onDiscarded` tells the editor to read it again.
57
+ * Versions are compared for equality, never as times; only the discard rule compares a time.
53
58
  */
54
59
  import { documentKind, normaliseAnyDocument, type AnyDocument } from '../../../document.ts';
55
60
  import { applyDocumentEdits } from '../../../serialize.ts';
@@ -106,6 +111,8 @@ export interface StoredUnsent {
106
111
  document: AnyDocument;
107
112
  /** The version of the file these edits were made against (see above). */
108
113
  fileVersion: string;
114
+ /** ms since the epoch: the newest change in them (the discard rule). Absent in copies stored before it was added. */
115
+ at?: number;
109
116
  }
110
117
 
111
118
  /** The parts of Storage the keeper uses (localStorage, or a fake in tests). */
@@ -162,6 +169,8 @@ export interface KeeperOptions {
162
169
  keepCap?: number;
163
170
  /** A write for this file was Kept (the server has it, and the write took or kept the lock). */
164
171
  onKept?: (file: string) => void;
172
+ /** A write got 409 `discarded`: someone put the document back to what's live; this tab's edits in it are dropped. */
173
+ onDiscarded?: (file: string, body: { error?: string; by?: Person; at?: string }) => void;
165
174
  }
166
175
 
167
176
  /** A held record the editor may need to look at again: once the lock lapses, or the session is back. */
@@ -307,7 +316,7 @@ export function holdsEdits(text: string, stored: Pick<StoredUnsent, 'base' | 'do
307
316
  * `{ conflict: by }` (another editor changed the document since, Decision 11).
308
317
  */
309
318
  export function verdict(
310
- stored: Pick<StoredUnsent, 'base' | 'document' | 'fileVersion'>,
319
+ stored: Pick<StoredUnsent, 'base' | 'document' | 'fileVersion' | 'at'>,
311
320
  file: FileNow,
312
321
  site: KeeperSite,
313
322
  you: Person | null,
@@ -315,6 +324,9 @@ export function verdict(
315
324
  if (stored.fileVersion === file.version) return 'send'; // nothing has written it since
316
325
  if (holdsEdits(file.text, stored, site)) return 'drop';
317
326
  const last = file.lastChange;
327
+ // Discard changes: edits from before someone put it back to what's live are what was discarded.
328
+ const discarded = last?.discarded;
329
+ if (discarded && !(typeof stored.at === 'number' && stored.at > Date.parse(discarded.at))) return 'drop';
318
330
  if (!last) return 'send'; // local mode (M3): the last write wins
319
331
  if (last.known === false) return { conflict: null }; // the server can't tell who changed it: never re-sent over unknown work
320
332
  if (!last.by) return 'send'; // only merges from main are known
@@ -421,7 +433,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
421
433
  return r;
422
434
  };
423
435
  const stash = (r: Record) => {
424
- if (r.document) storage.set(r.id, { v: 1, file: r.file, base: r.base, document: r.document, fileVersion: r.fileVersion });
436
+ if (r.document) storage.set(r.id, { v: 1, file: r.file, base: r.base, document: r.document, fileVersion: r.fileVersion, ...(r.lastChange ? { at: r.lastChange } : {}) });
425
437
  };
426
438
  /** Decision 10 (live): ms until the change waiting goes, once the document is held: the delay after the last change, capped from the first unsent one. */
427
439
  const due = (r: Record) => {
@@ -523,6 +535,16 @@ export function createKeeper(options: KeeperOptions): Keeper {
523
535
  r.by = refusal.lastChange?.by ?? refusal.lock?.person ?? null;
524
536
  log(`${r.file} is locked: ${message}`);
525
537
  options.onLocked?.(r.file, refusal);
538
+ } else if (options.tab && error.status === 409 && code === 'discarded') {
539
+ // Someone put the document back to what's live while this tab had it: these edits were discarded.
540
+ r.written = r.revision;
541
+ r.document = null;
542
+ r.failure = null;
543
+ r.refusal = null;
544
+ r.by = null;
545
+ storage.remove(r.id);
546
+ log(`${r.file}: ${message}`);
547
+ options.onDiscarded?.(r.file, error.body as { error?: string; by?: Person; at?: string });
526
548
  } else {
527
549
  r.failure = 'refused';
528
550
  r.refusal = message;
@@ -562,6 +584,8 @@ export function createKeeper(options: KeeperOptions): Keeper {
562
584
  r.document = stored.document;
563
585
  r.fileVersion = stored.fileVersion;
564
586
  r.revision += 1;
587
+ // The discard rule reads when they were made, not when they were adopted.
588
+ if (typeof stored.at === 'number') r.lastChange = stored.at;
565
589
  return r;
566
590
  }
567
591
 
@@ -727,7 +751,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
727
751
  recheck(id, file) {
728
752
  const r = records.get(id);
729
753
  if (!r || !dirty(r) || !r.document) return null;
730
- const v = verdict({ base: r.base, document: r.document, fileVersion: r.fileVersion }, file, options.site, options.you?.() ?? null);
754
+ const v = verdict({ base: r.base, document: r.document, fileVersion: r.fileVersion, at: r.lastChange || undefined }, file, options.site, options.you?.() ?? null);
731
755
  if (v === 'drop') {
732
756
  r.written = r.revision;
733
757
  r.failure = null;
@@ -18,7 +18,7 @@
18
18
  * - **Signed out**: any 401 (`SignedOutError`) sets `signedOut`; the next poll that
19
19
  * answers 200 clears it (signing in from another tab brings the session back).
20
20
  */
21
- import { ENDPOINTS, TAB_HEADER, type HistoryResponse, type LiveProps, type LockRefusal, type LockResponse, type PublishResponse, type SignedOutError, type StatusResponse, type TryAgainResponse } from '../../types.ts';
21
+ import { ENDPOINTS, TAB_HEADER, type DiscardRefusal, type DiscardResponse, type HistoryResponse, type LiveProps, type LockRefusal, type LockResponse, type PublishResponse, type SignedOutError, type StatusResponse, type TryAgainResponse } from '../../types.ts';
22
22
  import { createStore, type Store } from './store.ts';
23
23
 
24
24
  /**
@@ -65,6 +65,10 @@ export interface LiveSession {
65
65
  take(file: string): Promise<{ ok: true; lock: LockResponse } | { ok: false; refusal: LockRefusal | null }>;
66
66
  /** Take over: move anyone's lock on the document to this tab (their autosave is committed first). */
67
67
  takeOver(file: string): Promise<{ ok: true; lock: LockResponse } | { ok: false }>;
68
+ /** Discard changes: put one waiting document back to what's live. A refusal carries the server's sentence. */
69
+ discard(file: string): Promise<{ ok: true; body: DiscardResponse } | { ok: false; error: string; code: DiscardRefusal['code'] | null }>;
70
+ /** This tab has let go of its copy of a document someone discarded (`open.discarded`). */
71
+ discardSeen(file: string): Promise<void>;
68
72
  /** Someone typed, clicked or scrolled in this tab: each poll says how long ago (the lock's idle rule). */
69
73
  active(): void;
70
74
  signOut(): Promise<string>;
@@ -269,6 +273,19 @@ export function createLiveSession(options: LiveOptions): LiveSession {
269
273
  state.set((s) => (s.status ? { ...s, status: { ...s.status, notices: s.status.notices.filter((n) => n.id !== id) } } : s));
270
274
  await call(ENDPOINTS.notice, { method: 'POST', body: { id } });
271
275
  },
276
+ async discard(file) {
277
+ const answer = await call<DiscardResponse | DiscardRefusal>(ENDPOINTS.discard, { method: 'POST', body: { file } });
278
+ void poll();
279
+ if (!answer) return { ok: false, error: "The server can't be reached. Nothing was discarded; try again in a moment.", code: null };
280
+ if (answer.status !== 200) {
281
+ const said = answer.body as DiscardRefusal;
282
+ return { ok: false, error: typeof said?.error === 'string' && said.error ? said.error : `The server said no (${answer.status}).`, code: said?.code ?? null };
283
+ }
284
+ return { ok: true, body: answer.body as DiscardResponse };
285
+ },
286
+ async discardSeen(file) {
287
+ await call(ENDPOINTS.discard, { method: 'POST', body: { file, seen: true } });
288
+ },
272
289
  active() {
273
290
  used();
274
291
  },
@@ -10,7 +10,7 @@ import type { LiveEditor } from '../../live/editor.ts';
10
10
  import { personOf } from '../../live/locals.ts';
11
11
  import { liveEditorOf } from '../server/mode.ts';
12
12
 
13
- type Name = 'status' | 'lock' | 'publish' | 'history' | 'tryAgain' | 'notice';
13
+ type Name = 'status' | 'lock' | 'publish' | 'history' | 'tryAgain' | 'notice' | 'discard';
14
14
 
15
15
  export const liveRoute =
16
16
  (name: Name): APIRoute =>
@@ -20,7 +20,9 @@ import geistMono from '../fonts/geist-mono-latin-wght-normal.woff2?url';
20
20
  import instrumentSans from '../fonts/instrument-sans-latin-wght-normal.woff2?url';
21
21
  import { site } from 'virtual:caracms/config';
22
22
  import { monogramIcon } from '../brand.ts';
23
+ import tokensCss from '../styles/tokens.css?raw';
23
24
  import { adminProps } from '../server/admin-props.ts';
25
+ import { emptySitePage } from '../server/empty-site.ts';
24
26
  import { personOf } from '../../live/locals.ts';
25
27
  import { editorMode, editorStore } from '../server/mode.ts';
26
28
  import { renderEditor } from '../server/render.tsx';
@@ -39,6 +41,13 @@ if (editorMode().kind === 'live' && !personOf(Astro.locals)) {
39
41
  }
40
42
 
41
43
  const result = await adminProps({ site, store: await editorStore(site), url: Astro.url, cookie: (name) => Astro.cookies.get(name)?.value, person: personOf(Astro.locals) });
44
+ if (!result.ok && result.empty) {
45
+ // Editor wording note (caracms-cloud adversarial review): a new site's /admin says what's missing and what to do.
46
+ return new Response(emptySitePage(result.empty, tokensCss), {
47
+ status: result.status,
48
+ headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
49
+ });
50
+ }
42
51
  if (!result.ok) {
43
52
  const escape = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
44
53
  return new Response(
@@ -0,0 +1,11 @@
1
+ /**
2
+ * POST /_caracms/discard { file }: Discard changes, one waiting document back to what's live;
3
+ * { file, seen: true }: this tab has let go of its copy of a document someone discarded.
4
+ * Live mode only (local mode has no waiting list or published state to go back to); the
5
+ * handler is ../../live/editor.ts → `discard`. Shapes: ../types.ts → DiscardRequest.
6
+ */
7
+ import { liveRoute } from './_live.ts';
8
+
9
+ export const prerender = false;
10
+
11
+ export const ALL = liveRoute('discard');
@@ -72,7 +72,16 @@ export function editorSite(site: ResolvedSite, documents: readonly ListedDocumen
72
72
  };
73
73
  }
74
74
 
75
- export type AdminPropsResult = { ok: true; props: AdminProps } | { ok: false; status: number; message: string };
75
+ /**
76
+ * Editor wording note (caracms-cloud adversarial review): `/admin` on a site with no page to
77
+ * open yet (a new site, right after `init`): the file Home would be, site-relative, for the
78
+ * empty state (empty-site.ts) instead of a bare "no document" line.
79
+ */
80
+ export interface EmptySite {
81
+ file: string;
82
+ }
83
+
84
+ export type AdminPropsResult = { ok: true; props: AdminProps } | { ok: false; status: number; message: string; empty?: EmptySite };
76
85
 
77
86
  export async function adminProps(input: {
78
87
  site: ResolvedSite;
@@ -106,7 +115,14 @@ export async function adminProps(input: {
106
115
  const inboxFirst = inbox && submissions.unread !== null ? await inboxFirstPaint(site, url, inbox.id, input.person ?? null) : null;
107
116
  return { ok: true, props: { site: editor, documents, opened, ui, unsent, live, media: mediaSetup(), inbox, ...(arrange ? { arrange } : {}), submissions, forms: formsOf(site), inboxFirst } };
108
117
  } catch (error) {
109
- if (error instanceof DocumentError) return { ok: false, status: error.code === 'not-found' ? 404 : 400, message: error.message };
118
+ if (error instanceof DocumentError) {
119
+ // No page to open at all (homeOf found none): the empty state, at the editor's own address only.
120
+ const bare = url.pathname.replace(/\/+$/, '') === '/admin';
121
+ const home = editor.home;
122
+ const nothing = error.code === 'not-found' && bare && ref.collection === home.collection && ref.path === home.path && !documents.some((d) => d.kind === 'sections' && !d.warning);
123
+ const empty = nothing ? { file: `${relative(site.root, site.collections[home.collection]!.dir).split('\\').join('/')}/${home.path}.json` } : undefined;
124
+ return { ok: false, status: error.code === 'not-found' ? 404 : 400, message: error.message, ...(empty ? { empty } : {}) };
125
+ }
110
126
  throw error;
111
127
  }
112
128
  }
@@ -25,6 +25,7 @@ import type { ArrangeRequest, ArrangeResponse } from '../types.ts';
25
25
  import { refused } from './create.ts';
26
26
  import { movesUnder, type PathMove } from './move.ts';
27
27
  import { checkOrigin, seqOf, tabOf, WriteRefused, type WriteContext } from './write.ts';
28
+ import { readCappedText } from '../../body.ts';
28
29
 
29
30
  type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
30
31
 
@@ -225,10 +226,9 @@ export async function handleArrange(request: Request, store: Arranges, who: { pe
225
226
  checkOrigin(request);
226
227
  const type = request.headers.get('content-type') ?? '';
227
228
  if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
228
- const length = Number(request.headers.get('content-length') ?? 0);
229
- if (length > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
230
- const raw = await request.text();
231
- if (Buffer.byteLength(raw) > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
229
+ // caracms-cloud adversarial review, finding 10: counted as it's read, so a chunked body stops at the cap too.
230
+ const raw = await readCappedText(request, MAX_ARRANGE_BODY_BYTES);
231
+ if (raw === null) throw new WriteRefused(413, 'That body is too large.');
232
232
  let body: unknown;
233
233
  try {
234
234
  body = JSON.parse(raw);
@@ -54,6 +54,7 @@ import { addressProblem, INDEX_MESSAGE, isSlug, NO_ADDRESS_MESSAGE, RESERVED_SLU
54
54
  import { RELEASE_HEADER, type CreateDeleteCode, type CreateRequest, type CreateResponse, type DeleteRequest, type DeleteResponse } from '../types.ts';
55
55
  import { checkOrigin, confineDocumentFile, confinePath, seqOf, tabOf, WriteRefused, type WriteContext } from './write.ts';
56
56
  import { schemaRef, stampSchema } from '../../schema-key.ts';
57
+ import { readCappedText } from '../../body.ts';
57
58
 
58
59
  type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks' | 'codeRoutes'>;
59
60
 
@@ -395,10 +396,9 @@ async function handle(request: Request, who: { person?: Person | null }, run: (b
395
396
  checkOrigin(request);
396
397
  const type = request.headers.get('content-type') ?? '';
397
398
  if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
398
- const length = Number(request.headers.get('content-length') ?? 0);
399
- if (length > MAX_CREATE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
400
- const raw = await request.text();
401
- if (Buffer.byteLength(raw) > MAX_CREATE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
399
+ // caracms-cloud adversarial review, finding 10: counted as it's read, so a chunked body stops at the cap too.
400
+ const raw = await readCappedText(request, MAX_CREATE_BODY_BYTES);
401
+ if (raw === null) throw new WriteRefused(413, 'That body is too large.');
402
402
  let body: unknown;
403
403
  try {
404
404
  body = JSON.parse(raw);
@@ -26,7 +26,7 @@ export interface DraftsStore {
26
26
  * each with its public URL and its kind; a shared block with its `usedOn`.
27
27
  */
28
28
  list(): Promise<ListedDocument[]>;
29
- /** One document, of any kind (M5a). Throws content.ts's DocumentError ('not-found', 'bad-path', a warning code…). */
29
+ /** One document, of any kind (M5a). Throws content.ts's DocumentError ('not-found', 'bad-path', a warning code…); one with no title opens (caracms-cloud adversarial review, finding 15). */
30
30
  read(collection: string, path: string): Promise<AnyOpenedDocument>;
31
31
  /**
32
32
  * The store's content as a content.ts source (M5a Decision 9): what a preview's
@@ -107,7 +107,7 @@ export function localDraftsStore(site: Site): DraftsStore {
107
107
  );
108
108
  },
109
109
  async read(collection, path) {
110
- const read = await readDocument(site, collection, path);
110
+ const read = await readDocument(site, collection, path, diskSource, { untitled: true });
111
111
  const { mtimeMs } = await stat(join(site.root, read.entry.file));
112
112
  return { ...read, mtimeMs, version: fileVersion(read.text) };
113
113
  },
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `/admin` on a site with no page yet (caracms-cloud adversarial review, editor wording note):
3
+ * right after `caracms init`, before any page is in `content/`, the editor has nothing to
4
+ * open. Instead of a bare "no document" line whose link went back to `/admin` itself, a page
5
+ * that says what's missing and what to do next, linking to the README's Start section.
6
+ * `init` doesn't write pages (the `caracms-adopt` skill brings them in), so it isn't offered
7
+ * as the way to make one. Styled like the sign-in pages' narrow note (`.nw`, on the
8
+ * editor's tokens), with no script.
9
+ */
10
+ import { html, LOGIN_CSS } from '../../live/auth/views.ts';
11
+ import type { EmptySite } from './admin-props.ts';
12
+
13
+ /** The package README's getting-started section, on npm. */
14
+ export const START_URL = 'https://www.npmjs.com/package/caracms#start';
15
+
16
+ const EMPTY_CSS = `.nw code{font-family:var(--font-mono);font-size:var(--text-sm);padding:1px 5px;border-radius:var(--radius-sm);background:var(--surface);border:1px solid var(--border);color:var(--text);overflow-wrap:anywhere}
17
+ .nw a{color:var(--select-strong);font-weight:var(--weight-medium)}`;
18
+
19
+ /** The whole page. `tokensCss`: the editor's tokens (styles/tokens.css), as text. */
20
+ export function emptySitePage(empty: EmptySite, tokensCss: string): string {
21
+ const body = html`<main class="nw" data-empty-site=""><div class="nw-in"><h1>This site has no pages yet</h1><p>The editor opens on the home page, and there isn't one. Add <code>${empty.file}</code> (a title and no blocks is enough: <code>{ "title": "Home", "blocks": [] }</code>), then reload this page.</p><p>To bring a page the site already has into the editor, ask your coding agent to use the <code>caracms-adopt</code> skill.</p><p class="fine"><a href="${START_URL}">Getting started with caracms</a></p></div></main>`;
22
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>No pages yet</title><style>${tokensCss}${LOGIN_CSS}${EMPTY_CSS}</style></head><body>${body}</body></html>`;
23
+ }
@@ -11,7 +11,9 @@
11
11
  *
12
12
  * **The cloud's refusals become `MuxRouteError`s**, so the media handler's own catch answers
13
13
  * them: `not_found` is the routes' documented 404 ('Not found.'); `video_limit` is a 403
14
- * with the cloud's sentence as sent; any other refusal (`video_unavailable`, the key's own
14
+ * with the cloud's sentence as sent; `uploads_in_flight` (the site already has two clips
15
+ * uploading) is a 429 with the cloud's sentence as sent, not logged: it's the cloud asking
16
+ * the person to wait, not a failure; any other refusal (`video_unavailable`, the key's own
15
17
  * refusals…) is a 502 with the cloud's sentence as sent. A cloud that can't be reached, or
16
18
  * an answer that isn't the shape, is a 502 with the package's own sentence. Each 502 is
17
19
  * logged with the refusal's code. The key is never logged nor put in an error.
@@ -42,6 +44,7 @@ export function cloudMux(
42
44
  if (!isCloudRefusal(error)) throw error;
43
45
  if (error.error === 'not_found') throw new MuxRouteError(404, 'Not found.');
44
46
  if (error.error === 'video_limit') throw new MuxRouteError(403, error.message);
47
+ if (error.error === 'uploads_in_flight') throw new MuxRouteError(429, error.message);
45
48
  log(`[caracms] video ${where}: CaraCMS Cloud (${error.error}): ${error.message}`);
46
49
  throw new MuxRouteError(502, error.error === 'unreachable' ? CLOUD_VIDEO_UNREACHABLE : error.message);
47
50
  }
@@ -16,9 +16,12 @@
16
16
  * - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
17
17
  * cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
18
18
  * beside it is a problem sentence naming them (one credential, not two).
19
+ * - **With the config's `services`** (caracms-cloud M5 Decision 2) the other mode's variables
20
+ * are never read (live/services.ts), and a cloud site without its key has neither.
19
21
  */
20
22
  import type { CloudConnection } from '../../../live/cloud.ts';
21
23
  import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
24
+ import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from '../../../live/services.ts';
22
25
  import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
23
26
 
24
27
  type Env = Record<string, string | undefined>;
@@ -53,15 +56,17 @@ const get = (env: Env, name: string) => {
53
56
  const memory = (env: Env) => get(env, 'CARACMS_MEDIA') === 'memory';
54
57
 
55
58
  /** The image routes' settings: the bucket, and imgproxy's for the thumbnails. */
56
- export function readImagesConfig(env: Env = process.env, production = isProductionBuild()): ImagesConfig {
59
+ export function readImagesConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): ImagesConfig {
60
+ env = servicesEnv(env, services);
57
61
  if (memory(env)) {
58
62
  if (production) return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
59
- const signing = readMediaConfig(env, production);
63
+ const signing = readMediaConfig(env, production, services);
60
64
  if (signing.kind !== 'ready') return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
61
65
  return { kind: 'memory', signing: signing.settings };
62
66
  }
63
- const signing = readMediaConfig(env, production);
64
- const cloud = cloudMedia(env);
67
+ if (services === 'cloud' && !get(env, 'CARACMS_CLOUD_KEY')) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
68
+ const signing = readMediaConfig(env, production, services);
69
+ const cloud = cloudMedia(env, services);
65
70
  if (cloud) {
66
71
  if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
67
72
  return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
@@ -86,9 +91,11 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
86
91
  }
87
92
 
88
93
  /** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
89
- export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
94
+ export function readMuxConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MuxConfig {
95
+ env = servicesEnv(env, services);
90
96
  if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
91
97
  const key = get(env, 'CARACMS_CLOUD_KEY');
98
+ if (services === 'cloud' && !key) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
92
99
  if (key) {
93
100
  const problems: string[] = [];
94
101
  const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
@@ -25,47 +25,8 @@ import { MEDIA_MAX_BYTES, MEDIA_MAX_PIXELS, MEDIA_NAME_HEADER, type MediaRefusal
25
25
  import type { Bucket, ObjectMeta } from './bucket.ts';
26
26
  import { countsPixels, sniffUpload } from './sniff.ts';
27
27
 
28
- /** How many bytes are read before deciding (the cap, plus one to know it was passed). */
29
- export interface CappedBody {
30
- ok: true;
31
- bytes: Uint8Array;
32
- }
33
- export interface OverCap {
34
- ok: false;
35
- /** The size, when the request said it (Content-Length); null when the read was stopped without knowing. */
36
- size: number | null;
37
- }
38
-
39
- /** Read a request's body, stopping as soon as it passes `cap` bytes. */
40
- export async function readCapped(request: Request, cap: number): Promise<CappedBody | OverCap> {
41
- const declared = Number(request.headers.get('content-length') ?? NaN);
42
- const said = Number.isFinite(declared) && declared >= 0 ? declared : null;
43
- if (said !== null && said > cap) {
44
- await request.body?.cancel().catch(() => {});
45
- return { ok: false, size: said };
46
- }
47
- const reader = request.body?.getReader();
48
- if (!reader) return { ok: true, bytes: new Uint8Array(0) };
49
- const chunks: Uint8Array[] = [];
50
- let total = 0;
51
- for (;;) {
52
- const { done, value } = await reader.read();
53
- if (done) break;
54
- total += value.byteLength;
55
- if (total > cap) {
56
- await reader.cancel().catch(() => {});
57
- return { ok: false, size: said !== null && said > total ? said : null };
58
- }
59
- chunks.push(value);
60
- }
61
- const bytes = new Uint8Array(total);
62
- let at = 0;
63
- for (const c of chunks) {
64
- bytes.set(c, at);
65
- at += c.byteLength;
66
- }
67
- return { ok: true, bytes };
68
- }
28
+ // The capped read moved to body.ts (caracms-cloud adversarial review, finding 10), for every route that takes a body.
29
+ export { readCapped, type CappedBody, type OverCap } from '../../../body.ts';
69
30
 
70
31
  /** "52", or "40.3" when rounding would land on the cap itself (so it never reads as allowed). */
71
32
  function amount(value: number, cap: number): string {
@@ -15,6 +15,7 @@
15
15
  import type { ResolvedSite } from '../../config.ts';
16
16
  import { liveEditor, type LiveEditor } from '../../live/editor.ts';
17
17
  import { readMode, unconfiguredMessage, type Baked, type Mode } from '../../live/mode.ts';
18
+ import { bakedServices } from '../../live/services.ts';
18
19
  import { localDraftsStore, type DraftsStore } from './drafts-store.ts';
19
20
 
20
21
  declare const __CARACMS_PRODUCTION__: boolean | undefined;
@@ -26,11 +27,12 @@ export function isProductionServer(): boolean {
26
27
  return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
27
28
  }
28
29
 
29
- /** The site's address and the commit the build baked in (runs-anywhere Decisions 7 and 9); empty in plain Node (tests). */
30
+ /** The site's address, the commit and the config's `services` the build baked in (runs-anywhere Decisions 7 and 9, caracms-cloud M5 Decision 1); empty in plain Node (tests). */
30
31
  export function bakedAtBuild(): Baked {
31
32
  return {
32
33
  site: typeof __CARACMS_SITE__ === 'string' ? __CARACMS_SITE__ : null,
33
34
  commit: typeof __CARACMS_BUILD_COMMIT__ === 'string' ? __CARACMS_BUILD_COMMIT__ : null,
35
+ services: bakedServices(),
34
36
  };
35
37
  }
36
38