caracms 0.5.1 → 0.6.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,76 @@
1
+ /**
2
+ * The settings fetch for a cloud site's media (caracms-cloud M3 Decision 20). The package
3
+ * has no boot hook in the built server, so **the first request that reaches the server, of
4
+ * any kind,** fetches `GET /v1/settings` (live/cloud.ts → `loadCloudSettings`) and every
5
+ * request waits on that one shared fetch before anything renders. The live runtime reads the
6
+ * same held answer, so it's one fetch per process. Prerendered pages are files and never wait.
7
+ *
8
+ * - Registered first (`addMiddleware({ order: 'pre' })` in index.ts), before the editor's.
9
+ * - Only for a cloud site's media: `CARACMS_CLOUD_KEY` set, `CARACMS_MEDIA` not `memory`,
10
+ * and none of the site's own media variables (that's `readMediaConfig`'s problem sentence).
11
+ * - A fetch that fails leaves the media unset (v3's handling: the editor's media routes 503
12
+ * with the cloud's sentence) and is tried again by a request after `RETRY_MS`, so a cloud
13
+ * that's away doesn't hold every request for its timeout.
14
+ *
15
+ * `cloudSettingsForBuild()` is the build's start (`astro:build:start`): the same fetch, and
16
+ * a refusal or an unreachable cloud **fails the build** with the cloud's sentence or the
17
+ * package's own. The build ignores `github.repo`: a site with no repo linked still builds.
18
+ */
19
+ import type { MiddlewareHandler } from 'astro';
20
+ import { CLOUD_NO_MEDIA, CLOUD_UNREACHABLE_BUILD, isCloudRefusal, heldCloudSettings, loadCloudSettings } from './live/cloud.ts';
21
+ import { cloudMedia } from './media.ts';
22
+
23
+ type Env = Record<string, string | undefined>;
24
+
25
+ /** After a failed fetch, requests in the next half minute don't wait on another. */
26
+ export const RETRY_MS = 30_000;
27
+
28
+ const NEXT_TRY = Symbol.for('caracms.cloudSettingsNextTry');
29
+ const LOGGED = Symbol.for('caracms.cloudSettingsLogged');
30
+ type Shared = { [NEXT_TRY]?: number; [LOGGED]?: string };
31
+
32
+ /** Fetch the settings if this process doesn't hold them yet (and isn't waiting out a failure). */
33
+ export async function ensureCloudSettings(env: Env = process.env, now = Date.now(), log: (line: string) => void = (line) => console.error(line)): Promise<void> {
34
+ const cloud = cloudMedia(env);
35
+ if (!cloud || cloud.problems.length) return;
36
+ if (heldCloudSettings(cloud.connection)?.kind === 'ready') return;
37
+ const g = globalThis as Shared;
38
+ if ((g[NEXT_TRY] ?? 0) > now) return;
39
+ try {
40
+ await loadCloudSettings(cloud.connection);
41
+ delete g[NEXT_TRY];
42
+ delete g[LOGGED];
43
+ } catch (error) {
44
+ g[NEXT_TRY] = now + RETRY_MS;
45
+ const line = isCloudRefusal(error) ? `CaraCMS Cloud (${error.error}): ${error.message}` : 'CaraCMS Cloud: the settings could not be read';
46
+ if (g[LOGGED] !== line) log(`[caracms] the media settings could not be fetched: ${line}`);
47
+ g[LOGGED] = line;
48
+ }
49
+ }
50
+
51
+ /** Forget the retry clock (tests). */
52
+ export function resetCloudSettingsRetry(): void {
53
+ const g = globalThis as Shared;
54
+ delete g[NEXT_TRY];
55
+ delete g[LOGGED];
56
+ }
57
+
58
+ export const onRequest: MiddlewareHandler = async (_context, next) => {
59
+ await ensureCloudSettings();
60
+ return next();
61
+ };
62
+
63
+ /** The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud. */
64
+ export async function cloudSettingsForBuild(env: Env = process.env): Promise<void> {
65
+ const cloud = cloudMedia(env);
66
+ if (!cloud) return;
67
+ if (cloud.problems.length) throw new Error(`caracms: ${cloud.problems.join(' ')}`);
68
+ let settings;
69
+ try {
70
+ settings = await loadCloudSettings(cloud.connection);
71
+ } catch (error) {
72
+ const message = isCloudRefusal(error) && error.error !== 'unreachable' ? error.message : CLOUD_UNREACHABLE_BUILD;
73
+ throw new Error(`caracms: ${message}`);
74
+ }
75
+ if (!settings.media) throw new Error(`caracms: ${CLOUD_NO_MEDIA}`);
76
+ }
@@ -29,7 +29,8 @@ and an alt already used), `GET thumbnail?key=&width=` (a 302 to the signed URL),
29
29
  `POST video-upload`, `GET video-upload/<id>`, `GET clips`, `GET clips/<id>`, `GET playback/<id>`.
30
30
  Every shape is in `types.ts` → `MEDIA_ENDPOINTS`. `server/media/`: `sniff.ts` (the type and size
31
31
  from the bytes), `upload.ts` (the 40 MB cap, the key rule, no overwrite), `bucket.ts` (S3 through
32
- aws4fetch, and the in-memory bucket), `library.ts` (the 60 s listing, metadata per key,
32
+ aws4fetch, and the in-memory bucket), `cloud-bucket.ts` (the same seam on CaraCMS Cloud's media
33
+ API, `CARACMS_CLOUD_KEY`), `library.ts` (the 60 s listing, metadata per key,
33
34
  where-used), `mux.ts` (the template's Mux rules), `alt.ts` (`WaitingView.missingAlt`),
34
35
  `config.ts` (503s, images and clips apart) and `memory.ts` (`CARACMS_MEDIA=memory`: `memoryMedia()`
35
36
  to seed the bucket and the fake Mux in tests).
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The media bucket on CaraCMS Cloud (caracms-cloud M3 Decision 21), behind bucket.ts's
3
+ * seam, so the upload route (checks, key, dedupe, metadata) and the files route don't
4
+ * change. The site's server never holds R2 credentials: it asks the API (live/cloud.ts,
5
+ * the site key) and the API answers for the site's own folder. Keys stay `uploads/…`:
6
+ * the cloud adds `sites/<folder>/` and strips it again.
7
+ *
8
+ * - `list` and `first`: `GET /v1/media/list`, following its `next` tokens (`list`) or one page (`first`).
9
+ * - `head`: `GET /v1/media/object`; `404 not_found` is "no such object".
10
+ * - `get`: `POST /v1/media/download-url`, then the presigned GET, streamed (the files route).
11
+ * - `put`: `POST /v1/media/upload-url`, then the presigned PUT with exactly the headers it names.
12
+ *
13
+ * **The cloud's refusals are shown as sent** (`CloudRefusal`: `upload_limit`'s sentence,
14
+ * `storage_unavailable`'s); the media handler puts the message in its 502. A cloud that
15
+ * can't be reached, and the storage's own answers to the presigned URLs, are BucketErrors
16
+ * (the handler's own sentences). Presigned URLs go nowhere but the fetch here: never in
17
+ * a log line, an error or an answer to the browser.
18
+ */
19
+ import { isCloudRefusal, cloudCall, type CloudConnection } from '../../../live/cloud.ts';
20
+ import { BucketError, type Bucket, type BucketObject, type ObjectMeta } from './bucket.ts';
21
+
22
+ const STORAGE_TIMEOUT_MS = 20_000;
23
+ /** A runaway-token guard, as s3Bucket's: 1000 pages of 1000 is far past one site's library. */
24
+ const MAX_PAGES = 1000;
25
+
26
+ const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : null);
27
+ const text = (v: unknown) => (typeof v === 'string' && v ? v : null);
28
+ const iso = (v: unknown) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? new Date(v).toISOString() : null);
29
+
30
+ export function cloudBucket(connection: Pick<CloudConnection, 'key' | 'url'>, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args)): Bucket {
31
+ const conn: CloudConnection = { ...connection, fetch: fetchImpl };
32
+
33
+ /** One API call; a cloud that can't be reached is a BucketError, any other refusal goes up as sent. */
34
+ async function api(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
35
+ try {
36
+ return await cloudCall(conn, method, path, body);
37
+ } catch (error) {
38
+ if (isCloudRefusal(error) && error.error === 'unreachable') throw new BucketError(`${method} ${path.split('?')[0]}: CaraCMS Cloud unreachable`);
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ /** A presigned URL's request. The timeout covers the wait for the storage's headers (a `get` streams after). */
44
+ async function storage(what: string, url: string, init: RequestInit): Promise<Response> {
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(new Error(`no answer in ${STORAGE_TIMEOUT_MS / 1000} s`)), STORAGE_TIMEOUT_MS);
47
+ try {
48
+ return await fetchImpl(url, { ...init, signal: controller.signal });
49
+ } catch (error) {
50
+ throw new BucketError(`${what}: storage unreachable (${error instanceof Error ? error.message : String(error)})`);
51
+ } finally {
52
+ clearTimeout(timer);
53
+ }
54
+ }
55
+
56
+ async function page(prefix: string, after: string | null): Promise<{ objects: BucketObject[]; next: string | null }> {
57
+ const query = new URLSearchParams({ prefix });
58
+ if (after) query.set('after', after);
59
+ const answer = await api('GET', `/v1/media/list?${query}`);
60
+ if (!Array.isArray(answer.objects)) throw new BucketError(`list ${prefix}: the answer has no objects`);
61
+ const objects: BucketObject[] = [];
62
+ for (const o of answer.objects as Record<string, unknown>[]) {
63
+ const key = o && typeof o === 'object' ? text(o.key) : null;
64
+ if (!key) continue;
65
+ objects.push({ key, lastModified: iso(o.lastModified), size: typeof o.size === 'number' && Number.isFinite(o.size) ? o.size : null });
66
+ }
67
+ return { objects, next: text(answer.next) };
68
+ }
69
+
70
+ return {
71
+ async list(prefix) {
72
+ const out: BucketObject[] = [];
73
+ const seen = new Set<string>();
74
+ let after: string | null = null;
75
+ for (let n = 0; n < MAX_PAGES; n++) {
76
+ const { objects, next } = await page(prefix, after);
77
+ out.push(...objects);
78
+ if (!next || seen.has(next)) break;
79
+ seen.add(next);
80
+ after = next;
81
+ }
82
+ return out;
83
+ },
84
+ async first(prefix) {
85
+ const { objects } = await page(prefix, null);
86
+ return objects[0]?.key ?? null;
87
+ },
88
+ async head(key): Promise<ObjectMeta | null> {
89
+ let answer: Record<string, unknown>;
90
+ try {
91
+ answer = await api('GET', `/v1/media/object?${new URLSearchParams({ key })}`);
92
+ } catch (error) {
93
+ if (isCloudRefusal(error) && error.error === 'not_found') return null;
94
+ throw error;
95
+ }
96
+ return {
97
+ w: num(answer.w),
98
+ h: num(answer.h),
99
+ name: text(answer.name),
100
+ by: text(answer.by),
101
+ contentType: text(answer.contentType),
102
+ lastModified: iso(answer.lastModified),
103
+ size: typeof answer.size === 'number' && Number.isFinite(answer.size) ? answer.size : null,
104
+ };
105
+ },
106
+ async get(key) {
107
+ const { url } = await api('POST', '/v1/media/download-url', { key });
108
+ if (typeof url !== 'string' || !/^https?:\/\//.test(url)) throw new BucketError(`get ${key}: the answer has no URL`);
109
+ const res = await storage(`get ${key}`, url, { method: 'GET' });
110
+ if (res.status === 404) {
111
+ await res.body?.cancel().catch(() => {});
112
+ return null;
113
+ }
114
+ if (!res.ok || !res.body) {
115
+ await res.body?.cancel().catch(() => {});
116
+ throw new BucketError(`get ${key}: ${res.status}`, res.status);
117
+ }
118
+ const size = Number(res.headers.get('content-length'));
119
+ return { body: res.body, size: res.headers.has('content-length') && Number.isFinite(size) ? size : null };
120
+ },
121
+ async put({ key, body, contentType, sha256, meta }) {
122
+ const answer = await api('POST', '/v1/media/upload-url', {
123
+ key,
124
+ size: body.byteLength,
125
+ contentType,
126
+ sha256,
127
+ meta: { ...(meta.w !== undefined ? { w: meta.w } : {}), ...(meta.h !== undefined ? { h: meta.h } : {}), name: meta.name, by: meta.by },
128
+ });
129
+ const { url, method, headers } = answer;
130
+ if (typeof url !== 'string' || !/^https?:\/\//.test(url) || (method !== undefined && method !== 'PUT') || !headers || typeof headers !== 'object') {
131
+ throw new BucketError(`put ${key}: the upload answer isn't one`);
132
+ }
133
+ // The headers as the cloud named them, verbatim (the URL signs each one). Content-Length and Host
134
+ // are fetch's own to send: the body is exactly the declared size, and the URL's host is the signed one.
135
+ const sent = Object.fromEntries(
136
+ Object.entries(headers as Record<string, unknown>).filter(([name, v]) => typeof v === 'string' && !/^(content-length|host)$/i.test(name)),
137
+ ) as Record<string, string>;
138
+ const res = await storage(`put ${key}`, url, { method: 'PUT', headers: sent, body: body as BodyInit });
139
+ await res.body?.cancel().catch(() => {});
140
+ // 412: R2 refused the signed If-None-Match: * because the key is there, and the key carries the bytes' hash: already stored.
141
+ if (!res.ok && res.status !== 412) throw new BucketError(`put ${key}: ${res.status}`, res.status);
142
+ },
143
+ };
144
+ }
@@ -10,8 +10,12 @@
10
10
  * - Images need imgproxy's (`CARACMS_MEDIA_URL`, `IMGPROXY_KEY`, `IMGPROXY_SALT`, which
11
11
  * sign the thumbnails) and the bucket's (`S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`,
12
12
  * `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`). Clips need `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET`.
13
+ * - **On CaraCMS Cloud** (caracms-cloud M3 Decision 19) `CARACMS_CLOUD_KEY` is the images'
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.
13
16
  */
14
- import { isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
17
+ import type { CloudConnection } from '../../../live/cloud.ts';
18
+ import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
15
19
 
16
20
  type Env = Record<string, string | undefined>;
17
21
 
@@ -26,6 +30,7 @@ export interface BucketSettings {
26
30
  export type ImagesConfig =
27
31
  | { kind: 'memory'; signing: MediaSettings }
28
32
  | { kind: 's3'; signing: MediaSettings; bucket: BucketSettings }
33
+ | { kind: 'cloud'; signing: MediaSettings; connection: Pick<CloudConnection, 'key' | 'url'> }
29
34
  | { kind: 'unset'; message: string; missing: string[] };
30
35
 
31
36
  export type MuxConfig = { kind: 'memory' } | { kind: 'mux'; tokenId: string; tokenSecret: string } | { kind: 'unset'; message: string; missing: string[] };
@@ -48,6 +53,11 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
48
53
  return { kind: 'memory', signing: signing.settings };
49
54
  }
50
55
  const signing = readMediaConfig(env, production);
56
+ const cloud = cloudMedia(env);
57
+ if (cloud) {
58
+ if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
59
+ return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
60
+ }
51
61
  const missing = signing.kind === 'unset' ? [...signing.missing] : [];
52
62
  const problems = signing.kind === 'unset' ? [...signing.problems] : [];
53
63
  const need = (name: string) => {
@@ -41,6 +41,8 @@ import type { DraftsStore } from '../drafts-store.ts';
41
41
  import { parseReplaceBody } from '../replace.ts';
42
42
  import { checkOrigin, seqOf, tabOf, WriteRefused } from '../write.ts';
43
43
  import { BucketError, s3Bucket, type Bucket } from './bucket.ts';
44
+ import { cloudBucket } from './cloud-bucket.ts';
45
+ import { isCloudRefusal } from '../../../live/cloud.ts';
44
46
  import { readImagesConfig, readMuxConfig } from './config.ts';
45
47
  import { isFileKey } from '../../../files.ts';
46
48
  import { entryOf, fileEntryOf, libraryFor, mediaUses, UPLOADS, type Library, type MediaUses } from './library.ts';
@@ -76,6 +78,7 @@ function images(ctx: MediaContext): { library: Library; signing: MediaSettings }
76
78
  const config = readImagesConfig(ctx.env ?? process.env, ctx.production);
77
79
  if (config.kind === 'unset') return json(503, { error: config.message, code: 'media-unset', missing: config.missing } satisfies MediaUnset);
78
80
  if (config.kind === 'memory') return { library: libraryFor('memory', () => memoryMedia().bucket), signing: config.signing };
81
+ if (config.kind === 'cloud') return { library: libraryFor(`cloud:${config.connection.url}:${config.signing.prefix}`, (): Bucket => cloudBucket(config.connection, ctx.fetch)), signing: config.signing };
79
82
  const b = config.bucket;
80
83
  return { library: libraryFor(`s3:${b.endpoint}:${b.bucket}:${b.accessKeyId}`, (): Bucket => s3Bucket(b, ctx.fetch)), signing: config.signing };
81
84
  }
@@ -210,6 +213,11 @@ export async function handleMedia(request: Request, ctx: MediaContext): Promise<
210
213
  if (error instanceof UploadRefused) return json(error.status, { error: error.message, code: error.code } satisfies MediaRefusal);
211
214
  if (error instanceof MuxRouteError) return json(error.status, { error: error.message } satisfies ApiError);
212
215
  if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body });
216
+ if (isCloudRefusal(error)) {
217
+ // caracms-cloud M3: the cloud's own sentence, as sent (a full site, its storage away).
218
+ log(`[caracms] media bucket: CaraCMS Cloud (${error.error}): ${error.message}`);
219
+ return json(502, { error: error.message } satisfies ApiError);
220
+ }
213
221
  if (error instanceof BucketError) {
214
222
  log(`[caracms] media bucket: ${error.message}`);
215
223
  const said = error.status !== null && error.status >= 400 && error.status < 500;
@@ -32,6 +32,7 @@ import type { APIRoute } from 'astro';
32
32
  import { clientIp, NO_GATE_MESSAGE } from './live/auth/request.ts';
33
33
  import { fileAddress, fileKeyOf, fileTypeOf } from './files.ts';
34
34
  import { s3Bucket, type Bucket } from './editor/server/media/bucket.ts';
35
+ import { cloudBucket } from './editor/server/media/cloud-bucket.ts';
35
36
  import { readImagesConfig } from './editor/server/media/config.ts';
36
37
  import { memoryMedia } from './editor/server/media/memory.ts';
37
38
  import { isProductionBuild } from './media.ts';
@@ -168,7 +169,7 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
168
169
  }
169
170
 
170
171
  // 6. The one key the allow-list passed.
171
- const bucket: Bucket = config.kind === 'memory' ? memoryMedia().bucket : s3Bucket(config.bucket, ctx.fetch);
172
+ const bucket: Bucket = config.kind === 'memory' ? memoryMedia().bucket : config.kind === 'cloud' ? cloudBucket(config.connection, ctx.fetch) : s3Bucket(config.bucket, ctx.fetch);
172
173
  let object;
173
174
  try {
174
175
  object = await bucket.get(file.key);
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 |
@@ -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
  }
package/src/live/cloud.ts CHANGED
@@ -3,8 +3,14 @@
3
3
  *
4
4
  * - `CARACMS_CLOUD_KEY` (the site key, `cck_…`) is sent as `Authorization: Bearer` and
5
5
  * nowhere else; `CARACMS_CLOUD_URL` overrides the production address below.
6
- * - `cloudSettings()`: `GET /v1/settings` at start, for the site's repo (`github.repo`;
7
- * null is `no_repo`, with the cloud's own sentence).
6
+ * - `fetchCloudSettings()`: `GET /v1/settings`, the whole answer the package uses: the
7
+ * site's repo, its folder and its media (bucket, image host, imgproxy pair; M3).
8
+ * `loadCloudSettings()` is the one fetch per process (M3 Decision 20): the build's start,
9
+ * or the first request a server gets, and the live runtime, all share it, held on
10
+ * `globalThis` (the build renders pages through another module instance than its hooks);
11
+ * `heldCloudSettings()` reads that copy synchronously (`readMediaConfig`).
12
+ * - `cloudSettings()`: the site's repo from those settings (`github.repo`; null is
13
+ * `no_repo`, with the cloud's own sentence). Only the editor needs a repo.
8
14
  * - `cloudToken()`: `POST /v1/github/token`, a fresh installation token on the site's repo,
9
15
  * about an hour long. `GitHubClient` calls it and refreshes five minutes early.
10
16
  * - **A refusal** (any answer that isn't 2xx, carrying `{ error, message }`) throws a
@@ -15,7 +21,7 @@
15
21
  * - `cloudUnavailable()`: the sign-in guard's 503 for a refusal at start (Decision 14),
16
22
  * the sentence escaped and the install link a link.
17
23
  *
18
- * The key and the tokens are never logged and never put in an error.
24
+ * The key, the tokens and the imgproxy pair are never logged and never put in an error.
19
25
  */
20
26
 
21
27
  /** The production API's address (caracms-cloud M0 → Contracts). The one place it's written. */
@@ -25,6 +31,8 @@ export const DEFAULT_CLOUD_URL = 'https://glad-bird-412.ap-southeast-2.convex.si
25
31
  export const CLOUD_KEY_SHAPE = /^cck_[A-Za-z0-9_-]{43}$/;
26
32
 
27
33
  export const CLOUD_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so the editor can't start. Try again in a minute.";
34
+ /** M3 Decision 20: the build's own sentence for a cloud it can't reach. */
35
+ export const CLOUD_UNREACHABLE_BUILD = "CaraCMS Cloud couldn't be reached, so the site can't be built. Try again in a minute.";
28
36
  /** The cloud's own `no_repo` sentence (Contracts), for `github.repo: null` in the settings. */
29
37
  export const CLOUD_NO_REPO = 'This site has no GitHub repo in CaraCMS Cloud yet. Ask CaraCMS to set one.';
30
38
  /** Decision 13: a token answer named another repo than the one the server started with. */
@@ -41,8 +49,23 @@ export interface CloudConnection {
41
49
  timeoutMs?: number;
42
50
  }
43
51
 
52
+ /**
53
+ * Marks a `CloudRefusal`. `Symbol.for` is shared across module instances, so a refusal
54
+ * thrown by a second copy of this file (Vite's dev SSR graph can hold two) is still known.
55
+ */
56
+ const REFUSAL_BRAND = Symbol.for('caracms.CloudRefusal');
57
+
58
+ /**
59
+ * Is this a `CloudRefusal`, from any instance of this module? Use this, never
60
+ * `instanceof CloudRefusal`: that fails when the refusal came from the other copy.
61
+ */
62
+ export function isCloudRefusal(value: unknown): value is CloudRefusal {
63
+ return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[REFUSAL_BRAND] === true;
64
+ }
65
+
44
66
  /** The cloud said no (or couldn't be reached). `message` is safe to show and to log. */
45
67
  export class CloudRefusal extends Error {
68
+ readonly [REFUSAL_BRAND] = true;
46
69
  /** The cloud's `error` (`no_repo`, `app_not_installed`, `unknown_key`…), or the package's `unreachable` / `repo_changed`. */
47
70
  readonly error: string;
48
71
  /** An `https://` link the message names (the App's install page), or null. */
@@ -57,45 +80,155 @@ export class CloudRefusal extends Error {
57
80
 
58
81
  const unreachable = () => new CloudRefusal('unreachable', CLOUD_UNREACHABLE);
59
82
 
60
- async function call(connection: CloudConnection, method: 'GET' | 'POST', path: string): Promise<Record<string, unknown>> {
83
+ /**
84
+ * One call to the API: `path` with its query, a JSON `body` for a POST. The answer's JSON
85
+ * object, or a `CloudRefusal` (the cloud's own, or `unreachable`). The media bucket
86
+ * (editor/server/media/cloud-bucket.ts) calls it too.
87
+ */
88
+ export async function cloudCall(connection: CloudConnection, method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
61
89
  let res: Response;
62
- let body: unknown;
90
+ let answer: unknown;
63
91
  try {
64
92
  res = await (connection.fetch ?? fetch)(`${connection.url}${path}`, {
65
93
  method,
66
- headers: { authorization: `Bearer ${connection.key}`, accept: 'application/json', 'user-agent': 'caracms' },
94
+ headers: {
95
+ authorization: `Bearer ${connection.key}`,
96
+ accept: 'application/json',
97
+ 'user-agent': 'caracms',
98
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
99
+ },
100
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
67
101
  signal: AbortSignal.timeout(connection.timeoutMs ?? CLOUD_TIMEOUT_MS),
68
102
  });
69
- body = await res.json();
103
+ answer = await res.json();
70
104
  } catch {
71
105
  throw unreachable();
72
106
  }
73
- const answer = body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
74
- if (!answer) throw unreachable();
107
+ const object = answer && typeof answer === 'object' && !Array.isArray(answer) ? (answer as Record<string, unknown>) : null;
108
+ if (!object) throw unreachable();
75
109
  if (!res.ok) {
76
- const { error, message, installUrl } = answer;
110
+ const { error, message, installUrl } = object;
77
111
  if (typeof error !== 'string' || typeof message !== 'string' || !message.trim()) throw unreachable();
78
112
  throw new CloudRefusal(error, message, typeof installUrl === 'string' && /^https:\/\//i.test(installUrl) ? installUrl : null);
79
113
  }
80
- return answer;
114
+ return object;
81
115
  }
82
116
 
83
117
  const REPO = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/;
84
118
 
85
- /** The site's repo, from `GET /v1/settings`. */
86
- export async function cloudSettings(connection: CloudConnection): Promise<{ owner: string; repo: string }> {
87
- const answer = await call(connection, 'GET', '/v1/settings');
119
+ /** What the package uses of `GET /v1/settings` (caracms-cloud M1's shape; M3 adds nothing it needs). */
120
+ export interface CloudSettings {
121
+ /** The linked repo, or null (`no_repo`, which only the editor minds: a build ignores it). */
122
+ repo: { owner: string; repo: string } | null;
123
+ /** The site's media, or null when the answer lacks any of it (the editor can still start; images can't be built). */
124
+ media: {
125
+ /** The site's folder in the media bucket, `sites/<folder>/`: the prefix the cloud adds to every key. */
126
+ folder: string;
127
+ bucket: string;
128
+ /** Bare, `img.caracms.cloud`. */
129
+ imageHost: string;
130
+ /** imgproxy's pair for this site, hex. Never logged, never sent to a browser. */
131
+ key: string;
132
+ salt: string;
133
+ } | null;
134
+ }
135
+
136
+ /** The package's sentence for settings with no usable media (a cloud set up without its bucket or image host). */
137
+ export const CLOUD_NO_MEDIA = "CaraCMS Cloud's settings for this site have no media yet, so images can't be built. Ask CaraCMS to check the site.";
138
+
139
+ const FOLDER = /^sites\/[a-z0-9][a-z0-9-]*\/$/;
140
+ const HOST = /^[A-Za-z0-9.-]+(?::\d+)?$/;
141
+ const HEX = /^(?:[0-9a-fA-F]{2})+$/;
142
+ const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
143
+
144
+ /** `GET /v1/settings`, checked. No `github` or a malformed repo counts as unreachable; incomplete media is `media: null`. */
145
+ export async function fetchCloudSettings(connection: CloudConnection): Promise<CloudSettings> {
146
+ const answer = await cloudCall(connection, 'GET', '/v1/settings');
88
147
  const github = answer.github as { repo?: unknown } | undefined;
148
+ const media = answer.media as { bucket?: unknown; imageHost?: unknown } | undefined;
149
+ const pair = answer.imgproxy as { key?: unknown; salt?: unknown } | undefined;
89
150
  if (!github || typeof github !== 'object') throw unreachable();
90
- if (github.repo === null) throw new CloudRefusal('no_repo', CLOUD_NO_REPO);
91
151
  const m = typeof github.repo === 'string' ? REPO.exec(github.repo) : null;
92
- if (!m) throw unreachable();
93
- return { owner: m[1]!, repo: m[2]! };
152
+ if (github.repo !== null && !m) throw unreachable();
153
+ const folder = str(answer.folder);
154
+ const bucket = str(media && typeof media === 'object' ? media.bucket : null);
155
+ const imageHost = str(media && typeof media === 'object' ? media.imageHost : null);
156
+ const key = str(pair && typeof pair === 'object' ? pair.key : null);
157
+ const salt = str(pair && typeof pair === 'object' ? pair.salt : null);
158
+ const ok = folder && FOLDER.test(folder) && bucket && imageHost && HOST.test(imageHost) && key && HEX.test(key) && salt && HEX.test(salt);
159
+ return { repo: m ? { owner: m[1]!, repo: m[2]! } : null, media: ok ? { folder, bucket, imageHost, key, salt } : null };
160
+ }
161
+
162
+ /** The site's repo (the editor's start): `no_repo` when none is linked. */
163
+ export async function cloudSettings(connection: CloudConnection): Promise<{ owner: string; repo: string }> {
164
+ return repoOf(await fetchCloudSettings(connection));
165
+ }
166
+
167
+ export function repoOf(settings: CloudSettings): { owner: string; repo: string } {
168
+ if (!settings.repo) throw new CloudRefusal('no_repo', CLOUD_NO_REPO);
169
+ return settings.repo;
170
+ }
171
+
172
+ // ── The one fetch per process (M3 Decision 20) ─────────────────────────────────
173
+
174
+ const HELD = Symbol.for('caracms.cloudSettings');
175
+ interface Held {
176
+ key: string;
177
+ url: string;
178
+ pending: Promise<CloudSettings> | null;
179
+ value: CloudSettings | null;
180
+ /** The last fetch's refusal, while no fetch has succeeded: the media-unset sentence. */
181
+ failure: CloudRefusal | null;
182
+ }
183
+ const held = () => globalThis as { [HELD]?: Held };
184
+
185
+ /**
186
+ * The settings, fetched once per process and shared: the build's start, a server's first
187
+ * request and the live runtime all come here, so there's one `GET /v1/settings`. A fetch
188
+ * that fails is forgotten (its refusal kept for `heldCloudSettings`), so the next caller
189
+ * tries again. Another key or address than the held one fetches afresh (tests).
190
+ */
191
+ export function loadCloudSettings(connection: CloudConnection): Promise<CloudSettings> {
192
+ const g = held();
193
+ const h = g[HELD];
194
+ if (h && h.key === connection.key && h.url === connection.url) {
195
+ if (h.value) return Promise.resolve(h.value);
196
+ if (h.pending) return h.pending;
197
+ }
198
+ const next: Held = { key: connection.key, url: connection.url, pending: null, value: null, failure: h?.key === connection.key && h.url === connection.url ? h.failure : null };
199
+ next.pending = fetchCloudSettings(connection).then(
200
+ (value) => {
201
+ next.value = value;
202
+ next.failure = null;
203
+ next.pending = null;
204
+ return value;
205
+ },
206
+ (error: unknown) => {
207
+ next.pending = null;
208
+ next.failure = isCloudRefusal(error) ? error : unreachable();
209
+ throw next.failure;
210
+ },
211
+ );
212
+ g[HELD] = next;
213
+ return next.pending;
214
+ }
215
+
216
+ /** The held copy for this key and address: ready, the last fetch's refusal, or null (not fetched yet, or fetching). */
217
+ export function heldCloudSettings(connection: Pick<CloudConnection, 'key' | 'url'>): { kind: 'ready'; settings: CloudSettings } | { kind: 'failed'; refusal: CloudRefusal } | null {
218
+ const h = held()[HELD];
219
+ if (!h || h.key !== connection.key || h.url !== connection.url) return null;
220
+ if (h.value) return { kind: 'ready', settings: h.value };
221
+ return h.failure ? { kind: 'failed', refusal: h.failure } : null;
222
+ }
223
+
224
+ /** Forget the held settings (tests). */
225
+ export function resetCloudSettings(): void {
226
+ delete held()[HELD];
94
227
  }
95
228
 
96
229
  /** A fresh installation token on the site's repo, from `POST /v1/github/token`. */
97
230
  export async function cloudToken(connection: CloudConnection): Promise<{ token: string; expiresAt: number; repo: string }> {
98
- const answer = await call(connection, 'POST', '/v1/github/token');
231
+ const answer = await cloudCall(connection, 'POST', '/v1/github/token');
99
232
  const { token, expiresAt, repo } = answer;
100
233
  const at = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
101
234
  if (typeof token !== 'string' || !token || Number.isNaN(at) || typeof repo !== 'string') throw unreachable();
@@ -31,7 +31,7 @@
31
31
  * The private key, the site key and the tokens are never logged.
32
32
  */
33
33
  import { createSign } from 'node:crypto';
34
- import { CLOUD_REPO_CHANGED, CloudRefusal, cloudToken } from './cloud.ts';
34
+ import { CLOUD_REPO_CHANGED, CloudRefusal, isCloudRefusal, cloudToken } from './cloud.ts';
35
35
 
36
36
  const API = 'https://api.github.com';
37
37
 
@@ -341,7 +341,7 @@ export class GitHubClient {
341
341
  try {
342
342
  answer = await cloudToken({ ...cloud, fetch: this.fetcher, timeoutMs: this.timeoutMs });
343
343
  } catch (error) {
344
- if (error instanceof CloudRefusal && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
344
+ if (isCloudRefusal(error) && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
345
345
  throw error;
346
346
  }
347
347
  if (answer.repo.toLowerCase() !== this.nameWithOwner.toLowerCase()) {