@caelo-cms/shared 0.10.48 → 0.10.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/ai-tools.d.ts +4 -3
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +5 -4
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auto-update.d.ts +124 -0
  6. package/dist/auto-update.d.ts.map +1 -0
  7. package/dist/auto-update.js +228 -0
  8. package/dist/auto-update.js.map +1 -0
  9. package/dist/index.d.ts +7 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +7 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/platform-updates.d.ts +13 -7
  14. package/dist/platform-updates.d.ts.map +1 -1
  15. package/dist/platform-updates.js +24 -8
  16. package/dist/platform-updates.js.map +1 -1
  17. package/dist/redirect-emit-caddy.d.ts +44 -0
  18. package/dist/redirect-emit-caddy.d.ts.map +1 -0
  19. package/dist/redirect-emit-caddy.js +76 -0
  20. package/dist/redirect-emit-caddy.js.map +1 -0
  21. package/dist/redirect-emit-cloudfront.d.ts +55 -0
  22. package/dist/redirect-emit-cloudfront.d.ts.map +1 -0
  23. package/dist/redirect-emit-cloudfront.js +99 -0
  24. package/dist/redirect-emit-cloudfront.js.map +1 -0
  25. package/dist/redirect-emit-front-door.d.ts +63 -0
  26. package/dist/redirect-emit-front-door.d.ts.map +1 -0
  27. package/dist/redirect-emit-front-door.js +79 -0
  28. package/dist/redirect-emit-front-door.js.map +1 -0
  29. package/dist/redirect-emit-gcp.d.ts +89 -0
  30. package/dist/redirect-emit-gcp.d.ts.map +1 -0
  31. package/dist/redirect-emit-gcp.js +91 -0
  32. package/dist/redirect-emit-gcp.js.map +1 -0
  33. package/dist/redirect-plan.d.ts +69 -0
  34. package/dist/redirect-plan.d.ts.map +1 -0
  35. package/dist/redirect-plan.js +218 -0
  36. package/dist/redirect-plan.js.map +1 -0
  37. package/dist/release-manifest.d.ts +99 -0
  38. package/dist/release-manifest.d.ts.map +1 -0
  39. package/dist/release-manifest.js +161 -0
  40. package/dist/release-manifest.js.map +1 -0
  41. package/dist/seo.d.ts.map +1 -1
  42. package/dist/seo.js +4 -1
  43. package/dist/seo.js.map +1 -1
  44. package/dist/static-cache-policy.d.ts +7 -4
  45. package/dist/static-cache-policy.d.ts.map +1 -1
  46. package/dist/static-cache-policy.js +7 -4
  47. package/dist/static-cache-policy.js.map +1 -1
  48. package/dist/subagents.d.ts +1 -1
  49. package/dist/version.d.ts +2 -2
  50. package/dist/version.js +1 -1
  51. package/package.json +1 -1
  52. package/src/ai-tools.ts +5 -4
  53. package/src/auto-update.test.ts +321 -0
  54. package/src/auto-update.ts +315 -0
  55. package/src/index.ts +7 -0
  56. package/src/platform-updates.test.ts +5 -0
  57. package/src/platform-updates.ts +31 -7
  58. package/src/redirect-emit-caddy.ts +103 -0
  59. package/src/redirect-emit-cloudfront.ts +145 -0
  60. package/src/redirect-emit-front-door.ts +140 -0
  61. package/src/redirect-emit-gcp.ts +165 -0
  62. package/src/redirect-emit.test.ts +291 -0
  63. package/src/redirect-plan.test.ts +143 -0
  64. package/src/redirect-plan.ts +298 -0
  65. package/src/release-manifest.ts +212 -0
  66. package/src/seo.ts +4 -1
  67. package/src/static-cache-policy.test.ts +6 -2
  68. package/src/static-cache-policy.ts +7 -4
  69. package/src/version.ts +1 -1
@@ -0,0 +1,298 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The redirect plan: what every serving layer answers for the rows of the
5
+ * `redirects` table (#587).
6
+ *
7
+ * Each provider serves redirects through its own edge mechanism (Caddy
8
+ * `map` + `redir`, a GCP URL map, Firebase Hosting `config.redirects`, a
9
+ * CloudFront Function with a KeyValueStore, Front Door rules). They all
10
+ * consume this one plan, so a redirect means the same thing on every
11
+ * provider:
12
+ *
13
+ * - **Exact paths.** A row matches its `from` path exactly (case-sensitive)
14
+ * and the same path with a trailing slash — in the directory page style
15
+ * `/old/` is the URL people link to. A from path whose last segment
16
+ * looks like a file (`/old.html`) matches only as written.
17
+ * - **The build wins.** A row whose from path a file of the build serves
18
+ * is left out (#575): a live page always beats a recorded redirect, as
19
+ * in the admin's 404 fallback (`redirects.lookup`). The edge layers
20
+ * evaluate redirects BEFORE static files, so without this a slug moved
21
+ * back to its old path would be shadowed (or loop with the reverse row).
22
+ * - **Chains collapse.** `/a → /b` plus `/b → /c` serves `/a → /c` in one
23
+ * hop; crawlers follow few hops and every hop costs a round-trip. A hop
24
+ * is followed only through rows that are themselves in the plan. The
25
+ * collapsed status is permanent only when every hop is, and preserves
26
+ * the method (307/308) only when every hop does.
27
+ * - **Loops fail the build.** A cycle can only ever answer "too many
28
+ * redirects"; the error names it.
29
+ * - **Location.** In the directory style a page's canonical URL ends in
30
+ * `/`; the Location points there directly (the host would otherwise add
31
+ * a second hop). The visitor's query string is carried over unless the
32
+ * target has its own query or fragment.
33
+ * - **410.** Rows with status 410 become `gone` entries. Layers that can
34
+ * answer a bare status (Caddy, CloudFront) send 410; the others answer
35
+ * the 404 a path without a file gets anyway.
36
+ */
37
+
38
+ import { redirectTargetIssue } from "./redirect-target.js";
39
+ import { trimTrailingSlashes } from "./url.js";
40
+
41
+ /** The redirect statuses the redirects table stores (besides 410). */
42
+ export type RedirectStatusCode = 301 | 302 | 307 | 308;
43
+
44
+ /** A row of the redirects table, as the static generator dumps it. */
45
+ export interface RedirectTableRow {
46
+ readonly fromPath: string;
47
+ readonly toPath: string;
48
+ readonly statusCode: number;
49
+ }
50
+
51
+ /** How the deploy target emits page URLs (`DeployTarget.pageUrlStyle`). */
52
+ export type RedirectPageUrlStyle = "directory" | "no-extension";
53
+
54
+ /** One redirect the serving layer answers. */
55
+ export interface PlannedRedirect {
56
+ /** The stored from path without a trailing slash (`/` for the root). */
57
+ readonly fromPath: string;
58
+ /** Every request path this entry answers, exactly (`/old`, `/old/`). */
59
+ readonly matchPaths: readonly string[];
60
+ /** The Location header value (a same-site path, see module comment). */
61
+ readonly location: string;
62
+ readonly statusCode: RedirectStatusCode;
63
+ /** Append the request's query string to {@link location}. */
64
+ readonly preserveQuery: boolean;
65
+ }
66
+
67
+ /** One path the serving layer answers with 410 Gone (where it can). */
68
+ export interface PlannedGone {
69
+ readonly fromPath: string;
70
+ readonly matchPaths: readonly string[];
71
+ }
72
+
73
+ /** Everything a serving layer needs to answer the redirects table. */
74
+ export interface RedirectPlan {
75
+ readonly redirects: readonly PlannedRedirect[];
76
+ readonly gone: readonly PlannedGone[];
77
+ /** From paths left out because a build file serves them (#575). */
78
+ readonly shadowedByBuild: readonly string[];
79
+ }
80
+
81
+ const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 307, 308]);
82
+ const GONE_STATUS = 410;
83
+
84
+ /**
85
+ * Parse the generator's `_redirects` file (`<from> <to> <status>` per line,
86
+ * `#` comments). Throws on a malformed line — a rule silently dropped is a
87
+ * 404 on a URL someone links to.
88
+ *
89
+ * @example
90
+ * parseRedirectsFile("# generated\n/old /new 301\n")
91
+ * // → [{ fromPath: "/old", toPath: "/new", statusCode: 301 }]
92
+ */
93
+ export function parseRedirectsFile(text: string): RedirectTableRow[] {
94
+ const rows: RedirectTableRow[] = [];
95
+ for (const [index, raw] of text.split("\n").entries()) {
96
+ const line = raw.trim();
97
+ if (line.length === 0 || line.startsWith("#")) continue;
98
+ const parts = line.split(" ");
99
+ const [fromPath, toPath, status] = parts;
100
+ const statusCode = Number(status);
101
+ if (parts.length !== 3 || !fromPath || !toPath || !Number.isInteger(statusCode)) {
102
+ throw new Error(
103
+ `redirect-plan: _redirects line ${index + 1} is not "<from> <to> <status>": ${JSON.stringify(raw)}. The static generator writes this file from the redirects table — fix the row there (find_redirects / bulk_create_redirects) and rebuild.`,
104
+ );
105
+ }
106
+ rows.push({ fromPath, toPath, statusCode });
107
+ }
108
+ return rows;
109
+ }
110
+
111
+ /**
112
+ * Serialise rows into the `_redirects` file format {@link parseRedirectsFile}
113
+ * reads (also the Netlify / Cloudflare Pages format).
114
+ */
115
+ export function formatRedirectsFile(rows: readonly RedirectTableRow[]): string {
116
+ return [
117
+ "# Auto-generated by Caelo static-generator. Do not edit by hand.",
118
+ ...rows.map((r) => `${r.fromPath} ${r.toPath} ${r.statusCode}`),
119
+ "",
120
+ ].join("\n");
121
+ }
122
+
123
+ /** Arguments of {@link buildRedirectPlan}. */
124
+ export interface BuildRedirectPlanArgs {
125
+ readonly rows: readonly RedirectTableRow[];
126
+ readonly pageUrlStyle: RedirectPageUrlStyle;
127
+ /**
128
+ * Every file of the build, relative to the build root without a leading
129
+ * slash (`de/preise/index.html`). A from path one of them serves is left
130
+ * out of the plan.
131
+ */
132
+ readonly servedFiles: ReadonlySet<string>;
133
+ }
134
+
135
+ interface NormalisedRow {
136
+ readonly bare: string;
137
+ readonly toPath: string;
138
+ readonly statusCode: number;
139
+ }
140
+
141
+ /**
142
+ * Build the redirect plan every provider serves (see the module comment for
143
+ * the semantics). Pure and deterministic: entries keep the rows' order.
144
+ * Throws with an actionable message on rows no edge layer can serve
145
+ * correctly — a malformed path, an unsafe target, conflicting duplicates, a
146
+ * loop.
147
+ */
148
+ export function buildRedirectPlan(args: BuildRedirectPlanArgs): RedirectPlan {
149
+ const rows = normaliseRows(args.rows);
150
+ const shadowedByBuild: string[] = [];
151
+ const live = new Map<string, NormalisedRow>();
152
+ for (const row of rows) {
153
+ if (servedByBuild(row.bare, args.servedFiles)) {
154
+ shadowedByBuild.push(row.bare);
155
+ continue;
156
+ }
157
+ live.set(row.bare, row);
158
+ }
159
+
160
+ const redirects: PlannedRedirect[] = [];
161
+ const gone: PlannedGone[] = [];
162
+ for (const row of live.values()) {
163
+ const matchPaths = matchPathsFor(row.bare);
164
+ if (row.statusCode === GONE_STATUS) {
165
+ gone.push({ fromPath: row.bare, matchPaths });
166
+ continue;
167
+ }
168
+ const { target, statusCode } = collapseChain(row, live);
169
+ const location = locationFor(target, args.pageUrlStyle);
170
+ redirects.push({
171
+ fromPath: row.bare,
172
+ matchPaths,
173
+ location,
174
+ statusCode,
175
+ preserveQuery: !/[?#]/.test(location),
176
+ });
177
+ }
178
+ return { redirects, gone, shadowedByBuild };
179
+ }
180
+
181
+ function normaliseRows(rows: readonly RedirectTableRow[]): NormalisedRow[] {
182
+ const byBare = new Map<string, NormalisedRow>();
183
+ const out: NormalisedRow[] = [];
184
+ for (const row of rows) {
185
+ const { fromPath, toPath, statusCode } = row;
186
+ const shown = JSON.stringify(fromPath);
187
+ if (!fromPath.startsWith("/") || /[\s\p{Cc}]/u.test(fromPath)) {
188
+ throw new Error(
189
+ `redirect-plan: from path ${shown} must start with '/' and contain no whitespace or control characters. Fix the row with bulk_create_redirects (upsert) or delete it with bulk_delete_redirects, then rebuild.`,
190
+ );
191
+ }
192
+ if (/[?#]/.test(fromPath)) {
193
+ throw new Error(
194
+ `redirect-plan: from path ${shown} contains '?' or '#'. Redirects match the URL path only, so this row can never match. Re-create it for the path alone (e.g. '${pathBeforeQuery(fromPath)}') or delete it with bulk_delete_redirects, then rebuild.`,
195
+ );
196
+ }
197
+ if (statusCode !== GONE_STATUS && !REDIRECT_STATUSES.has(statusCode)) {
198
+ throw new Error(
199
+ `redirect-plan: the redirect for ${shown} has status ${statusCode}; the redirects table allows 301, 302, 307, 308 or 410. Fix the row and rebuild.`,
200
+ );
201
+ }
202
+ if (statusCode !== GONE_STATUS) {
203
+ const targetIssue = redirectTargetIssue(toPath);
204
+ if (targetIssue) throw new Error(`redirect-plan: the redirect for ${shown}: ${targetIssue}`);
205
+ }
206
+ const bare = stripTrailingSlash(fromPath);
207
+ const normalised: NormalisedRow = { bare, toPath, statusCode };
208
+ const existing = byBare.get(bare);
209
+ if (existing) {
210
+ if (existing.toPath === toPath && existing.statusCode === statusCode) continue;
211
+ throw new Error(
212
+ `redirect-plan: two redirects answer ${JSON.stringify(bare)} (with and without the trailing slash) differently: → ${existing.toPath} (${existing.statusCode}) and → ${toPath} (${statusCode}). Delete one with bulk_delete_redirects, then rebuild.`,
213
+ );
214
+ }
215
+ byBare.set(bare, normalised);
216
+ out.push(normalised);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** Follow `row`'s target through the plan's own rows to the final hop. */
222
+ function collapseChain(
223
+ row: NormalisedRow,
224
+ live: ReadonlyMap<string, NormalisedRow>,
225
+ ): { target: string; statusCode: RedirectStatusCode } {
226
+ const visited = [row.bare];
227
+ let target = row.toPath;
228
+ let permanent = isPermanent(row.statusCode);
229
+ let preservesMethod = preservesRequestMethod(row.statusCode);
230
+ for (;;) {
231
+ // A target with its own query or fragment is a different URL than the
232
+ // path the next row matches; the chain ends there.
233
+ if (/[?#]/.test(target)) break;
234
+ const next = live.get(stripTrailingSlash(target));
235
+ if (!next || next.statusCode === GONE_STATUS) break;
236
+ if (visited.includes(next.bare)) {
237
+ const cycle = [...visited.slice(visited.indexOf(next.bare)), next.bare].join(" → ");
238
+ throw new Error(
239
+ `redirect-plan: the redirects ${cycle} form a loop, which browsers answer with "too many redirects". Delete the row that should no longer apply with bulk_delete_redirects (find_redirects lists them), then rebuild.`,
240
+ );
241
+ }
242
+ visited.push(next.bare);
243
+ permanent &&= isPermanent(next.statusCode);
244
+ preservesMethod &&= preservesRequestMethod(next.statusCode);
245
+ target = next.toPath;
246
+ }
247
+ return { target, statusCode: combinedStatus(permanent, preservesMethod) };
248
+ }
249
+
250
+ function isPermanent(status: number): boolean {
251
+ return status === 301 || status === 308;
252
+ }
253
+
254
+ function preservesRequestMethod(status: number): boolean {
255
+ return status === 307 || status === 308;
256
+ }
257
+
258
+ function combinedStatus(permanent: boolean, preservesMethod: boolean): RedirectStatusCode {
259
+ if (permanent) return preservesMethod ? 308 : 301;
260
+ return preservesMethod ? 307 : 302;
261
+ }
262
+
263
+ function stripTrailingSlash(path: string): string {
264
+ return path.length > 1 ? trimTrailingSlashes(path) || "/" : path;
265
+ }
266
+
267
+ /** The part of a stored path before its first `?` or `#` (`/` if empty). */
268
+ function pathBeforeQuery(path: string): string {
269
+ const cut = path.search(/[?#]/);
270
+ return (cut === -1 ? path : path.slice(0, cut)) || "/";
271
+ }
272
+
273
+ /** Whether the last path segment names a file (`/a/b.html`). */
274
+ function looksLikeFile(path: string): boolean {
275
+ return path.slice(path.lastIndexOf("/") + 1).includes(".");
276
+ }
277
+
278
+ function matchPathsFor(bare: string): readonly string[] {
279
+ if (bare === "/" || looksLikeFile(bare)) return [bare];
280
+ return [bare, `${bare}/`];
281
+ }
282
+
283
+ /** Does a file of the build answer `bare`? Hosts serve `/x` from the file
284
+ * `x` and from the directory index `x/index.html`. */
285
+ function servedByBuild(bare: string, servedFiles: ReadonlySet<string>): boolean {
286
+ if (bare === "/") return servedFiles.has("index.html");
287
+ const rel = bare.slice(1);
288
+ return servedFiles.has(rel) || servedFiles.has(`${rel}/index.html`);
289
+ }
290
+
291
+ /** In the directory style, internal page paths end in "/"; files (anything
292
+ * with an extension in its last segment) and URLs with a query or fragment
293
+ * stay as they are. */
294
+ function locationFor(toPath: string, pageUrlStyle: RedirectPageUrlStyle): string {
295
+ if (pageUrlStyle !== "directory" || toPath.endsWith("/")) return toPath;
296
+ if (/[?#]/.test(toPath) || looksLikeFile(toPath)) return toPath;
297
+ return `${toPath}/`;
298
+ }
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The release manifest's shape and where it lives (epic #696) — shared by
5
+ * the provisioning CLI / upgrader job, which verify its Sigstore signature
6
+ * with cosign before trusting it (provisioning release-manifest.ts), and
7
+ * the admin's auto-update scheduler, which only *reads* it to decide when
8
+ * to queue an install (the admin image ships no cosign; the job verifies).
9
+ *
10
+ * The artifact is one OCI manifest with two layers — the JSON manifest and
11
+ * its Sigstore bundle — pushed by `release.yml` on the release tag.
12
+ * {@link fetchReleaseArtifact} fetches both blobs and checks each against
13
+ * its content digest; it does NOT check the signature.
14
+ */
15
+
16
+ import { z } from "zod";
17
+
18
+ /** Release channels. Pre-release versions name theirs (`0.11.0-rc.1` → `rc`). */
19
+ export const RELEASE_CHANNELS = ["stable", "rc", "beta", "alpha"] as const;
20
+ export type ReleaseChannel = (typeof RELEASE_CHANNELS)[number];
21
+
22
+ /** The services a release ships images for. */
23
+ export const RELEASE_IMAGES = ["admin", "gateway", "ops"] as const;
24
+ export type ReleaseImage = (typeof RELEASE_IMAGES)[number];
25
+
26
+ /** A `sha256:<64 hex>` image / blob digest. */
27
+ export const IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/;
28
+
29
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
30
+ const GHSA_ID = /^GHSA(-[23456789cfghjmpqrvwx]{4}){3}$/;
31
+
32
+ const digest = z.string().regex(IMAGE_DIGEST, "expected sha256:<64 hex>");
33
+
34
+ /** Zod schema of `release-manifest.json` (manifestVersion 1). */
35
+ export const ReleaseManifestSchema = z.object({
36
+ manifestVersion: z.literal(1),
37
+ version: z.string().regex(SEMVER, "expected a SemVer version without a leading v"),
38
+ channel: z.enum(RELEASE_CHANNELS),
39
+ digests: z.object({ admin: digest, gateway: digest, ops: digest }),
40
+ minFromVersion: z.string().regex(SEMVER),
41
+ infraContractVersion: z.number().int().positive(),
42
+ migrations: z.object({
43
+ /** True when the release carries destructive (contract) migrations. */
44
+ contract: z.boolean(),
45
+ /**
46
+ * Newest release up to and including this one that carried contract
47
+ * migrations (null: none known). An install below it that jumps
48
+ * straight here would run those destructive steps while its old image
49
+ * still serves — so it is manual too, even when `contract` is false.
50
+ */
51
+ lastContractVersion: z.string().regex(SEMVER).nullable(),
52
+ /** Newest cms_admin migration in the release, e.g. `0262_tool_approval_branch`. */
53
+ head: z.string().min(1),
54
+ }),
55
+ /** Set only via the release-cut input, backed by published GitHub Security Advisories. */
56
+ security: z.boolean(),
57
+ advisories: z.array(z.string().regex(GHSA_ID, "expected a GHSA id")),
58
+ });
59
+ export type ReleaseManifest = z.infer<typeof ReleaseManifestSchema>;
60
+
61
+ /** Registry the manifests are read from — the public AR mirror Cloud Run pulls from. */
62
+ export const RELEASE_MANIFEST_REGISTRY = "europe-west1-docker.pkg.dev";
63
+ /** Repository path of the manifest artifact inside {@link RELEASE_MANIFEST_REGISTRY}. */
64
+ export const RELEASE_MANIFEST_REPOSITORY = "caelo-website/caelo-cms-images/release-manifest";
65
+
66
+ /** OCI artifact type + layer media types of the pushed manifest. */
67
+ export const RELEASE_MANIFEST_ARTIFACT_TYPE = "application/vnd.caelo.release-manifest.v1";
68
+ export const RELEASE_MANIFEST_MEDIA_TYPE = "application/vnd.caelo.release-manifest.v1+json";
69
+ export const SIGSTORE_BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json";
70
+
71
+ /** Why fetching the artifact failed. */
72
+ export type ReleaseArtifactErrorCode = "registry" | "artifact-shape" | "blob-digest";
73
+
74
+ export type ReleaseArtifactResult =
75
+ | { readonly ok: true; readonly manifestBytes: Uint8Array; readonly bundleBytes: Uint8Array }
76
+ | {
77
+ readonly ok: false;
78
+ readonly error: { readonly code: ReleaseArtifactErrorCode; readonly message: string };
79
+ };
80
+
81
+ /** Where and how {@link fetchReleaseArtifact} reaches the registry. */
82
+ export interface ReleaseArtifactDeps {
83
+ readonly fetch: (url: string, init?: RequestInit) => Promise<Response>;
84
+ /** Registry host + repository; default to the fixed public mirror. */
85
+ readonly registry?: string;
86
+ readonly repository?: string;
87
+ }
88
+
89
+ const OciManifestSchema = z.object({
90
+ artifactType: z.string().optional(),
91
+ layers: z.array(z.object({ mediaType: z.string(), digest: z.string().regex(IMAGE_DIGEST) })),
92
+ });
93
+
94
+ async function sha256Digest(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
95
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
96
+ return `sha256:${[...hash].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
97
+ }
98
+
99
+ const fail = (code: ReleaseArtifactErrorCode, message: string): ReleaseArtifactResult => ({
100
+ ok: false,
101
+ error: { code, message },
102
+ });
103
+
104
+ /**
105
+ * Fetch the release manifest artifact of `version` and both its layers,
106
+ * each checked against its content digest. Verifies NO signature: callers
107
+ * that act on the manifest verify the bundle (provisioning
108
+ * fetchVerifiedReleaseManifest); the admin's scheduler only plans with it.
109
+ *
110
+ * @param version - SemVer without a leading `v` (the caller validates it).
111
+ */
112
+ export async function fetchReleaseArtifact(
113
+ version: string,
114
+ deps: ReleaseArtifactDeps,
115
+ ): Promise<ReleaseArtifactResult> {
116
+ const base = `https://${deps.registry ?? RELEASE_MANIFEST_REGISTRY}/v2/${deps.repository ?? RELEASE_MANIFEST_REPOSITORY}`;
117
+ let artifact: z.infer<typeof OciManifestSchema>;
118
+ try {
119
+ const res = await deps.fetch(`${base}/manifests/${version}`, {
120
+ headers: { Accept: "application/vnd.oci.image.manifest.v1+json" },
121
+ });
122
+ if (!res.ok) {
123
+ return fail(
124
+ "registry",
125
+ `no release manifest for v${version} at ${base} (HTTP ${res.status}). Releases before the signed manifest (epic #696) have none; otherwise check the version exists.`,
126
+ );
127
+ }
128
+ const parsed = OciManifestSchema.safeParse(await res.json());
129
+ if (!parsed.success) {
130
+ return fail("artifact-shape", `not an OCI manifest: ${parsed.error.message}`);
131
+ }
132
+ artifact = parsed.data;
133
+ } catch (e) {
134
+ return fail("registry", `registry unreachable: ${e instanceof Error ? e.message : String(e)}`);
135
+ }
136
+
137
+ if (artifact.artifactType !== RELEASE_MANIFEST_ARTIFACT_TYPE) {
138
+ return fail(
139
+ "artifact-shape",
140
+ `artifactType is ${artifact.artifactType ?? "(none)"}, expected ${RELEASE_MANIFEST_ARTIFACT_TYPE}`,
141
+ );
142
+ }
143
+ const manifestLayers = artifact.layers.filter((l) => l.mediaType === RELEASE_MANIFEST_MEDIA_TYPE);
144
+ const bundleLayers = artifact.layers.filter((l) => l.mediaType === SIGSTORE_BUNDLE_MEDIA_TYPE);
145
+ const [manifestLayer] = manifestLayers;
146
+ const [bundleLayer] = bundleLayers;
147
+ if (manifestLayers.length !== 1 || bundleLayers.length !== 1 || !manifestLayer || !bundleLayer) {
148
+ return fail(
149
+ "artifact-shape",
150
+ "the artifact must hold exactly one release-manifest layer and one Sigstore bundle layer",
151
+ );
152
+ }
153
+
154
+ const blob = async (layerDigest: string): Promise<Uint8Array | ReleaseArtifactResult> => {
155
+ const res = await deps.fetch(`${base}/blobs/${layerDigest}`);
156
+ if (!res.ok) return fail("registry", `blob ${layerDigest}: HTTP ${res.status}`);
157
+ const bytes = new Uint8Array(await res.arrayBuffer());
158
+ const actual = await sha256Digest(bytes);
159
+ if (actual !== layerDigest) {
160
+ return fail(
161
+ "blob-digest",
162
+ `blob content does not match its digest (${layerDigest} expected, ${actual} served) — the registry or a proxy altered it`,
163
+ );
164
+ }
165
+ return bytes;
166
+ };
167
+ try {
168
+ const manifestBytes = await blob(manifestLayer.digest);
169
+ if (!(manifestBytes instanceof Uint8Array)) return manifestBytes;
170
+ const bundleBytes = await blob(bundleLayer.digest);
171
+ if (!(bundleBytes instanceof Uint8Array)) return bundleBytes;
172
+ return { ok: true, manifestBytes, bundleBytes };
173
+ } catch (e) {
174
+ return fail("registry", `registry unreachable: ${e instanceof Error ? e.message : String(e)}`);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Parse manifest bytes against {@link ReleaseManifestSchema} and check they
180
+ * describe `version`.
181
+ *
182
+ * @returns the manifest, or a plain-words problem
183
+ */
184
+ export function parseReleaseManifest(
185
+ bytes: Uint8Array,
186
+ version: string,
187
+ ):
188
+ | { readonly ok: true; readonly manifest: ReleaseManifest }
189
+ | { readonly ok: false; readonly code: "schema" | "version-mismatch"; readonly message: string } {
190
+ let json: unknown;
191
+ try {
192
+ json = JSON.parse(new TextDecoder().decode(bytes));
193
+ } catch {
194
+ return { ok: false, code: "schema", message: "the manifest is not valid JSON" };
195
+ }
196
+ const parsed = ReleaseManifestSchema.safeParse(json);
197
+ if (!parsed.success) {
198
+ return {
199
+ ok: false,
200
+ code: "schema",
201
+ message: `the manifest does not match manifestVersion 1 — this Caelo may be too old to read it: ${parsed.error.message}`,
202
+ };
203
+ }
204
+ if (parsed.data.version !== version) {
205
+ return {
206
+ ok: false,
207
+ code: "version-mismatch",
208
+ message: `asked for v${version} but the manifest describes v${parsed.data.version}`,
209
+ };
210
+ }
211
+ return { ok: true, manifest: parsed.data };
212
+ }
package/src/seo.ts CHANGED
@@ -44,7 +44,10 @@ export type SeoSetInput = z.infer<typeof seoSetInputSchema>;
44
44
  export const seoAutofillInputSchema = z
45
45
  .object({
46
46
  pageId: z.string().uuid(),
47
- metaDescription: z.string().min(1).max(SEO_DESCRIPTION_HARD_MAX),
47
+ // Trimmed before the length check: a whitespace-only description would
48
+ // stamp `autofilled_at` (the fill-once marker) while the page still
49
+ // ships without a description — and then refuse every later autofill.
50
+ metaDescription: z.string().trim().min(1).max(SEO_DESCRIPTION_HARD_MAX),
48
51
  ogImageAssetId: z.string().uuid().nullable().optional(),
49
52
  })
50
53
  .strict();
@@ -8,8 +8,8 @@ const HASHED_PATHS = [
8
8
  // Lighthouse report on gcp-firebase staging (v0.10.28).
9
9
  "/_assets/fonts/inter/29ede7bd4be32ab0.woff2",
10
10
  "/_assets/fonts/manrope/f3a06e9b32049b82.woff2",
11
- `/_assets/fonts/pinned/${"a".repeat(64)}.woff2`,
12
- `/_assets/fonts/pinned/${"0".repeat(64)}.ttf`,
11
+ `/_assets/fonts/pinned/${"a".repeat(64)}.latin.0123456789ab.woff2`,
12
+ `/_assets/fonts/pinned/${"0".repeat(64)}.cyrillic-ext.0123456789ab.woff2`,
13
13
  "/_caelo/plugin/consent-manager/runtime.0123456789ab.js",
14
14
  "/_caelo/plugin/consent-manager/banner.abcdef012345.css",
15
15
  "/_app/immutable/chunks/abc.js",
@@ -40,6 +40,10 @@ const STABLE_PATHS = [
40
40
  // Pinned-font license file is id-named, not hashed.
41
41
  "/_assets/fonts/pinned/0b1f6c2e-9d7a-4c1e-8f3a-2b6d9e0c1a4f.license.txt",
42
42
  // Look-alikes that must not slip through.
43
+ // The registry source itself is never published; were a raw pinned file to
44
+ // appear, it must not be pinned in caches for a year.
45
+ `/_assets/fonts/pinned/${"0".repeat(64)}.ttf`,
46
+ `/_assets/fonts/pinned/${"a".repeat(64)}.latin.woff2`,
43
47
  "/_assets/fonts/inter/not-a-hash.woff2",
44
48
  "/_assets/fonts/inter/29ede7bd4be32ab0.woff2.html",
45
49
  "/_caelo/plugin/consent-manager/runtime.js",
@@ -17,9 +17,12 @@
17
17
  * Google Fonts faces; the name is a hash of the upstream face URL,
18
18
  * which Google versions per file (a changed face gets a new URL,
19
19
  * hence a new name).
20
- * - `_assets/fonts/pinned/<sha256>.<ttf|otf|woff|woff2>` — library
21
- * fonts pinned by the sha256 of their bytes. (The sibling
22
- * `pinned/<font-id>.license.txt` is id-named, NOT hashed.)
20
+ * - `_assets/fonts/pinned/<sha256>.<slice>.<12 hex>.woff2` — WOFF2
21
+ * unicode-range slices derived from a registry font: the sha256 of
22
+ * the pinned source plus the delivery-toolchain tag
23
+ * (`font-service/src/delivery.ts`), which together determine the
24
+ * bytes. (The sibling `pinned/<font-id>.license.txt` is id-named,
25
+ * NOT hashed.)
23
26
  * - `_caelo/plugin/<slug>/<stem>.<12 hex>.<js|css>` — plugin client
24
27
  * assets, sha256 of the content in the name
25
28
  * (`plugin-host/src/client-assets.ts`).
@@ -52,7 +55,7 @@ export const HTML_CACHE_CONTROL = "public, max-age=60, stale-while-revalidate=86
52
55
  * matcher and Caddy's `path_regexp` (both RE2) and to `RegExp` here.
53
56
  */
54
57
  export const CONTENT_HASHED_PATH_PATTERN =
55
- "^/(?:_app/immutable/.+|_assets/fonts/[^/]+/[0-9a-f]{16,64}\\.(?:woff2|woff|ttf|otf)|_assets/[a-z0-9][a-z0-9-]*(?:/[a-z][a-z0-9-]*)?\\.[0-9a-f]{16}\\.[a-z0-9]{2,5}|_caelo/plugin/[^/]+/[^/]+\\.[0-9a-f]{12}\\.(?:js|css))$";
58
+ "^/(?:_app/immutable/.+|_assets/fonts/[^/]+/[0-9a-f]{16}\\.woff2|_assets/fonts/pinned/[0-9a-f]{64}\\.[a-z]+(?:-[a-z]+)?\\.[0-9a-f]{12}\\.woff2|_assets/[a-z0-9][a-z0-9-]*(?:/[a-z][a-z0-9-]*)?\\.[0-9a-f]{16}\\.[a-z0-9]{2,5}|_caelo/plugin/[^/]+/[^/]+\\.[0-9a-f]{12}\\.(?:js|css))$";
56
59
 
57
60
  /** Hex length of the content hash in published media names (#667). */
58
61
  export const MEDIA_CONTENT_HASH_HEX_LENGTH = 16;
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.10.48";
25
+ export const CAELO_VERSION = "0.10.50";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this