caracms 0.5.1 → 0.7.0

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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The submissions store on CaraCMS Cloud (caracms-cloud M4 Decisions 5–9 and 23), behind
3
+ * store.ts's `SubmissionStore`, so the endpoint's checks and the inbox don't change. The
4
+ * site's server never holds R2 or Resend credentials: it asks the API (live/cloud.ts, the
5
+ * site key), which answers for the site's own folder.
6
+ *
7
+ * - `create`: mints the id (`newSubmissionId`, the package's own shape), builds the
8
+ * `StoredSubmission`, asks the `email` callback to write the email for it, and sends both
9
+ * (`POST /v1/submissions`). On `409 exists` it mints a new id, writes the email again (its
10
+ * link names the id) and tries once more. With files, the answer is **pending** with one
11
+ * presigned PUT per file: each is uploaded with exactly the headers named, then
12
+ * `POST /v1/submissions/confirm`, tried once more on a timeout or an unreachable cloud.
13
+ * An upload that fails, or a confirm answered `409 incomplete`, removes the row
14
+ * (`POST /v1/submissions/delete`); a confirm that never got through is left to the
15
+ * cloud's hourly clean-up. Either way `create` throws: the visitor gets the endpoint's 502.
16
+ * - `get`, `list`, `update` (`readAt` only), `delete` and `getFile` (a 5-minute download
17
+ * URL, then the bytes): one route each.
18
+ * - **The unread count comes back with every answer**; `unreadCount()` gives the last one
19
+ * this store saw, or asks (`GET /v1/submissions/unread`). store.ts makes a store per
20
+ * request, so the count is never older than the request.
21
+ *
22
+ * Refusals are `CloudRefusal`s with the cloud's own sentence (the endpoint logs it, the
23
+ * inbox shows it). Presigned URLs go nowhere but the fetch here: never in a log line or an error.
24
+ */
25
+ import { createHash } from 'node:crypto';
26
+ import { cloudCall, CloudRefusal, isCloudRefusal, type CloudConnection } from '../live/cloud.ts';
27
+ import { isSubmissionId } from './rules.ts';
28
+ import { fileKey, isFileKey, newSubmissionId, PAGE_SIZE, type StoredFile, type StoredSubmission, type SubmissionStore } from './store.ts';
29
+
30
+ const STORAGE_TIMEOUT_MS = 20_000;
31
+
32
+ const malformed = (what: string) => new CloudRefusal('unreachable', `CaraCMS Cloud's answer to ${what} isn't one the package knows.`);
33
+
34
+ const count = (v: unknown): number | null => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
35
+
36
+ /** A submission as the cloud answered it: the package's own shape, checked enough to be read by the inbox. */
37
+ function submissionOf(value: unknown, what: string): StoredSubmission {
38
+ const s = value as StoredSubmission | null;
39
+ if (!s || typeof s !== 'object' || !isSubmissionId(s.id) || typeof s.form !== 'string' || typeof s.receivedAt !== 'string' || !s.fields || typeof s.fields !== 'object') {
40
+ throw malformed(what);
41
+ }
42
+ return { ...s, v: 1, readAt: typeof s.readAt === 'string' ? s.readAt : null };
43
+ }
44
+
45
+ export function cloudStore(connection: CloudConnection): SubmissionStore {
46
+ const fetchImpl = connection.fetch ?? ((...args: Parameters<typeof fetch>) => globalThis.fetch(...args));
47
+ /** The unread count from the last answer that carried one. */
48
+ let unread: number | null = null;
49
+ const seen = (answer: Record<string, unknown>) => {
50
+ const n = count(answer.unread);
51
+ if (n !== null) unread = n;
52
+ };
53
+ const api = (method: 'GET' | 'POST', path: string, body?: unknown) => cloudCall(connection, method, path, body);
54
+ /** Null for the cloud's `404 not_found`; any other refusal goes up as sent. */
55
+ async function orNull(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown> | null> {
56
+ try {
57
+ return await api(method, path, body);
58
+ } catch (error) {
59
+ if (isCloudRefusal(error) && error.error === 'not_found') return null;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ /** A presigned URL's request, with its own timeout. */
65
+ async function storage(what: string, url: string, init: RequestInit): Promise<Response> {
66
+ try {
67
+ return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(STORAGE_TIMEOUT_MS) });
68
+ } catch (error) {
69
+ throw new Error(`submissions: ${what}: storage unreachable (${error instanceof Error ? error.message : String(error)})`);
70
+ }
71
+ }
72
+
73
+ /** The confirm, tried once more when the cloud didn't answer (Decision 6). */
74
+ async function confirm(id: string): Promise<void> {
75
+ for (let attempt = 1; ; attempt++) {
76
+ try {
77
+ await api('POST', '/v1/submissions/confirm', { id });
78
+ return;
79
+ } catch (error) {
80
+ if (attempt < 2 && isCloudRefusal(error) && error.error === 'unreachable') continue;
81
+ throw error;
82
+ }
83
+ }
84
+ }
85
+
86
+ /** The row goes, best effort: the cloud's hourly clean-up takes it otherwise. */
87
+ const discard = (id: string) => api('POST', '/v1/submissions/delete', { id }).catch(() => {});
88
+
89
+ async function get(id: string): Promise<StoredSubmission | null> {
90
+ if (!isSubmissionId(id)) return null;
91
+ const answer = await orNull('GET', `/v1/submissions/get?${new URLSearchParams({ id })}`);
92
+ if (!answer) return null;
93
+ seen(answer);
94
+ const submission = submissionOf(answer.submission, 'get');
95
+ return submission.id === id ? submission : null;
96
+ }
97
+
98
+ return {
99
+ sendsEmail: true,
100
+ async create({ form, receivedAt, fields, files, email }) {
101
+ const hashes = (files ?? []).map((f) => createHash('sha256').update(f.data).digest('hex'));
102
+ for (let attempt = 1; ; attempt++) {
103
+ const id = newSubmissionId(receivedAt);
104
+ const stored: StoredFile[] = (files ?? []).map((f, i) => ({ name: f.name, size: f.data.byteLength, type: f.type, key: fileKey(id, i + 1, f.name) }));
105
+ const record: StoredSubmission = { v: 1, id, form, receivedAt, fields, ...(stored.length ? { files: stored } : {}), readAt: null };
106
+ const written = email?.(record) ?? { emailSkipped: 'No email was written for this submission.' };
107
+ let answer: Record<string, unknown>;
108
+ try {
109
+ answer = await api('POST', '/v1/submissions', {
110
+ id,
111
+ form,
112
+ receivedAt,
113
+ fields,
114
+ ...(stored.length ? { files: stored.map((f, i) => ({ name: f.name, type: f.type, size: f.size, key: f.key, sha256: hashes[i] })) } : {}),
115
+ ...written,
116
+ });
117
+ } catch (error) {
118
+ // The id was already the site's (Decision 5): a new one, and the email written again for it.
119
+ if (attempt < 2 && isCloudRefusal(error) && error.error === 'exists') continue;
120
+ throw error;
121
+ }
122
+ if (answer.state === 'complete') return record;
123
+ if (answer.state !== 'pending' || !Array.isArray(answer.uploads)) throw malformed('a new submission');
124
+ try {
125
+ for (const file of stored) {
126
+ const upload = (answer.uploads as Record<string, unknown>[]).find((u) => u && typeof u === 'object' && u.key === file.key);
127
+ const { url, method, headers } = upload ?? {};
128
+ if (typeof url !== 'string' || !/^https?:\/\//.test(url) || (method !== undefined && method !== 'PUT') || !headers || typeof headers !== 'object') {
129
+ throw malformed('a new submission');
130
+ }
131
+ // The headers as the cloud named them (the URL signs each); Content-Length and Host are fetch's own.
132
+ const sent = Object.fromEntries(
133
+ Object.entries(headers as Record<string, unknown>).filter(([name, v]) => typeof v === 'string' && !/^(content-length|host)$/i.test(name)),
134
+ ) as Record<string, string>;
135
+ const n = stored.indexOf(file);
136
+ const res = await storage(`upload ${n + 1}`, url, { method: 'PUT', headers: sent, body: files![n]!.data as BodyInit });
137
+ await res.body?.cancel().catch(() => {});
138
+ // 412: the signed If-None-Match: * found the key already there, so an earlier try put it.
139
+ if (!res.ok && res.status !== 412) throw new Error(`submissions: upload ${n + 1} of ${stored.length}: the storage answered ${res.status}`);
140
+ }
141
+ } catch (error) {
142
+ await discard(id);
143
+ throw error;
144
+ }
145
+ try {
146
+ await confirm(id);
147
+ } catch (error) {
148
+ // `409 incomplete`: a file isn't there at its size, so the row goes. Unreachable: the cloud's clean-up takes it.
149
+ if (isCloudRefusal(error) && error.error !== 'unreachable') await discard(id);
150
+ throw error;
151
+ }
152
+ return record;
153
+ }
154
+ },
155
+ get,
156
+ async getFile(id, n) {
157
+ if (!isSubmissionId(id) || !Number.isInteger(n) || n < 1) return null;
158
+ const answer = await orNull('POST', '/v1/submissions/file-url', { id, n });
159
+ if (!answer) return null;
160
+ const file = answer.file as StoredFile | undefined;
161
+ // Checked against the id asked for: a key that isn't this submission's is never read.
162
+ if (typeof answer.url !== 'string' || !file || typeof file !== 'object' || !isFileKey(id, file.key)) throw malformed('a file');
163
+ const res = await storage(`download ${n}`, answer.url, { method: 'GET' });
164
+ if (res.status === 404) {
165
+ await res.body?.cancel().catch(() => {});
166
+ return null;
167
+ }
168
+ if (!res.ok) {
169
+ await res.body?.cancel().catch(() => {});
170
+ throw new Error(`submissions: download ${n}: the storage answered ${res.status}`);
171
+ }
172
+ return { file: { name: String(file.name ?? ''), size: Number(file.size) || 0, type: String(file.type ?? ''), key: file.key }, body: new Uint8Array(await res.arrayBuffer()) };
173
+ },
174
+ async list(opts = {}) {
175
+ const limit = Math.max(1, Math.min(500, opts.limit ?? PAGE_SIZE));
176
+ const query = new URLSearchParams({ limit: String(limit) });
177
+ if (opts.before) {
178
+ // The cloud refuses a cursor not of the id's shape; v3's stores treat it as "after everything".
179
+ if (!isSubmissionId(opts.before)) return { items: [], older: null };
180
+ query.set('before', opts.before);
181
+ }
182
+ const answer = await api('GET', `/v1/submissions?${query}`);
183
+ if (!Array.isArray(answer.items)) throw malformed('the list');
184
+ seen(answer);
185
+ const older = answer.older;
186
+ return { items: answer.items.map((s) => submissionOf(s, 'the list')), older: typeof older === 'string' && isSubmissionId(older) ? older : null };
187
+ },
188
+ async update(id, patch) {
189
+ // The cloud records the email's outcome itself; only `readAt` is the inbox's to change.
190
+ if (!('readAt' in patch)) return get(id);
191
+ if (!isSubmissionId(id)) return null;
192
+ const answer = await orNull('POST', '/v1/submissions/update', { id, readAt: patch.readAt ?? null });
193
+ if (!answer) return null;
194
+ seen(answer);
195
+ return submissionOf(answer.submission, 'an update');
196
+ },
197
+ async delete(id) {
198
+ if (!isSubmissionId(id)) return false;
199
+ const answer = await api('POST', '/v1/submissions/delete', { id });
200
+ seen(answer);
201
+ return answer.deleted === true;
202
+ },
203
+ async unreadCount() {
204
+ if (unread !== null) return unread;
205
+ const answer = await api('GET', '/v1/submissions/unread');
206
+ const n = count(answer.unread);
207
+ if (n === null) throw malformed('the unread count');
208
+ unread = n;
209
+ return n;
210
+ },
211
+ };
212
+ }
@@ -15,13 +15,16 @@
15
15
  * dropped, one that doesn't look like an address skipped. An empty list is `skipped`.
16
16
  * - **Only a production server sends.** Elsewhere the email is written to the log and
17
17
  * recorded `skipped`, saying so.
18
+ * - **On CaraCMS Cloud** (caracms-cloud M4 Decision 24) the site writes the email the same
19
+ * way (`writeNotification`), from "<brand> – <form label>", to at most ten addresses,
20
+ * and the cloud store hands it over with the submission; the cloud sends it.
18
21
  */
19
22
  import { readFile } from 'node:fs/promises';
20
23
  import { join } from 'node:path';
21
24
  import { SITE_SETTINGS_FILE } from '../reserved.ts';
22
25
  import { sendEmail, sendsEmail } from '../email.ts';
23
26
  import { formatBytes, looksLikeEmail, shownFileName, submissionAddress, type ResolvedForm } from './rules.ts';
24
- import type { EmailOutcome, StoredSubmission } from './store.ts';
27
+ import type { EmailOutcome, StoredSubmission, WrittenEmail } from './store.ts';
25
28
 
26
29
  type Env = Record<string, string | undefined>;
27
30
 
@@ -116,6 +119,53 @@ export function buildNotification(input: { submission: StoredSubmission; form: R
116
119
  };
117
120
  }
118
121
 
122
+ /** The most addresses one form email goes to on CaraCMS Cloud (caracms-cloud M4 Decision 4). */
123
+ export const MAX_RECIPIENTS = 10;
124
+
125
+ /** The sender's display name on CaraCMS Cloud (M4 Decision 11): `<brand name> – <form label>`, "Urban Return – Contact". The cloud cleans it further. */
126
+ export function fromName(brandName: string, formLabel: string): string {
127
+ return [brandName.trim(), formLabel.trim()].filter(Boolean).join(' – ');
128
+ }
129
+
130
+ /** The sentence for a form with no one set to get its emails. */
131
+ const noRecipients = (label: string) => `No one is set to get the ${label} form's emails (Site Settings → Form emails).`;
132
+
133
+ /**
134
+ * The email a cloud site's server hands the cloud with a submission (M4 Decisions 11 and 24):
135
+ * written as `sendNotification` writes it, the recipients capped at the first ten (the rest
136
+ * logged), or the reason there is none. `settings` is the Site Settings read, or the error
137
+ * reading them threw. Never throws.
138
+ */
139
+ export function writeNotification(input: {
140
+ submission: StoredSubmission;
141
+ form: ResolvedForm | null;
142
+ settings: FormSettings | { error: unknown };
143
+ siteUrl: string | null;
144
+ timeZone: string;
145
+ log?: (line: string) => void;
146
+ }): WrittenEmail {
147
+ const log = input.log ?? ((line: string) => console.log(line));
148
+ if ('error' in input.settings) {
149
+ const { error } = input.settings;
150
+ return { emailFailed: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
151
+ }
152
+ const settings = input.settings;
153
+ const label = input.form?.label ?? input.submission.form;
154
+ const all = sendableAddresses(settings.recipients[input.submission.form] ?? []);
155
+ if (all.length === 0) return { emailSkipped: noRecipients(label) };
156
+ const to = all.slice(0, MAX_RECIPIENTS);
157
+ if (all.length > to.length) {
158
+ const left = all.length - to.length;
159
+ log(`[caracms] forms: the ${label} form has ${all.length} recipients; its email goes to the first ${MAX_RECIPIENTS}, and ${left} ${left === 1 ? 'was' : 'were'} left off (Site Settings → Form emails).`);
160
+ }
161
+ try {
162
+ const built = buildNotification({ submission: input.submission, form: input.form, brandName: settings.brandName, siteUrl: input.siteUrl, timeZone: input.timeZone });
163
+ return { email: { to, subject: built.subject, text: built.text, ...(built.replyTo ? { replyTo: built.replyTo } : {}), fromName: fromName(settings.brandName, label) } };
164
+ } catch (error) {
165
+ return { emailFailed: `Could not write the email: ${error instanceof Error ? error.message : String(error)}` };
166
+ }
167
+ }
168
+
119
169
  /** Send a submission's email and say what happened (the outcome recorded on it). Never throws. */
120
170
  export async function sendNotification(input: {
121
171
  submission: StoredSubmission;
@@ -133,7 +183,7 @@ export async function sendNotification(input: {
133
183
  const log = input.log ?? ((line: string) => console.log(line));
134
184
  const to = sendableAddresses(input.settings.recipients[input.submission.form] ?? []);
135
185
  const label = input.form?.label ?? input.submission.form;
136
- if (to.length === 0) return { status: 'skipped', at, to, error: `No one is set to get the ${label} form's emails (Site Settings → Form emails).` };
186
+ if (to.length === 0) return { status: 'skipped', at, to, error: noRecipients(label) };
137
187
  let built: ReturnType<typeof buildNotification>;
138
188
  try {
139
189
  built = buildNotification({ submission: input.submission, form: input.form, brandName: input.settings.brandName, siteUrl: input.siteUrl, timeZone: input.timeZone });
@@ -29,7 +29,9 @@
29
29
  * 403; Cloudflare unreachable fails open, logged. Unset: accepted, with a warning.
30
30
  * 8. **Store** (store.ts), then **email** (email.ts), then **record** the email's outcome
31
31
  * on the submission, then reply. A storage failure is a 502; an email failure never
32
- * fails the visitor.
32
+ * fails the visitor. **On CaraCMS Cloud** (caracms-cloud M4 Decision 24) the store
33
+ * takes the written email with the submission and the cloud sends and records it, so
34
+ * nothing is sent or recorded here; a cloud refusal is the same 502, its sentence logged.
33
35
  *
34
36
  * Replies: `Accept: application/json` gets `{ ok: true }` or `{ error }`; a no-JS post
35
37
  * gets a 303 to `<return>?sent=1`, or the error as plain text. Nothing about the visitor
@@ -40,7 +42,7 @@ import { publicOrigin } from '../live/auth/index.ts';
40
42
  import { clientIp, NO_GATE_MESSAGE } from '../live/auth/request.ts';
41
43
  import { readCapped } from '../editor/server/media/upload.ts';
42
44
  import { isProductionBuild } from '../media.ts';
43
- import { readFormSettings, sendNotification, type FormSettings } from './email.ts';
45
+ import { readFormSettings, sendNotification, writeNotification, type FormSettings } from './email.ts';
44
46
  import { formLimiter, type FormRateLimiter } from './rate-limit.ts';
45
47
  import { cleanReturn, FILE_KINDS, FILE_TYPES, formsAcceptFiles, HONEYPOT, looksLikeEmail, MAX_FILE_BYTES, MAX_FILES, type ResolvedForm } from './rules.ts';
46
48
  import { cleanFileName, fileExtension, readSubmissionsConfig, submissionStore, type NewFile, type SubmissionStore } from './store.ts';
@@ -313,21 +315,31 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
313
315
  }
314
316
  store = chosen;
315
317
  }
318
+ const siteUrl = publicOrigin(ctx.astroSite, env);
319
+ const readSettings = ctx.readSettings ?? (() => readFormSettings(ctx.site.root));
320
+ // On CaraCMS Cloud (caracms-cloud M4 Decision 24) the email goes with the submission: written here, sent by the cloud.
321
+ const settingsRead = store.sendsEmail ? await readSettings().catch((error: unknown) => ({ error })) : null;
316
322
  let submission;
317
323
  try {
318
324
  const files: NewFile[] = await Promise.all(vetted.files.map(async (f) => ({ name: f.name, type: f.type, data: new Uint8Array(await f.blob.arrayBuffer()) })));
319
- submission = await store.create({ form: form.id, receivedAt: now().toISOString(), fields: vetted.fields, ...(files.length ? { files } : {}) });
325
+ submission = await store.create({
326
+ form: form.id,
327
+ receivedAt: now().toISOString(),
328
+ fields: vetted.fields,
329
+ ...(files.length ? { files } : {}),
330
+ ...(settingsRead ? { email: (stored) => writeNotification({ submission: stored, form, settings: settingsRead, siteUrl, timeZone: ctx.site.timeZone, log }) } : {}),
331
+ });
320
332
  } catch (error) {
321
333
  log(`[caracms] forms: couldn't store a ${form.id} submission: ${error instanceof Error ? error.message : String(error)}`);
322
334
  return reply(502, { error: STORE_FAILED_MESSAGE });
323
335
  }
324
336
  await limiter.hit(ip);
337
+ if (store.sendsEmail) return reply(200, { ok: true });
325
338
 
326
339
  // EMAIL, then RECORD its outcome. Neither changes the visitor's reply.
327
340
  let email;
328
341
  try {
329
- const settings = await (ctx.readSettings ?? (() => readFormSettings(ctx.site.root)))();
330
- const siteUrl = publicOrigin(ctx.astroSite, env);
342
+ const settings = await readSettings();
331
343
  email = await sendNotification({ submission, form, settings, siteUrl, timeZone: ctx.site.timeZone, env, fetch: ctx.fetch, now, log });
332
344
  } catch (error) {
333
345
  email = { status: 'failed' as const, at: now().toISOString(), to: [], error: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
@@ -9,8 +9,12 @@
9
9
  * gets these. **The production server never falls back to disk**: with any of them
10
10
  * unset, the store is `unset` (the endpoint's 502, the inbox's sentence).
11
11
  * - **Everywhere else**: `.caracms/submissions/` under the site's root (gitignored),
12
- * **even with the bucket variables set**, so a laptop never lands a test enquiry in
12
+ * **even with the bucket variables or the cloud key set**, so a laptop never lands a test enquiry in
13
13
  * the client's inbox, nor copies their visitors' data down.
14
+ * - **CaraCMS Cloud** (caracms-cloud M4 Decision 23): a production server with
15
+ * `CARACMS_CLOUD_KEY` stores through the cloud's API (cloud-store.ts), and the cloud sends
16
+ * the email. The key with any of the site's own forms and email variables is `unset`
17
+ * with Decision 22's sentence.
14
18
  * - **`CARACMS_SUBMISSIONS=memory`**: in memory, one per process, for tests. Refused in
15
19
  * production, as `CARACMS_MEDIA=memory` is.
16
20
  *
@@ -31,6 +35,8 @@ import { createHash, randomInt } from 'node:crypto';
31
35
  import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from 'node:fs/promises';
32
36
  import { dirname, join, resolve, sep } from 'node:path';
33
37
  import { AwsClient } from 'aws4fetch';
38
+ import { cloudForms } from '../live/mode.ts';
39
+ import { cloudStore } from './cloud-store.ts';
34
40
  import { isSubmissionId, shownFileName } from './rules.ts';
35
41
 
36
42
  type Env = Record<string, string | undefined>;
@@ -77,8 +83,24 @@ export interface NewFile {
77
83
  data: Uint8Array;
78
84
  }
79
85
 
86
+ /**
87
+ * What a cloud site's server sends with a submission (caracms-cloud M4 Decision 11): the
88
+ * email, written, or the reason there is none. The cloud sends it once the row is stored.
89
+ */
90
+ export type WrittenEmail =
91
+ | { email: { to: string[]; subject: string; text: string; replyTo?: string; fromName: string } }
92
+ | { emailSkipped: string }
93
+ | { emailFailed: string };
94
+
80
95
  export interface SubmissionStore {
81
- create(input: { form: string; receivedAt: string; fields: Record<string, string>; files?: NewFile[] }): Promise<StoredSubmission>;
96
+ /**
97
+ * `email` writes the email for the submission as stored (its id, files and time), and only
98
+ * the cloud store calls it (M4 Decision 23): it may call it twice, when the cloud already
99
+ * had the first id. The other stores ignore it; the endpoint sends their email itself.
100
+ */
101
+ create(input: { form: string; receivedAt: string; fields: Record<string, string>; files?: NewFile[]; email?: (submission: StoredSubmission) => WrittenEmail }): Promise<StoredSubmission>;
102
+ /** True for the cloud store: the email goes with `create`, and the cloud sends it and records the outcome. */
103
+ readonly sendsEmail?: boolean;
82
104
  get(id: string): Promise<StoredSubmission | null>;
83
105
  getFile(id: string, n: number): Promise<{ file: StoredFile; body: Uint8Array } | null>;
84
106
  /** Newest first, `limit` (default 50) after `before`; `older` is the cursor for the next page, or null. */
@@ -426,6 +448,8 @@ export type SubmissionsConfig =
426
448
  | { kind: 'memory' }
427
449
  | { kind: 'folder'; dir: string }
428
450
  | { kind: 'bucket'; bucket: SubmissionsBucket }
451
+ /** CaraCMS Cloud (caracms-cloud M4 Decision 23): a production server with `CARACMS_CLOUD_KEY`. */
452
+ | { kind: 'cloud'; connection: { key: string; url: string } }
429
453
  /** The production server without its bucket: `message` names what's missing (the endpoint's log, the inbox's sentence). */
430
454
  | { kind: 'unset'; message: string; missing: string[] };
431
455
 
@@ -440,10 +464,19 @@ export function readSubmissionsConfig(root: string, env: Env = process.env, prod
440
464
  const mode = get('CARACMS_SUBMISSIONS');
441
465
  if (mode === 'memory') return production ? { kind: 'unset', message: SUBMISSIONS_MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
442
466
  if (mode) return { kind: 'unset', message: `CARACMS_SUBMISSIONS must be "memory" or unset (got "${mode}").`, missing: [] };
467
+ // The key with the site's own variables is a mistake everywhere (M4 Decision 22), as the media's is.
468
+ const cloud = cloudForms(env);
469
+ if (cloud?.problems.length) return { kind: 'unset', message: cloud.problems.join(' '), missing: [] };
470
+ // Outside production: the folder, even with the cloud key (M4's Core framing).
443
471
  if (!production) return { kind: 'folder', dir: join(root, LOCAL_SUBMISSIONS_FOLDER) };
472
+ if (cloud) return { kind: 'cloud', connection: cloud.connection };
444
473
  const missing = [...SUBMISSIONS_ENV, ...SHARED_ENV].filter((name) => !get(name));
445
474
  if (missing.length) {
446
- return { kind: 'unset', message: `Form submissions aren't set up on this server: set ${list(missing)} to the site's submissions bucket.`, missing };
475
+ return {
476
+ kind: 'unset',
477
+ message: `Form submissions aren't set up on this server: set CARACMS_CLOUD_KEY to the site's CaraCMS Cloud key, or set ${list(missing)} to the site's submissions bucket.`,
478
+ missing,
479
+ };
447
480
  }
448
481
  return {
449
482
  kind: 'bucket',
@@ -474,6 +507,8 @@ export function resetSubmissions(): void {
474
507
  /** The store for this config, one per process per backend (so the unread count is kept), or the `unset` config itself. */
475
508
  export function submissionStore(config: SubmissionsConfig, fetchImpl?: typeof fetch): SubmissionStore | Extract<SubmissionsConfig, { kind: 'unset' }> {
476
509
  if (config.kind === 'unset') return config;
510
+ // One per call (a request): the cloud keeps the unread count, and the store holds only the count its own last answer gave.
511
+ if (config.kind === 'cloud') return cloudStore({ ...config.connection, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
477
512
  const s = shared();
478
513
  const id = config.kind === 'memory' ? 'memory' : config.kind === 'folder' ? `folder:${config.dir}` : `bucket:${config.bucket.endpoint}:${config.bucket.bucket}:${config.bucket.accessKeyId}`;
479
514
  let store = s.stores.get(id);
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@
46
46
  * (Decision 5) and has Vite pre-bundle the editor's browser deps. The public pages
47
47
  * have no script, no React and no editor markers in either.
48
48
  */
49
+ import { cloudSettingsForBuild } from './cloud-middleware.ts';
49
50
  import type { AstroIntegration } from 'astro';
50
51
  import { fontProviders } from 'astro/config';
51
52
  import { execFileSync } from 'node:child_process';
@@ -198,6 +199,8 @@ export const REDIRECT_MIDDLEWARE = 'redirect-middleware.ts';
198
199
 
199
200
  /** M4 Phase 2: the unconfigured server's 503, registered before Phase 3's guard. */
200
201
  export const EDITOR_MIDDLEWARE = 'editor/middleware.ts';
202
+ /** caracms-cloud M3 Decision 20: a cloud site's settings, fetched by the first request (cloud-middleware.ts). */
203
+ export const CLOUD_MIDDLEWARE = 'cloud-middleware.ts';
201
204
 
202
205
  /**
203
206
  * Content files out of Vite's watch (Decision 5): a write never reloads `/admin`
@@ -410,6 +413,8 @@ export default function caracms(config: SiteConfig): AstroIntegration {
410
413
  if (address.url) injectRoute({ pattern: SITEMAP_ROUTE.pattern, entrypoint: new URL(`./${SITEMAP_ROUTE.entrypoint}`, import.meta.url), prerender: true });
411
414
  // M4 Phase 3: the sign-in pages (live/auth/routes.ts).
412
415
  for (const route of LOGIN_ROUTES) injectRoute({ pattern: route.pattern, entrypoint: new URL(`./${route.entrypoint}`, import.meta.url), prerender: false });
416
+ // caracms-cloud M3 Decision 20: a cloud site's settings, awaited before anything renders. First of all.
417
+ addMiddleware({ order: 'pre', entrypoint: new URL(`./${CLOUD_MIDDLEWARE}`, import.meta.url) });
413
418
  // M4 Phase 2: an unconfigured production server's 503 on /admin and /_caracms/*. First, so it runs before the guard.
414
419
  addMiddleware({ order: 'pre', entrypoint: new URL(`./${EDITOR_MIDDLEWARE}`, import.meta.url) });
415
420
  // M4 Phase 3: the sign-in guard on /admin and /_caracms/ (live mode only). Must stay after the 503 middleware.
@@ -510,7 +515,11 @@ export default function caracms(config: SiteConfig): AstroIntegration {
510
515
  server.httpServer?.once('close', stop);
511
516
  },
512
517
  // Posts and tags, Decision 15: a production build reads each document once (read.ts). Never in dev.
513
- 'astro:build:start': () => shareBuildReads(true),
518
+ // caracms-cloud M3 Decision 20: a cloud site's settings, fetched once before any page renders; a refusal fails the build.
519
+ 'astro:build:start': async () => {
520
+ shareBuildReads(true);
521
+ await cloudSettingsForBuild();
522
+ },
514
523
  'astro:build:done': () => {
515
524
  shareBuildReads(false);
516
525
  if (buildInfo && serverDir && existsSync(serverDir)) writeFileSync(new URL(BUILD_INFO_FILE, serverDir), `${JSON.stringify(buildInfo)}\n`);
@@ -19,7 +19,7 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
19
19
  | `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
20
20
  | `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
21
21
  | `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts). A third credential, `cloud`: the token from CaraCMS Cloud, refreshed the same way; its refusals are an access problem of kind `cloud` |
22
- | `cloud.ts` | CaraCMS Cloud (`CARACMS_CLOUD_KEY`; caracms-cloud M2): `DEFAULT_CLOUD_URL`, `cloudSettings()` (the repo, at start), `cloudToken()`, `CloudRefusal` (the cloud's `error`, `message`, `installUrl`), and `cloudUnavailable()`, the sign-in guard's 503 for a refusal at start |
22
+ | `cloud.ts` | CaraCMS Cloud (`CARACMS_CLOUD_KEY`; caracms-cloud M2): `DEFAULT_CLOUD_URL`, `fetchCloudSettings()` (the repo and the media; M3), `loadCloudSettings()` / `heldCloudSettings()` (one fetch per process, shared by the media and the runtime), `cloudSettings()` (the repo, at start), `cloudCall()` (one call, for the cloud bucket too), `cloudToken()`, `CloudRefusal` (the cloud's `error`, `message`, `installUrl`), and `cloudUnavailable()`, the sign-in guard's 503 for a refusal at start |
23
23
  | `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
24
24
  | `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
25
25
  | `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll (Redis diet: only when `needsBeat`), lapse only once committed, releasing, Edit here; presence, each record holding for twice its tab's poll interval plus 3 s |
@@ -7,10 +7,18 @@
7
7
  * server log and nothing is sent, so no test from a laptop reaches anyone's inbox.
8
8
  * The email names the site by its brand name. The link carries only the token.
9
9
  *
10
+ * **On CaraCMS Cloud** (caracms-cloud M4 Decision 25), a production server with
11
+ * `CARACMS_CLOUD_KEY` asks the cloud instead (`POST /v1/email/sign-in`), which writes the
12
+ * email from its own copy of this template and sends it from `accounts.caracms.com`. The
13
+ * key beside `RESEND_API_KEY` or `FORMS_FROM` (Decision 22) sends nothing and logs why.
14
+ * Outside production the link is logged, key or not.
15
+ *
10
16
  * The HTML part is one table layout with inline styles (mail clients strip `<style>`),
11
17
  * the system font stack, no images; the plain-text part says the same.
12
18
  */
13
19
  import { sendEmail, sendsEmail } from '../../email.ts';
20
+ import { cloudCall, isCloudRefusal } from '../cloud.ts';
21
+ import { cloudForms } from '../mode.ts';
14
22
 
15
23
  export { RESEND_URL } from '../../email.ts';
16
24
  const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
@@ -76,6 +84,20 @@ export async function deliverLink(input: {
76
84
  log(`sign-in link for ${to} (not emailed outside production): ${link}`);
77
85
  return 'logged';
78
86
  }
87
+ const cloud = cloudForms(env);
88
+ if (cloud) {
89
+ if (cloud.problems.length) {
90
+ log(`could not email a sign-in link: ${cloud.problems.join(' ')}`);
91
+ return 'failed';
92
+ }
93
+ try {
94
+ await cloudCall({ ...cloud.connection, ...(input.fetch ? { fetch: input.fetch } : {}) }, 'POST', '/v1/email/sign-in', { to, link, siteName });
95
+ return 'sent';
96
+ } catch (error) {
97
+ log(`could not email a sign-in link: CaraCMS Cloud${isCloudRefusal(error) ? ` (${error.error}): ${error.message}` : `: ${error instanceof Error ? error.message : String(error)}`}`);
98
+ return 'failed';
99
+ }
100
+ }
79
101
  const { subject, text, html } = buildLinkEmail({ siteName, link, email: to });
80
102
  const result = await sendEmail({ to: [to], subject, text, html }, { env, fetch: input.fetch });
81
103
  if (result.status === 'sent') return 'sent';
@@ -25,7 +25,7 @@
25
25
  import type { MiddlewareHandler } from 'astro';
26
26
  import { site } from 'virtual:caracms/config';
27
27
  import { editorMode } from '../../editor/server/mode.ts';
28
- import { CloudRefusal, cloudUnavailable } from '../cloud.ts';
28
+ import { isCloudRefusal, cloudUnavailable } from '../cloud.ts';
29
29
  import { setPerson } from '../locals.ts';
30
30
  import { guard, isFormEndpoint, isGuardedPath } from './guard.ts';
31
31
  import { authFor } from './index.ts';
@@ -42,7 +42,7 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
42
42
  try {
43
43
  auth = await authFor(site, mode.settings, context.site);
44
44
  } catch (error) {
45
- if (error instanceof CloudRefusal) {
45
+ if (isCloudRefusal(error)) {
46
46
  console.error(`[caracms] sign-in: the live runtime could not start: CaraCMS Cloud (${error.error}): ${error.message}`);
47
47
  return cloudUnavailable(error, context.url.pathname.startsWith('/_caracms/'));
48
48
  }