@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
@@ -163,7 +163,15 @@ export const updatePolicySchema = z
163
163
  windowStart: z.string().regex(WALL_CLOCK, "expected HH:MM").nullable(),
164
164
  windowEnd: z.string().regex(WALL_CLOCK, "expected HH:MM").nullable(),
165
165
  windowTimezone: z.string().min(1).max(64).refine(isKnownTimeZone, "unknown time zone"),
166
+ /** Hours after publication before a non-security release installs automatically. */
166
167
  soakHours: z.number().int().min(0).max(720),
168
+ /**
169
+ * The same for a release whose signed manifest names a published GitHub
170
+ * Security Advisory (auto-update.ts `isSecurityRelease`). Shorter by
171
+ * default (2 h vs 24 h): the fix is public, so every hour unpatched is
172
+ * exposure, while two hours still let a broken release be pulled first.
173
+ */
174
+ securitySoakHours: z.number().int().min(0).max(720),
167
175
  })
168
176
  .strict()
169
177
  .refine((p) => (p.windowStart === null) === (p.windowEnd === null), {
@@ -171,6 +179,9 @@ export const updatePolicySchema = z
171
179
  })
172
180
  .refine((p) => p.mode !== "auto" || p.channel === "stable", {
173
181
  message: "automatic installs follow the stable channel only",
182
+ })
183
+ .refine((p) => p.securitySoakHours <= p.soakHours, {
184
+ message: "security fixes cannot wait longer than other releases",
174
185
  });
175
186
  export type UpdatePolicy = z.infer<typeof updatePolicySchema>;
176
187
 
@@ -179,6 +190,10 @@ export interface StoredUpdatePolicy extends UpdatePolicy {
179
190
  readonly paused: boolean;
180
191
  readonly pausedReason: string | null;
181
192
  readonly pausedAt: string | null;
193
+ /** End of the Owner's pause; null = until resumed (or not paused). */
194
+ readonly pausedUntil: string | null;
195
+ /** True when the Owner paused; false when the upgrader paused after a failure. */
196
+ readonly pausedByOwner: boolean;
182
197
  readonly updatedAt: string;
183
198
  }
184
199
 
@@ -238,6 +253,7 @@ export function autoInstallRefusal(
238
253
  | "autoPatch"
239
254
  | "autoMinor"
240
255
  | "paused"
256
+ | "pausedUntil"
241
257
  | "windowStart"
242
258
  | "windowEnd"
243
259
  | "windowTimezone"
@@ -246,7 +262,14 @@ export function autoInstallRefusal(
246
262
  at: Date,
247
263
  ): string | null {
248
264
  if (policy.mode !== "auto") return "automatic installs are switched off in the update policy";
249
- if (policy.paused) return "automatic installs are paused after a failed install";
265
+ // An Owner pause with an end that has passed no longer holds (the
266
+ // scheduler lifts it on its next tick; this keeps the job consistent).
267
+ if (
268
+ policy.paused &&
269
+ (policy.pausedUntil === null || Date.parse(policy.pausedUntil) > at.getTime())
270
+ ) {
271
+ return "automatic installs are paused";
272
+ }
250
273
  if (candidate.channel !== "stable") return "only stable releases install automatically";
251
274
  if (candidate.contractMigrations) {
252
275
  return "the release carries destructive database changes; the Owner installs it by hand";
@@ -264,14 +287,15 @@ export function autoInstallRefusal(
264
287
  }
265
288
 
266
289
  /**
267
- * How long a verified Google sign-in authorizes one guarded action. Long
268
- * enough to read the confirmation dialog, short enough that an unattended
269
- * browser is useless soon after.
290
+ * How long a password confirmation authorizes one guarded action (#757). The
291
+ * admin confirms and acts in the same request, so the window only has to
292
+ * cover that request; anything longer would let an unattended browser reuse
293
+ * a confirmation the Owner made for an action that then failed.
270
294
  */
271
- export const STEP_UP_MAX_AGE_SECONDS = 600;
295
+ export const STEP_UP_MAX_AGE_SECONDS = 60;
272
296
 
273
- /** What a step-up is for (`step_up_challenges.purpose`). */
274
- export const STEP_UP_PURPOSES = ["platform_update", "update_policy", "step_up_client"] as const;
297
+ /** What a step-up is for (`sessions.step_up_purpose`). */
298
+ export const STEP_UP_PURPOSES = ["platform_update", "update_policy"] as const;
275
299
  export type StepUpPurpose = (typeof STEP_UP_PURPOSES)[number];
276
300
 
277
301
  /**
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The redirect plan as a Caddyfile snippet (self-hosted, #587).
5
+ *
6
+ * The static generator writes it to `<build>/_redirects.caddy`; each public
7
+ * Caddy site block imports the file of the build it serves, and the Caddy
8
+ * container reloads itself when a deploy changes it (caddy-redirects.ts in
9
+ * @caelo-cms/provisioning). A redirect is answered by Caddy itself with no
10
+ * compute on the request path and no channel from the admin into Caddy. A
11
+ * snippet Caddy rejects leaves the previous config running.
12
+ *
13
+ * Shape: one `map` from the request path to the target and status, then one
14
+ * `redir` per status keyed on the mapped status. `map` is an exact,
15
+ * case-sensitive string lookup — unlike Caddy's `path` matcher, which is
16
+ * case-insensitive and would let `/About` shadow a live `/about`. One map
17
+ * keeps the config O(1) in directives however many rows the table holds.
18
+ * `redir` sorts before `try_files` / `file_server` / `handle`, so the
19
+ * directive order inside the site block does not matter.
20
+ */
21
+
22
+ import type { RedirectPlan } from "./redirect-plan.js";
23
+ import { trimTrailingSlashes } from "./url.js";
24
+
25
+ /** The file name the generator writes the snippet to, at the build root. */
26
+ export const CADDY_REDIRECTS_FILE = "_redirects.caddy";
27
+
28
+ /**
29
+ * The `import` line for a site block serving `siteRoot`. The trailing `*`
30
+ * (Caddy's import globs allow exactly one) lets a site root without a build
31
+ * yet — fresh install, nothing published — import nothing instead of failing
32
+ * Caddy's start; Caddy only errors on a missing import without a wildcard.
33
+ * Every build the generator writes carries the file.
34
+ */
35
+ export function caddyRedirectsImport(siteRoot: string): string {
36
+ return `import ${trimTrailingSlashes(siteRoot)}/${CADDY_REDIRECTS_FILE}*`;
37
+ }
38
+
39
+ const PLACEHOLDER_TARGET = "caelo_redirect_to";
40
+ const PLACEHOLDER_STATUS = "caelo_redirect_status";
41
+
42
+ /**
43
+ * Characters a Caddyfile token cannot carry literally: `{`/`}` start a
44
+ * placeholder, quotes and backticks delimit tokens. Stored paths are
45
+ * percent-encoded URLs, so these never occur in practice; a row that has
46
+ * them fails the build rather than becoming a different rule.
47
+ */
48
+ const CADDY_UNSAFE = /[{}"`\\]/;
49
+
50
+ /**
51
+ * Render the plan as a Caddyfile snippet for a site block.
52
+ *
53
+ * @example
54
+ * emitCaddyRedirects({ redirects: [{ fromPath: "/old", matchPaths: ["/old", "/old/"],
55
+ * location: "/new/", statusCode: 301, preserveQuery: true }], gone: [], shadowedByBuild: [] })
56
+ * // map {path} {caelo_redirect_to} {caelo_redirect_status} {
57
+ * // /old /new/{?query} 301
58
+ * // /old/ /new/{?query} 301
59
+ * // }
60
+ * // @caelo_redirect_301 vars {caelo_redirect_status} 301
61
+ * // redir @caelo_redirect_301 {caelo_redirect_to} 301
62
+ */
63
+ export function emitCaddyRedirects(plan: RedirectPlan): string {
64
+ const lines = [
65
+ "# Auto-generated by Caelo static-generator from the redirects table. Do not edit.",
66
+ ];
67
+ if (plan.redirects.length === 0 && plan.gone.length === 0) return `${lines[0]}\n`;
68
+
69
+ lines.push(`map {path} {${PLACEHOLDER_TARGET}} {${PLACEHOLDER_STATUS}} {`);
70
+ for (const r of plan.redirects) {
71
+ assertCaddySafe(r.fromPath, r.location);
72
+ const target = r.preserveQuery ? `${r.location}{?query}` : r.location;
73
+ for (const path of r.matchPaths) lines.push(`\t${path} ${target} ${r.statusCode}`);
74
+ }
75
+ for (const g of plan.gone) {
76
+ assertCaddySafe(g.fromPath, "");
77
+ for (const path of g.matchPaths) lines.push(`\t${path} - 410`);
78
+ }
79
+ lines.push("}");
80
+
81
+ const statuses = [...new Set(plan.redirects.map((r) => r.statusCode))].sort();
82
+ for (const status of statuses) {
83
+ lines.push(`@caelo_redirect_${status} vars {${PLACEHOLDER_STATUS}} ${status}`);
84
+ lines.push(`redir @caelo_redirect_${status} {${PLACEHOLDER_TARGET}} ${status}`);
85
+ }
86
+ if (plan.gone.length > 0) {
87
+ // `handle` (not a bare `respond`): `respond` sorts after `handle`, so a
88
+ // catch-all `handle { file_server }` in the site block would win.
89
+ lines.push(`@caelo_gone vars {${PLACEHOLDER_STATUS}} 410`);
90
+ lines.push("handle @caelo_gone {", "\trespond 410", "}");
91
+ }
92
+ return `${lines.join("\n")}\n`;
93
+ }
94
+
95
+ function assertCaddySafe(fromPath: string, location: string): void {
96
+ for (const value of [fromPath, location]) {
97
+ if (CADDY_UNSAFE.test(value)) {
98
+ throw new Error(
99
+ `redirect-emit-caddy: the redirect for ${JSON.stringify(fromPath)} contains one of { } " \` \\ in ${JSON.stringify(value)}, which a Caddyfile cannot carry literally. Percent-encode the character in the stored path (e.g. '{' is '%7B') with bulk_create_redirects (upsert), then rebuild.`,
100
+ );
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,145 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The redirect plan for CloudFront (provider `aws`, #587).
5
+ *
6
+ * CloudFront has no static redirect rules; the edge-native way is a
7
+ * CloudFront Function on viewer-request reading a CloudFront KeyValueStore.
8
+ * The function is a sub-millisecond lookup that runs at the edge before the
9
+ * S3 origin (no Lambda, no origin round-trip), and the store is updated by
10
+ * the deploy without republishing the function. S3 website routing rules are
11
+ * not an option: they are capped at 50 and need the website endpoint, while
12
+ * the stack serves S3 through an origin access identity.
13
+ *
14
+ * One key per request path the plan answers (`/old`, `/old/`), value a small
15
+ * JSON document. Store limits (CloudFront quotas, fixed): 5 MB per store,
16
+ * keys up to 512 bytes, values up to 1 KB. A plan that does not fit is
17
+ * refused with {@link CloudFrontKvsLimitError}; nothing is written.
18
+ */
19
+
20
+ import type { RedirectPlan } from "./redirect-plan.js";
21
+
22
+ /** CloudFront KeyValueStore limits. */
23
+ export const CLOUDFRONT_KVS_MAX_TOTAL_BYTES = 5 * 1024 * 1024;
24
+ export const CLOUDFRONT_KVS_MAX_KEY_BYTES = 512;
25
+ export const CLOUDFRONT_KVS_MAX_VALUE_BYTES = 1024;
26
+
27
+ /** One KeyValueStore entry. */
28
+ export interface CloudFrontKvsEntry {
29
+ readonly key: string;
30
+ readonly value: string;
31
+ }
32
+
33
+ /**
34
+ * The value stored per path: `s` status, `l` Location, `q` 1 when the
35
+ * visitor's query string is appended. A 410 entry carries only `s`.
36
+ */
37
+ export interface CloudFrontRedirectValue {
38
+ readonly s: number;
39
+ readonly l?: string;
40
+ readonly q?: 1;
41
+ }
42
+
43
+ /** The plan does not fit a CloudFront KeyValueStore; nothing was written. */
44
+ export class CloudFrontKvsLimitError extends Error {
45
+ constructor(detail: string) {
46
+ super(
47
+ `redirect-emit-cloudfront: ${detail} Nothing was published and the live redirects are unchanged. Delete redirects nobody links to any more (find_redirects lists them, bulk_delete_redirects removes them), then publish again.`,
48
+ );
49
+ this.name = "CloudFrontKvsLimitError";
50
+ }
51
+ }
52
+
53
+ const utf8Length = (s: string): number => new TextEncoder().encode(s).length;
54
+
55
+ /**
56
+ * The KeyValueStore entries for a plan, sorted by key.
57
+ *
58
+ * @throws {CloudFrontKvsLimitError} when a key, a value or the whole set is
59
+ * over the store's limits.
60
+ */
61
+ export function cloudFrontKeyValueEntries(plan: RedirectPlan): CloudFrontKvsEntry[] {
62
+ const entries: CloudFrontKvsEntry[] = [];
63
+ for (const r of plan.redirects) {
64
+ const value: CloudFrontRedirectValue = {
65
+ s: r.statusCode,
66
+ l: r.location,
67
+ ...(r.preserveQuery ? { q: 1 as const } : {}),
68
+ };
69
+ for (const key of r.matchPaths) entries.push({ key, value: JSON.stringify(value) });
70
+ }
71
+ for (const g of plan.gone) {
72
+ for (const key of g.matchPaths) entries.push({ key, value: JSON.stringify({ s: 410 }) });
73
+ }
74
+ entries.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
75
+
76
+ let total = 0;
77
+ for (const e of entries) {
78
+ if (utf8Length(e.key) > CLOUDFRONT_KVS_MAX_KEY_BYTES) {
79
+ throw new CloudFrontKvsLimitError(
80
+ `the redirect path ${JSON.stringify(e.key)} is longer than the ${CLOUDFRONT_KVS_MAX_KEY_BYTES}-byte key limit.`,
81
+ );
82
+ }
83
+ if (utf8Length(e.value) > CLOUDFRONT_KVS_MAX_VALUE_BYTES) {
84
+ throw new CloudFrontKvsLimitError(
85
+ `the redirect target for ${JSON.stringify(e.key)} is longer than the ${CLOUDFRONT_KVS_MAX_VALUE_BYTES}-byte value limit.`,
86
+ );
87
+ }
88
+ total += utf8Length(e.key) + utf8Length(e.value);
89
+ }
90
+ if (total > CLOUDFRONT_KVS_MAX_TOTAL_BYTES) {
91
+ throw new CloudFrontKvsLimitError(
92
+ `the ${entries.length} redirect path(s) need ${total} bytes; a store holds ${CLOUDFRONT_KVS_MAX_TOTAL_BYTES}.`,
93
+ );
94
+ }
95
+ return entries;
96
+ }
97
+
98
+ /**
99
+ * Source of the viewer-request CloudFront Function (runtime
100
+ * `cloudfront-js-2.0`) that answers the store's entries. A path without an
101
+ * entry passes through to the origin unchanged.
102
+ *
103
+ * @param kvsId The KeyValueStore's id (the last segment of its ARN).
104
+ */
105
+ export function cloudFrontRedirectFunctionCode(kvsId: string): string {
106
+ if (!/^[A-Za-z0-9-]+$/.test(kvsId)) {
107
+ throw new Error(`redirect-emit-cloudfront: ${JSON.stringify(kvsId)} is not a KeyValueStore id`);
108
+ }
109
+ return `import cf from 'cloudfront';
110
+
111
+ // Generated by @caelo-cms/shared (redirect-emit-cloudfront.ts). Answers the
112
+ // redirects table from the KeyValueStore the deploy writes (#587).
113
+ const kvs = cf.kvs('${kvsId}');
114
+ const REASON = {
115
+ 301: 'Moved Permanently',
116
+ 302: 'Found',
117
+ 307: 'Temporary Redirect',
118
+ 308: 'Permanent Redirect',
119
+ };
120
+
121
+ async function handler(event) {
122
+ const request = event.request;
123
+ let hit;
124
+ try {
125
+ hit = await kvs.get(request.uri, { format: 'json' });
126
+ } catch (e) {
127
+ // kvs.get throws for a key that is not in the store: no redirect.
128
+ return request;
129
+ }
130
+ if (hit.s === 410) {
131
+ return { statusCode: 410, statusDescription: 'Gone' };
132
+ }
133
+ let location = hit.l;
134
+ if (hit.q === 1) {
135
+ const query = request.rawQueryString();
136
+ if (query) location += '?' + query;
137
+ }
138
+ return {
139
+ statusCode: hit.s,
140
+ statusDescription: REASON[hit.s],
141
+ headers: { location: { value: location } },
142
+ };
143
+ }
144
+ `;
145
+ }
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The redirect plan as Azure Front Door (Standard) rules-engine rules
5
+ * (provider `azure`, #587).
6
+ *
7
+ * Front Door's rules engine answers a `UrlRedirect` action at the edge,
8
+ * before the Blob static-website origin, so no compute sits on the request
9
+ * path. Each planned redirect is one rule: a `UrlPath` condition (Front Door
10
+ * matches the path WITHOUT its leading slash) and a `UrlRedirect` action.
11
+ *
12
+ * Limit (Front Door Standard and Premium, fixed): 100 rules per rule set and
13
+ * per route. A plan with more redirects is refused with
14
+ * {@link FrontDoorRuleLimitError} before anything is written — never a
15
+ * partial set. Front Door cannot answer a bare 410, so `gone` entries get
16
+ * the origin's 404. Writing the rules at deploy time is tracked in #750.
17
+ */
18
+
19
+ import type { PlannedRedirect, RedirectPlan, RedirectStatusCode } from "./redirect-plan.js";
20
+
21
+ /** Rules per rule set / per route on Front Door Standard and Premium. */
22
+ export const FRONT_DOOR_MAX_RULES = 100;
23
+
24
+ type FrontDoorRedirectType = "Moved" | "Found" | "TemporaryRedirect" | "PermanentRedirect";
25
+
26
+ const REDIRECT_TYPE: Record<RedirectStatusCode, FrontDoorRedirectType> = {
27
+ 301: "Moved",
28
+ 302: "Found",
29
+ 307: "TemporaryRedirect",
30
+ 308: "PermanentRedirect",
31
+ };
32
+
33
+ /** One rule, in the shape of `azure-native` `cdn.Rule` arguments (ARM). */
34
+ export interface FrontDoorRedirectRule {
35
+ readonly ruleName: string;
36
+ readonly order: number;
37
+ readonly matchProcessingBehavior: "Stop";
38
+ readonly conditions: readonly [
39
+ {
40
+ readonly name: "UrlPath";
41
+ readonly parameters: {
42
+ readonly typeName: "DeliveryRuleUrlPathMatchConditionParameters";
43
+ readonly operator: "Equal";
44
+ readonly negateCondition: false;
45
+ readonly matchValues: readonly string[];
46
+ readonly transforms: readonly [];
47
+ };
48
+ },
49
+ ];
50
+ readonly actions: readonly [
51
+ {
52
+ readonly name: "UrlRedirect";
53
+ readonly parameters: {
54
+ readonly typeName: "DeliveryRuleUrlRedirectActionParameters";
55
+ readonly redirectType: FrontDoorRedirectType;
56
+ readonly destinationProtocol: "MatchRequest";
57
+ readonly customPath: string;
58
+ readonly customQueryString?: string;
59
+ readonly customFragment?: string;
60
+ };
61
+ },
62
+ ];
63
+ }
64
+
65
+ /** The plan has more redirects than a Front Door route can hold. */
66
+ export class FrontDoorRuleLimitError extends Error {
67
+ constructor(readonly redirectCount: number) {
68
+ super(
69
+ `Azure Front Door serves at most ${FRONT_DOOR_MAX_RULES} redirect rules per site (a fixed rules-engine limit); this site has ${redirectCount}. Nothing was published and the live redirects are unchanged. Delete redirects nobody links to any more (find_redirects lists them, bulk_delete_redirects removes them), then publish again. Larger sets on Azure are tracked in https://github.com/caelo-cms/caelo-cms/issues/750.`,
70
+ );
71
+ this.name = "FrontDoorRuleLimitError";
72
+ }
73
+ }
74
+
75
+ /**
76
+ * One rule per planned redirect, ordered as the plan.
77
+ *
78
+ * @throws {FrontDoorRuleLimitError} over {@link FRONT_DOOR_MAX_RULES}.
79
+ */
80
+ export function frontDoorRedirectRules(plan: RedirectPlan): FrontDoorRedirectRule[] {
81
+ if (plan.redirects.length > FRONT_DOOR_MAX_RULES) {
82
+ throw new FrontDoorRuleLimitError(plan.redirects.length);
83
+ }
84
+ return plan.redirects.map((r, i) => toRule(r, i + 1));
85
+ }
86
+
87
+ function toRule(r: PlannedRedirect, order: number): FrontDoorRedirectRule {
88
+ const { path, query, fragment } = splitLocation(r.location);
89
+ return {
90
+ // Rule names: letters and digits only, starting with a letter.
91
+ ruleName: `caeloRedirect${order}`,
92
+ order,
93
+ matchProcessingBehavior: "Stop",
94
+ conditions: [
95
+ {
96
+ name: "UrlPath",
97
+ parameters: {
98
+ typeName: "DeliveryRuleUrlPathMatchConditionParameters",
99
+ operator: "Equal",
100
+ negateCondition: false,
101
+ matchValues: r.matchPaths.map((p) => p.slice(1)),
102
+ transforms: [],
103
+ },
104
+ },
105
+ ],
106
+ actions: [
107
+ {
108
+ name: "UrlRedirect",
109
+ parameters: {
110
+ typeName: "DeliveryRuleUrlRedirectActionParameters",
111
+ redirectType: REDIRECT_TYPE[r.statusCode],
112
+ destinationProtocol: "MatchRequest",
113
+ customPath: path,
114
+ // Without customQueryString Front Door keeps the visitor's query,
115
+ // which is the plan's preserveQuery semantics.
116
+ ...(query !== null ? { customQueryString: query } : {}),
117
+ ...(fragment !== null ? { customFragment: fragment } : {}),
118
+ },
119
+ },
120
+ ],
121
+ };
122
+ }
123
+
124
+ function splitLocation(location: string): {
125
+ path: string;
126
+ query: string | null;
127
+ fragment: string | null;
128
+ } {
129
+ const hashAt = location.indexOf("#");
130
+ const beforeHash = hashAt === -1 ? location : location.slice(0, hashAt);
131
+ const fragment = hashAt === -1 ? null : location.slice(hashAt + 1);
132
+ const queryAt = beforeHash.indexOf("?");
133
+ return {
134
+ path: queryAt === -1 ? beforeHash : beforeHash.slice(0, queryAt),
135
+ // A target with only a fragment still replaces the visitor's query, as
136
+ // on every other provider (preserveQuery is false for it).
137
+ query: queryAt === -1 ? (fragment === null ? null : "") : beforeHash.slice(queryAt + 1),
138
+ fragment,
139
+ };
140
+ }
@@ -0,0 +1,165 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The redirect plan as GCP URL-map path rules (provider `gcp`: GCS backend
5
+ * bucket behind the global external Application Load Balancer, #587).
6
+ *
7
+ * Why URL-map rules: the load balancer answers a `urlRedirect` path rule
8
+ * itself, at the edge, before the backend bucket is asked — no compute on
9
+ * the static request path (CLAUDE.md §11.B Tier 1). The alternatives do not
10
+ * exist on this stack: GCS has no per-object redirects, and the URL map's
11
+ * custom error responses can only point at a backend bucket, so a bucket
12
+ * 404 cannot be handed to the gateway for a `redirects.lookup`.
13
+ *
14
+ * Limits (https://cloud.google.com/load-balancing/docs/quotas, "URL maps",
15
+ * fixed — they cannot be raised): per path matcher at most 1,000 path
16
+ * rules and 1,000 predicates, where every path of a path rule is one
17
+ * predicate. Every host has exactly one path matcher, so the site host's
18
+ * redirects share that budget with its own routing rules (`/api/*`). A plan
19
+ * that does not fit is refused with {@link GcpUrlMapBudgetError} BEFORE
20
+ * anything changes — the load balancer keeps the previous rules, and the
21
+ * error names the counts and the ways out. Dropping redirects silently
22
+ * would turn linked URLs into 404s without anyone noticing. Going past the
23
+ * quota is tracked in #751.
24
+ *
25
+ * Rules this module owns are recognised by their `urlRedirect` (the stack's
26
+ * own rules route to a service), so re-applying a plan replaces exactly the
27
+ * previous plan's rules and keeps everything else in the path matcher.
28
+ */
29
+
30
+ import type { PlannedRedirect, RedirectPlan, RedirectStatusCode } from "./redirect-plan.js";
31
+
32
+ /** Path rules per path matcher (URL map quota, global external ALB). */
33
+ export const GCP_URL_MAP_MAX_PATH_RULES = 1000;
34
+ /** Predicates (paths across all path rules) per path matcher. */
35
+ export const GCP_URL_MAP_MAX_PREDICATES = 1000;
36
+
37
+ /** `urlRedirect.redirectResponseCode` per stored status. */
38
+ const RESPONSE_CODE: Record<RedirectStatusCode, GcpRedirectResponseCode> = {
39
+ 301: "MOVED_PERMANENTLY_DEFAULT",
40
+ 302: "FOUND",
41
+ 307: "TEMPORARY_REDIRECT",
42
+ 308: "PERMANENT_REDIRECT",
43
+ };
44
+
45
+ export type GcpRedirectResponseCode =
46
+ | "MOVED_PERMANENTLY_DEFAULT"
47
+ | "FOUND"
48
+ | "SEE_OTHER"
49
+ | "TEMPORARY_REDIRECT"
50
+ | "PERMANENT_REDIRECT";
51
+
52
+ /** A path rule of a URL map path matcher (Compute REST shape). */
53
+ export interface GcpPathRule {
54
+ readonly paths: readonly string[];
55
+ readonly service?: string;
56
+ readonly urlRedirect?: {
57
+ readonly pathRedirect?: string;
58
+ readonly redirectResponseCode?: GcpRedirectResponseCode;
59
+ readonly stripQuery?: boolean;
60
+ readonly [key: string]: unknown;
61
+ };
62
+ readonly [key: string]: unknown;
63
+ }
64
+
65
+ /** A URL map path matcher (Compute REST shape; other fields kept as-is). */
66
+ export interface GcpPathMatcher {
67
+ readonly name: string;
68
+ readonly pathRules?: readonly GcpPathRule[];
69
+ readonly routeRules?: readonly unknown[];
70
+ readonly [key: string]: unknown;
71
+ }
72
+
73
+ /** The plan does not fit the path matcher's quota; nothing was changed. */
74
+ export class GcpUrlMapBudgetError extends Error {
75
+ constructor(
76
+ readonly redirectCount: number,
77
+ readonly predicatesNeeded: number,
78
+ readonly rulesNeeded: number,
79
+ ) {
80
+ super(
81
+ `The GCP load balancer serves at most ${GCP_URL_MAP_MAX_PREDICATES} redirect paths and ${GCP_URL_MAP_MAX_PATH_RULES} rules per site (a fixed URL-map quota); this site's ${redirectCount} redirect(s) need ${predicatesNeeded} paths and ${rulesNeeded} rules, counting the site's own routing. Nothing was published and the live redirects are unchanged. Next step: delete redirects nobody links to any more (find_redirects lists them, bulk_delete_redirects removes them) — every redirect whose old URL ends in '/' or not costs two paths, a file-like one ('/old.html') costs one — then publish again. Serving larger redirect sets on this provider is tracked in https://github.com/caelo-cms/caelo-cms/issues/751.`,
82
+ );
83
+ this.name = "GcpUrlMapBudgetError";
84
+ }
85
+ }
86
+
87
+ /**
88
+ * One `urlRedirect` path rule per planned redirect. 410 entries have no
89
+ * URL-map equivalent and answer the bucket's 404.
90
+ *
91
+ * @throws when a planned redirect cannot be expressed as a URL-map rule
92
+ * (a `*` in the from path would turn it into a wildcard; a target
93
+ * with a query or fragment cannot be a `pathRedirect`).
94
+ */
95
+ export function gcpRedirectPathRules(plan: RedirectPlan): GcpPathRule[] {
96
+ return plan.redirects.map(toPathRule);
97
+ }
98
+
99
+ function toPathRule(r: PlannedRedirect): GcpPathRule {
100
+ if (r.matchPaths.some((p) => p.includes("*"))) {
101
+ throw new Error(
102
+ `redirect-emit-gcp: the redirect for ${JSON.stringify(r.fromPath)} contains '*', which a GCP URL map reads as a wildcard. Percent-encode it ('%2A') with bulk_create_redirects (upsert), then publish again.`,
103
+ );
104
+ }
105
+ if (!r.preserveQuery) {
106
+ throw new Error(
107
+ `redirect-emit-gcp: the redirect ${r.fromPath} → ${r.location} targets a URL with a query string or fragment, which a GCP URL-map redirect cannot carry. Point it at the page's path with bulk_create_redirects (upsert), then publish again.`,
108
+ );
109
+ }
110
+ return {
111
+ paths: [...r.matchPaths],
112
+ urlRedirect: {
113
+ pathRedirect: r.location,
114
+ redirectResponseCode: RESPONSE_CODE[r.statusCode],
115
+ // The visitor's query string rides along, like on every provider.
116
+ stripQuery: false,
117
+ },
118
+ };
119
+ }
120
+
121
+ /** Whether a path rule is one a redirect plan wrote (see module comment). */
122
+ export function isGcpRedirectRule(rule: GcpPathRule): boolean {
123
+ return rule.urlRedirect !== undefined && rule.service === undefined;
124
+ }
125
+
126
+ /**
127
+ * The path matcher with its redirect rules replaced by the plan's. Rules
128
+ * that are not redirects stay first, in their order — `/api/*` keeps
129
+ * routing to the gateway. Path rules match the longest path, so the order
130
+ * between them does not change routing.
131
+ *
132
+ * @throws {GcpUrlMapBudgetError} when the result exceeds the quota.
133
+ */
134
+ export function withGcpRedirects(matcher: GcpPathMatcher, plan: RedirectPlan): GcpPathMatcher {
135
+ if (matcher.routeRules && matcher.routeRules.length > 0) {
136
+ throw new Error(
137
+ `redirect-emit-gcp: path matcher ${JSON.stringify(matcher.name)} uses routeRules; a path matcher holds either pathRules or routeRules, and Caelo's stack writes pathRules. The URL map was changed outside the stack — restore it with the installer, then publish again.`,
138
+ );
139
+ }
140
+ const kept = (matcher.pathRules ?? []).filter((r) => !isGcpRedirectRule(r));
141
+ const redirects = gcpRedirectPathRules(plan);
142
+ const pathRules = [...kept, ...redirects];
143
+ const predicates = pathRules.reduce((n, r) => n + r.paths.length, 0);
144
+ if (predicates > GCP_URL_MAP_MAX_PREDICATES || pathRules.length > GCP_URL_MAP_MAX_PATH_RULES) {
145
+ throw new GcpUrlMapBudgetError(redirects.length, predicates, pathRules.length);
146
+ }
147
+ return { ...matcher, pathRules };
148
+ }
149
+
150
+ /**
151
+ * Check a plan against the budget a path matcher with `baseRules` leaves,
152
+ * without a URL map at hand (Stage runs it so an over-quota redirect set
153
+ * is reported before Confirm-publish).
154
+ *
155
+ * @throws {GcpUrlMapBudgetError} when the plan does not fit.
156
+ */
157
+ export function assertGcpRedirectBudget(
158
+ plan: RedirectPlan,
159
+ baseRules: readonly GcpPathRule[],
160
+ ): void {
161
+ withGcpRedirects({ name: "budget-check", pathRules: baseRules }, plan);
162
+ }
163
+
164
+ /** The site's own routing in the `public` path matcher (stack + budget). */
165
+ export const GCP_PUBLIC_BASE_PATHS: readonly string[] = ["/api/*"];