caracms 0.7.0 → 0.8.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.
@@ -16,9 +16,12 @@
16
16
  * - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
17
17
  * cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
18
18
  * beside it is a problem sentence naming them (one credential, not two).
19
+ * - **With the config's `services`** (caracms-cloud M5 Decision 2) the other mode's variables
20
+ * are never read (live/services.ts), and a cloud site without its key has neither.
19
21
  */
20
22
  import type { CloudConnection } from '../../../live/cloud.ts';
21
23
  import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
24
+ import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from '../../../live/services.ts';
22
25
  import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
23
26
 
24
27
  type Env = Record<string, string | undefined>;
@@ -53,15 +56,17 @@ const get = (env: Env, name: string) => {
53
56
  const memory = (env: Env) => get(env, 'CARACMS_MEDIA') === 'memory';
54
57
 
55
58
  /** The image routes' settings: the bucket, and imgproxy's for the thumbnails. */
56
- export function readImagesConfig(env: Env = process.env, production = isProductionBuild()): ImagesConfig {
59
+ export function readImagesConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): ImagesConfig {
60
+ env = servicesEnv(env, services);
57
61
  if (memory(env)) {
58
62
  if (production) return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
59
- const signing = readMediaConfig(env, production);
63
+ const signing = readMediaConfig(env, production, services);
60
64
  if (signing.kind !== 'ready') return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
61
65
  return { kind: 'memory', signing: signing.settings };
62
66
  }
63
- const signing = readMediaConfig(env, production);
64
- const cloud = cloudMedia(env);
67
+ if (services === 'cloud' && !get(env, 'CARACMS_CLOUD_KEY')) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
68
+ const signing = readMediaConfig(env, production, services);
69
+ const cloud = cloudMedia(env, services);
65
70
  if (cloud) {
66
71
  if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
67
72
  return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
@@ -86,9 +91,11 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
86
91
  }
87
92
 
88
93
  /** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
89
- export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
94
+ export function readMuxConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MuxConfig {
95
+ env = servicesEnv(env, services);
90
96
  if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
91
97
  const key = get(env, 'CARACMS_CLOUD_KEY');
98
+ if (services === 'cloud' && !key) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
92
99
  if (key) {
93
100
  const problems: string[] = [];
94
101
  const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
@@ -15,6 +15,7 @@
15
15
  import type { ResolvedSite } from '../../config.ts';
16
16
  import { liveEditor, type LiveEditor } from '../../live/editor.ts';
17
17
  import { readMode, unconfiguredMessage, type Baked, type Mode } from '../../live/mode.ts';
18
+ import { bakedServices } from '../../live/services.ts';
18
19
  import { localDraftsStore, type DraftsStore } from './drafts-store.ts';
19
20
 
20
21
  declare const __CARACMS_PRODUCTION__: boolean | undefined;
@@ -26,11 +27,12 @@ export function isProductionServer(): boolean {
26
27
  return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
27
28
  }
28
29
 
29
- /** The site's address and the commit the build baked in (runs-anywhere Decisions 7 and 9); empty in plain Node (tests). */
30
+ /** The site's address, the commit and the config's `services` the build baked in (runs-anywhere Decisions 7 and 9, caracms-cloud M5 Decision 1); empty in plain Node (tests). */
30
31
  export function bakedAtBuild(): Baked {
31
32
  return {
32
33
  site: typeof __CARACMS_SITE__ === 'string' ? __CARACMS_SITE__ : null,
33
34
  commit: typeof __CARACMS_BUILD_COMMIT__ === 'string' ? __CARACMS_BUILD_COMMIT__ : null,
35
+ services: bakedServices(),
34
36
  };
35
37
  }
36
38
 
package/src/email.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  * reaches anyone's inbox. It never throws: an unset key or sender, Resend's refusal
11
11
  * or a timeout all come back as a result with a sentence.
12
12
  */
13
+ import { bakedServices, servicesEnv } from './live/services.ts';
14
+
13
15
  export const RESEND_URL = 'https://api.resend.com/emails';
14
16
  export const SEND_TIMEOUT_MS = 10_000;
15
17
 
@@ -30,7 +32,8 @@ export async function sendEmail(
30
32
  email: Email,
31
33
  options: { env?: Record<string, string | undefined>; fetch?: typeof fetch; timeoutMs?: number } = {},
32
34
  ): Promise<SendResult> {
33
- const env = options.env ?? process.env;
35
+ // caracms-cloud M5 Decision 2: on a cloud site RESEND_API_KEY and FORMS_FROM are never read (the cloud sends).
36
+ const env = servicesEnv(options.env ?? process.env, bakedServices());
34
37
  const timeoutMs = options.timeoutMs ?? SEND_TIMEOUT_MS;
35
38
  const apiKey = (env['RESEND_API_KEY'] ?? '').trim();
36
39
  const from = (env['FORMS_FROM'] ?? '').trim();
@@ -36,6 +36,7 @@ import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from 'node:fs/prom
36
36
  import { dirname, join, resolve, sep } from 'node:path';
37
37
  import { AwsClient } from 'aws4fetch';
38
38
  import { cloudForms } from '../live/mode.ts';
39
+ import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from '../live/services.ts';
39
40
  import { cloudStore } from './cloud-store.ts';
40
41
  import { isSubmissionId, shownFileName } from './rules.ts';
41
42
 
@@ -459,17 +460,21 @@ const list = (names: readonly string[]) => (names.length <= 1 ? names.join('') :
459
460
  export const isProductionEnv = (env: Env): boolean => env['NODE_ENV'] === 'production';
460
461
 
461
462
  /** Decision 7's rule, from the environment. Read on each call (cheap). */
462
- export function readSubmissionsConfig(root: string, env: Env = process.env, production = isProductionEnv(env)): SubmissionsConfig {
463
+ export function readSubmissionsConfig(root: string, env: Env = process.env, production = isProductionEnv(env), services: Services | null = bakedServices()): SubmissionsConfig {
464
+ // caracms-cloud M5 Decision 2: with the config's `services`, the other mode's variables are never read.
465
+ env = servicesEnv(env, services);
463
466
  const get = (name: string) => env[name]?.trim() || '';
464
467
  const mode = get('CARACMS_SUBMISSIONS');
465
468
  if (mode === 'memory') return production ? { kind: 'unset', message: SUBMISSIONS_MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
466
469
  if (mode) return { kind: 'unset', message: `CARACMS_SUBMISSIONS must be "memory" or unset (got "${mode}").`, missing: [] };
467
470
  // 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);
471
+ const cloud = cloudForms(env, services);
469
472
  if (cloud?.problems.length) return { kind: 'unset', message: cloud.problems.join(' '), missing: [] };
470
473
  // Outside production: the folder, even with the cloud key (M4's Core framing).
471
474
  if (!production) return { kind: 'folder', dir: join(root, LOCAL_SUBMISSIONS_FOLDER) };
472
475
  if (cloud) return { kind: 'cloud', connection: cloud.connection };
476
+ // M5 Decision 4: a cloud site without its key, never the site's own bucket instead.
477
+ if (services === 'cloud') return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: ['CARACMS_CLOUD_KEY'] };
473
478
  const missing = [...SUBMISSIONS_ENV, ...SHARED_ENV].filter((name) => !get(name));
474
479
  if (missing.length) {
475
480
  return {
package/src/index.ts CHANGED
@@ -466,6 +466,8 @@ export default function caracms(config: SiteConfig): AstroIntegration {
466
466
  // Runs-anywhere Decisions 7 and 9: the address and the commit the build was made with (editor/server/mode.ts → bakedAtBuild).
467
467
  __CARACMS_SITE__: JSON.stringify(astroConfig.site ?? address.url ?? null),
468
468
  __CARACMS_BUILD_COMMIT__: JSON.stringify(commit),
469
+ // caracms-cloud M5 Decision 1: the config's `services`, for the key-or-own readers (live/services.ts → bakedServices).
470
+ __CARACMS_SERVICES__: JSON.stringify(site.services),
469
471
  __CARACMS_TOKENS_CSS__: JSON.stringify(readFileSync(new URL('./editor/styles/tokens.css', import.meta.url), 'utf8')),
470
472
  },
471
473
  // Installed from npm, the package ships TypeScript and .astro source.
@@ -518,7 +520,7 @@ export default function caracms(config: SiteConfig): AstroIntegration {
518
520
  // caracms-cloud M3 Decision 20: a cloud site's settings, fetched once before any page renders; a refusal fails the build.
519
521
  'astro:build:start': async () => {
520
522
  shareBuildReads(true);
521
- await cloudSettingsForBuild();
523
+ await cloudSettingsForBuild(process.env, site.services);
522
524
  },
523
525
  'astro:build:done': () => {
524
526
  shareBuildReads(false);
package/src/live/mode.ts CHANGED
@@ -17,12 +17,20 @@
17
17
  * browser runs, never against the site's own repo. It's live mode with no App and
18
18
  * no repo, and it's **refused in production**.
19
19
  *
20
+ * - **The config's `services`** (caracms-cloud M5, live/services.ts, baked in as `Baked.services`):
21
+ * with it, the other mode's variables are removed before anything here reads them, so the
22
+ * "not both" sentences below only ever apply to a config without the field (Decision 3);
23
+ * `'cloud'` without the key is `CLOUD_KEY_MISSING_PROBLEM` on a production server (Decision 4).
24
+ *
20
25
  * This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
21
26
  * Phase 3 guards with it).
22
27
  */
23
28
  import { CLOUD_KEY_SHAPE, DEFAULT_CLOUD_URL } from './cloud.ts';
24
29
  import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
25
30
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
31
+ import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES, servicesEnv, type Services } from './services.ts';
32
+
33
+ export { OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES };
26
34
 
27
35
  export interface LiveSettings {
28
36
  /** `memory`: the in-memory GitHub backend (Decision 2). `token`: CARACMS_GITHUB_TOKEN (runs-anywhere Decision 3). */
@@ -81,6 +89,8 @@ export interface Baked {
81
89
  site?: string | null;
82
90
  /** The commit the build was made from (runs-anywhere Decision 9). */
83
91
  commit?: string | null;
92
+ /** The config's `services` (caracms-cloud M5 Decision 1; live/services.ts). Null or missing: the key decides (Decision 3). */
93
+ services?: Services | null;
84
94
  }
85
95
 
86
96
  // The sentences the configuration problems print, kept here so the docs can quote them.
@@ -97,15 +107,9 @@ export const CLOUD_KEY_PROBLEM =
97
107
  "CARACMS_CLOUD_KEY isn't a CaraCMS Cloud site key: a key is cck_ followed by 43 letters, digits, - and _. Copy it again from app.caracms.com.";
98
108
  export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
99
109
  `Both CARACMS_CLOUD_KEY and ${other} are set: a site uses one GitHub credential, so remove ${other}.`;
100
- /** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
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
110
  const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
103
111
  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
112
  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
113
  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
114
 
111
115
  /** The key beside any of the site's own forms and email variables: Decision 22's sentence, or null. */
@@ -121,7 +125,8 @@ export function cloudFormsConflict(env: Env): string | null {
121
125
  * email variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site. Whether
122
126
  * the cloud is *used* is the caller's (only on a production server; the Core framing).
123
127
  */
124
- export function cloudForms(env: Env): { connection: { key: string; url: string }; problems: string[] } | null {
128
+ export function cloudForms(env: Env, services: Services | null = bakedServices()): { connection: { key: string; url: string }; problems: string[] } | null {
129
+ env = servicesEnv(env, services);
125
130
  const get = (name: string) => env[name]?.trim() || null;
126
131
  const key = get('CARACMS_CLOUD_KEY');
127
132
  if (!key) return null;
@@ -192,6 +197,9 @@ const isLocalhost = (host: string) => host === 'localhost' || host.endsWith('.lo
192
197
  * `baked` is what the build baked in (the address and the commit).
193
198
  */
194
199
  export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production', baked: Baked = {}): Mode {
200
+ // caracms-cloud M5 Decision 2: with the config's `services`, the other mode's variables are never read.
201
+ const services = baked.services ?? null;
202
+ env = servicesEnv(env, services);
195
203
  const get = (name: string) => {
196
204
  const v = env[name]?.trim();
197
205
  return v ? v : null;
@@ -215,7 +223,10 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
215
223
  if (other && other !== 'memory') problems.push(`CARACMS_GITHUB must be "memory" or unset (got "${other}").`);
216
224
 
217
225
  let github: LiveSettings['github'] = { kind: 'memory' };
218
- if (cloudKey && (!memory || production)) {
226
+ if (services === 'cloud' && !cloudKey && (!memory || production)) {
227
+ // M5 Decision 4: never the self-hosted variables instead (they were removed above).
228
+ problems.push(CLOUD_KEY_MISSING_PROBLEM);
229
+ } else if (cloudKey && (!memory || production)) {
219
230
  // The cloud names the repo (Decision 13), so GITHUB_REPO is a mistake and Railway's own variables are ignored.
220
231
  if (!CLOUD_KEY_SHAPE.test(cloudKey)) problems.push(CLOUD_KEY_PROBLEM);
221
232
  if (ghToken) problems.push(CLOUD_AND_GITHUB_PROBLEM('CARACMS_GITHUB_TOKEN'));
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Where the site's services come from (caracms-cloud M5): the config's `services` field,
3
+ * not whichever variables happen to be set.
4
+ *
5
+ * - **`'cloud'`**: `CARACMS_CLOUD_KEY` is the one credential. The self-hosted variables
6
+ * (`REPLACED_VARIABLES`) are **removed from the environment the readers see**, so none is
7
+ * ever read, not even as a fallback (Decision 2, check 5), and each is named once per
8
+ * process as ignored.
9
+ * - **`'self-hosted'`**: the site's own variables; `CARACMS_CLOUD_KEY` is removed and named
10
+ * as ignored the same way.
11
+ * - **Unset**: the environment as it is, so the key decides and the "not both" refusals
12
+ * stand (Decision 3).
13
+ *
14
+ * Every key-or-own reader (live/mode.ts `readMode` and `cloudForms`, media.ts, the editor's
15
+ * media config, forms/store.ts, cloud-middleware.ts, and `media import` in bin/media.mjs)
16
+ * starts from `servicesEnv`. The running site's value is baked in at build
17
+ * (`__CARACMS_SERVICES__`, a Vite define from the config: `bakedServices`), since the readers
18
+ * are synchronous and have no config in hand.
19
+ */
20
+ import type { Services } from '../config.ts';
21
+
22
+ export type { Services };
23
+ type Env = Record<string, string | undefined>;
24
+
25
+ /** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
26
+ 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;
27
+ /** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
28
+ export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
29
+ /** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
30
+ 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;
31
+
32
+ /** The site's own GitHub variables, which the cloud key replaces (caracms-cloud M2 Decision 11). `GITHUB_WEBHOOK_SECRET` is harmless and not here. */
33
+ export const OWN_GITHUB_VARIABLES = ['CARACMS_GITHUB_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY', 'GITHUB_REPO'] as const;
34
+
35
+ /** Every variable the cloud replaces, by group, in the move list's order of retiring (M5 Contracts). */
36
+ export const REPLACED_VARIABLES = {
37
+ media: OWN_MEDIA_VARIABLES,
38
+ video: OWN_MUX_VARIABLES,
39
+ forms: OWN_FORMS_VARIABLES,
40
+ github: OWN_GITHUB_VARIABLES,
41
+ } as const;
42
+
43
+ /** What each group's ignored line ends with: when it can go. */
44
+ const RETIRE: Record<keyof typeof REPLACED_VARIABLES, string> = {
45
+ media: 'Remove them once the old bucket and imgproxy are retired.',
46
+ video: 'Remove them once every clip is uploaded again in the editor.',
47
+ forms: 'Remove them once the old submissions are exported or no longer wanted.',
48
+ github: 'Remove them: the cloud gives the site its repo and access.',
49
+ };
50
+
51
+ /** The sentence Decision 4 fails a production build and `/admin` with. */
52
+ export const CLOUD_KEY_MISSING_PROBLEM = "This site runs on CaraCMS Cloud (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in app.caracms.com.";
53
+
54
+ const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
55
+
56
+ /** The ignored line for some of one group's variables (cloud), or for the key (self-hosted). */
57
+ export function ignoredMessage(names: readonly string[], services: Services, group?: keyof typeof REPLACED_VARIABLES): string {
58
+ const one = names.length === 1;
59
+ const set = `${listed(names)} ${one ? 'is' : 'are'} set`;
60
+ if (services === 'self-hosted') return `${set}, but this site is self-hosted (caracms.config.ts): ${one ? "it's" : "they're"} ignored. Remove ${one ? 'it' : 'them'}, or run \`caracms init --cloud\` to move the site to CaraCMS Cloud.`;
61
+ const tail = group ? RETIRE[group] : 'Remove them once the move is done.';
62
+ return `${set}, but this site runs on CaraCMS Cloud (caracms.config.ts): ${one ? "it's" : "they're"} ignored. ${one ? tail.replace(/them/g, 'it') : tail}`;
63
+ }
64
+
65
+ const WARNED = Symbol.for('caracms.ignoredVariables');
66
+ type Shared = { [WARNED]?: Set<string> };
67
+ const warned = () => ((globalThis as Shared)[WARNED] ??= new Set());
68
+
69
+ /** Forget which variables were named (tests). */
70
+ export function resetIgnoredWarnings(): void {
71
+ delete (globalThis as Shared)[WARNED];
72
+ }
73
+
74
+ const set = (env: Env, name: string) => Boolean(env[name]?.trim());
75
+
76
+ /**
77
+ * The environment the readers see for this `services` (null: the environment as it is),
78
+ * with the other mode's variables removed. Each removed variable is named once per process
79
+ * (shared on globalThis, so the build's own module and its bundled chunks warn once between them).
80
+ */
81
+ export function servicesEnv(env: Env, services: Services | null | undefined, log: (line: string) => void = (line) => console.warn(line)): Env {
82
+ if (!services) return env;
83
+ const seen = warned();
84
+ const out: Env = { ...env };
85
+ if (services === 'self-hosted') {
86
+ if (!set(env, 'CARACMS_CLOUD_KEY')) return env;
87
+ delete out.CARACMS_CLOUD_KEY;
88
+ if (!seen.has('CARACMS_CLOUD_KEY')) log(`[caracms] ${ignoredMessage(['CARACMS_CLOUD_KEY'], services)}`);
89
+ seen.add('CARACMS_CLOUD_KEY');
90
+ return out;
91
+ }
92
+ for (const [group, names] of Object.entries(REPLACED_VARIABLES) as [keyof typeof REPLACED_VARIABLES, readonly string[]][]) {
93
+ const found = names.filter((name) => set(env, name));
94
+ for (const name of found) delete out[name];
95
+ const fresh = found.filter((name) => !seen.has(name));
96
+ if (fresh.length) log(`[caracms] ${ignoredMessage(fresh, services, group)}`);
97
+ for (const name of fresh) seen.add(name);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** The names `servicesEnv` would ignore in `env` for `services` (init's move list, tests). */
103
+ export function ignoredVariables(env: Env, services: Services | null | undefined): string[] {
104
+ if (!services) return [];
105
+ if (services === 'self-hosted') return set(env, 'CARACMS_CLOUD_KEY') ? ['CARACMS_CLOUD_KEY'] : [];
106
+ return Object.values(REPLACED_VARIABLES).flatMap((names) => names.filter((name) => set(env, name)));
107
+ }
108
+
109
+ declare const __CARACMS_SERVICES__: Services | null | undefined;
110
+
111
+ /** The config's `services` as the build baked it in; null in plain Node (tests) and for a config without one. */
112
+ export function bakedServices(): Services | null {
113
+ return typeof __CARACMS_SERVICES__ === 'string' ? __CARACMS_SERVICES__ : null;
114
+ }
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) => {