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
package/src/live/sweep.ts CHANGED
@@ -25,6 +25,10 @@
25
25
  * → `ancestorFor`), a "gone" one as a removed file (git.ts → `commitDrafts`). A newer
26
26
  * write that came in while a new document was being committed is no longer a new
27
27
  * document: its `created` mark is dropped (retried if a write beats the swap), so its ancestor is that commit.
28
+ * - **`exclusively`** runs other work under the sweep's own locks (this process's queue and
29
+ * the Redis lock): Discard changes, so no tick commits the edit being thrown away while
30
+ * it's being thrown away. It waits a few seconds for another server's tick, then gives
31
+ * up (`SweepBusyError`) rather than go ahead without the lock.
28
32
  */
29
33
  import { randomBytes } from 'node:crypto';
30
34
  import type { DraftEntry, LiveGit } from './git.ts';
@@ -64,6 +68,14 @@ export interface SweepResult {
64
68
 
65
69
  type Select = 'ready' | 'all' | { files: string[] };
66
70
 
71
+ /** `exclusively` couldn't get the sweep's Redis lock: another server's tick held it throughout. */
72
+ export class SweepBusyError extends Error {
73
+ constructor() {
74
+ super('the sweep is busy on another server');
75
+ this.name = 'SweepBusyError';
76
+ }
77
+ }
78
+
67
79
  /** Is an autosave ready for a tick: marked to go at once, idle long enough, or past the cap? */
68
80
  export function ready(a: Autosave, now: number, timings: Pick<LiveTimings, 'sweepIdleMs' | 'sweepCapMs'>): boolean {
69
81
  return a.sweepAtOnce === true || now - a.updatedAt >= timings.sweepIdleMs || now - a.firstAt >= timings.sweepCapMs;
@@ -90,11 +102,16 @@ export class Sweep {
90
102
  this.lockKey = `${options.prefix ?? 'caracms:'}sweep:lock`;
91
103
  }
92
104
 
93
- /** The startup check: anything a previous server left behind starts the ticking. */
105
+ /**
106
+ * The startup check: anything a previous server left behind starts the ticking. An
107
+ * autosave that still carries the week's expiry older versions gave it loses it
108
+ * (caracms-cloud adversarial review, finding 4: an uncommitted edit never expires).
109
+ */
94
110
  async start(): Promise<void> {
95
111
  const pending = await this.o.autosaves.all();
96
112
  if (pending.length) {
97
113
  this.log(`sweep: ${pending.length} autosave(s) waiting from before this server started`);
114
+ for (const a of pending) await this.o.autosaves.keepForever(a);
98
115
  this.poke();
99
116
  }
100
117
  }
@@ -151,6 +168,32 @@ export class Sweep {
151
168
  return (await this.run({ files })).result;
152
169
  }
153
170
 
171
+ /**
172
+ * Run `fn` holding the sweep's locks: after any run of this process's in flight, and with
173
+ * the Redis lock (waiting up to about 5 s for another server's tick, else SweepBusyError).
174
+ */
175
+ exclusively<T>(fn: () => Promise<T>): Promise<T> {
176
+ const work = async (): Promise<T> => {
177
+ const { redis, timings } = this.o;
178
+ const token = randomBytes(9).toString('base64url');
179
+ const lockMs = Math.max(30_000, timings.sweepTickMs);
180
+ let locked = await redis.set(this.lockKey, token, { nx: true, px: lockMs });
181
+ for (let i = 0; i < 20 && !locked; i++) {
182
+ await this.sleep(250);
183
+ locked = await redis.set(this.lockKey, token, { nx: true, px: lockMs });
184
+ }
185
+ if (!locked) throw new SweepBusyError();
186
+ try {
187
+ return await fn();
188
+ } finally {
189
+ await redis.compareAndDelete(this.lockKey, token).catch(() => false);
190
+ }
191
+ };
192
+ const next = this.running.then(work, work);
193
+ this.running = next.catch(() => {});
194
+ return next;
195
+ }
196
+
154
197
  /** Runs one at a time in this process; the Redis lock keeps two servers apart. */
155
198
  private run(select: Select): Promise<{ result: SweepResult; left: boolean }> {
156
199
  const next = this.running.then(
@@ -8,8 +8,11 @@
8
8
  * compared in constant time, or it's 401 and nothing happens.
9
9
  * - **Nothing in the body is trusted or even read**: a good signature only prompts
10
10
  * Decision 8's check, which asks GitHub itself what moved.
11
+ * - **At most 25 MB is read**, counted as it comes (body.ts): a chunked body with no
12
+ * `Content-Length`, or one that lies, is refused at the cap, not after (finding 10).
11
13
  */
12
14
  import { createHmac, timingSafeEqual } from 'node:crypto';
15
+ import { readCapped } from '../body.ts';
13
16
 
14
17
  /** Largest body read. GitHub caps payloads at 25 MB; a push is far smaller, and it's never parsed. */
15
18
  const MAX_BODY_BYTES = 25 * 1024 * 1024;
@@ -30,9 +33,11 @@ const text = (status: number, body: string) => new Response(body, { status, head
30
33
  export async function handleGithubWebhook(request: Request, options: { secret: string | null; trigger: () => Promise<unknown> }): Promise<Response> {
31
34
  if (!options.secret) return text(404, 'Not found');
32
35
  if (request.method !== 'POST') return new Response('The webhook only takes POST.', { status: 405, headers: { Allow: 'POST' } });
33
- if (Number(request.headers.get('content-length') ?? 0) > MAX_BODY_BYTES) return text(413, 'Too large.');
34
- const body = Buffer.from(await request.arrayBuffer());
35
- if (body.length > MAX_BODY_BYTES) return text(413, 'Too large.');
36
+ // caracms-cloud adversarial review, finding 10: the bytes are counted as they're read and the read
37
+ // stops at the cap, with or without a Content-Length, before the signature is checked.
38
+ const read = await readCapped(request, MAX_BODY_BYTES);
39
+ if (!read.ok) return text(413, 'Too large.');
40
+ const body = Buffer.from(read.bytes.buffer, read.bytes.byteOffset, read.bytes.byteLength);
36
41
  if (!signatureMatches(options.secret, body, request.headers.get('x-hub-signature-256'))) return text(401, 'Bad signature.');
37
42
  void options.trigger().catch((error: unknown) => console.error('[caracms] the check after a webhook failed:', error));
38
43
  return text(202, 'Checking.');
package/src/media.ts CHANGED
@@ -35,6 +35,7 @@ import { createHmac } from 'node:crypto';
35
35
  import type { ImageFormat } from './fields.ts';
36
36
  import { CLOUD_NO_MEDIA, heldCloudSettings, type CloudConnection } from './live/cloud.ts';
37
37
  import { CLOUD_AND_MEDIA_PROBLEM, OWN_MEDIA_VARIABLES, readCloudUrl } from './live/mode.ts';
38
+ import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from './live/services.ts';
38
39
  import { renderMedia, type RenderImage } from './media-value.ts';
39
40
 
40
41
  /** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
@@ -92,8 +93,10 @@ export const CLOUD_MEDIA_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so im
92
93
  * The cloud as the media credential: the connection when `CARACMS_CLOUD_KEY` is set and
93
94
  * `CARACMS_MEDIA` isn't `memory` (which wins over everything), with any problems (the
94
95
  * site's own media variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site's media.
96
+ * With the config's `services` (caracms-cloud M5), the other mode's variables aren't read.
95
97
  */
96
- export function cloudMedia(env: Env = process.env): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
98
+ export function cloudMedia(env: Env = process.env, services: Services | null = bakedServices()): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
99
+ env = servicesEnv(env, services);
97
100
  const get = (name: string) => env[name]?.trim() || null;
98
101
  const key = get('CARACMS_CLOUD_KEY');
99
102
  if (!key || get('CARACMS_MEDIA') === 'memory') return null;
@@ -117,7 +120,8 @@ function cloudMediaConfig(cloud: NonNullable<ReturnType<typeof cloudMedia>>, pro
117
120
  }
118
121
 
119
122
  /** The media variables (Decision 5). Read on each call (cheap), so tests can change them. */
120
- export function readMediaConfig(env: Env = process.env, production = isProductionBuild()): MediaConfig {
123
+ export function readMediaConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MediaConfig {
124
+ env = servicesEnv(env, services);
121
125
  const get = (name: string) => {
122
126
  const v = env[name]?.trim();
123
127
  return v ? v : null;
@@ -129,7 +133,9 @@ export function readMediaConfig(env: Env = process.env, production = isProductio
129
133
  return { kind: 'ready', settings: { ...MEMORY_MEDIA, memory: true } };
130
134
  }
131
135
  if (mode) problems.push(`CARACMS_MEDIA must be "memory" or unset (got "${mode}").`);
132
- const cloud = cloudMedia(env);
136
+ // M5 Decision 4: a cloud site without its key has no media, never the site's own instead.
137
+ if (services === 'cloud' && !get('CARACMS_CLOUD_KEY')) return { kind: 'unset', missing: [], problems: [...problems, CLOUD_KEY_MISSING_PROBLEM] };
138
+ const cloud = cloudMedia(env, services);
133
139
  if (cloud) return cloudMediaConfig(cloud, problems);
134
140
  const missing: string[] = [];
135
141
  const need = (name: string) => {