caracms 0.6.0 → 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.
package/README.md CHANGED
@@ -129,7 +129,7 @@ These go on the site's server. `caracms init` prints the same list.
129
129
 
130
130
  | Name | Needed | What it's for |
131
131
  |---|---|---|
132
- | `CARACMS_CLOUD_KEY` | instead of the GitHub and image variables | The site's key from CaraCMS Cloud (`cck_…`). It replaces the token or App, `GITHUB_REPO` and the image variables: see [CaraCMS Cloud](#caracms-cloud). |
132
+ | `CARACMS_CLOUD_KEY` | instead of the GitHub, image, form and email variables | The site's key from CaraCMS Cloud (`cck_…`). It replaces the token or App, `GITHUB_REPO`, the image variables, and the form and email ones: see [CaraCMS Cloud](#caracms-cloud). |
133
133
  | `GITHUB_REPO` | required, but not with the cloud key | The site's repository, as owner/name. |
134
134
  | `CARACMS_GITHUB_TOKEN` | required, or the App, or the cloud key | A fine-grained token (`github_pat_…`) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never `GITHUB_TOKEN`. |
135
135
  | `GITHUB_APP_ID` | instead of the token | The GitHub App's ID, for anyone setting up many sites. Set the token or the App, not both. |
@@ -139,8 +139,8 @@ These go on the site's server. `caracms init` prints the same list.
139
139
  | `CARACMS_SECRET` | required | Signs the editors' sessions. A long random string. |
140
140
  | `CARACMS_CLIENT_IP_HEADER` | required off Railway, Fly and Render | Where the visitor's IP comes from: `x-real-ip` for nginx with `proxy_set_header X-Real-IP $remote_addr`, `x-forwarded-for` for Caddy, or `socket` if nothing sits in front of this server. `caracms start` won't start without it. |
141
141
  | `CARACMS_ALLOW` | for email sign-in | Who may sign in, comma-separated: addresses (`aoife@fernhill.ie`) or whole domains (`@fernhill.ie`). The client adds their own people in Site Settings. |
142
- | `RESEND_API_KEY` | for email sign-in | Sends the sign-in links (and form notifications) through Resend. |
143
- | `FORMS_FROM` | for email sign-in | The address those emails come from, on a domain verified in Resend. |
142
+ | `RESEND_API_KEY` | for email sign-in, but not with the cloud key | Sends the sign-in links (and form notifications) through Resend. |
143
+ | `FORMS_FROM` | for email sign-in, but not with the cloud key | The address those emails come from, on a domain verified in Resend. |
144
144
  | `CARACMS_BACKUP_USER` | for the backup sign-in | The backup sign-in, for when email sign-in links can't arrive. Both or neither. |
145
145
  | `CARACMS_BACKUP_PASSWORD` | for the backup sign-in | The backup sign-in's password. |
146
146
  | `CARACMS_RUNNING_COMMIT` | optional | The commit this server runs, where the host doesn't say (Railway and Render do). |
@@ -155,9 +155,9 @@ A site that uses images, video or forms needs these as well. The image variables
155
155
  | Feature | Variables |
156
156
  |---|---|
157
157
  | Images (the media bucket and imgproxy) | `CARACMS_MEDIA_URL` (imgproxy's address), `IMGPROXY_KEY`, `IMGPROXY_SALT`, `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
158
- | Video | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` |
159
- | Form submissions (a second bucket, at the media bucket's endpoint and region) | `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY` |
160
- | Form emails | `RESEND_API_KEY`, `FORMS_FROM` (as above) |
158
+ | Video | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
159
+ | Form submissions (a second bucket, at the media bucket's endpoint and region) | `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY`; on CaraCMS Cloud, none of them: `CARACMS_CLOUD_KEY` alone |
160
+ | Form emails | `RESEND_API_KEY`, `FORMS_FROM` (as above); on CaraCMS Cloud, none of them |
161
161
  | Spam checks on forms | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` |
162
162
  | A password on the whole site before launch | `CARACMS_PRELAUNCH` |
163
163
  | Try again through Railway's API | `CARACMS_RAILWAY_TOKEN` (Railway sets the rest) |
@@ -331,9 +331,11 @@ repo, and only that repo, about once an hour.
331
331
  The key replaces `CARACMS_GITHUB_TOKEN` (or `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`)
332
332
  and `GITHUB_REPO`: the repo comes from the cloud when the server starts, so setting
333
333
  `GITHUB_REPO` beside the key is refused, and so is setting a token or an App as well. It
334
- also replaces the image variables ([Images](#images-on-caracms-cloud), below).
334
+ also replaces the image variables ([Images](#images-on-caracms-cloud), below), the
335
+ video ones ([Video](#video-on-caracms-cloud)), and the form and email ones
336
+ ([Forms and email](#forms-and-email-on-caracms-cloud)).
335
337
  Everything else in [the variables](#the-variables) is still the site's own: `SITE_URL`,
336
- `REDIS_URL`, `CARACMS_SECRET`, the sign-in variables, and the video and form variables.
338
+ `REDIS_URL`, `CARACMS_SECRET`, `CARACMS_ALLOW`, the backup sign-in, and Turnstile.
337
339
  There's no webhook: a check once a minute keeps the editor's Publish status up to date.
338
340
 
339
341
  If the cloud says no when the server starts (the App isn't installed on the repo, the
@@ -368,6 +370,38 @@ builds. A running site keeps serving whatever the cloud does.
368
370
  `caracms media import <files…>` works with the key too (from the environment or the
369
371
  site's `.env`), with the same rules and output, and its uploads count like any other.
370
372
 
373
+ #### Forms and email on CaraCMS Cloud
374
+
375
+ With the key, the site needs no submissions bucket and no Resend account. Set none of
376
+ `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY`,
377
+ `RESEND_API_KEY` or `FORMS_FROM`: setting any of them beside the key is refused, at build
378
+ and when the server starts, with
379
+
380
+ > Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove RESEND_API_KEY and FORMS_FROM.
381
+
382
+ The site's server still makes every check on a submission (the fields, the honeypot, the
383
+ rate limit, Turnstile) and writes its email, as before. The cloud stores the submission
384
+ and its attachments, and sends the email once it's stored. The editor's inbox reads them
385
+ from the cloud, unchanged.
386
+
387
+ - **Form emails** come from `forms@notify.caracms.com`, named "<brand name> – <form label>"
388
+ (for example "Urban Return – Contact"), with Reply-To the visitor. They go to the form's
389
+ recipients in Site Settings, at most the first ten (the server log says when some were
390
+ left off).
391
+ - **Each site sends at most 50 form emails a day** (UTC) unless CaraCMS raises it. Past
392
+ that, a submission is still stored, and the inbox shows it as "Not emailed: this site
393
+ reached its daily email limit in CaraCMS Cloud (50). Ask CaraCMS to raise it." It isn't
394
+ sent later.
395
+ - **Sign-in links** come from `no-reply@accounts.caracms.com`, in the same words as before.
396
+
397
+ **On a laptop**, or anywhere that isn't a production server (`NODE_ENV=production`),
398
+ nothing goes to the cloud, even with the key: submissions are kept in
399
+ `.caracms/submissions/` and both emails are written to the server log, so a test enquiry
400
+ never reaches a client's inbox.
401
+
402
+ A site on an older caracms keeps its own bucket and Resend until it updates and removes
403
+ them.
404
+
371
405
  #### Moving a site's images in
372
406
 
373
407
  `caracms media push` copies everything under `uploads/` in the site's own bucket into its
@@ -385,6 +419,30 @@ uploader, and never changes the source bucket. It prints `{ copied, skipped, fai
385
419
  A site bigger than its storage limit stops at the limit with the cloud's sentence: ask
386
420
  CaraCMS to raise it, then run it again.
387
421
 
422
+ #### Video on CaraCMS Cloud
423
+
424
+ With the key, the site's clips are uploaded to CaraCMS Cloud's Mux and listed from the
425
+ site's own clips there; the editor works exactly as it does with a Mux account of your
426
+ own. Set neither `MUX_TOKEN_ID` nor `MUX_TOKEN_SECRET`: setting either beside the key is
427
+ refused with
428
+
429
+ > Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove MUX_TOKEN_ID.
430
+
431
+ Clips play on the site straight from Mux (`stream.mux.com`, `image.mux.com`), as before:
432
+ the public pages never call the cloud. The minutes a site stores count towards its video
433
+ limit (60 minutes unless CaraCMS has set another), and once it's reached, a new upload is
434
+ refused with the cloud's sentence: "This site has reached its video limit in CaraCMS
435
+ Cloud (60 minutes). Ask CaraCMS to raise it." A clip counts once Mux has finished with
436
+ it, so one upload can take a site past its limit; the next is refused.
437
+
438
+ **Moving a site's video in** means uploading its clips again in the editor and placing
439
+ them where the old ones were. There's no copy command. Until a clip is replaced, the old
440
+ one keeps playing on the site from the site's old Mux account: the page only needs its
441
+ playback ID. In the editor it shows its poster and "A clip" (it isn't in the cloud's
442
+ library), and saving other changes on the page leaves it as it is. Remove `MUX_TOKEN_ID`
443
+ and `MUX_TOKEN_SECRET` when you set the key; keep the old Mux account until every clip
444
+ has been replaced.
445
+
388
446
  #### Working locally
389
447
 
390
448
  **Anyone working on a real site, wherever they are, uses that site's CaraCMS Cloud**: your
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,9 +15,12 @@
15
15
  * `cloudSettingsForBuild()` is the build's start (`astro:build:start`): the same fetch, and
16
16
  * a refusal or an unreachable cloud **fails the build** with the cloud's sentence or the
17
17
  * package's own. The build ignores `github.repo`: a site with no repo linked still builds.
18
+ * The key beside the site's own forms and email variables fails it too (caracms-cloud M4
19
+ * Decision 22), whether or not the media is the cloud's.
18
20
  */
19
21
  import type { MiddlewareHandler } from 'astro';
20
22
  import { CLOUD_NO_MEDIA, CLOUD_UNREACHABLE_BUILD, isCloudRefusal, heldCloudSettings, loadCloudSettings } from './live/cloud.ts';
23
+ import { cloudFormsConflict } from './live/mode.ts';
21
24
  import { cloudMedia } from './media.ts';
22
25
 
23
26
  type Env = Record<string, string | undefined>;
@@ -62,9 +65,15 @@ export const onRequest: MiddlewareHandler = async (_context, next) => {
62
65
 
63
66
  /** The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud. */
64
67
  export async function cloudSettingsForBuild(env: Env = process.env): Promise<void> {
68
+ // caracms-cloud M4 Decision 22: the key with the site's own forms and email variables fails the build too.
69
+ const conflict = cloudFormsConflict(env);
70
+ const forms = conflict ? [conflict] : [];
65
71
  const cloud = cloudMedia(env);
66
- if (!cloud) return;
67
- if (cloud.problems.length) throw new Error(`caracms: ${cloud.problems.join(' ')}`);
72
+ if (!cloud) {
73
+ if (forms.length) throw new Error(`caracms: ${forms.join(' ')}`);
74
+ return;
75
+ }
76
+ if (cloud.problems.length || forms.length) throw new Error(`caracms: ${[...cloud.problems, ...forms].join(' ')}`);
68
77
  let settings;
69
78
  try {
70
79
  settings = await loadCloudSettings(cloud.connection);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Mux on CaraCMS Cloud (caracms-cloud M4.5 Decision 16), behind mux.ts's seam, so the clip
3
+ * routes, where-used (`withUses`) and the editor don't change. The site's server holds no
4
+ * Mux token: it asks the API (live/cloud.ts, the site key), which asks the cloud's own
5
+ * Mux environment and answers from the site's own clips only. One route per method:
6
+ *
7
+ * - `createUpload`: `POST /v1/video/upload` `{ origin, title }` → `{ id, url }`.
8
+ * - `upload`: `GET /v1/video/upload?id=` → `VideoUploadStatus`.
9
+ * - `clip`: `GET /v1/video/clip?id=` → `Clip`; `clipOfPlayback`: `GET /v1/video/playback?id=`.
10
+ * - `clips`: `GET /v1/video/clips` → `{ clips }`, newest first (the cloud keeps the order).
11
+ *
12
+ * **The cloud's refusals become `MuxRouteError`s**, so the media handler's own catch answers
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
15
+ * refusals…) is a 502 with the cloud's sentence as sent. A cloud that can't be reached, or
16
+ * an answer that isn't the shape, is a 502 with the package's own sentence. Each 502 is
17
+ * logged with the refusal's code. The key is never logged nor put in an error.
18
+ */
19
+ import type { VideoUploadStatus } from '../../types.ts';
20
+ import { cloudCall, isCloudRefusal, type CloudConnection } from '../../../live/cloud.ts';
21
+ import { MuxRouteError, MUX_ID, type Clip, type MuxClient } from './mux.ts';
22
+
23
+ export const CLOUD_VIDEO_UNREACHABLE = "CaraCMS Cloud couldn't be reached just now. Try again in a minute.";
24
+ export const CLOUD_VIDEO_UNREADABLE = 'CaraCMS Cloud sent an unreadable reply.';
25
+
26
+ const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
27
+ const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
28
+ const px = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
29
+
30
+ export function cloudMux(
31
+ connection: Pick<CloudConnection, 'key' | 'url'>,
32
+ options: { fetch?: typeof fetch; log?: (line: string) => void } = {},
33
+ ): MuxClient {
34
+ const conn: CloudConnection = { ...connection, fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)) };
35
+ const log = options.log ?? ((line: string) => console.error(line));
36
+
37
+ async function api(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
38
+ const where = `${method} ${path.split('?')[0]}`;
39
+ try {
40
+ return await cloudCall(conn, method, path, body);
41
+ } catch (error) {
42
+ if (!isCloudRefusal(error)) throw error;
43
+ if (error.error === 'not_found') throw new MuxRouteError(404, 'Not found.');
44
+ if (error.error === 'video_limit') throw new MuxRouteError(403, error.message);
45
+ log(`[caracms] video ${where}: CaraCMS Cloud (${error.error}): ${error.message}`);
46
+ throw new MuxRouteError(502, error.error === 'unreachable' ? CLOUD_VIDEO_UNREACHABLE : error.message);
47
+ }
48
+ }
49
+
50
+ const unreadable = (where: string): never => {
51
+ log(`[caracms] video ${where}: CaraCMS Cloud sent an unreadable reply`);
52
+ throw new MuxRouteError(502, CLOUD_VIDEO_UNREADABLE);
53
+ };
54
+
55
+ /** The cloud's `Clip`, checked field by field (it passes `toClip`'s shape through). */
56
+ const clipOf = (raw: unknown, where: string): Clip => {
57
+ const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
58
+ const id = str(c?.id);
59
+ if (!c || !id) return unreadable(where);
60
+ const playbackId = str(c.playbackId);
61
+ const errored = c.errored === true;
62
+ return {
63
+ id,
64
+ playbackId: playbackId && MUX_ID.test(playbackId) ? playbackId : null,
65
+ status: str(c.status) ?? 'unknown',
66
+ ready: c.ready === true,
67
+ errored,
68
+ error: errored ? (str(c.error) ?? 'Mux could not process this file.') : null,
69
+ title: str(c.title),
70
+ duration: num(c.duration),
71
+ aspectRatio: str(c.aspectRatio),
72
+ w: px(c.w),
73
+ h: px(c.h),
74
+ createdAt: str(c.createdAt),
75
+ };
76
+ };
77
+
78
+ const query = (id: string) => new URLSearchParams({ id }).toString();
79
+
80
+ return {
81
+ async createUpload(origin, title) {
82
+ const answer = await api('POST', '/v1/video/upload', { origin, title });
83
+ const id = str(answer.id);
84
+ const url = str(answer.url);
85
+ if (!id || !url || !/^https:\/\//.test(url)) return unreadable('POST /v1/video/upload');
86
+ return { id, url };
87
+ },
88
+ async upload(id): Promise<VideoUploadStatus> {
89
+ const answer = await api('GET', `/v1/video/upload?${query(id)}`);
90
+ const assetId = str(answer.assetId);
91
+ return {
92
+ id: str(answer.id) ?? id,
93
+ status: str(answer.status) ?? 'unknown',
94
+ assetId: assetId && MUX_ID.test(assetId) ? assetId : null,
95
+ error: str(answer.error),
96
+ };
97
+ },
98
+ async clip(assetId) {
99
+ return clipOf(await api('GET', `/v1/video/clip?${query(assetId)}`), 'GET /v1/video/clip');
100
+ },
101
+ async clipOfPlayback(playbackId) {
102
+ return clipOf(await api('GET', `/v1/video/playback?${query(playbackId)}`), 'GET /v1/video/playback');
103
+ },
104
+ async clips() {
105
+ const answer = await api('GET', '/v1/video/clips');
106
+ if (!Array.isArray(answer.clips)) return unreadable('GET /v1/video/clips');
107
+ return answer.clips.map((c) => clipOf(c, 'GET /v1/video/clips'));
108
+ },
109
+ };
110
+ }
@@ -12,9 +12,13 @@
12
12
  * `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`). Clips need `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET`.
13
13
  * - **On CaraCMS Cloud** (caracms-cloud M3 Decision 19) `CARACMS_CLOUD_KEY` is the images'
14
14
  * one credential: the bucket is the cloud's (cloud-bucket.ts), the signing from the
15
- * settings the process holds (../../../media.ts). Mux is unchanged.
15
+ * settings the process holds (../../../media.ts).
16
+ * - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
17
+ * cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
18
+ * beside it is a problem sentence naming them (one credential, not two).
16
19
  */
17
20
  import type { CloudConnection } from '../../../live/cloud.ts';
21
+ import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
18
22
  import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
19
23
 
20
24
  type Env = Record<string, string | undefined>;
@@ -33,7 +37,11 @@ export type ImagesConfig =
33
37
  | { kind: 'cloud'; signing: MediaSettings; connection: Pick<CloudConnection, 'key' | 'url'> }
34
38
  | { kind: 'unset'; message: string; missing: string[] };
35
39
 
36
- export type MuxConfig = { kind: 'memory' } | { kind: 'mux'; tokenId: string; tokenSecret: string } | { kind: 'unset'; message: string; missing: string[] };
40
+ export type MuxConfig =
41
+ | { kind: 'memory' }
42
+ | { kind: 'mux'; tokenId: string; tokenSecret: string }
43
+ | { kind: 'cloud'; connection: Pick<CloudConnection, 'key' | 'url'> }
44
+ | { kind: 'unset'; message: string; missing: string[] };
37
45
 
38
46
  export const MEMORY_IN_PRODUCTION = 'CARACMS_MEDIA=memory is for tests and local runs; a production server never uses the in-memory media.';
39
47
 
@@ -77,9 +85,18 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
77
85
  return { kind: 's3', signing: signing.settings, bucket: { endpoint, region, bucket: signing.settings.bucket, accessKeyId, secretAccessKey } };
78
86
  }
79
87
 
80
- /** The clip routes' settings: the site's Mux environment's token. */
88
+ /** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
81
89
  export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
82
90
  if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
91
+ const key = get(env, 'CARACMS_CLOUD_KEY');
92
+ if (key) {
93
+ const problems: string[] = [];
94
+ const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
95
+ if (both.length) problems.push(CLOUD_AND_MUX_PROBLEM(both));
96
+ const url = readCloudUrl(get(env, 'CARACMS_CLOUD_URL'), problems);
97
+ if (problems.length) return { kind: 'unset', message: problems.join(' '), missing: [] };
98
+ return { kind: 'cloud', connection: { key, url } };
99
+ }
83
100
  const tokenId = get(env, 'MUX_TOKEN_ID');
84
101
  const tokenSecret = get(env, 'MUX_TOKEN_SECRET');
85
102
  const missing = [...(tokenId ? [] : ['MUX_TOKEN_ID']), ...(tokenSecret ? [] : ['MUX_TOKEN_SECRET'])];
@@ -10,7 +10,8 @@
10
10
  * - **No lock** (Decision 7): an upload, or reading the library, changes no document.
11
11
  * - **Every reply is `Cache-Control: private, no-store`.**
12
12
  * - **Unconfigured: 503 with a sentence naming what's missing**, images (the bucket and
13
- * imgproxy) and clips (Mux) apart: one missing never disables the other.
13
+ * imgproxy) and clips (Mux, or CaraCMS Cloud's: cloud-mux.ts) apart: one missing never
14
+ * disables the other.
14
15
  * - **The Mux token never reaches the browser, nor do Mux's error bodies** (server log
15
16
  * only). Ids are checked against `^[A-Za-z0-9]{1,128}$` before they reach a URL.
16
17
  * - **The upload has its own body cap** (40 MB, upload.ts), not the write route's 2 MB or
@@ -42,6 +43,7 @@ import { parseReplaceBody } from '../replace.ts';
42
43
  import { checkOrigin, seqOf, tabOf, WriteRefused } from '../write.ts';
43
44
  import { BucketError, s3Bucket, type Bucket } from './bucket.ts';
44
45
  import { cloudBucket } from './cloud-bucket.ts';
46
+ import { cloudMux } from './cloud-mux.ts';
45
47
  import { isCloudRefusal } from '../../../live/cloud.ts';
46
48
  import { readImagesConfig, readMuxConfig } from './config.ts';
47
49
  import { isFileKey } from '../../../files.ts';
@@ -88,6 +90,8 @@ function mux(ctx: MediaContext): MuxClient | Response {
88
90
  if (config.kind === 'unset') return json(503, { error: config.message, code: 'mux-unset', missing: config.missing } satisfies MediaUnset);
89
91
  const log = ctx.log ?? ((line: string) => console.error(line));
90
92
  if (config.kind === 'memory') return muxClient({ tokenId: 'memory', tokenSecret: 'memory', fetch: memoryMedia().mux.fetch, log });
93
+ // caracms-cloud M4.5: the cloud's Mux, through the site key; its refusals arrive as MuxRouteErrors.
94
+ if (config.kind === 'cloud') return cloudMux(config.connection, { fetch: ctx.fetch, log });
91
95
  return muxClient({ tokenId: config.tokenId, tokenSecret: config.tokenSecret, fetch: ctx.fetch, log });
92
96
  }
93
97
 
@@ -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);
@@ -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';
package/src/live/mode.ts CHANGED
@@ -101,6 +101,36 @@ export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
101
101
  export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
102
102
  const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
103
103
  export const CLOUD_AND_MEDIA_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove ${listed(names)}.`;
104
+ /** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
105
+ export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
106
+ export const CLOUD_AND_MUX_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove ${listed(names)}.`;
107
+ /** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
108
+ export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
109
+ export const CLOUD_AND_FORMS_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove ${listed(names)}.`;
110
+
111
+ /** The key beside any of the site's own forms and email variables: Decision 22's sentence, or null. */
112
+ export function cloudFormsConflict(env: Env): string | null {
113
+ if (!env.CARACMS_CLOUD_KEY?.trim()) return null;
114
+ const both = OWN_FORMS_VARIABLES.filter((name) => env[name]?.trim());
115
+ return both.length ? CLOUD_AND_FORMS_PROBLEM(both) : null;
116
+ }
117
+
118
+ /**
119
+ * The cloud as the forms and email credential (caracms-cloud M4 Decision 22): the
120
+ * connection when `CARACMS_CLOUD_KEY` is set, with any problems (the site's own forms and
121
+ * email variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site. Whether
122
+ * the cloud is *used* is the caller's (only on a production server; the Core framing).
123
+ */
124
+ export function cloudForms(env: Env): { connection: { key: string; url: string }; problems: string[] } | null {
125
+ const get = (name: string) => env[name]?.trim() || null;
126
+ const key = get('CARACMS_CLOUD_KEY');
127
+ if (!key) return null;
128
+ const conflict = cloudFormsConflict(env);
129
+ const problems: string[] = conflict ? [conflict] : [];
130
+ const url = readCloudUrl(get('CARACMS_CLOUD_URL'), problems);
131
+ return { connection: { key, url }, problems };
132
+ }
133
+
104
134
  export const CLOUD_REPO_PROBLEM = 'CARACMS_CLOUD_KEY is set, so the repo comes from CaraCMS Cloud; remove GITHUB_REPO.';
105
135
  export const CLOUD_URL_PROBLEM = (url: string) =>
106
136
  `CARACMS_CLOUD_URL must be the cloud's https:// address (http:// works for localhost only; got "${url}"). Leave it unset for CaraCMS Cloud itself.`;
@@ -194,6 +224,12 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
194
224
  // M3 Decision 19: the key is the media credential too (unless the tests' in-memory media is on).
195
225
  const ownMedia = OWN_MEDIA_VARIABLES.filter((name) => get(name));
196
226
  if (ownMedia.length && get('CARACMS_MEDIA') !== 'memory') problems.push(CLOUD_AND_MEDIA_PROBLEM(ownMedia));
227
+ // M4.5 Decision 15: and the video credential.
228
+ const ownMux = OWN_MUX_VARIABLES.filter((name) => get(name));
229
+ if (ownMux.length && get('CARACMS_MEDIA') !== 'memory') problems.push(CLOUD_AND_MUX_PROBLEM(ownMux));
230
+ // M4 Decision 22: and the forms and email credential.
231
+ const formsConflict = cloudFormsConflict(env);
232
+ if (formsConflict) problems.push(formsConflict);
197
233
  github = { kind: 'cloud', key: cloudKey, url: readCloudUrl(get('CARACMS_CLOUD_URL'), problems) };
198
234
  } else if (!memory || production) {
199
235
  const appId = get('GITHUB_APP_ID');