@tokenoftrust/storefront-runner 2.4.25 → 2.4.27

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.
@@ -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 input.store.get(publicationVersionStateKey(input.plan.manifest.tenant));
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 input.kvGet(publicationVersionStateKey(input.tenantId));
99
- if (stateJson === null) return null;
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.25",
3
+ "version": "2.4.27",
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",
@@ -0,0 +1,223 @@
1
+ /**
2
+ * A tenant's publication topology as a developer's workspace sees it: one ROOT repository serving
3
+ * `/`, plus any MOUNTED spaces — each its own repository serving one mount (`/blog`, `/docs`).
4
+ *
5
+ * The platform declares the topology; the workspace only records what it was told, in
6
+ * `.tot/publication-spaces.json` (written by `tot clone`, never committed — the root repo's git
7
+ * excludes it, and the `spaces/` folder each mounted space is cloned into). Every command that needs
8
+ * to know which repository owns a URL or a file reads it through this module, so `tot validate`,
9
+ * `tot dev`, `tot clone` and the change router answer the same way.
10
+ *
11
+ * Mount matching mirrors packages/public-runtime/src/publication-space.ts
12
+ * (`publicationMountMatchesPath`): a mount owns itself and everything below it, segment-wise and
13
+ * case-sensitively; the longest matching mount wins and `/` owns what nothing else claims.
14
+ * apps/storefront/src/config/publicationSpaceOwnership.test.ts pins the two together.
15
+ *
16
+ * Dependency-free: the published CLI ships it as-is.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ /** Where `tot clone` puts each mounted space, relative to the root checkout: `spaces/<spaceId>/`. */
22
+ export const SPACES_DIR = "spaces";
23
+ /** The workspace's record of the declared topology, relative to the root checkout. */
24
+ export const TOPOLOGY_FILE = ".tot/publication-spaces.json";
25
+ export const TOPOLOGY_VERSION = 1;
26
+
27
+ const SPACE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
28
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
+
30
+ /**
31
+ * @typedef {{ forge: string, repo: string, defaultBranch: string }} SpaceSource
32
+ * @typedef {{ id: string, mount: string, visibility: "public"|"internal", source: SpaceSource,
33
+ * aliases?: string[], collections?: Record<string, string> }} TopologySpace
34
+ * @typedef {{ version: 1, tenant: string, spaces: TopologySpace[] }} Topology
35
+ */
36
+
37
+ /** A mount in canonical form: leading `/`, no trailing `/` except the root itself. Null when not a mount. */
38
+ export function normalizeMount(value) {
39
+ if (typeof value !== "string" || !value.startsWith("/") || /[?#\\]/.test(value)) return null;
40
+ const trimmed = value.replace(/\/+$/, "");
41
+ if (!trimmed) return "/";
42
+ if (trimmed.split("/").slice(1).some((seg) => !seg || seg === "." || seg === "..")) return null;
43
+ return trimmed;
44
+ }
45
+
46
+ /** A request path reduced to what routing reads: no origin, query or fragment; NFC; single slashes. */
47
+ export function normalizeRequestPath(value) {
48
+ let path = String(value ?? "").trim();
49
+ try {
50
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) path = new URL(path).pathname;
51
+ } catch {
52
+ return null;
53
+ }
54
+ path = path.replace(/[?#].*$/, "");
55
+ if (!path.startsWith("/")) path = `/${path}`;
56
+ try {
57
+ path = decodeURIComponent(path);
58
+ } catch {
59
+ return null;
60
+ }
61
+ path = path.normalize("NFC").replace(/\/{2,}/g, "/");
62
+ return path;
63
+ }
64
+
65
+ /** Does `mount` own `path` (both normalized)? A mount owns itself and every path below it. */
66
+ export function mountMatchesPath(mount, path) {
67
+ if (mount === "/") return true;
68
+ return path === mount || path === `${mount}/` || path.startsWith(`${mount}/`);
69
+ }
70
+
71
+ /**
72
+ * Parse a recorded topology. Throws with the reason on anything malformed — a half-read topology
73
+ * would route a change to the wrong repository.
74
+ * @param {unknown} input
75
+ * @returns {Topology}
76
+ */
77
+ export function parseTopology(input) {
78
+ const raw = /** @type {any} */ (input);
79
+ if (!isObject(raw) || raw.version !== TOPOLOGY_VERSION || typeof raw.tenant !== "string" || !raw.tenant ||
80
+ !Array.isArray(raw.spaces)) {
81
+ throw new Error(`${TOPOLOGY_FILE} is not a version-${TOPOLOGY_VERSION} topology`);
82
+ }
83
+ const ids = new Set();
84
+ const mounts = new Set();
85
+ const spaces = raw.spaces.map((space, index) => {
86
+ const at = `${TOPOLOGY_FILE} spaces[${index}]`;
87
+ if (!isObject(space) || typeof space.id !== "string" || !SPACE_ID.test(space.id)) throw new Error(`${at}: invalid id`);
88
+ const mount = normalizeMount(space.mount);
89
+ if (!mount) throw new Error(`${at}: invalid mount`);
90
+ if (space.visibility !== "public" && space.visibility !== "internal") throw new Error(`${at}: invalid visibility`);
91
+ const source = space.source;
92
+ if (!isObject(source) || typeof source.forge !== "string" || typeof source.repo !== "string" || !source.repo ||
93
+ typeof source.defaultBranch !== "string" || !source.defaultBranch) throw new Error(`${at}: invalid source`);
94
+ if (ids.has(space.id)) throw new Error(`${at}: duplicate id ${space.id}`);
95
+ if (mounts.has(mount)) throw new Error(`${at}: duplicate mount ${mount}`);
96
+ ids.add(space.id);
97
+ mounts.add(mount);
98
+ const aliases = Array.isArray(space.aliases) ? space.aliases.map(normalizeMount).filter(Boolean) : [];
99
+ return {
100
+ id: space.id,
101
+ mount,
102
+ visibility: space.visibility,
103
+ source: { forge: source.forge, repo: source.repo, defaultBranch: source.defaultBranch },
104
+ ...(aliases.length ? { aliases } : {}),
105
+ ...(isObject(space.collections) ? { collections: { ...space.collections } } : {}),
106
+ };
107
+ });
108
+ if (spaces.filter((space) => space.mount === "/").length !== 1) {
109
+ throw new Error(`${TOPOLOGY_FILE}: exactly one space must own "/"`);
110
+ }
111
+ return { version: TOPOLOGY_VERSION, tenant: raw.tenant, spaces };
112
+ }
113
+
114
+ /**
115
+ * Build a topology from the platform's declared-space list (`GET /api/publication-spaces`, whose
116
+ * rows carry `id`, `visibility`, `canonicalMount`, `source` and `routes`).
117
+ * @param {string} tenant
118
+ * @param {unknown} rows
119
+ * @returns {Topology}
120
+ */
121
+ export function topologyFromDeclaredSpaces(tenant, rows) {
122
+ if (!Array.isArray(rows)) throw new Error("the platform returned no publication-space list");
123
+ return parseTopology({
124
+ version: TOPOLOGY_VERSION,
125
+ tenant,
126
+ spaces: rows.map((row) => ({
127
+ id: row?.id,
128
+ mount: row?.routes?.canonicalMount ?? row?.canonicalMount,
129
+ visibility: row?.visibility,
130
+ source: row?.source,
131
+ aliases: Array.isArray(row?.routes?.aliases) ? row.routes.aliases.map((alias) => alias?.from) : undefined,
132
+ collections: row?.routes?.collections,
133
+ })),
134
+ });
135
+ }
136
+
137
+ /** The root space (owns `/`; `parseTopology` guarantees exactly one). @param {Topology} topology */
138
+ export function rootSpace(topology) {
139
+ return /** @type {TopologySpace} */ (topology.spaces.find((space) => space.mount === "/"));
140
+ }
141
+
142
+ /** The mounted spaces, longest mount first. @param {Topology} topology */
143
+ export function mountedSpaces(topology) {
144
+ return topology.spaces.filter((space) => space.mount !== "/").sort((a, b) => b.mount.length - a.mount.length);
145
+ }
146
+
147
+ /**
148
+ * Which space owns a URL path: the longest canonical mount (or alias) that owns it, else the root.
149
+ * Returns null for a path that cannot be read as a URL path.
150
+ * @param {Topology} topology
151
+ * @param {string} urlPath
152
+ * @returns {{ space: TopologySpace, via: "mount"|"alias"|"root" } | null}
153
+ */
154
+ export function owningSpace(topology, urlPath) {
155
+ const path = normalizeRequestPath(urlPath);
156
+ if (path === null) return null;
157
+ /** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
158
+ let best = null;
159
+ for (const space of topology.spaces) {
160
+ if (space.mount === "/") continue;
161
+ /** @type {Array<[string, "mount"|"alias"]>} */
162
+ const claims = [[space.mount, "mount"], ...(space.aliases ?? []).map((alias) => /** @type {[string, "alias"]} */ ([alias, "alias"]))];
163
+ for (const [mount, via] of claims) {
164
+ if (mountMatchesPath(mount, path) && (!best || mount.length > best.mount.length)) best = { space, via, mount };
165
+ }
166
+ }
167
+ if (best) return { space: best.space, via: best.via };
168
+ return { space: rootSpace(topology), via: "root" };
169
+ }
170
+
171
+ /** A mounted space's working tree, relative to the root checkout. @param {TopologySpace} space */
172
+ export function spaceDir(space) {
173
+ return space.mount === "/" ? "" : `${SPACES_DIR}/${space.id}`;
174
+ }
175
+
176
+ /**
177
+ * Which space a workspace file belongs to, from its path relative to the root checkout:
178
+ * `spaces/<id>/...` is that space's, everything else is the root's. A file under `spaces/` naming
179
+ * no declared space belongs to none (null) — it must not be routed anywhere.
180
+ * @param {Topology} topology
181
+ * @param {string} relPath
182
+ * @returns {{ space: TopologySpace, path: string } | null}
183
+ */
184
+ export function spaceForWorkspaceFile(topology, relPath) {
185
+ const rel = String(relPath).replace(/\\/g, "/").replace(/^\.\//, "");
186
+ if (rel === SPACES_DIR || rel.startsWith(`${SPACES_DIR}/`)) {
187
+ const [, id, ...rest] = rel.split("/");
188
+ const space = topology.spaces.find((candidate) => candidate.mount !== "/" && candidate.id === id);
189
+ return space ? { space, path: rest.join("/") } : null;
190
+ }
191
+ return { space: rootSpace(topology), path: rel };
192
+ }
193
+
194
+ /** Read a workspace's recorded topology, or null when the workspace records none. Throws when malformed. */
195
+ export function readWorkspaceTopology(workspace) {
196
+ const file = join(workspace, TOPOLOGY_FILE);
197
+ if (!existsSync(file)) return null;
198
+ return parseTopology(JSON.parse(readFileSync(file, "utf8")));
199
+ }
200
+
201
+ /**
202
+ * Record a topology in a root checkout, and keep it — and the cloned spaces — out of the root
203
+ * repository: both are listed in the checkout's own `.git/info/exclude`, never in a committed file.
204
+ */
205
+ export function writeWorkspaceTopology(workspace, topology) {
206
+ const file = join(workspace, TOPOLOGY_FILE);
207
+ mkdirSync(dirname(file), { recursive: true });
208
+ writeFileSync(file, `${JSON.stringify(parseTopology(topology), null, 2)}\n`);
209
+ excludeFromRootRepo(workspace, [`/${TOPOLOGY_FILE}`, `/${SPACES_DIR}/`]);
210
+ }
211
+
212
+ /** Append patterns to `<workspace>/.git/info/exclude`, once each. No-op outside a git checkout. */
213
+ export function excludeFromRootRepo(workspace, patterns) {
214
+ const gitDir = join(workspace, ".git");
215
+ if (!existsSync(gitDir)) return;
216
+ const file = join(gitDir, "info", "exclude");
217
+ mkdirSync(dirname(file), { recursive: true });
218
+ const current = existsSync(file) ? readFileSync(file, "utf8") : "";
219
+ const lines = new Set(current.split("\n").map((line) => line.trim()));
220
+ const missing = patterns.filter((pattern) => !lines.has(pattern));
221
+ if (!missing.length) return;
222
+ appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
223
+ }
@@ -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
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `tot dev`'s mounted publication spaces: run `publication-spaces-dev.ts` (compose the tenant's
3
+ * declared spaces from their local working trees through the publish-path composition, then seed
4
+ * the dev server's local state) alongside the dev server, and again whenever a space's working tree
5
+ * changes — so `/docs`, `/blog`, ... render locally from the same route plan visitors get, and a
6
+ * save in a space shows on the next reload.
7
+ *
8
+ * The composition is the private control plane, so it runs only where the platform tree is present
9
+ * (the storefront monorepo). The public runner ships without it; there a checkout that has cloned
10
+ * spaces is told which paths will not render, instead of silently serving 404s under each mount.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { startWatch } from "./checkout-watch.mjs";
16
+
17
+ export const SEEDER = "scripts/dev/publication-spaces-dev.ts";
18
+
19
+ /** Can this tree compose publication spaces? (The public runner cannot: no control plane.) */
20
+ export function publicationSpacesComposable(repoRoot) {
21
+ return existsSync(join(repoRoot, SEEDER)) &&
22
+ existsSync(join(repoRoot, "packages", "private-controlplane")) &&
23
+ existsSync(join(repoRoot, "apps", "storefront", "node_modules", ".bin", "tsx"));
24
+ }
25
+
26
+ /** The seeder argv for a tenant (and the developer's checkout, in workspace mode). Pure. */
27
+ export function seederArgs(repoRoot, tenant, workspace) {
28
+ return [join(repoRoot, SEEDER), "--tenant", tenant, ...(workspace ? ["--workspace", workspace] : [])];
29
+ }
30
+
31
+ /** Read the seeder's one-line JSON verdict from its stdout; null when it printed none. Pure. */
32
+ export function parseSeederVerdict(stdout) {
33
+ const line = String(stdout).trim().split("\n").filter(Boolean).pop();
34
+ if (!line) return null;
35
+ try {
36
+ const parsed = JSON.parse(line);
37
+ return parsed && typeof parsed.status === "string" ? parsed : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** The developer-facing line for a verdict. Pure. */
44
+ export function describeVerdict(verdict, { first }) {
45
+ if (!verdict) return " ⚠ mounted publication spaces could not be composed — their paths will 404 locally.";
46
+ switch (verdict.status) {
47
+ case "seeded": {
48
+ const mounts = verdict.spaces.map((space) => `${space.mount}/ (${space.spaceId})`).join(", ");
49
+ return first ? ` ✓ mounted spaces render locally: ${mounts}` : ` ↻ re-composed mounted spaces: ${mounts} — reload to see it`;
50
+ }
51
+ case "incomplete":
52
+ return ` ⚠ mounted spaces not composed — ${verdict.missing.join("; ")}. Their paths 404 locally until cloned (tot clone).`;
53
+ case "refused":
54
+ return ` ✗ the publish-path composition refused ${verdict.spaceId ?? "this topology"}: ${verdict.code} — a publish would refuse it too.`;
55
+ default:
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Compose + seed once now, then again on every change under `watchDirs()`. Runs are serialized: a
62
+ * save during a run schedules exactly one more. Returns `{ close }`.
63
+ * @param {{ repoRoot: string, tenant: string, workspace: string|null, log?: (line: string) => void,
64
+ * spawnImpl?: typeof spawn, watch?: typeof startWatch, watchDirs?: (verdict: any) => string[] }} input
65
+ */
66
+ export function startPublicationSpacesLoop({
67
+ repoRoot, tenant, workspace, log = console.log, spawnImpl = spawn, watch = startWatch,
68
+ watchDirs = (verdict) => verdict?.watch ?? [],
69
+ }) {
70
+ const appDir = join(repoRoot, "apps", "storefront");
71
+ const tsx = join(appDir, "node_modules", ".bin", "tsx");
72
+ const watchers = [];
73
+ let running = false;
74
+ let again = false;
75
+ let closed = false;
76
+ let first = true;
77
+ let watching = false;
78
+
79
+ const run = () => {
80
+ if (closed) return;
81
+ if (running) { again = true; return; }
82
+ running = true;
83
+ let stdout = "";
84
+ let stderr = "";
85
+ const child = spawnImpl(tsx, seederArgs(repoRoot, tenant, workspace), { cwd: appDir, stdio: ["ignore", "pipe", "pipe"] });
86
+ child.stdout?.on("data", (chunk) => { stdout += chunk; });
87
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
88
+ child.on("close", () => {
89
+ running = false;
90
+ const verdict = parseSeederVerdict(stdout);
91
+ if (!verdict && stderr.trim()) log(` ⚠ ${stderr.trim().split("\n").pop()}`);
92
+ const line = describeVerdict(verdict, { first });
93
+ if (line && !(verdict?.status === "none")) log(line);
94
+ first = false;
95
+ if (!watching && verdict && verdict.status !== "none") {
96
+ watching = true;
97
+ for (const dir of watchDirs(verdict)) {
98
+ try {
99
+ watchers.push(watch(dir, true, () => run()));
100
+ } catch {
101
+ log(` ⚠ could not watch ${dir} — re-run tot dev to pick up its changes.`);
102
+ }
103
+ }
104
+ }
105
+ if (again) { again = false; run(); }
106
+ });
107
+ child.on("error", (error) => {
108
+ running = false;
109
+ log(` ⚠ mounted publication spaces could not be composed: ${error.message}`);
110
+ });
111
+ };
112
+
113
+ run();
114
+ return {
115
+ close() {
116
+ closed = true;
117
+ for (const watcher of watchers) {
118
+ try { watcher.close(); } catch { /* best effort */ }
119
+ }
120
+ },
121
+ };
122
+ }
@@ -68,6 +68,8 @@ import { busyPortMessage, classifyBusyPort, lockPort, unlockPort } from "./dev/p
68
68
  import { resync, startWatch } from "./dev/checkout-watch.mjs";
69
69
  import { standaloneGraftPlan } from "./dev/standalone-graft-plan.mjs";
70
70
  import { findMissingCtaIds } from "./tenant/validate.mjs";
71
+ import { publicationSpacesComposable, startPublicationSpacesLoop } from "./dev/publication-spaces-loop.mjs";
72
+ import { SPACES_DIR } from "../packages/cli/src/publication-spaces.mjs";
71
73
 
72
74
  const here = dirname(fileURLToPath(import.meta.url));
73
75
  const repoRoot = resolve(here, ".."); // scripts/ -> repo root
@@ -415,14 +417,13 @@ console.log(` Private local preview — nothing is published. Server binds all
415
417
  }
416
418
 
417
419
  // --- boot astro dev (Vite HMR); --host so IPv4 `localhost`/127.0.0.1 both work -
418
- // In standalone mode we MUST keep astro in the FOREGROUND, attached to this
419
- // process: Astro 7 auto-detects an agent-run terminal and forks the dev server to
420
- // a detached daemon (`astro dev stop/status`), which would make THIS process exit
421
- // immediately — firing our cleanup and tearing down the graft symlinks while the
422
- // daemon keeps serving the (restored) in-tree content. Setting ASTRO_DEV_BACKGROUND
423
- // tells Astro "you are already the managed child → run foreground" (see astro's
424
- // cli/dev/{index,background}.js), so our SIGINT/exit lifecycle and symlink cleanup
425
- // stay correct. Monorepo mode is left untouched (current behavior).
420
+ // astro MUST stay in the FOREGROUND, attached to this process: Astro 7 auto-detects
421
+ // an agent-run terminal and forks the dev server to a detached daemon (`astro dev
422
+ // stop/status`), which would make THIS process exit immediately — firing our cleanup
423
+ // (tearing down the graft, stopping the publication-space loop) while the daemon
424
+ // keeps serving. Setting ASTRO_DEV_BACKGROUND tells Astro "you are already the
425
+ // managed child → run foreground" (see astro's cli/dev/{index,background}.js), so
426
+ // our SIGINT/exit lifecycle stays correct in both modes.
426
427
  // TOT_DEV_TENANT tells the inline-AI edit loop (astro.config.mjs's
427
428
  // inlineAiEditPlugin, scripts/dev/ai-edit.mjs) which tenant's content/theme to
428
429
  // read+write: one `tot dev <tenant>` run always serves exactly one tenant, so
@@ -433,9 +434,7 @@ console.log(` Private local preview — nothing is published. Server binds all
433
434
  // MUST read+write it, NOT the in-tree graft, or browser edits are silently lost
434
435
  // on the next resync. MONOREPO: the in-tree tenants/<id>/ dir.
435
436
  const devSourceRoot = resolveDevSourceRoot(workspacePath, repoRoot, tenant);
436
- const spawnEnv = workspacePath
437
- ? { ...process.env, ASTRO_DEV_BACKGROUND: "1", TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot }
438
- : { ...process.env, TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot };
437
+ const spawnEnv = { ...process.env, ASTRO_DEV_BACKGROUND: "1", TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot };
439
438
  // LAUNCHER-AGNOSTIC astro spawn: the published runner tree may have been
440
439
  // installed by pnpm OR npm (npm ships with every Node — the runner must not
441
440
  // require pnpm at RUNTIME), so resolve astro's real entry through Node's own
@@ -453,12 +452,31 @@ const child = spawn(
453
452
  { cwd: appDir, stdio: "inherit", env: spawnEnv },
454
453
  );
455
454
 
455
+ // --- mounted publication spaces (/docs, /blog, ...) -----------------------------
456
+ // Composed from their local working trees through the publish-path composition and seeded into
457
+ // the dev server's local state while astro boots, then again on every save in a space (see
458
+ // scripts/dev/publication-spaces-loop.mjs). The public runner has no composition, so there a
459
+ // checkout with cloned spaces is told its mounted paths will not render rather than left to 404.
460
+ let spacesLoop = null;
461
+ if (publicationSpacesComposable(repoRoot)) {
462
+ spacesLoop = startPublicationSpacesLoop({
463
+ repoRoot,
464
+ tenant,
465
+ workspace: workspacePath ? resolve(workspacePath) : null,
466
+ });
467
+ } else if (workspacePath && existsSync(join(resolve(workspacePath), SPACES_DIR))) {
468
+ console.warn(
469
+ ` ⚠ this checkout has mounted publication spaces (${SPACES_DIR}/) — the public preview engine renders the root only, so their paths 404 here.`,
470
+ );
471
+ }
472
+
456
473
  // Clean up grafts + the port lock on every exit path so we never leave
457
474
  // symlinks in the tree, or a lock a LATER run would misread as still live.
458
475
  function shutdown(signal) {
459
476
  child.kill(signal);
460
477
  }
461
478
  function cleanupAll() {
479
+ spacesLoop?.close();
462
480
  cleanupGrafts();
463
481
  unlockPort(resolvedPort);
464
482
  }