@tokenoftrust/storefront-runner 2.4.25 → 2.4.26
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/apps/storefront/src/lib/publication/mountedDiscovery.ts +6 -1
- package/apps/storefront/src/lib/publication/runtimeSource.ts +12 -2
- package/apps/storefront/src/lib/publication/servingMode.ts +52 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +7 -2
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/publication-recovery.ts +132 -0
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
PUBLICATION_DISCOVERY_ENTRY_KINDS_V1,
|
|
29
29
|
type PublicationDiscoveryEntryKindV1,
|
|
30
30
|
} from "@tot/public-runtime";
|
|
31
|
+
import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
|
|
31
32
|
import { rawPageCanonicalUrl } from "../rawMarketingHtml.js";
|
|
32
33
|
|
|
33
34
|
export type DiscoverySurfaceV1 = "sitemap" | "feeds" | "publicSearch" | "llms";
|
|
@@ -135,14 +136,18 @@ export async function readMountedPublicationDiscoveryV1(input: {
|
|
|
135
136
|
if (mounts.size === 0 || !input.store) return [];
|
|
136
137
|
let projectionJson: string | null;
|
|
137
138
|
let stateJson: string | null;
|
|
139
|
+
let recoveryJson: string | null;
|
|
138
140
|
try {
|
|
139
|
-
[projectionJson, stateJson] = await Promise.all([
|
|
141
|
+
[projectionJson, stateJson, recoveryJson] = await Promise.all([
|
|
140
142
|
input.store.get(publicationMountedDiscoveryKeyV1(input.tenant.tenant_id)),
|
|
141
143
|
input.store.get(publicationVersionStateKey(input.tenant.tenant_id)),
|
|
144
|
+
input.store.get(publicationRecoveryKeyV1(input.tenant.tenant_id)),
|
|
142
145
|
]);
|
|
143
146
|
} catch {
|
|
144
147
|
return [];
|
|
145
148
|
}
|
|
149
|
+
// Under a whole-site recovery the mounts serve the whole-site release, which lists itself.
|
|
150
|
+
if (publicationRecoveryActiveV1(recoveryJson)) return [];
|
|
146
151
|
if (!projectionJson || !stateJson || projectionJson.length > MAX_PROJECTION_CHARS) return [];
|
|
147
152
|
const state = parsePublicationVersionStateV1Json(stateJson);
|
|
148
153
|
if (!state.ok) return [];
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type PublicationRoutePlanV1,
|
|
13
13
|
type RouteTierIndexV1,
|
|
14
14
|
} from "@tot/public-runtime";
|
|
15
|
+
import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
|
|
15
16
|
|
|
16
17
|
export interface PublicationStateStoreV1 {
|
|
17
18
|
get(key: string): Promise<string | null>;
|
|
@@ -110,7 +111,9 @@ export type PublicationRuntimeSourceV1 =
|
|
|
110
111
|
* Resolve one live request from the public KV state projection into its exact
|
|
111
112
|
* immutable artifact. A carried plan is checked against current route authority
|
|
112
113
|
* before state or object-store I/O. Only an unselected root keeps the legacy
|
|
113
|
-
* adapter; a mounted space never falls through to root content
|
|
114
|
+
* adapter; a mounted space never falls through to root content — except under a
|
|
115
|
+
* whole-site recovery (`publicationRecoveryKeyV1`), when every public space serves
|
|
116
|
+
* the tenant's whole-site release on `live`, exactly as before its spaces went live.
|
|
114
117
|
*/
|
|
115
118
|
export async function resolvePublicationRuntimeSourceV1(input: {
|
|
116
119
|
index: RouteTierIndexV1;
|
|
@@ -132,11 +135,18 @@ export async function resolvePublicationRuntimeSourceV1(input: {
|
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
let stateJson: string | null;
|
|
138
|
+
let recoveryJson: string | null = null;
|
|
135
139
|
try {
|
|
136
|
-
stateJson = await
|
|
140
|
+
[stateJson, recoveryJson] = await Promise.all([
|
|
141
|
+
input.store.get(publicationVersionStateKey(input.plan.manifest.tenant)),
|
|
142
|
+
input.channel === "live" && input.plan.manifest.visibility === "public"
|
|
143
|
+
? input.store.get(publicationRecoveryKeyV1(input.plan.manifest.tenant))
|
|
144
|
+
: Promise.resolve(null),
|
|
145
|
+
]);
|
|
137
146
|
} catch {
|
|
138
147
|
return { status: "unavailable", code: "publication_artifact_source_unavailable" };
|
|
139
148
|
}
|
|
149
|
+
if (publicationRecoveryActiveV1(recoveryJson)) return { status: "legacy-root" };
|
|
140
150
|
if (stateJson === null) {
|
|
141
151
|
return isRoot
|
|
142
152
|
? { status: "legacy-root" }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a tenant's ROOT is served by a publication space, which decides what a whole-site
|
|
3
|
+
* promotion (ship, rollback, hotfix, re-publish, platform reconcile) can do for it.
|
|
4
|
+
*
|
|
5
|
+
* While the root public space is live and no whole-site recovery holds, a whole-site promotion
|
|
6
|
+
* cannot reach visitors: on Cloudflare the selected root space shadows the whole-site live channel
|
|
7
|
+
* (the job would report success while visitors kept the space), and on CloudFront the aggregate
|
|
8
|
+
* pointer refuses a whole-site publish after minutes of rendering. So it refuses up front with
|
|
9
|
+
* `publication_root_is_space`, naming the root space and how its content goes live instead.
|
|
10
|
+
*
|
|
11
|
+
* Behaviour follows the declared topology and the editorial state, never the tenant id.
|
|
12
|
+
*/
|
|
13
|
+
import type { PublicationVersionStateV1, TenantConfig } from "@tot/public-runtime";
|
|
14
|
+
import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
|
|
15
|
+
|
|
16
|
+
export const PUBLICATION_ROOT_IS_SPACE = "publication_root_is_space";
|
|
17
|
+
|
|
18
|
+
export type PublicationServingModeV1 =
|
|
19
|
+
| { mode: "whole-site"; reason: "no-root-space" | "root-not-live" | "recovered" }
|
|
20
|
+
| { mode: "spaces"; root: { spaceId: string; versionId: string; revision: number; source: string } };
|
|
21
|
+
|
|
22
|
+
export async function publicationServingModeV1(input: {
|
|
23
|
+
tenant: Pick<TenantConfig, "tenant_id" | "publicationSpaces">;
|
|
24
|
+
readState(): Promise<PublicationVersionStateV1>;
|
|
25
|
+
kvGet(key: string): Promise<string | null>;
|
|
26
|
+
}): Promise<PublicationServingModeV1> {
|
|
27
|
+
const root = input.tenant.publicationSpaces?.find((space) => space.tenant === input.tenant.tenant_id &&
|
|
28
|
+
space.visibility === "public" && space.routes.canonicalMount === "/");
|
|
29
|
+
if (!root) return { mode: "whole-site", reason: "no-root-space" };
|
|
30
|
+
if (publicationRecoveryActiveV1(await input.kvGet(publicationRecoveryKeyV1(input.tenant.tenant_id)))) {
|
|
31
|
+
return { mode: "whole-site", reason: "recovered" };
|
|
32
|
+
}
|
|
33
|
+
const pointer = (await input.readState()).pointers.find((candidate) =>
|
|
34
|
+
candidate.tenant === input.tenant.tenant_id && candidate.spaceId === root.id && candidate.channel === "live");
|
|
35
|
+
if (!pointer) return { mode: "whole-site", reason: "root-not-live" };
|
|
36
|
+
return { mode: "spaces", root: { spaceId: root.id, versionId: pointer.versionId, revision: pointer.revision,
|
|
37
|
+
source: `${root.source.forge}:${root.source.repo}` } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The refusal a whole-site promotion gets while the root is a live publication space. */
|
|
41
|
+
export function publicationRootIsSpaceRefusalV1(
|
|
42
|
+
tenantId: string,
|
|
43
|
+
mode: Extract<PublicationServingModeV1, { mode: "spaces" }>,
|
|
44
|
+
): string {
|
|
45
|
+
const { spaceId, versionId, revision, source } = mode.root;
|
|
46
|
+
return `${PUBLICATION_ROOT_IS_SPACE}: ${tenantId}'s root is served by publication space "${spaceId}" ` +
|
|
47
|
+
`(source ${source}, live ${versionId} at revision ${revision}), so a whole-site release would never reach visitors. ` +
|
|
48
|
+
`Put root content live by activating a root-space version: build it ` +
|
|
49
|
+
`(scripts/publish/build-publication-space-version.ts --tenant ${tenantId} --space ${spaceId} --source-sha <sha>), then ` +
|
|
50
|
+
`POST /api/publication-spaces/${spaceId}/review and /api/publication-spaces/${spaceId}/activate. ` +
|
|
51
|
+
`To serve the whole site instead, recover it first (scripts/publish/recover-publication-tenant.mjs recover).`;
|
|
52
|
+
}
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type R2BucketLike,
|
|
42
42
|
type TenantConfig,
|
|
43
43
|
} from "@tot/public-runtime";
|
|
44
|
+
import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
|
|
44
45
|
import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
|
|
45
46
|
import { readR2, readKv, readEnv } from "@/lib/env";
|
|
46
47
|
import {
|
|
@@ -95,8 +96,12 @@ async function tryServePublicationRootAsset(input: {
|
|
|
95
96
|
const tenant = input.tenant;
|
|
96
97
|
if (!tenant || tenant.tenant_id !== input.tenantId ||
|
|
97
98
|
!tenant.publicationSpaces?.some((space) => space.routes.canonicalMount === "/")) return null;
|
|
98
|
-
const stateJson = await
|
|
99
|
-
|
|
99
|
+
const [stateJson, recoveryJson] = await Promise.all([
|
|
100
|
+
input.kvGet(publicationVersionStateKey(input.tenantId)),
|
|
101
|
+
input.channel === "live" ? input.kvGet(publicationRecoveryKeyV1(input.tenantId)) : Promise.resolve(null),
|
|
102
|
+
]);
|
|
103
|
+
// A whole-site recovery serves the whole-site release's assets, through the legacy pointer.
|
|
104
|
+
if (stateJson === null || publicationRecoveryActiveV1(recoveryJson)) return null;
|
|
100
105
|
const state = parsePublicationVersionStateV1Json(stateJson);
|
|
101
106
|
if (!state.ok) return null;
|
|
102
107
|
const read = await readPublicationRootAssetV1({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.26",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./embed-catalog": "./src/embed-catalog.mjs",
|
|
22
22
|
"./retention-holds": "./src/retention-holds.ts",
|
|
23
23
|
"./publication-content-redirects": "./src/publication-content-redirects.ts",
|
|
24
|
+
"./publication-recovery": "./src/publication-recovery.ts",
|
|
24
25
|
"./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
|
|
25
26
|
"./declared-tenant-config": "./src/declared-tenant-config.mjs",
|
|
26
27
|
"./compliance-coherence": "./src/compliance-coherence.mjs",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A whole-site recovery: a tenant served by its public publication spaces is put back on a
|
|
3
|
+
* whole-site release, and later resumed onto its spaces, each in one reviewed action
|
|
4
|
+
* (scripts/publish/recover-publication-tenant.mjs).
|
|
5
|
+
*
|
|
6
|
+
* The Worker holds the record under `publicationRecoveryKeyV1(tenant)`. While it is present every
|
|
7
|
+
* public space of the tenant serves through the whole-site path instead (`legacy-root`), whatever
|
|
8
|
+
* its live pointer selects; the editorial state is untouched, so a resume serves exactly what was
|
|
9
|
+
* live before plus anything activated during the recovery. On CloudFront the same fence rides in
|
|
10
|
+
* the KVS `live` value (scripts/publish/lib/cloudfront-live-pointer.mjs).
|
|
11
|
+
*
|
|
12
|
+
* Every recover and resume also writes an immutable audit record under
|
|
13
|
+
* `publicationRecoveryAuditKeyV1`, which is never deleted.
|
|
14
|
+
*/
|
|
15
|
+
import type { PublicationSpaceManifestV1 } from "./publication-space.js";
|
|
16
|
+
import type { PublicationVersionStateV1 } from "./publication-version.js";
|
|
17
|
+
|
|
18
|
+
export const PUBLICATION_RECOVERY_ID_RE = /^rcv_[a-z0-9][a-z0-9-]{6,78}[a-z0-9]$/;
|
|
19
|
+
const SHA40_RE = /^[0-9a-f]{40}$/;
|
|
20
|
+
const DIGEST_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
21
|
+
const PRINCIPAL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/;
|
|
22
|
+
const INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
23
|
+
|
|
24
|
+
/** One public space as it was serving when the recovery replaced it. */
|
|
25
|
+
export interface PublicationRecoverySpaceV1 {
|
|
26
|
+
spaceId: string;
|
|
27
|
+
revision: number;
|
|
28
|
+
versionId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PublicationRecoveryV1 {
|
|
32
|
+
schemaVersion: 1;
|
|
33
|
+
tenant: string;
|
|
34
|
+
recoveryId: string;
|
|
35
|
+
/**
|
|
36
|
+
* The whole-site release now served: its version (a tenant commit sha) and bundle digest, each
|
|
37
|
+
* null when the tenant has none on that plane (a tenant whose whole site is its committed
|
|
38
|
+
* baseline has neither).
|
|
39
|
+
*/
|
|
40
|
+
wholeSite: { versionId: string | null; digest: string | null };
|
|
41
|
+
/** The public spaces' live pointers it replaced, and their generation (the sum of revisions). */
|
|
42
|
+
fromSpaces: { generation: number; spaces: PublicationRecoverySpaceV1[] };
|
|
43
|
+
principal: string;
|
|
44
|
+
at: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type PublicationRecoveryEventV1 = "recover" | "resume";
|
|
48
|
+
|
|
49
|
+
export function publicationRecoveryKeyV1(tenant: string): string {
|
|
50
|
+
return `cust:publication-recovery:${tenant}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function publicationRecoveryAuditKeyV1(tenant: string, recoveryId: string, event: PublicationRecoveryEventV1): string {
|
|
54
|
+
if (!PUBLICATION_RECOVERY_ID_RE.test(recoveryId)) throw new Error("publication recovery id is malformed");
|
|
55
|
+
return `cust:publication-recovery-audit:${tenant}:${recoveryId}:${event}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function exactKeys(value: object, keys: readonly string[]): boolean {
|
|
59
|
+
const actual = Object.keys(value).sort();
|
|
60
|
+
const expected = [...keys].sort();
|
|
61
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nullable(value: unknown, re: RegExp): value is string | null {
|
|
65
|
+
return value === null || (typeof value === "string" && re.test(value));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function validatePublicationRecoveryV1(value: unknown): value is PublicationRecoveryV1 {
|
|
69
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
70
|
+
const record = value as Record<string, unknown>;
|
|
71
|
+
if (!exactKeys(record, ["schemaVersion", "tenant", "recoveryId", "wholeSite", "fromSpaces", "principal", "at"])) return false;
|
|
72
|
+
const wholeSite = record.wholeSite as Record<string, unknown> | null;
|
|
73
|
+
const fromSpaces = record.fromSpaces as Record<string, unknown> | null;
|
|
74
|
+
if (record.schemaVersion !== 1 || typeof record.tenant !== "string" || !record.tenant ||
|
|
75
|
+
typeof record.recoveryId !== "string" || !PUBLICATION_RECOVERY_ID_RE.test(record.recoveryId) ||
|
|
76
|
+
typeof record.principal !== "string" || !PRINCIPAL_RE.test(record.principal) ||
|
|
77
|
+
typeof record.at !== "string" || !INSTANT_RE.test(record.at) || new Date(record.at).toISOString() !== record.at ||
|
|
78
|
+
!wholeSite || typeof wholeSite !== "object" || !exactKeys(wholeSite, ["versionId", "digest"]) ||
|
|
79
|
+
!nullable(wholeSite.versionId, SHA40_RE) || !nullable(wholeSite.digest, DIGEST_RE) ||
|
|
80
|
+
!fromSpaces || typeof fromSpaces !== "object" || !exactKeys(fromSpaces, ["generation", "spaces"]) ||
|
|
81
|
+
!Array.isArray(fromSpaces.spaces) || fromSpaces.spaces.length === 0 ||
|
|
82
|
+
!Number.isSafeInteger(fromSpaces.generation)) return false;
|
|
83
|
+
let sum = 0;
|
|
84
|
+
const ids = new Set<string>();
|
|
85
|
+
for (const space of fromSpaces.spaces as unknown[]) {
|
|
86
|
+
if (!space || typeof space !== "object" || !exactKeys(space, ["spaceId", "revision", "versionId"])) return false;
|
|
87
|
+
const { spaceId, revision, versionId } = space as Record<string, unknown>;
|
|
88
|
+
if (typeof spaceId !== "string" || !spaceId || ids.has(spaceId) || typeof versionId !== "string" || !versionId ||
|
|
89
|
+
!Number.isSafeInteger(revision) || (revision as number) < 1) return false;
|
|
90
|
+
ids.add(spaceId);
|
|
91
|
+
sum += revision as number;
|
|
92
|
+
}
|
|
93
|
+
return sum === fromSpaces.generation;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parsePublicationRecoveryV1Json(json: string | null): PublicationRecoveryV1 | null {
|
|
97
|
+
if (json === null) return null;
|
|
98
|
+
try {
|
|
99
|
+
const value = JSON.parse(json) as unknown;
|
|
100
|
+
return validatePublicationRecoveryV1(value) ? value : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The tenant's public spaces as they serve on `live`, sorted by space, and their generation. This
|
|
108
|
+
* is what a recovery replaces and what its caller must name (a compare-and-set): for a CloudFront
|
|
109
|
+
* tenant it is the aggregate generation its publish wrote.
|
|
110
|
+
*/
|
|
111
|
+
export function publicationServingSpacesV1(
|
|
112
|
+
tenant: { tenant_id: string; publicationSpaces?: readonly PublicationSpaceManifestV1[] },
|
|
113
|
+
state: PublicationVersionStateV1,
|
|
114
|
+
): { generation: number; spaces: PublicationRecoverySpaceV1[] } {
|
|
115
|
+
const spaces: PublicationRecoverySpaceV1[] = [];
|
|
116
|
+
for (const manifest of [...(tenant.publicationSpaces ?? [])].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
|
|
117
|
+
if (manifest.visibility !== "public" || manifest.tenant !== tenant.tenant_id) continue;
|
|
118
|
+
const pointer = state.pointers.find((candidate) =>
|
|
119
|
+
candidate.tenant === tenant.tenant_id && candidate.spaceId === manifest.id && candidate.channel === "live");
|
|
120
|
+
if (pointer) spaces.push({ spaceId: manifest.id, revision: pointer.revision, versionId: pointer.versionId });
|
|
121
|
+
}
|
|
122
|
+
return { generation: spaces.reduce((sum, space) => sum + space.revision, 0), spaces };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether the tenant is recovered to its whole site, read fail-safe: a record that exists but does
|
|
127
|
+
* not validate still counts as recovered, because serving the whole site is the direction a
|
|
128
|
+
* recovery exists to guarantee. Only an absent record means the spaces serve.
|
|
129
|
+
*/
|
|
130
|
+
export function publicationRecoveryActiveV1(json: string | null): boolean {
|
|
131
|
+
return json !== null;
|
|
132
|
+
}
|