@tokenoftrust/storefront-runner 2.4.24 → 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/contentProvider.ts +21 -4
- 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/middleware/index.ts +15 -4
- package/apps/storefront/src/middleware/renderChain.ts +19 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +63 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +13 -0
- package/packages/public-runtime/src/publication-artifact.ts +278 -46
- package/packages/public-runtime/src/publication-content-redirects.ts +61 -0
- package/packages/public-runtime/src/publication-reader.ts +89 -77
- package/packages/public-runtime/src/publication-recovery.ts +132 -0
- package/packages/public-runtime/src/publication-space.ts +76 -1
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
type
|
|
2
|
+
readPublicationArtifactFileV1,
|
|
3
|
+
readPublicationArtifactIndexV1,
|
|
4
|
+
type PublicationArtifactIndexFileV1,
|
|
5
|
+
type PublicationArtifactIndexV1,
|
|
6
|
+
type PublicationArtifactReadBucketV1,
|
|
7
|
+
type PublicationArtifactStoredObjectV1,
|
|
8
8
|
} from "./publication-artifact.js";
|
|
9
9
|
import {
|
|
10
|
+
resolvePublicationRoutePlanV1,
|
|
10
11
|
selectPublicationRouteVersionV1,
|
|
11
12
|
type PublicationRouteFailureCodeV1,
|
|
12
13
|
type PublicationRoutePlanV1,
|
|
@@ -18,15 +19,10 @@ import type {
|
|
|
18
19
|
} from "./publication-version.js";
|
|
19
20
|
import type { RouteTierIndexV1 } from "./route-tier.js";
|
|
20
21
|
|
|
21
|
-
export
|
|
22
|
-
text(): Promise<string>;
|
|
23
|
-
arrayBuffer(): Promise<ArrayBuffer>;
|
|
24
|
-
}
|
|
22
|
+
export type PublicationStoredObjectV1 = PublicationArtifactStoredObjectV1;
|
|
25
23
|
|
|
26
24
|
/** Provider-neutral object-store read surface; R2 and test stores both satisfy it. */
|
|
27
|
-
export
|
|
28
|
-
get(key: string): Promise<PublicationStoredObjectV1 | null>;
|
|
29
|
-
}
|
|
25
|
+
export type PublicationArtifactBucketV1 = PublicationArtifactReadBucketV1;
|
|
30
26
|
|
|
31
27
|
export type PublicationArtifactOpenFailureCodeV1 =
|
|
32
28
|
| PublicationRouteFailureCodeV1
|
|
@@ -38,13 +34,17 @@ export type PublicationArtifactOpenFailureCodeV1 =
|
|
|
38
34
|
|
|
39
35
|
export interface OpenPublicationArtifactV1 {
|
|
40
36
|
selection: SelectedPublicationRouteV1;
|
|
41
|
-
|
|
37
|
+
/** The admitted file index of the selected version. */
|
|
38
|
+
index: PublicationArtifactIndexV1;
|
|
42
39
|
/** Read a UTF-8 artifact relative to the selected space's content/ root. */
|
|
43
40
|
readContent(relativePath: string): string | null;
|
|
44
41
|
/** List paths relative to the selected space's content/ root. */
|
|
45
42
|
listContentPaths(prefix?: string): string[];
|
|
46
|
-
/**
|
|
47
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Read bytes relative to this space's declared asset namespace. Fetched on demand and proven
|
|
45
|
+
* against the index, so opening a version never reads its assets.
|
|
46
|
+
*/
|
|
47
|
+
readAsset(relativePath: string): Promise<Uint8Array | null>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export type OpenPublicationArtifactResultV1 =
|
|
@@ -57,11 +57,6 @@ function safeRelativePath(path: string, allowEmpty = false): boolean {
|
|
|
57
57
|
path.split("/").every((part) => !!part && part !== "." && part !== "..");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
61
|
-
return left.byteLength === right.byteLength &&
|
|
62
|
-
left.every((value, index) => value === right[index]);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
60
|
/**
|
|
66
61
|
* Open exactly one selected publication version and validate every physical
|
|
67
62
|
* object before rendering from it. A route/state/provenance/object failure is
|
|
@@ -91,25 +86,7 @@ async function openSelectedPublicationArtifactV1(
|
|
|
91
86
|
bucket: PublicationArtifactBucketV1,
|
|
92
87
|
): Promise<OpenPublicationArtifactResultV1> {
|
|
93
88
|
const { manifest, publicationVersion } = selection;
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
marker = await bucket.get(publicationArtifactBundleKeyV1(
|
|
97
|
-
manifest.tenant,
|
|
98
|
-
manifest.id,
|
|
99
|
-
publicationVersion.versionId,
|
|
100
|
-
));
|
|
101
|
-
} catch {
|
|
102
|
-
return { ok: false, code: "publication_artifact_source_unavailable" };
|
|
103
|
-
}
|
|
104
|
-
if (!marker) return { ok: false, code: "publication_artifact_marker_missing" };
|
|
105
|
-
|
|
106
|
-
let markerValue: unknown;
|
|
107
|
-
try {
|
|
108
|
-
markerValue = JSON.parse(await marker.text());
|
|
109
|
-
} catch {
|
|
110
|
-
return { ok: false, code: "publication_artifact_marker_malformed" };
|
|
111
|
-
}
|
|
112
|
-
const checked = await validatePublicationArtifactBundleV1(markerValue, {
|
|
89
|
+
const admitted = await readPublicationArtifactIndexV1(bucket, {
|
|
113
90
|
tenant: manifest.tenant,
|
|
114
91
|
spaceId: manifest.id,
|
|
115
92
|
versionId: publicationVersion.versionId,
|
|
@@ -121,55 +98,38 @@ async function openSelectedPublicationArtifactV1(
|
|
|
121
98
|
assetNamespace: manifest.assets.namespace,
|
|
122
99
|
assetAccess: manifest.assets.access,
|
|
123
100
|
});
|
|
124
|
-
if (!
|
|
101
|
+
if (!admitted.ok) return { ok: false, code: admitted.code };
|
|
102
|
+
const { index } = admitted;
|
|
125
103
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
file.lane,
|
|
137
|
-
file.path,
|
|
138
|
-
));
|
|
139
|
-
} catch {
|
|
140
|
-
return { ok: false, code: "publication_artifact_source_unavailable" };
|
|
141
|
-
}
|
|
142
|
-
if (!stored) return { ok: false, code: "publication_artifact_file_missing" };
|
|
143
|
-
let actual: Uint8Array;
|
|
104
|
+
// The renderer reads content synchronously, so the content lane is loaded and proven up front.
|
|
105
|
+
// Assets are not: a page never needs every image its space carries.
|
|
106
|
+
const contentFiles = index.files.filter(
|
|
107
|
+
(file): file is PublicationArtifactIndexFileV1 & { encoding: "utf8" } =>
|
|
108
|
+
file.lane === "content" && file.encoding === "utf8" && file.path.startsWith("content/"),
|
|
109
|
+
);
|
|
110
|
+
const content = new Map<string, string>();
|
|
111
|
+
for (const file of contentFiles) {
|
|
112
|
+
const read = await readPublicationArtifactFileV1(bucket, index, file);
|
|
113
|
+
if (!read.ok) return { ok: false, code: read.code };
|
|
144
114
|
try {
|
|
145
|
-
|
|
115
|
+
content.set(file.path, new TextDecoder("utf-8", { fatal: true }).decode(read.bytes));
|
|
146
116
|
} catch {
|
|
147
|
-
return { ok: false, code: "publication_artifact_file_missing" };
|
|
148
|
-
}
|
|
149
|
-
if (!equalBytes(actual, expected)) {
|
|
150
117
|
return { ok: false, code: "publication_artifact_file_mismatch" };
|
|
151
118
|
}
|
|
152
|
-
physical.set(`${file.lane}\0${file.path}`, actual);
|
|
153
119
|
}
|
|
154
|
-
|
|
155
|
-
const contentFiles = checked.value.files.filter(
|
|
156
|
-
(file): file is PublicationArtifactFileV1 & { encoding: "utf8" } =>
|
|
157
|
-
file.lane === "content" && file.encoding === "utf8" && file.path.startsWith("content/"),
|
|
158
|
-
);
|
|
159
120
|
const assetLane = manifest.assets.access === "public" ? "public-asset" : "authorized-asset";
|
|
160
121
|
const assetPrefix = `${manifest.assets.namespace}/`;
|
|
122
|
+
const assets = new Map(index.files
|
|
123
|
+
.filter((file) => file.lane === assetLane && file.path.startsWith(assetPrefix))
|
|
124
|
+
.map((file) => [file.path.slice(assetPrefix.length), file]));
|
|
161
125
|
return {
|
|
162
126
|
ok: true,
|
|
163
127
|
value: {
|
|
164
128
|
selection,
|
|
165
|
-
|
|
129
|
+
index,
|
|
166
130
|
readContent(relativePath) {
|
|
167
131
|
if (!safeRelativePath(relativePath)) return null;
|
|
168
|
-
|
|
169
|
-
const file = contentFiles.find((candidate) => candidate.path === path);
|
|
170
|
-
if (!file) return null;
|
|
171
|
-
const bytes = physical.get(`content\0${path}`);
|
|
172
|
-
return bytes ? new TextDecoder().decode(bytes) : null;
|
|
132
|
+
return content.get(`content/${relativePath}`) ?? null;
|
|
173
133
|
},
|
|
174
134
|
listContentPaths(prefix = "") {
|
|
175
135
|
if (!safeRelativePath(prefix.replace(/\/$/, ""), true)) return [];
|
|
@@ -178,10 +138,62 @@ async function openSelectedPublicationArtifactV1(
|
|
|
178
138
|
.filter((path) => path.startsWith(prefix))
|
|
179
139
|
.sort();
|
|
180
140
|
},
|
|
181
|
-
readAsset(relativePath) {
|
|
141
|
+
async readAsset(relativePath) {
|
|
182
142
|
if (!safeRelativePath(relativePath)) return null;
|
|
183
|
-
|
|
143
|
+
const file = assets.get(relativePath);
|
|
144
|
+
if (!file) return null;
|
|
145
|
+
const read = await readPublicationArtifactFileV1(bucket, index, file);
|
|
146
|
+
return read.ok ? read.bytes : null;
|
|
184
147
|
},
|
|
185
148
|
},
|
|
186
149
|
};
|
|
187
150
|
}
|
|
151
|
+
|
|
152
|
+
export type PublicationRootAssetReadV1 =
|
|
153
|
+
| { status: "legacy-root" }
|
|
154
|
+
| { status: "found"; bytes: Uint8Array; sha256: string }
|
|
155
|
+
| { status: "absent" };
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One file of the ROOT space's public asset lane, by its path under the namespace — what a
|
|
159
|
+
* whole-site tenant serves at `/tenants/<tenant>/<path>`. Reads the selected version's index and
|
|
160
|
+
* that one object, never the content lane. `legacy-root` when the tenant's root is not a public
|
|
161
|
+
* publication space this channel selected, so the legacy asset pointer still answers.
|
|
162
|
+
*/
|
|
163
|
+
export async function readPublicationRootAssetV1(input: {
|
|
164
|
+
index: RouteTierIndexV1;
|
|
165
|
+
state: PublicationVersionStateV1;
|
|
166
|
+
channel: PublicationChannelV1;
|
|
167
|
+
bucket: PublicationArtifactBucketV1;
|
|
168
|
+
relativePath: string;
|
|
169
|
+
}): Promise<PublicationRootAssetReadV1> {
|
|
170
|
+
const plan = resolvePublicationRoutePlanV1(input.index, "/");
|
|
171
|
+
if (plan.action !== "render" || plan.manifest.routes.canonicalMount !== "/" ||
|
|
172
|
+
plan.manifest.visibility !== "public" || plan.manifest.assets.access !== "public") {
|
|
173
|
+
return { status: "legacy-root" };
|
|
174
|
+
}
|
|
175
|
+
const selected = selectPublicationRouteVersionV1(input.index, "/", plan, input.state, input.channel);
|
|
176
|
+
if (!selected.ok) {
|
|
177
|
+
return selected.code === "publication_channel_unselected" ? { status: "legacy-root" } : { status: "absent" };
|
|
178
|
+
}
|
|
179
|
+
if (!safeRelativePath(input.relativePath)) return { status: "absent" };
|
|
180
|
+
const { manifest, publicationVersion } = selected.value;
|
|
181
|
+
const admitted = await readPublicationArtifactIndexV1(input.bucket, {
|
|
182
|
+
tenant: manifest.tenant,
|
|
183
|
+
spaceId: manifest.id,
|
|
184
|
+
versionId: publicationVersion.versionId,
|
|
185
|
+
sourceSha: publicationVersion.source.commitSha,
|
|
186
|
+
artifactDigest: publicationVersion.artifactDigest,
|
|
187
|
+
forge: manifest.source.forge,
|
|
188
|
+
repo: manifest.source.repo,
|
|
189
|
+
visibility: manifest.visibility,
|
|
190
|
+
assetNamespace: manifest.assets.namespace,
|
|
191
|
+
assetAccess: manifest.assets.access,
|
|
192
|
+
});
|
|
193
|
+
if (!admitted.ok) return { status: "absent" };
|
|
194
|
+
const path = `${manifest.assets.namespace}/${input.relativePath}`;
|
|
195
|
+
const row = admitted.index.files.find((file) => file.lane === "public-asset" && file.path === path);
|
|
196
|
+
if (!row) return { status: "absent" };
|
|
197
|
+
const read = await readPublicationArtifactFileV1(input.bucket, admitted.index, row);
|
|
198
|
+
return read.ok ? { status: "found", bytes: read.bytes, sha256: row.sha256 } : { status: "absent" };
|
|
199
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
publicationCollectionBaseV1,
|
|
13
13
|
type PublicationCollectionV1,
|
|
14
14
|
} from "./blog-visibility.mjs";
|
|
15
|
+
import { normalizeRedirectPath } from "./publication-redirects.mjs";
|
|
15
16
|
import {
|
|
16
17
|
ANNOTATION_KEYS,
|
|
17
18
|
type ContractResult,
|
|
@@ -113,6 +114,14 @@ export interface PublicationSpaceAliasV1 {
|
|
|
113
114
|
to?: string;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/** One exact path the root space sends to another origin. */
|
|
118
|
+
export interface PublicationSpaceExternalRedirectV1 {
|
|
119
|
+
/** Normalized exact request path (`/my-account/`), never a rule. */
|
|
120
|
+
from: string;
|
|
121
|
+
/** Absolute `https:` destination without credentials, query, or fragment. */
|
|
122
|
+
to: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
export interface PublicationSpaceManifestV1 {
|
|
117
126
|
version: typeof PUBLICATION_SPACE_MANIFEST_VERSION;
|
|
118
127
|
id: string;
|
|
@@ -127,6 +136,11 @@ export interface PublicationSpaceManifestV1 {
|
|
|
127
136
|
* it by `publicationCollectionPathV1`.
|
|
128
137
|
*/
|
|
129
138
|
collections?: Partial<Record<PublicationCollectionV1, string>>;
|
|
139
|
+
/**
|
|
140
|
+
* Exact paths sent off the site, as a whole-site publish does. Only the ROOT space may declare
|
|
141
|
+
* them: a mounted space owns only its mount, and its redirects stay on the tenant's origin.
|
|
142
|
+
*/
|
|
143
|
+
externalRedirects?: PublicationSpaceExternalRedirectV1[];
|
|
130
144
|
};
|
|
131
145
|
visibility: PublicationSpaceVisibilityV1;
|
|
132
146
|
authorization:
|
|
@@ -500,6 +514,62 @@ function validateCollections(
|
|
|
500
514
|
return Object.keys(out).length ? out : undefined;
|
|
501
515
|
}
|
|
502
516
|
|
|
517
|
+
function isExternalDestination(value: string): boolean {
|
|
518
|
+
try {
|
|
519
|
+
const url = new URL(value);
|
|
520
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash &&
|
|
521
|
+
url.toString().replace(/\/$/, url.pathname === "/" ? "/" : "") === value;
|
|
522
|
+
} catch {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The root space's exact off-site redirects; a mounted space declaring any is refused. */
|
|
528
|
+
function validateExternalRedirects(
|
|
529
|
+
raw: unknown,
|
|
530
|
+
canonicalMount: string | undefined,
|
|
531
|
+
v: Violations,
|
|
532
|
+
path: string,
|
|
533
|
+
): PublicationSpaceExternalRedirectV1[] | undefined {
|
|
534
|
+
if (raw === undefined) return undefined;
|
|
535
|
+
if (!Array.isArray(raw)) {
|
|
536
|
+
v.add(path, "type", "Expected externalRedirects to be an array.");
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
if (canonicalMount !== undefined && canonicalMount !== "/") {
|
|
540
|
+
v.add(path, "publication_external_redirect_on_mount",
|
|
541
|
+
"Only the root space may redirect off the site; a mounted space owns only its mount.");
|
|
542
|
+
}
|
|
543
|
+
const out: PublicationSpaceExternalRedirectV1[] = [];
|
|
544
|
+
const seen = new Set<string>();
|
|
545
|
+
raw.forEach((item, index) => {
|
|
546
|
+
const at = `${path}[${index}]`;
|
|
547
|
+
const entry = reqObject(item, v, at);
|
|
548
|
+
if (!entry) return;
|
|
549
|
+
rejectPublicationUnknownKeys(entry, ["from", "to"], v, at);
|
|
550
|
+
const from = reqString(entry, "from", v, at);
|
|
551
|
+
const to = reqString(entry, "to", v, at);
|
|
552
|
+
if (from !== undefined && (normalizeRedirectPath(from) !== from || from === "/")) {
|
|
553
|
+
v.add(`${at}.from`, "publication_external_redirect_source",
|
|
554
|
+
"An off-site redirect source is one normalized exact path, never the site root or a rule.");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (to !== undefined && !isExternalDestination(to)) {
|
|
558
|
+
v.add(`${at}.to`, "publication_external_redirect_target",
|
|
559
|
+
"An off-site redirect target is an absolute https URL without credentials, query, or fragment.");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (from === undefined || to === undefined) return;
|
|
563
|
+
if (seen.has(from)) {
|
|
564
|
+
v.add(`${at}.from`, "publication_external_redirect_duplicate", "An off-site redirect source is declared twice.");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
seen.add(from);
|
|
568
|
+
out.push({ from, to });
|
|
569
|
+
});
|
|
570
|
+
return out.length ? out.sort((left, right) => left.from < right.from ? -1 : left.from > right.from ? 1 : 0) : undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
503
573
|
/** Validate, close, and normalize one serialized publication-space manifest. */
|
|
504
574
|
export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult<PublicationSpaceManifestV1> {
|
|
505
575
|
const v = new Violations();
|
|
@@ -519,8 +589,10 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
519
589
|
let canonicalMount: string | undefined;
|
|
520
590
|
let aliases: PublicationSpaceAliasV1[] | undefined;
|
|
521
591
|
let collections: PublicationSpaceManifestV1["routes"]["collections"];
|
|
592
|
+
let externalRedirects: PublicationSpaceExternalRedirectV1[] | undefined;
|
|
522
593
|
if (routes) {
|
|
523
|
-
rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases", "collections"], v,
|
|
594
|
+
rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases", "collections", "externalRedirects"], v,
|
|
595
|
+
"routes");
|
|
524
596
|
const rawMount = reqString(routes, "canonicalMount", v, "routes");
|
|
525
597
|
canonicalMount = rawMount
|
|
526
598
|
? append(v, "routes.canonicalMount", normalizePublicationPath(rawMount, { mode: "mount" }))
|
|
@@ -530,6 +602,8 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
530
602
|
}
|
|
531
603
|
aliases = validateAliases(routes["aliases"], canonicalMount, v, "routes.aliases");
|
|
532
604
|
collections = validateCollections(routes["collections"], canonicalMount, v, "routes.collections");
|
|
605
|
+
externalRedirects = validateExternalRedirects(routes["externalRedirects"], canonicalMount, v,
|
|
606
|
+
"routes.externalRedirects");
|
|
533
607
|
}
|
|
534
608
|
const visibility = reqEnum<PublicationSpaceVisibilityV1>(root, "visibility", ["public", "internal"], v, "");
|
|
535
609
|
const authorization = validateAuthorization(root["authorization"], v, "authorization");
|
|
@@ -581,6 +655,7 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
581
655
|
canonicalMount,
|
|
582
656
|
...(aliases === undefined ? {} : { aliases }),
|
|
583
657
|
...(collections === undefined ? {} : { collections }),
|
|
658
|
+
...(externalRedirects === undefined ? {} : { externalRedirects }),
|
|
584
659
|
},
|
|
585
660
|
visibility,
|
|
586
661
|
authorization,
|