@tokenoftrust/storefront-runner 2.0.22 → 2.0.24
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/astro.config.mjs +5 -0
- package/apps/storefront/integrations/lifecycle-host-asset.mjs +125 -0
- package/apps/storefront/src/layouts/Layout.astro +8 -10
- package/apps/storefront/src/lib/shared/viewerGrant.ts +166 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -1
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +72 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +3 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -0
- package/packages/public-runtime/src/integration/lifecycle-host-entry.ts +16 -0
- package/packages/public-runtime/src/integration/lifecycle-runtime.ts +59 -0
- package/packages/public-runtime/src/integration/render-phases.ts +30 -2
|
@@ -58,6 +58,7 @@ import preact from "@astrojs/preact";
|
|
|
58
58
|
import tailwindcss from "@tailwindcss/vite";
|
|
59
59
|
import { tenantHotReload } from "./dev-plugins/tenant-hot-reload.mjs";
|
|
60
60
|
import { materializeGuard } from "./integrations/materialize-guard.mjs";
|
|
61
|
+
import { lifecycleHostAsset } from "./integrations/lifecycle-host-asset.mjs";
|
|
61
62
|
|
|
62
63
|
// SSR on Cloudflare Workers. `output: 'server'` because every response is
|
|
63
64
|
// tenant-resolved at the edge from the Host header (see src/middleware).
|
|
@@ -94,6 +95,10 @@ export default defineConfig({
|
|
|
94
95
|
// resolves from KV/R2 at request time. The same integration becomes strict
|
|
95
96
|
// only when TOT_BUILD_TENANTS names a per-tenant static publish input.
|
|
96
97
|
materializeGuard(),
|
|
98
|
+
// The document.deferred lifecycle host, emitted as one self-contained module at a
|
|
99
|
+
// stable same-origin URL so BOTH document mounts load the same boot — the raw
|
|
100
|
+
// marketing mount has no bundler to hoist a module import through.
|
|
101
|
+
lifecycleHostAsset(),
|
|
97
102
|
preact({ compat: true }),
|
|
98
103
|
// Astro's file-based router ignores ANY path segment starting with "_" (see
|
|
99
104
|
// `astro/dist/core/routing/create-manifest.js`), so a page literally named
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit the document.deferred lifecycle host as ONE self-contained module at a stable
|
|
3
|
+
* same-origin URL, so both document mounts can load the same host.
|
|
4
|
+
*
|
|
5
|
+
* WHY NOT A ROLLUP CHUNK. `Layout.astro` gets its client code through Astro's bundler,
|
|
6
|
+
* which hashes filenames and splits shared modules into sibling chunks. The raw marketing
|
|
7
|
+
* mount can use neither: it serves a tenant's own document verbatim, so it needs a URL it
|
|
8
|
+
* can hardcode. And a SPLIT bundle is actively unsafe here — a published static bundle
|
|
9
|
+
* (`scripts/publish/materialize-tenant.mjs`) copies only what the rendered HTML references
|
|
10
|
+
* and follows CSS `url()` but NOT JavaScript imports, so a sibling chunk would resolve on
|
|
11
|
+
* the Cloudflare plane and 404 on the S3/CloudFront one. That asymmetry is exactly the
|
|
12
|
+
* both-CDNs failure this repo treats as an invariant, and nothing downstream would report
|
|
13
|
+
* it: validate-bundle only checks HTML-referenced paths.
|
|
14
|
+
*
|
|
15
|
+
* So the host is bundled ONCE by esbuild with no code splitting, and this REFUSES to emit
|
|
16
|
+
* anything carrying an import. A single import-free file behaves identically on both
|
|
17
|
+
* providers by construction.
|
|
18
|
+
*
|
|
19
|
+
* It is an Astro INTEGRATION rather than a bare Vite plugin because Astro is the only
|
|
20
|
+
* thing that knows where the client output actually goes: every one of Astro's build
|
|
21
|
+
* passes — the client pass included — runs with `build.ssr === true`, so a Vite plugin
|
|
22
|
+
* cannot tell them apart, while `config.build.client` says it outright.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { dirname } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { LIFECYCLE_HOST_ASSET_PATH } from "@tot/public-runtime/integration/lifecycle-host-asset";
|
|
29
|
+
|
|
30
|
+
const HOST_ENTRY = fileURLToPath(
|
|
31
|
+
new URL(
|
|
32
|
+
"../../../packages/public-runtime/src/integration/lifecycle-host-entry.ts",
|
|
33
|
+
import.meta.url,
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bundle the host to a single self-contained ES module.
|
|
39
|
+
*
|
|
40
|
+
* esbuild is resolved through `astro` (a direct dependency of this app, always present
|
|
41
|
+
* whenever this integration runs) rather than declared again here, so its version cannot
|
|
42
|
+
* drift from the one the build already uses.
|
|
43
|
+
* @returns {Promise<string>}
|
|
44
|
+
*/
|
|
45
|
+
export async function bundleLifecycleHost() {
|
|
46
|
+
const require = createRequire(import.meta.url);
|
|
47
|
+
const esbuild = createRequire(require.resolve("astro/package.json"))("esbuild");
|
|
48
|
+
const result = await esbuild.build({
|
|
49
|
+
entryPoints: [HOST_ENTRY],
|
|
50
|
+
bundle: true,
|
|
51
|
+
write: false,
|
|
52
|
+
metafile: true,
|
|
53
|
+
format: "esm",
|
|
54
|
+
platform: "browser",
|
|
55
|
+
target: "es2022",
|
|
56
|
+
minify: true,
|
|
57
|
+
treeShaking: true,
|
|
58
|
+
legalComments: "none",
|
|
59
|
+
sourcemap: false,
|
|
60
|
+
logLevel: "silent",
|
|
61
|
+
});
|
|
62
|
+
if (result.outputFiles.length !== 1) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`The lifecycle host bundled to ${result.outputFiles.length} files; it must be one ` +
|
|
65
|
+
`self-contained module (a sibling chunk 404s on the S3/CloudFront plane).`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
// The metafile is authoritative about imports in a way a regex over minified code is
|
|
69
|
+
// not: any entry here is a file the browser would have to fetch separately.
|
|
70
|
+
for (const output of Object.values(result.metafile.outputs)) {
|
|
71
|
+
const external = (output.imports ?? []).filter((i) => i.kind !== "file-loader");
|
|
72
|
+
if (external.length > 0) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`The lifecycle host bundle imports ${external.map((i) => i.path).join(", ")}; it ` +
|
|
75
|
+
`must carry no imports, because the static publish pipeline does not follow them.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result.outputFiles[0].text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @returns {import("astro").AstroIntegration}
|
|
84
|
+
*/
|
|
85
|
+
export function lifecycleHostAsset() {
|
|
86
|
+
/** @type {URL | null} */
|
|
87
|
+
let clientDir = null;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
name: "tot-lifecycle-host-asset",
|
|
91
|
+
hooks: {
|
|
92
|
+
"astro:config:done": ({ config }) => {
|
|
93
|
+
clientDir = config.build.client;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// Dev serves it from memory: the raw marketing mount bypasses Astro's pipeline
|
|
97
|
+
// entirely, so without this the host 404s in `astro dev` and `tot dev` on exactly
|
|
98
|
+
// the pages this asset exists to cover. Rebundled per request (esbuild is fast) so
|
|
99
|
+
// an edit to the runtime shows up on reload rather than being pinned to server boot.
|
|
100
|
+
"astro:server:setup": ({ server }) => {
|
|
101
|
+
server.middlewares.use((req, res, next) => {
|
|
102
|
+
const path = (req.url ?? "").split("?")[0];
|
|
103
|
+
if (path !== `/${LIFECYCLE_HOST_ASSET_PATH}`) return next();
|
|
104
|
+
bundleLifecycleHost().then(
|
|
105
|
+
(code) => {
|
|
106
|
+
res.setHeader("content-type", "text/javascript; charset=utf-8");
|
|
107
|
+
res.setHeader("cache-control", "no-store");
|
|
108
|
+
res.end(code);
|
|
109
|
+
},
|
|
110
|
+
(error) => next(error),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
"astro:build:done": async ({ logger }) => {
|
|
116
|
+
if (!clientDir) throw new Error("the lifecycle host has no resolved client output dir");
|
|
117
|
+
const code = await bundleLifecycleHost();
|
|
118
|
+
const target = fileURLToPath(new URL(LIFECYCLE_HOST_ASSET_PATH, clientDir));
|
|
119
|
+
await mkdir(dirname(target), { recursive: true });
|
|
120
|
+
await writeFile(target, code, "utf8");
|
|
121
|
+
logger.info(`lifecycle host → /${LIFECYCLE_HOST_ASSET_PATH} (${code.length} bytes)`);
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -618,16 +618,14 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
618
618
|
entry in lib/cspStaticHashes.ts. */}
|
|
619
619
|
<script is:inline nonce={cspNonce} set:html={INTERACTION_TRACKING_BOOT_JS}></script>
|
|
620
620
|
|
|
621
|
-
{/* document.deferred global-entry boundary (ext-kernel-04) — the last phase,
|
|
622
|
-
|
|
621
|
+
{/* document.deferred global-entry boundary (ext-kernel-04) — the last phase, the
|
|
622
|
+
renderer-owned mount point for the deferred integration entry. This region also
|
|
623
|
+
CARRIES the lifecycle host tag (ext-kernel-05), which initializes the plan's
|
|
624
|
+
integrations exactly once per document, emits the governed page view, wires the
|
|
625
|
+
reserved completed-navigation event, and disposes on teardown; a no-op for an
|
|
626
|
+
empty plan. It ships with the phase markup rather than as a per-mount addition,
|
|
627
|
+
so the same bytes reach the raw marketing mount — which serves a tenant's
|
|
628
|
+
document verbatim and has no bundler to hoist a module import through. */}
|
|
623
629
|
<Fragment set:html={integrationPhases.bodyDeferred} />
|
|
624
|
-
|
|
625
|
-
{/* document.deferred lifecycle boot (ext-kernel-05) — initializes the plan's
|
|
626
|
-
integrations exactly once per document, wires the reserved completed-
|
|
627
|
-
navigation event, and disposes on teardown; a no-op for an empty plan. */}
|
|
628
|
-
<script>
|
|
629
|
-
import { bootIntegrationLifecycleFromDocument } from "@tot/public-runtime";
|
|
630
|
-
bootIntegrationLifecycleFromDocument();
|
|
631
|
-
</script>
|
|
632
630
|
</body>
|
|
633
631
|
</html>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewer ship-grant resolver (security-critical) — resolve a signed-in developer
|
|
3
|
+
* VIEWER's REAL, owner-revocable ship capability over a tenant, LIVE from the MCP
|
|
4
|
+
* subject-capability store. This is the near-side read of the Step-1 tot-mcp seam:
|
|
5
|
+
*
|
|
6
|
+
* GET `${origin}/api/internal/dev-access/subject-capability?subject=<email>&tenant=<appDomain>&app=storefront`
|
|
7
|
+
* Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (NOT a bearer)
|
|
8
|
+
* → 200 { capability: "ship-on-behalf" | "preview-access" }
|
|
9
|
+
* · an ABSENT / REVOKED grant answers `preview-access`
|
|
10
|
+
* · a store fault answers 502
|
|
11
|
+
*
|
|
12
|
+
* WHY it exists (the security constraint): a normally-invited developer's COARSE
|
|
13
|
+
* role is `preview-access`, NOT ship — the coarse "developer" role collapses to
|
|
14
|
+
* member. ship-on-behalf is a SEPARATE, explicit, OWNER-REVOCABLE grant. So the
|
|
15
|
+
* viewer/browser ship path must NEVER map the coarse role → ship and must NEVER
|
|
16
|
+
* trust a client-supplied `x-tot-capability` header; it resolves the REAL grant
|
|
17
|
+
* here, LIVE (no cache that could outlive a revoke).
|
|
18
|
+
*
|
|
19
|
+
* FAIL-CLOSED, TOTAL, NEVER THROWS: an unconfigured seam (no URL/secret), a missing
|
|
20
|
+
* subject/tenant, any non-2xx (INCLUDING 502), an unparseable body, an unexpected
|
|
21
|
+
* capability value, or a transport/timeout fault ALL resolve to `preview-access` —
|
|
22
|
+
* the deny default. Only an explicit `200 { capability: "ship-on-behalf" }` grants
|
|
23
|
+
* ship. This mirrors `dev/previewStatus.ts` + `grants/developerInvites.ts`'s
|
|
24
|
+
* `recordDeveloperAppBinding` transport (same MCP-origin + service-secret seam);
|
|
25
|
+
* it keeps its OWN small transport rather than importing another cluster's copy so
|
|
26
|
+
* the two stay decoupled (the same reason previewStatus duplicates its transport).
|
|
27
|
+
*
|
|
28
|
+
* The MCP HOST is the ORIGIN of `MCP_APP_BINDING_URL` — the same tier the invite
|
|
29
|
+
* app-binding WRITE (`recordDeveloperAppBinding`) targets, so this READ and that
|
|
30
|
+
* WRITE always agree on which subject-capability store they speak to.
|
|
31
|
+
*/
|
|
32
|
+
import { readEnv } from "@/lib/env";
|
|
33
|
+
import type { Capability } from "./capability.js";
|
|
34
|
+
|
|
35
|
+
/** Minimal GET transport (mirrors `dev/previewStatus.ts` — decoupled by design). */
|
|
36
|
+
export interface ViewerShipGrantGetTransport {
|
|
37
|
+
get(
|
|
38
|
+
url: string,
|
|
39
|
+
headers: Record<string, string>,
|
|
40
|
+
): Promise<{ status: number; json(): Promise<unknown> }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Default transport: JSON `fetch` GET. */
|
|
44
|
+
export class FetchShipGrantTransport implements ViewerShipGrantGetTransport {
|
|
45
|
+
async get(url: string, headers: Record<string, string>) {
|
|
46
|
+
// Bounded: this GET runs during admin-page SSR (AdminPublishTab frontmatter, on
|
|
47
|
+
// every admin tab), so an unbounded hang stalls the whole document stream. A
|
|
48
|
+
// timeout aborts to the documented deny-default (the resolver maps any transport/
|
|
49
|
+
// timeout fault to `preview-access`) — never a render-blocking wait on a slow MCP.
|
|
50
|
+
const res = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(2500) });
|
|
51
|
+
return { status: res.status, json: () => res.json() };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Injectable deps so the resolver is unit-testable without a live MCP. */
|
|
56
|
+
export interface ViewerShipGrantDeps {
|
|
57
|
+
transport?: ViewerShipGrantGetTransport;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The Step-1 subject-capability reader path (mounted OUTSIDE the session guard). */
|
|
61
|
+
const SUBJECT_CAPABILITY_PATH = "/api/internal/dev-access/subject-capability";
|
|
62
|
+
|
|
63
|
+
/** The viewer's blog-contribute capability — a NEW, blog-scoped projection kept OUT
|
|
64
|
+
* of the forge `Capability` union (which stays exactly two rungs). `ship-on-behalf`
|
|
65
|
+
* supersets contribute, so a ship grant also yields `"blog-contribute"` here. */
|
|
66
|
+
export type ViewerContributeCapability = "preview-access" | "blog-contribute";
|
|
67
|
+
|
|
68
|
+
/** The raw recognized capability read off the subject-capability seam. The private
|
|
69
|
+
* transport whitelists BOTH grant literals; the public wrappers narrow it. */
|
|
70
|
+
type RawViewerCapability = "preview-access" | "blog-contribute" | "ship-on-behalf";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ONE live subject-capability lookup, returning the raw whitelisted capability.
|
|
74
|
+
* FAIL-CLOSED, TOTAL, NEVER THROWS: an unconfigured seam (no URL/secret), a missing
|
|
75
|
+
* subject/tenant, any non-2xx (INCLUDING 502), an unparseable body, an unexpected
|
|
76
|
+
* capability value, or a transport/timeout fault ALL resolve to `"preview-access"` —
|
|
77
|
+
* the deny default. Only an explicit `200 { capability: "ship-on-behalf" }` or
|
|
78
|
+
* `200 { capability: "blog-contribute" }` returns a grant literal.
|
|
79
|
+
*/
|
|
80
|
+
async function resolveRawViewerCapability(
|
|
81
|
+
email: string | null | undefined,
|
|
82
|
+
appDomain: string | null | undefined,
|
|
83
|
+
deps: ViewerShipGrantDeps = {},
|
|
84
|
+
): Promise<RawViewerCapability> {
|
|
85
|
+
const subject = (email ?? "").trim().toLowerCase();
|
|
86
|
+
const tenant = (appDomain ?? "").trim();
|
|
87
|
+
if (!subject || !tenant) return "preview-access";
|
|
88
|
+
|
|
89
|
+
const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
|
|
90
|
+
const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
|
|
91
|
+
// Unconfigured seam → deny (the MCP route is only mounted once the secret exists).
|
|
92
|
+
if (!bindingUrl || !secret) return "preview-access";
|
|
93
|
+
|
|
94
|
+
let endpoint: string;
|
|
95
|
+
try {
|
|
96
|
+
endpoint = `${new URL(bindingUrl).origin}${SUBJECT_CAPABILITY_PATH}`;
|
|
97
|
+
} catch {
|
|
98
|
+
return "preview-access";
|
|
99
|
+
}
|
|
100
|
+
const full =
|
|
101
|
+
`${endpoint}?subject=${encodeURIComponent(subject)}` +
|
|
102
|
+
`&tenant=${encodeURIComponent(tenant)}&app=storefront`;
|
|
103
|
+
|
|
104
|
+
const transport = deps.transport ?? new FetchShipGrantTransport();
|
|
105
|
+
let res: { status: number; json(): Promise<unknown> };
|
|
106
|
+
try {
|
|
107
|
+
res = await transport.get(full, { "x-mcp-service-secret": secret });
|
|
108
|
+
} catch {
|
|
109
|
+
// Transport / timeout fault — fail closed.
|
|
110
|
+
return "preview-access";
|
|
111
|
+
}
|
|
112
|
+
// Any non-2xx (incl. 502 store fault, 401 unmounted, 404) → deny.
|
|
113
|
+
if (res.status < 200 || res.status >= 300) return "preview-access";
|
|
114
|
+
|
|
115
|
+
let payload: unknown;
|
|
116
|
+
try {
|
|
117
|
+
payload = await res.json();
|
|
118
|
+
} catch {
|
|
119
|
+
return "preview-access";
|
|
120
|
+
}
|
|
121
|
+
const capability = (payload as Record<string, unknown> | null)?.capability;
|
|
122
|
+
// Whitelist: ONLY the explicit grant literals admit; anything else is deny-default.
|
|
123
|
+
if (capability === "ship-on-behalf") return "ship-on-behalf";
|
|
124
|
+
if (capability === "blog-contribute") return "blog-contribute";
|
|
125
|
+
return "preview-access";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the viewer's LIVE ship capability over `appDomain`. Returns the granted
|
|
130
|
+
* `Capability` — `"ship-on-behalf"` ONLY on an explicit `200 { capability:
|
|
131
|
+
* "ship-on-behalf" }`; EVERYTHING else (unconfigured, absent, 502, timeout,
|
|
132
|
+
* malformed, unexpected value) fail-closes to `"preview-access"`. Never throws.
|
|
133
|
+
*
|
|
134
|
+
* @param email the signed-in viewer's email (the subject-capability store key)
|
|
135
|
+
* @param appDomain the resolved tenant the capability is scoped to (server-derived)
|
|
136
|
+
*/
|
|
137
|
+
export async function resolveViewerShipCapability(
|
|
138
|
+
email: string | null | undefined,
|
|
139
|
+
appDomain: string | null | undefined,
|
|
140
|
+
deps: ViewerShipGrantDeps = {},
|
|
141
|
+
): Promise<Capability> {
|
|
142
|
+
const raw = await resolveRawViewerCapability(email, appDomain, deps);
|
|
143
|
+
return raw === "ship-on-behalf" ? "ship-on-behalf" : "preview-access";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the viewer's LIVE blog-contribute capability over `appDomain`, from the
|
|
148
|
+
* SAME subject-capability seam the ship resolver reads. Returns `"blog-contribute"`
|
|
149
|
+
* ONLY on an explicit `200 { capability: "blog-contribute" }` OR `200 { capability:
|
|
150
|
+
* "ship-on-behalf" }` (ship supersets contribute); EVERYTHING else (unconfigured,
|
|
151
|
+
* absent, 502, timeout, malformed, unexpected value) fail-closes to
|
|
152
|
+
* `"preview-access"`. Never throws. Mirrors `resolveViewerShipCapability` exactly.
|
|
153
|
+
*
|
|
154
|
+
* @param email the signed-in viewer's email (the subject-capability store key)
|
|
155
|
+
* @param appDomain the resolved tenant the capability is scoped to (server-derived)
|
|
156
|
+
*/
|
|
157
|
+
export async function resolveViewerContributeCapability(
|
|
158
|
+
email: string | null | undefined,
|
|
159
|
+
appDomain: string | null | undefined,
|
|
160
|
+
deps: ViewerShipGrantDeps = {},
|
|
161
|
+
): Promise<ViewerContributeCapability> {
|
|
162
|
+
const raw = await resolveRawViewerCapability(email, appDomain, deps);
|
|
163
|
+
return raw === "blog-contribute" || raw === "ship-on-behalf"
|
|
164
|
+
? "blog-contribute"
|
|
165
|
+
: "preview-access";
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.24",
|
|
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",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"types": "./src/index.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
|
-
"./blog-visibility": "./src/blog-visibility.mjs"
|
|
12
|
+
"./blog-visibility": "./src/blog-visibility.mjs",
|
|
13
|
+
"./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs"
|
|
13
14
|
},
|
|
14
15
|
"scripts": {
|
|
15
16
|
"typecheck": "tsc --noEmit",
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
* the renderer-inlined default-deny consent snapshot and holds a non-essential entry
|
|
25
25
|
* back until its declared purposes are eligible. With an environment-only plan there
|
|
26
26
|
* are no integrations, so the runtime boots inert and ready.
|
|
27
|
+
*
|
|
28
|
+
* The binding also constructs this document's managed-analytics bridge over the same
|
|
29
|
+
* commerce broker the adapters subscribe to, and hands the lifecycle the bootstrap
|
|
30
|
+
* page-view input built from the host document (path + minimized attribution). An
|
|
31
|
+
* adapter's subscription is receive-only, so this is what an active analytics
|
|
32
|
+
* integration actually receives — without it every adapter loads, subscribes, and then
|
|
33
|
+
* waits on a broker nothing emits into.
|
|
27
34
|
*/
|
|
28
35
|
import { IntegrationLifecycle, type IntegrationModuleLoader } from "./lifecycle-runtime.js";
|
|
29
36
|
import type { LifecycleObserver } from "./lifecycle-runtime.js";
|
|
@@ -36,6 +43,8 @@ import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
|
|
|
36
43
|
import { CommerceEventBroker } from "./commerce-broker.js";
|
|
37
44
|
import { validateBrowserEnvironment, BROWSER_ENVIRONMENT_GLOBAL, type BrowserEnvironment } from "./environment.js";
|
|
38
45
|
import { makePlatformAdapterRuntimeLoader } from "./platform-adapter-runtime.js";
|
|
46
|
+
import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
|
|
47
|
+
import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
|
|
39
48
|
|
|
40
49
|
/** The window property holding the single per-document lifecycle (idempotent boot guard). */
|
|
41
50
|
export const LIFECYCLE_SINGLETON_GLOBAL = "__TOT_INTEGRATION_LIFECYCLE__";
|
|
@@ -54,6 +63,12 @@ export interface LifecycleWindow {
|
|
|
54
63
|
location: { pathname: string; search: string; hash: string; href: string };
|
|
55
64
|
addEventListener(type: string, listener: (event: unknown) => void): void;
|
|
56
65
|
fetch?: typeof fetch;
|
|
66
|
+
/**
|
|
67
|
+
* Read ONLY for the referrer the managed page view minimizes to a bare origin. The
|
|
68
|
+
* analytics data allowlist accepts no other document state — no title, no DOM, no
|
|
69
|
+
* globals — so this is deliberately the narrowest slice that supports attribution.
|
|
70
|
+
*/
|
|
71
|
+
document?: { referrer?: string };
|
|
57
72
|
[key: string]: unknown;
|
|
58
73
|
}
|
|
59
74
|
|
|
@@ -83,6 +98,14 @@ export interface BootLifecycleOptions {
|
|
|
83
98
|
* consent-filtered exactly like the platform path. Injectable for tests.
|
|
84
99
|
*/
|
|
85
100
|
commerceBroker?: CommerceEventBroker;
|
|
101
|
+
/**
|
|
102
|
+
* The managed-analytics bridge that emits this document's governed page view into the
|
|
103
|
+
* commerce broker; defaults to one constructed over the resolved broker. Injectable so
|
|
104
|
+
* a test can assert the emission without a DOM.
|
|
105
|
+
*/
|
|
106
|
+
managedAnalyticsBridge?: ManagedAnalyticsEventBrokerBridge;
|
|
107
|
+
/** Epoch-millisecond source for the page view's correlation expiry; defaults to the wall clock. */
|
|
108
|
+
now?: () => number;
|
|
86
109
|
/** Host-owned fetch capability for reviewed first-party platform adapters. */
|
|
87
110
|
fetchCapability?: typeof fetch;
|
|
88
111
|
/** Entry import capability; injectable for tests and non-browser hosts. */
|
|
@@ -178,6 +201,47 @@ function makeDocumentCommerceBroker(
|
|
|
178
201
|
});
|
|
179
202
|
}
|
|
180
203
|
|
|
204
|
+
/**
|
|
205
|
+
* How long the bootstrap page view's attribution thread stays valid. It matches the
|
|
206
|
+
* correlation TTL the cart-handoff seam uses, so one visit's page view and any later
|
|
207
|
+
* commerce event it is threaded to expire together.
|
|
208
|
+
*/
|
|
209
|
+
const PAGE_VIEW_CORRELATION_TTL_MS = 60 * 60 * 1000;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build this document's bootstrap page-view input from the host document.
|
|
213
|
+
*
|
|
214
|
+
* The data allowlist is the whole surface: the route PATH (never the query string, where
|
|
215
|
+
* preview/candidate wrappers and tokens live) plus attribution already minimized to a
|
|
216
|
+
* referrer ORIGIN and the five standard UTM tokens. Title, DOM state, and browser globals
|
|
217
|
+
* are not accepted by this seam and are not read.
|
|
218
|
+
*
|
|
219
|
+
* `logicalId` and the correlation id are derived from the plan revision rather than
|
|
220
|
+
* randomized: one traditional document is counted exactly once, each document load builds
|
|
221
|
+
* its own broker, and the broker's dedupe key is per-document — so a stable, opaque,
|
|
222
|
+
* non-identifying value is both sufficient and deterministically testable.
|
|
223
|
+
*/
|
|
224
|
+
function documentPageView(
|
|
225
|
+
win: LifecycleWindow,
|
|
226
|
+
descriptor: IntegrationBootDescriptor,
|
|
227
|
+
now: () => number,
|
|
228
|
+
): ManagedPageViewInput {
|
|
229
|
+
const attribution = minimizePageAttribution({
|
|
230
|
+
referrerUrl: win.document?.referrer ?? null,
|
|
231
|
+
routeUrl: win.location.href,
|
|
232
|
+
});
|
|
233
|
+
const id = `pageview-${descriptor.planRevision}`;
|
|
234
|
+
return {
|
|
235
|
+
logicalId: id,
|
|
236
|
+
path: win.location.pathname,
|
|
237
|
+
correlation: {
|
|
238
|
+
id,
|
|
239
|
+
expiresAt: new Date(now() + PAGE_VIEW_CORRELATION_TTL_MS).toISOString(),
|
|
240
|
+
},
|
|
241
|
+
...(attribution ? { attribution } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
181
245
|
function readDescriptor(win: LifecycleWindow): IntegrationBootDescriptor | null {
|
|
182
246
|
const descriptor = win[INTEGRATION_PLAN_GLOBAL];
|
|
183
247
|
if (!descriptor || typeof descriptor !== "object") return null;
|
|
@@ -259,6 +323,9 @@ export function bootIntegrationLifecycle(
|
|
|
259
323
|
const commerceBroker =
|
|
260
324
|
options.commerceBroker ?? makeDocumentCommerceBroker(descriptor, getConsentSnapshot);
|
|
261
325
|
|
|
326
|
+
// The managed-analytics bridge over the SAME broker the adapters subscribe to — the
|
|
327
|
+
// only seam that emits this document's governed page view. The lifecycle owns when it
|
|
328
|
+
// fires (after every adapter has subscribed, and re-attempted on a consent grant).
|
|
262
329
|
const lifecycle = new IntegrationLifecycle({
|
|
263
330
|
descriptor,
|
|
264
331
|
loadModule,
|
|
@@ -268,6 +335,11 @@ export function bootIntegrationLifecycle(
|
|
|
268
335
|
environment,
|
|
269
336
|
getConsentSnapshot,
|
|
270
337
|
commerceBroker,
|
|
338
|
+
managedAnalytics: {
|
|
339
|
+
bridge: options.managedAnalyticsBridge ??
|
|
340
|
+
new ManagedAnalyticsEventBrokerBridge({ broker: commerceBroker }),
|
|
341
|
+
pageView: () => documentPageView(win, descriptor, options.now ?? Date.now),
|
|
342
|
+
},
|
|
271
343
|
});
|
|
272
344
|
win[LIFECYCLE_SINGLETON_GLOBAL] = lifecycle;
|
|
273
345
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stable public URL of the document.deferred lifecycle host bundle.
|
|
3
|
+
*
|
|
4
|
+
* It lives in a `.mjs` module because it has TWO consumers that cannot share a `.ts`
|
|
5
|
+
* one: the render engine (`render-phases.ts`) that emits the `<script src>`, and the
|
|
6
|
+
* build plugin (`apps/storefront/build-plugins/lifecycle-host-asset.mjs`) that produces
|
|
7
|
+
* the file at that exact path. One source of truth, so the tag and the artifact cannot
|
|
8
|
+
* disagree — a mismatch would leave every static tenant page with plan data and no host,
|
|
9
|
+
* which fails silently in exactly the way it is not allowed to.
|
|
10
|
+
*
|
|
11
|
+
* The path is version-pinned (`/v1/`) like a platform adapter entry, so a future host
|
|
12
|
+
* with a different boot contract ships alongside rather than replacing this one.
|
|
13
|
+
*/
|
|
14
|
+
export const LIFECYCLE_HOST_ASSET_PATH = "platform/lifecycle/v1/boot.mjs";
|
|
15
|
+
|
|
16
|
+
/** The root-absolute, same-origin URL the served markup references. */
|
|
17
|
+
export const LIFECYCLE_HOST_ASSET_URL = `/${LIFECYCLE_HOST_ASSET_PATH}`;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document.deferred lifecycle HOST — the side-effecting entry both document mounts
|
|
3
|
+
* load, bundled to the stable same-origin URL in `./lifecycle-host-asset.mjs`.
|
|
4
|
+
*
|
|
5
|
+
* It is deliberately the whole module: importing it BOOTS. That is what makes it usable
|
|
6
|
+
* from a plain `<script type="module" src>` tag, which in turn is what lets the raw
|
|
7
|
+
* marketing mount host the lifecycle at all — it serves a tenant's own document verbatim
|
|
8
|
+
* and never passes through a bundler, so it cannot host a module-graph import the way
|
|
9
|
+
* `Layout.astro` can.
|
|
10
|
+
*
|
|
11
|
+
* NOT exported from the package barrel: `index.ts` re-exports its modules, and a
|
|
12
|
+
* self-invoking module there would boot the lifecycle on any server-side import.
|
|
13
|
+
*/
|
|
14
|
+
import { bootIntegrationLifecycleFromDocument } from "./lifecycle-boot.js";
|
|
15
|
+
|
|
16
|
+
bootIntegrationLifecycleFromDocument();
|
|
@@ -25,6 +25,8 @@ import type { CommerceEventBroker } from "./commerce-broker.js";
|
|
|
25
25
|
import type { IntegrationBootDescriptor, BootDescriptorIntegration } from "./render-phases.js";
|
|
26
26
|
import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
|
|
27
27
|
import { validateNavigationCompletedEvent } from "./events.js";
|
|
28
|
+
import type { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
|
|
29
|
+
import type { ManagedPageViewInput } from "./managed-analytics.js";
|
|
28
30
|
import { COMMERCE_EVENT_STOREFRONT_SCHEMA_VERSION, ENVIRONMENT_SCHEMA_VERSION } from "./version.js";
|
|
29
31
|
import type { BrowserEnvironment } from "./environment.js";
|
|
30
32
|
import type { ConsentSnapshot } from "./consent-broker.js";
|
|
@@ -125,6 +127,26 @@ export interface IntegrationLifecycleOptions {
|
|
|
125
127
|
* shape a DOM-free unit test uses when it wires no broker.
|
|
126
128
|
*/
|
|
127
129
|
commerceBroker?: CommerceEventBroker;
|
|
130
|
+
/**
|
|
131
|
+
* The document's managed-analytics bridge and the bootstrap page-view input for THIS
|
|
132
|
+
* document. Wiring it is what makes an active analytics adapter actually RECEIVE
|
|
133
|
+
* something: the bridge is the only seam that emits `storefront.page.viewed.v2` into
|
|
134
|
+
* {@link commerceBroker}, and an adapter's subscription is receive-only, so with no
|
|
135
|
+
* bridge every adapter initializes, subscribes, and then waits on a broker nothing
|
|
136
|
+
* ever emits into.
|
|
137
|
+
*
|
|
138
|
+
* The runtime owns the ORDERING, because the broker delivers to the subscribers
|
|
139
|
+
* registered at emit time and never replays: the bootstrap page view is emitted at the
|
|
140
|
+
* END of {@link init}, once every eligible integration has initialized (and so
|
|
141
|
+
* subscribed). `pageView` is a factory rather than a value because it is built from the
|
|
142
|
+
* host's DOM (path, referrer, UTM), which this DOM-free core must not read itself.
|
|
143
|
+
*
|
|
144
|
+
* Absent ⇒ no page view (the shape a DOM-free unit test uses when it wires no bridge).
|
|
145
|
+
*/
|
|
146
|
+
managedAnalytics?: {
|
|
147
|
+
bridge: ManagedAnalyticsEventBrokerBridge;
|
|
148
|
+
pageView: () => ManagedPageViewInput;
|
|
149
|
+
};
|
|
128
150
|
/** ISO-8601 timestamp source for the navigation event; defaults to wall clock. */
|
|
129
151
|
now?: () => string;
|
|
130
152
|
/** Opaque per-event id source; defaults to a monotonic, non-secret local counter. */
|
|
@@ -208,6 +230,34 @@ export class IntegrationLifecycle {
|
|
|
208
230
|
}
|
|
209
231
|
// A dispose that raced in during an await leaves us terminal; otherwise we are live.
|
|
210
232
|
if (this.state === "initializing") this.state = "initialized";
|
|
233
|
+
// Last, so every adapter that just subscribed is registered when this emits: the
|
|
234
|
+
// broker has no replay, so a page view emitted before their `init` reaches nobody.
|
|
235
|
+
if (this.state === "initialized") this.emitBootstrapPageView();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Emit this document's bootstrap-owned page view through the managed-analytics bridge
|
|
240
|
+
* (`storefront.page.viewed.v2`), exactly once.
|
|
241
|
+
*
|
|
242
|
+
* Consent is a PRECONDITION here rather than something the bridge is left to discover:
|
|
243
|
+
* the bridge counts a document once and is then terminal for `initialize`, so calling
|
|
244
|
+
* it while `analytics` is ineligible would burn the document's only page view on a
|
|
245
|
+
* default-deny snapshot and lose it permanently. Instead the attempt is held and
|
|
246
|
+
* retried from {@link update}, so a visitor who grants analytics after load still gets
|
|
247
|
+
* counted for the document they are on.
|
|
248
|
+
*
|
|
249
|
+
* Analytics never breaks the page: a throwing bridge is swallowed exactly like a
|
|
250
|
+
* throwing integration.
|
|
251
|
+
*/
|
|
252
|
+
private emitBootstrapPageView(): void {
|
|
253
|
+
const managed = this.opts.managedAnalytics;
|
|
254
|
+
if (!managed || managed.bridge.isInitialized || managed.bridge.isDisposed) return;
|
|
255
|
+
if (this.opts.commerceBroker?.isConsentEligible("analytics") === false) return;
|
|
256
|
+
try {
|
|
257
|
+
managed.bridge.initialize(managed.pageView());
|
|
258
|
+
} catch {
|
|
259
|
+
// A page view is never worth a broken document.
|
|
260
|
+
}
|
|
211
261
|
}
|
|
212
262
|
|
|
213
263
|
/** Whether the consent gate (when injected) permits loading this integration now. */
|
|
@@ -319,6 +369,10 @@ export class IntegrationLifecycle {
|
|
|
319
369
|
this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
|
|
320
370
|
}
|
|
321
371
|
}
|
|
372
|
+
// A consent grant is the case that matters: it both loads a previously-gated adapter
|
|
373
|
+
// (above) and makes the held-back bootstrap page view emittable, so the document the
|
|
374
|
+
// visitor consented ON is still counted rather than only its successors.
|
|
375
|
+
this.emitBootstrapPageView();
|
|
322
376
|
}
|
|
323
377
|
|
|
324
378
|
/**
|
|
@@ -340,6 +394,11 @@ export class IntegrationLifecycle {
|
|
|
340
394
|
}
|
|
341
395
|
this.active.length = 0;
|
|
342
396
|
this.deferred = []; // gated integrations never loaded — nothing to dispose.
|
|
397
|
+
try {
|
|
398
|
+
this.opts.managedAnalytics?.bridge.dispose();
|
|
399
|
+
} catch {
|
|
400
|
+
// Teardown is best-effort; a throwing bridge cannot leave the document undisposed.
|
|
401
|
+
}
|
|
343
402
|
this.state = "disposed";
|
|
344
403
|
}
|
|
345
404
|
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* `bodyStart` (body.start fallback), and `bodyDeferred` (document.deferred global
|
|
10
10
|
* entry). Both document mounts — `Layout.astro` and `rawHtmlResponse()` — splice
|
|
11
11
|
* the SAME strings, so their per-phase markup is identical by construction (golden
|
|
12
|
-
* parity is a property of calling one function, not of keeping two in sync).
|
|
12
|
+
* parity is a property of calling one function, not of keeping two in sync). The
|
|
13
|
+
* `document.deferred` region includes the lifecycle HOST tag, so "the plan is
|
|
14
|
+
* published" and "the plan runs" cannot come apart between mounts.
|
|
13
15
|
*
|
|
14
16
|
* Nonce discipline (ADR 0018): the engine stamps the {@link INTEGRATION_NONCE_PLACEHOLDER}
|
|
15
17
|
* sentinel, never a live nonce, onto its inline `<script>`s. The renderer replaces
|
|
@@ -37,6 +39,10 @@ import {
|
|
|
37
39
|
serializeForInlineScript,
|
|
38
40
|
} from "./environment.js";
|
|
39
41
|
import { defaultConsentSnapshot, type ConsentSnapshot } from "./consent-broker.js";
|
|
42
|
+
import { LIFECYCLE_HOST_ASSET_URL } from "./lifecycle-host-asset.mjs";
|
|
43
|
+
|
|
44
|
+
/** Re-exported so consumers and tests read the host URL from the engine that emits it. */
|
|
45
|
+
export { LIFECYCLE_HOST_ASSET_PATH, LIFECYCLE_HOST_ASSET_URL } from "./lifecycle-host-asset.mjs";
|
|
40
46
|
|
|
41
47
|
/**
|
|
42
48
|
* The sentinel a renderer stamps in place of a live CSP nonce. It is a valid
|
|
@@ -163,11 +169,33 @@ export function renderIntegrationPhases(
|
|
|
163
169
|
phaseBoundary("body.start") + `<div data-tot-integration-root hidden></div>`;
|
|
164
170
|
|
|
165
171
|
const bodyDeferred =
|
|
166
|
-
phaseBoundary("document.deferred") +
|
|
172
|
+
phaseBoundary("document.deferred") +
|
|
173
|
+
`<div data-tot-integration-deferred hidden></div>` +
|
|
174
|
+
lifecycleHostScript();
|
|
167
175
|
|
|
168
176
|
return { head, bodyStart, bodyDeferred };
|
|
169
177
|
}
|
|
170
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The document.deferred lifecycle HOST tag — what actually boots the plan.
|
|
181
|
+
*
|
|
182
|
+
* It belongs to the ENGINE, not to a mount. The engine's promise is that both mounts
|
|
183
|
+
* emit identical per-phase markup because they call one function; a host installed by
|
|
184
|
+
* only one of them breaks that promise in the way that matters most, because the mount
|
|
185
|
+
* that lacks it still publishes `__TOT_INTEGRATION_PLAN__`. A page then advertises a
|
|
186
|
+
* complete, enabled integration plan and runs none of it — indistinguishable, from every
|
|
187
|
+
* surface that reports configuration, from a working install.
|
|
188
|
+
*
|
|
189
|
+
* Carries no nonce by design: it is a same-origin `src` reference, admitted by
|
|
190
|
+
* `script-src 'self'` (see `csp.ts`), so it needs neither a nonce nor a content hash —
|
|
191
|
+
* the same treatment the static `/js/*.js` behaviors get. That also keeps it correct
|
|
192
|
+
* under the raw mount, which stamps nonces onto the tenant's own inline tags BEFORE
|
|
193
|
+
* splicing engine bytes.
|
|
194
|
+
*/
|
|
195
|
+
function lifecycleHostScript(): string {
|
|
196
|
+
return `<script type="module" src="${LIFECYCLE_HOST_ASSET_URL}"></script>`;
|
|
197
|
+
}
|
|
198
|
+
|
|
171
199
|
/**
|
|
172
200
|
* Replace every {@link INTEGRATION_NONCE_PLACEHOLDER} in `text` (an HTML body or a
|
|
173
201
|
* CSP header value) with one fresh per-response `nonce`. Idempotent on text with no
|