@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.
- package/dist/ai-tools.d.ts +4 -3
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +5 -4
- package/dist/ai-tools.js.map +1 -1
- package/dist/auto-update.d.ts +124 -0
- package/dist/auto-update.d.ts.map +1 -0
- package/dist/auto-update.js +228 -0
- package/dist/auto-update.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/platform-updates.d.ts +13 -7
- package/dist/platform-updates.d.ts.map +1 -1
- package/dist/platform-updates.js +24 -8
- package/dist/platform-updates.js.map +1 -1
- package/dist/redirect-emit-caddy.d.ts +44 -0
- package/dist/redirect-emit-caddy.d.ts.map +1 -0
- package/dist/redirect-emit-caddy.js +76 -0
- package/dist/redirect-emit-caddy.js.map +1 -0
- package/dist/redirect-emit-cloudfront.d.ts +55 -0
- package/dist/redirect-emit-cloudfront.d.ts.map +1 -0
- package/dist/redirect-emit-cloudfront.js +99 -0
- package/dist/redirect-emit-cloudfront.js.map +1 -0
- package/dist/redirect-emit-front-door.d.ts +63 -0
- package/dist/redirect-emit-front-door.d.ts.map +1 -0
- package/dist/redirect-emit-front-door.js +79 -0
- package/dist/redirect-emit-front-door.js.map +1 -0
- package/dist/redirect-emit-gcp.d.ts +89 -0
- package/dist/redirect-emit-gcp.d.ts.map +1 -0
- package/dist/redirect-emit-gcp.js +91 -0
- package/dist/redirect-emit-gcp.js.map +1 -0
- package/dist/redirect-plan.d.ts +69 -0
- package/dist/redirect-plan.d.ts.map +1 -0
- package/dist/redirect-plan.js +218 -0
- package/dist/redirect-plan.js.map +1 -0
- package/dist/release-manifest.d.ts +99 -0
- package/dist/release-manifest.d.ts.map +1 -0
- package/dist/release-manifest.js +161 -0
- package/dist/release-manifest.js.map +1 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +4 -1
- package/dist/seo.js.map +1 -1
- package/dist/static-cache-policy.d.ts +7 -4
- package/dist/static-cache-policy.d.ts.map +1 -1
- package/dist/static-cache-policy.js +7 -4
- package/dist/static-cache-policy.js.map +1 -1
- package/dist/subagents.d.ts +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +5 -4
- package/src/auto-update.test.ts +321 -0
- package/src/auto-update.ts +315 -0
- package/src/index.ts +7 -0
- package/src/platform-updates.test.ts +5 -0
- package/src/platform-updates.ts +31 -7
- package/src/redirect-emit-caddy.ts +103 -0
- package/src/redirect-emit-cloudfront.ts +145 -0
- package/src/redirect-emit-front-door.ts +140 -0
- package/src/redirect-emit-gcp.ts +165 -0
- package/src/redirect-emit.test.ts +291 -0
- package/src/redirect-plan.test.ts +143 -0
- package/src/redirect-plan.ts +298 -0
- package/src/release-manifest.ts +212 -0
- package/src/seo.ts +4 -1
- package/src/static-cache-policy.test.ts +6 -2
- package/src/static-cache-policy.ts +7 -4
- package/src/version.ts +1 -1
package/src/platform-updates.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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 =
|
|
295
|
+
export const STEP_UP_MAX_AGE_SECONDS = 60;
|
|
272
296
|
|
|
273
|
-
/** What a step-up is for (`
|
|
274
|
-
export const STEP_UP_PURPOSES = ["platform_update", "update_policy"
|
|
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/*"];
|