@tokenoftrust/storefront-runner 2.0.17 → 2.0.19
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/env.d.ts +21 -0
- package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
- package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
- package/apps/storefront/src/config/devTenantSeed.ts +24 -1
- package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
- package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
- package/apps/storefront/src/config/resolver.ts +5 -0
- package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
- package/apps/storefront/src/config/tenants.ts +11 -0
- package/apps/storefront/src/layouts/Layout.astro +5 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
- package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
- package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
- package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
- package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
- package/apps/storefront/src/lib/auth/session.ts +3 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
- package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
- package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
- package/apps/storefront/src/lib/edgeCache.ts +17 -1
- package/apps/storefront/src/lib/env.ts +13 -111
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
- package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
- package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
- package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
- package/apps/storefront/src/middleware/candidatePin.ts +35 -0
- package/apps/storefront/src/middleware/controlPlane.ts +291 -0
- package/apps/storefront/src/middleware/index.ts +200 -793
- package/apps/storefront/src/middleware/publicRender.ts +43 -0
- package/apps/storefront/src/middleware/renderChain.ts +730 -0
- package/apps/storefront/src/middleware/routeBranch.ts +192 -0
- package/apps/storefront/src/middleware/staticBundle.ts +109 -0
- package/apps/storefront/src/pages/[...slug].astro +1 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
- package/apps/storefront/src/pages/index.astro +1 -0
- package/apps/storefront/vitest.config.ts +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
- package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
- package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
- package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
- package/packages/public-runtime/src/activity/catalog.ts +1 -1
- package/packages/public-runtime/src/candidate-index.ts +6 -0
- package/packages/public-runtime/src/customization-reconcile.ts +8 -0
- package/packages/public-runtime/src/html-cache-policy.ts +57 -0
- package/packages/public-runtime/src/index.ts +9 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
- package/packages/public-runtime/src/integration/render-phases.ts +8 -3
- package/packages/public-runtime/src/publication-artifact.ts +220 -0
- package/packages/public-runtime/src/publication-reader.ts +187 -0
- package/packages/public-runtime/src/publication-routing.ts +246 -0
- package/packages/public-runtime/src/publication-space.ts +853 -0
- package/packages/public-runtime/src/publication-version.ts +602 -0
- package/packages/public-runtime/src/route-tier.ts +257 -0
- package/packages/public-runtime/src/static-bundle.ts +18 -5
- package/packages/public-runtime/src/tenant-assets.ts +85 -17
- package/packages/public-runtime/src/tenant.ts +70 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
- package/scripts/dev/ai-edit.mjs +16 -3
- package/scripts/dev/port-check.mjs +18 -3
- package/scripts/dev/publish.mjs +26 -3
- package/scripts/tenant/validate.mjs +14 -1
|
@@ -1,123 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* - Cloudflare Workers (prod): reads the `cloudflare:workers` env module.
|
|
2
|
+
* The FULL runtime binding surface: everything in `./runtimeEnv.js` plus the D1
|
|
3
|
+
* binding. Control-plane code imports this one and gets all of it.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The D1 accessor lives HERE rather than in `runtimeEnv.js` because ADR 0013
|
|
6
|
+
* keeps a live control-plane read off a tenant's public path, and a
|
|
7
|
+
* dependency-cruiser fence can only forbid a MODULE — never a symbol. Splitting
|
|
8
|
+
* `readD1` onto its own import graph is what gives
|
|
9
|
+
* `public-render-no-request-time-d1` (.dependency-cruiser.cjs) something to
|
|
10
|
+
* name: the public-render branch reaches `runtimeEnv.js` for the bindings that
|
|
11
|
+
* serve published bytes (KV cache, R2 bundles) and cannot reach this module at
|
|
12
|
+
* all, so a request-time D1 handle is unobtainable there rather than merely
|
|
13
|
+
* unused.
|
|
10
14
|
*/
|
|
11
15
|
import type { D1Like } from "./d1/catalog.js";
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
export * from "./runtimeEnv.js";
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
if (workerEnv !== undefined) return workerEnv;
|
|
17
|
-
try {
|
|
18
|
-
// @ts-expect-error — virtual module, only resolvable in the Workers runtime.
|
|
19
|
-
const mod = await import("cloudflare:workers");
|
|
20
|
-
workerEnv = (mod.env ?? null) as Record<string, unknown> | null;
|
|
21
|
-
} catch {
|
|
22
|
-
workerEnv = null;
|
|
23
|
-
}
|
|
24
|
-
return workerEnv;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function fromBuild(key: string): string | undefined {
|
|
28
|
-
const meta = import.meta.env as Record<string, string | undefined>;
|
|
29
|
-
if (meta[key] != null) return meta[key];
|
|
30
|
-
if (typeof process !== "undefined" && process.env && process.env[key] != null) {
|
|
31
|
-
return process.env[key];
|
|
32
|
-
}
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function readEnv(key: string): Promise<string | undefined> {
|
|
37
|
-
const w = await getWorkerEnv();
|
|
38
|
-
const wv = w?.[key];
|
|
39
|
-
if (typeof wv === "string" && wv.length) return wv;
|
|
40
|
-
return fromBuild(key);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The slice of the Workers ExecutionContext runAfterResponse needs — the
|
|
44
|
-
* @astrojs/cloudflare adapter exposes it to routes as `locals.cfContext`. */
|
|
45
|
-
export interface CfExecutionContextLike {
|
|
46
|
-
waitUntil(promise: Promise<unknown>): void;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Run `work` PAST the response — the request's `ExecutionContext.waitUntil`
|
|
51
|
-
* when the caller passes it, else a detached floating promise (dev/Node). For
|
|
52
|
-
* webhook RECEIVERS that must ACK fast: the upstream forwarder (tot-mcp
|
|
53
|
-
* gitea-webhook proxy) aborts its forward at 15s and Gitea bounds its delivery
|
|
54
|
-
* wait too, while a real reconcile takes tens of seconds — running it inside
|
|
55
|
-
* the request meant every healthy reconcile was killed mid-flight and recorded
|
|
56
|
-
* as `forward_failed` (a layer of Trello 13075, behind the auth fix).
|
|
57
|
-
*
|
|
58
|
-
* CALLERS MUST PASS `locals.cfContext` (the @astrojs/cloudflare adapter's
|
|
59
|
-
* per-request ExecutionContext, `createLocals: { cfContext: ctx }`): in the
|
|
60
|
-
* Workers runtime a merely-floating promise is CANCELLED the moment the
|
|
61
|
-
* response returns — an earlier revision here imported `waitUntil` from
|
|
62
|
-
* `cloudflare:workers` (not exported by this adapter/runtime version), silently
|
|
63
|
-
* degraded to the floating promise, and the async reconcile died unobserved
|
|
64
|
-
* right after its 202. The floating-promise branch is a DEV-ONLY fallback.
|
|
65
|
-
* Errors are the caller's to handle inside `work` (this never throws).
|
|
66
|
-
*/
|
|
67
|
-
export function runAfterResponse(
|
|
68
|
-
work: Promise<unknown>,
|
|
69
|
-
cfContext?: CfExecutionContextLike | null,
|
|
70
|
-
): void {
|
|
71
|
-
const swallowed = work.catch(() => {});
|
|
72
|
-
if (cfContext && typeof cfContext.waitUntil === "function") {
|
|
73
|
-
cfContext.waitUntil(swallowed);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
// dev/Node only — in the Workers runtime this promise would be cancelled at
|
|
77
|
-
// response end, which is exactly the bug the parameter above exists to avoid.
|
|
78
|
-
void swallowed;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** KV binding for the newsletter stub / tenant cache, if present in the runtime. */
|
|
82
|
-
export async function readKv(binding: string): Promise<KVNamespace | undefined> {
|
|
83
|
-
const w = await getWorkerEnv();
|
|
84
|
-
const b = w?.[binding];
|
|
85
|
-
return (b as KVNamespace | undefined) ?? undefined;
|
|
86
|
-
}
|
|
19
|
+
import { getWorkerEnv } from "./runtimeEnv.js";
|
|
87
20
|
|
|
88
21
|
/** D1 catalog binding for the edge read replica, if present in the runtime. */
|
|
89
22
|
export async function readD1(binding: string): Promise<D1Like | undefined> {
|
|
90
23
|
const w = await getWorkerEnv();
|
|
91
24
|
return (w?.[binding] as D1Like | undefined) ?? undefined;
|
|
92
25
|
}
|
|
93
|
-
|
|
94
|
-
/** R2 bucket binding (e.g. TENANT_ASSETS, PRODUCT_IMAGES), if present. */
|
|
95
|
-
export async function readR2(binding: string): Promise<R2Bucket | undefined> {
|
|
96
|
-
const w = await getWorkerEnv();
|
|
97
|
-
return (w?.[binding] as R2Bucket | undefined) ?? undefined;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Cloudflare Analytics Engine dataset binding (e.g. ACTIVITY_ANALYTICS), if
|
|
102
|
-
* present. Untyped (`unknown`) deliberately — the caller casts to its own
|
|
103
|
-
* minimal `writeDataPoint` shape (see `activity/store.ts`'s
|
|
104
|
-
* `AnalyticsEngineLike`) rather than this module taking a types dep. Absent in
|
|
105
|
-
* dev/Node and until the binding is provisioned; callers treat undefined as
|
|
106
|
-
* "analytics sink unavailable, skip it" (never an error).
|
|
107
|
-
*/
|
|
108
|
-
export async function readAnalytics(binding: string): Promise<unknown | undefined> {
|
|
109
|
-
const w = await getWorkerEnv();
|
|
110
|
-
return w?.[binding] ?? undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Cloudflare Queue producer binding (e.g. WEBHOOK_DELIVERY_QUEUE), if present.
|
|
115
|
-
* Untyped (`unknown`) deliberately — callers cast to their own minimal
|
|
116
|
-
* queue-shape interface (see `webhooks/cloudflareQueueDispatcher.ts`'s
|
|
117
|
-
* `CloudflareQueueSender`) rather than this module taking a types dep on a
|
|
118
|
-
* specific binding's shape.
|
|
119
|
-
*/
|
|
120
|
-
export async function readQueue(binding: string): Promise<unknown | undefined> {
|
|
121
|
-
const w = await getWorkerEnv();
|
|
122
|
-
return w?.[binding] ?? undefined;
|
|
123
|
-
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-owned analytics declaration surface (TIER 1).
|
|
3
|
+
*
|
|
4
|
+
* Today a tenant cannot declare a measurement destination from its OWN tree: the
|
|
5
|
+
* declaration lives in `tenant.integrations` (apps/storefront/src/config/tenants.ts),
|
|
6
|
+
* which is platform code a tenant may not touch (customization golden rule #1). So a
|
|
7
|
+
* merchant must file a request for what should be a self-serve, versioned, publishable
|
|
8
|
+
* edit — exactly like `theme.json` or `content/*`.
|
|
9
|
+
*
|
|
10
|
+
* This module is the tenant-tree producer that closes that gap. A tenant commits an
|
|
11
|
+
* `analytics.json` in its own tree (the tenant-owned surface, `tenants/<id>/**`), and
|
|
12
|
+
* this materializes it into the SAME already-consumed declaration seam
|
|
13
|
+
* ({@link IntegrationDeclarationInput}[], read by
|
|
14
|
+
* {@link loadTenantIntegrationDeclarations} and merged into the render plan by the
|
|
15
|
+
* interim bridge). It adds NO new runtime: it is a THIRD producer into an existing
|
|
16
|
+
* consumer, alongside the platform-authored `tenant.integrations` and the D1-backed
|
|
17
|
+
* managed installs.
|
|
18
|
+
*
|
|
19
|
+
* The heavy validation is NOT hand-rolled — each declared destination is built by the
|
|
20
|
+
* shipped, reviewed {@link buildPlatformAdapterDeclaration}, which fail-closes on an
|
|
21
|
+
* unknown adapter, a missing/invalid live id, an invalid per-stage id, or a non-live
|
|
22
|
+
* stage that reuses the live id. That is the whole point: a tenant supplies only public
|
|
23
|
+
* per-stage destination ids and gets a working, consent-gated, CSP-clean, non-live-safe
|
|
24
|
+
* measurement integration with no renderer change.
|
|
25
|
+
*
|
|
26
|
+
* Two callers, one core:
|
|
27
|
+
* - PUBLISH validation — {@link materializeTenantAnalyticsDeclarations} returns
|
|
28
|
+
* structured {@link ContractViolation}s so the ship/customization pipeline can
|
|
29
|
+
* REFUSE an invalid `analytics.json` with an actionable, per-field message, exactly
|
|
30
|
+
* as it refuses an invalid `theme.json`.
|
|
31
|
+
* - RUNTIME materialization — {@link loadTenantAnalyticsDeclarations} is fail-closed:
|
|
32
|
+
* a missing or (defensively) invalid file yields the empty set, never a throw, so an
|
|
33
|
+
* undeclared tenant resolves the environment-only plan and a byte-identical page.
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
buildPlatformAdapterDeclaration,
|
|
37
|
+
ENVIRONMENT_SCHEMA_VERSION,
|
|
38
|
+
Violations,
|
|
39
|
+
isPlainObject,
|
|
40
|
+
rejectUnknownKeys,
|
|
41
|
+
type BrowserEnvironment,
|
|
42
|
+
type ContractViolation,
|
|
43
|
+
type IntegrationDeclarationInput,
|
|
44
|
+
} from "@tot/public-runtime";
|
|
45
|
+
|
|
46
|
+
/** The tenant-owned file that declares measurement destinations, in the tenant tree. */
|
|
47
|
+
export const TENANT_ANALYTICS_FILE = "analytics.json";
|
|
48
|
+
|
|
49
|
+
/** The current `analytics.json` contract version. */
|
|
50
|
+
export const TENANT_ANALYTICS_SCHEMA_VERSION = 1 as const;
|
|
51
|
+
|
|
52
|
+
/** One declared measurement destination: a first-class adapter + its per-stage public ids. */
|
|
53
|
+
export interface TenantAnalyticsDestination {
|
|
54
|
+
/** The first-class platform adapter id, e.g. `platform.google-measurement`. */
|
|
55
|
+
adapter: string;
|
|
56
|
+
/** Public per-stage destination ids. `live` is required; non-live stages take SEPARATE test ids. */
|
|
57
|
+
ids: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The result of materializing a tenant's `analytics.json` into declarations. */
|
|
61
|
+
export interface TenantAnalyticsMaterialization {
|
|
62
|
+
/** True only when EVERY declared destination validated; false surfaces `violations`. */
|
|
63
|
+
ok: boolean;
|
|
64
|
+
/** The materialized declarations, in declared order. Empty whenever `ok` is false. */
|
|
65
|
+
declarations: readonly IntegrationDeclarationInput[];
|
|
66
|
+
/** Per-field violations for the publish surface. Empty whenever `ok` is true. */
|
|
67
|
+
violations: readonly ContractViolation[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The fixed environment the declaration is CONSTRUCTED against. A first-class adapter
|
|
72
|
+
* declaration is environment-INDEPENDENT in content — its per-stage ids are all baked
|
|
73
|
+
* into `publicConfig` and the client picks the current stage at render time — so the
|
|
74
|
+
* environment here only satisfies {@link buildPlatformAdapterDeclaration}'s validation
|
|
75
|
+
* and its (discarded) plan resolution. `canonicalHost` is a fixed placeholder, never the
|
|
76
|
+
* tenant id, so a clone id (which is not a legal hostname) can never fail construction.
|
|
77
|
+
*/
|
|
78
|
+
const CONSTRUCTION_ENVIRONMENT: BrowserEnvironment = {
|
|
79
|
+
schemaVersion: ENVIRONMENT_SCHEMA_VERSION,
|
|
80
|
+
siteSurface: "storefront",
|
|
81
|
+
environment: "live",
|
|
82
|
+
canonicalHost: "tenant-analytics.invalid",
|
|
83
|
+
revision: "tenant-analytics-declaration",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const EMPTY: readonly IntegrationDeclarationInput[] = Object.freeze([]);
|
|
87
|
+
|
|
88
|
+
/** Re-anchor a builder violation path under the declaring `destinations[i]` element. */
|
|
89
|
+
function reanchor(violation: ContractViolation, index: number): ContractViolation {
|
|
90
|
+
const base = `destinations[${index}]`;
|
|
91
|
+
const path =
|
|
92
|
+
violation.path === "(root)" || violation.path === ""
|
|
93
|
+
? base
|
|
94
|
+
: violation.path === "adapterId"
|
|
95
|
+
? `${base}.adapter`
|
|
96
|
+
: violation.path.startsWith("adapterId.")
|
|
97
|
+
? `${base}.adapter${violation.path.slice("adapterId".length)}`
|
|
98
|
+
: `${base}.${violation.path}`;
|
|
99
|
+
return { path, code: violation.code, message: violation.message };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validate the closed shape of one declared destination before it reaches the builder. */
|
|
103
|
+
function readDestination(
|
|
104
|
+
raw: unknown,
|
|
105
|
+
index: number,
|
|
106
|
+
v: Violations,
|
|
107
|
+
): TenantAnalyticsDestination | undefined {
|
|
108
|
+
const base = `destinations[${index}]`;
|
|
109
|
+
if (!isPlainObject(raw)) {
|
|
110
|
+
v.add(base, "type", "A destination must be an object of { adapter, ids }.");
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
rejectUnknownKeys(raw, ["adapter", "ids"], v, base);
|
|
114
|
+
const adapter = raw["adapter"];
|
|
115
|
+
if (typeof adapter !== "string" || adapter.length === 0) {
|
|
116
|
+
v.add(`${base}.adapter`, "type", "A destination adapter must be a non-empty string.");
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
if (!isPlainObject(raw["ids"])) {
|
|
120
|
+
v.add(`${base}.ids`, "type", "ids must be an object of { stage: destinationId }.");
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return { adapter, ids: raw["ids"] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse and materialize a tenant's `analytics.json` content into first-class adapter
|
|
128
|
+
* declarations. `raw` is the already-parsed JSON value (or `undefined`/`null` when the
|
|
129
|
+
* tenant declares none). Fail-closed and whole-file: if ANY declared destination is
|
|
130
|
+
* invalid, NO declaration is produced and the violations name every offending field —
|
|
131
|
+
* a half-valid analytics file never partially materializes.
|
|
132
|
+
*/
|
|
133
|
+
export function materializeTenantAnalyticsDeclarations(input: {
|
|
134
|
+
raw: unknown;
|
|
135
|
+
tenantPublicId: string;
|
|
136
|
+
}): TenantAnalyticsMaterialization {
|
|
137
|
+
const { raw, tenantPublicId } = input;
|
|
138
|
+
// An undeclared tenant (no file) is the zero-cost norm, not an error.
|
|
139
|
+
if (raw === undefined || raw === null) {
|
|
140
|
+
return { ok: true, declarations: EMPTY, violations: [] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const v = new Violations();
|
|
144
|
+
if (!isPlainObject(raw)) {
|
|
145
|
+
v.add("(root)", "type", "analytics.json must be a JSON object with a `destinations` array.");
|
|
146
|
+
return { ok: false, declarations: EMPTY, violations: v.items };
|
|
147
|
+
}
|
|
148
|
+
rejectUnknownKeys(raw, ["schemaVersion", "destinations"], v, "");
|
|
149
|
+
if ("schemaVersion" in raw && raw["schemaVersion"] !== TENANT_ANALYTICS_SCHEMA_VERSION) {
|
|
150
|
+
v.add(
|
|
151
|
+
"schemaVersion",
|
|
152
|
+
"unsupported_version",
|
|
153
|
+
`schemaVersion must equal ${TENANT_ANALYTICS_SCHEMA_VERSION}.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const destinationsRaw = raw["destinations"];
|
|
157
|
+
if (!Array.isArray(destinationsRaw)) {
|
|
158
|
+
v.add("destinations", "type", "`destinations` must be an array.");
|
|
159
|
+
return { ok: false, declarations: EMPTY, violations: v.items };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const declarations: IntegrationDeclarationInput[] = [];
|
|
163
|
+
destinationsRaw.forEach((destinationRaw, index) => {
|
|
164
|
+
const destination = readDestination(destinationRaw, index, v);
|
|
165
|
+
if (!destination) return;
|
|
166
|
+
const built = buildPlatformAdapterDeclaration({
|
|
167
|
+
adapterId: destination.adapter,
|
|
168
|
+
ids: destination.ids,
|
|
169
|
+
tenantPublicId,
|
|
170
|
+
environment: CONSTRUCTION_ENVIRONMENT,
|
|
171
|
+
});
|
|
172
|
+
if (!built.ok) {
|
|
173
|
+
for (const violation of built.violations) v.items.push(reanchor(violation, index));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Namespace the ref so a tenant-declared adapter can never be mistaken for — or
|
|
177
|
+
// collide with — the same adapter installed through the managed D1 path.
|
|
178
|
+
declarations.push({ ...built.declaration, ref: `tenant-analytics:${destination.adapter}` });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!v.ok) return { ok: false, declarations: EMPTY, violations: v.items };
|
|
182
|
+
return { ok: true, declarations, violations: [] };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read a tenant's already-materialized analytics declarations for the render hot path,
|
|
187
|
+
* fail-closed: a missing OR invalid file yields the empty set (never throws), so an
|
|
188
|
+
* undeclared tenant resolves the environment-only plan and a defensively-invalid file
|
|
189
|
+
* darks its own analytics rather than the page. Publish validation
|
|
190
|
+
* ({@link materializeTenantAnalyticsDeclarations}) is what keeps an invalid file from
|
|
191
|
+
* ever reaching here.
|
|
192
|
+
*/
|
|
193
|
+
export function loadTenantAnalyticsDeclarations(
|
|
194
|
+
raw: unknown,
|
|
195
|
+
tenantPublicId: string,
|
|
196
|
+
): readonly IntegrationDeclarationInput[] {
|
|
197
|
+
return materializeTenantAnalyticsDeclarations({ raw, tenantPublicId }).declarations;
|
|
198
|
+
}
|
|
@@ -115,7 +115,7 @@ export const MONITORS: readonly Monitor[] = [
|
|
|
115
115
|
title: "Page / Worker errors (5xx + unhandled exceptions)",
|
|
116
116
|
category: "availability",
|
|
117
117
|
signal: {
|
|
118
|
-
source: "Cloudflare Worker exceptions + non-2xx responses from middleware
|
|
118
|
+
source: "Cloudflare Worker exceptions + non-2xx responses from the middleware render path",
|
|
119
119
|
observe: "Workers 'Errors' metric + Logpush; alert on error-rate over the request baseline",
|
|
120
120
|
},
|
|
121
121
|
threshold: "5xx rate > 1% over 5m, or any sustained unhandled-exception stream",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OpenPublicationArtifactV1,
|
|
3
|
+
PublicationRouteRenderPlanV1,
|
|
4
|
+
} from "@tot/public-runtime";
|
|
5
|
+
import type { EditorialPage, HomeContent } from "@/lib/storyblok/content-model.js";
|
|
6
|
+
import type { ChromeConfig } from "@/lib/chrome/model.js";
|
|
7
|
+
import { parseChromeConfig } from "@/lib/chrome/parseConfig.js";
|
|
8
|
+
import { parseChromeAssignments } from "@/lib/chrome/model.js";
|
|
9
|
+
import { renderRawChrome } from "@/lib/rawChrome.js";
|
|
10
|
+
import { stampTrackingIdsAtPublish } from "@/lib/tracking/publishSafetyNet.js";
|
|
11
|
+
import type { ContentProvider } from "@/lib/storyblok/provider.js";
|
|
12
|
+
|
|
13
|
+
const EMPTY_CHROME_CONFIG: ChromeConfig = {
|
|
14
|
+
header: { variant: "primary", brand: { label: "" }, nav: [] },
|
|
15
|
+
footer: { variant: "default", columns: [] },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function parseJson<T>(raw: string | null): T | null {
|
|
19
|
+
if (raw === null) return null;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(raw) as T;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stripSlashes(value: string): string {
|
|
28
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Map the canonical Astro catch-all slug back to the already-resolved source
|
|
33
|
+
* root. The plan, not string-prefix guessing, owns which mount is stripped.
|
|
34
|
+
*/
|
|
35
|
+
function selectedPageSlug(plan: PublicationRouteRenderPlanV1, slug: string): string | null {
|
|
36
|
+
const requested = stripSlashes(slug);
|
|
37
|
+
const canonical = stripSlashes(plan.canonicalPath);
|
|
38
|
+
if (requested !== canonical) return null;
|
|
39
|
+
return stripSlashes(plan.contentPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function canonicalSlugForMount(plan: PublicationRouteRenderPlanV1, sourceSlug: string): string {
|
|
43
|
+
const mount = stripSlashes(plan.manifest.routes.canonicalMount);
|
|
44
|
+
const source = stripSlashes(sourceSlug);
|
|
45
|
+
return [mount, source].filter(Boolean).join("/");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rebasePage(
|
|
49
|
+
plan: PublicationRouteRenderPlanV1,
|
|
50
|
+
page: EditorialPage,
|
|
51
|
+
canonicalSlug = canonicalSlugForMount(plan, page.slug),
|
|
52
|
+
): EditorialPage {
|
|
53
|
+
return { ...page, slug: canonicalSlug };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Existing Storefront renderer adapter for one already-opened publication
|
|
58
|
+
* version. Selected page/blog artifacts never fall through to another source;
|
|
59
|
+
* a mounted space reuses only the root space's shared chrome/home services.
|
|
60
|
+
*/
|
|
61
|
+
export class PublicationSpaceContentProvider implements ContentProvider {
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly sharedRoot: ContentProvider,
|
|
64
|
+
private readonly publication: OpenPublicationArtifactV1,
|
|
65
|
+
) {}
|
|
66
|
+
|
|
67
|
+
private get plan(): PublicationRouteRenderPlanV1 {
|
|
68
|
+
return this.publication.selection;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private get ownsRoot(): boolean {
|
|
72
|
+
return this.plan.manifest.routes.canonicalMount === "/";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getHome(): Promise<HomeContent> {
|
|
76
|
+
if (!this.ownsRoot) return this.sharedRoot.getHome();
|
|
77
|
+
const home = parseJson<HomeContent>(this.publication.readContent("home.json"));
|
|
78
|
+
if (!home) throw new Error("selected root publication home is unavailable");
|
|
79
|
+
return home;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async getHomeHtml(): Promise<string | null> {
|
|
83
|
+
if (!this.ownsRoot) return this.sharedRoot.getHomeHtml();
|
|
84
|
+
const raw = this.publication.readContent("home.html");
|
|
85
|
+
return raw === null ? null : stampTrackingIdsAtPublish(raw);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async getPageHtml(slug: string): Promise<string | null> {
|
|
89
|
+
const selected = selectedPageSlug(this.plan, slug);
|
|
90
|
+
if (selected === null) return null;
|
|
91
|
+
const raw = this.publication.readContent(
|
|
92
|
+
selected ? `pages-html/${selected}.html` : "home.html",
|
|
93
|
+
);
|
|
94
|
+
return raw === null ? null : stampTrackingIdsAtPublish(raw);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async listPageHtmlSlugs(): Promise<string[]> {
|
|
98
|
+
return this.publication.listContentPaths("pages-html/")
|
|
99
|
+
.filter((path) => path.endsWith(".html"))
|
|
100
|
+
.map((path) => canonicalSlugForMount(
|
|
101
|
+
this.plan,
|
|
102
|
+
path.slice("pages-html/".length, -".html".length),
|
|
103
|
+
));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getChromeHtml(currentPath?: string): Promise<string | null> {
|
|
107
|
+
if (!this.ownsRoot) return this.sharedRoot.getChromeHtml(currentPath);
|
|
108
|
+
const html = this.publication.readContent("chrome.html");
|
|
109
|
+
if (html !== null) return stampTrackingIdsAtPublish(html);
|
|
110
|
+
const config = parseChromeConfig(parseJson(this.publication.readContent("chrome.json")));
|
|
111
|
+
if (!config) return null;
|
|
112
|
+
const assignments = parseChromeAssignments(
|
|
113
|
+
parseJson(this.publication.readContent("chrome-assignments.json")),
|
|
114
|
+
);
|
|
115
|
+
return renderRawChrome(config, currentPath, assignments);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async getChrome(): Promise<ChromeConfig> {
|
|
119
|
+
if (!this.ownsRoot) return this.sharedRoot.getChrome();
|
|
120
|
+
return parseChromeConfig(parseJson(this.publication.readContent("chrome.json"))) ??
|
|
121
|
+
EMPTY_CHROME_CONFIG;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getPage(slug: string): Promise<EditorialPage | null> {
|
|
125
|
+
const selected = selectedPageSlug(this.plan, slug);
|
|
126
|
+
if (!selected) return null;
|
|
127
|
+
const page = (await this.sourcePages()).find((candidate) => candidate.slug === selected);
|
|
128
|
+
return page ? rebasePage(this.plan, page, stripSlashes(this.plan.canonicalPath)) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async listPages(): Promise<EditorialPage[]> {
|
|
132
|
+
return (await this.sourcePages()).map((page) => rebasePage(this.plan, page));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async sourcePages(): Promise<EditorialPage[]> {
|
|
136
|
+
return this.publication.listContentPaths("pages/")
|
|
137
|
+
.filter((path) => path.endsWith(".json"))
|
|
138
|
+
.map((path) => parseJson<EditorialPage>(this.publication.readContent(path)))
|
|
139
|
+
.filter((page): page is EditorialPage => page !== null);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async readArtifact(relative: string): Promise<string | null> {
|
|
143
|
+
return this.publication.readContent(relative);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async listArtifactPaths(prefix = ""): Promise<string[]> {
|
|
147
|
+
return this.publication.listContentPaths(prefix);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Bind an opened exact publication into the existing renderer provider chain. */
|
|
152
|
+
export function bindPublicationSpaceContentProviderV1(
|
|
153
|
+
sharedRoot: ContentProvider,
|
|
154
|
+
publication?: OpenPublicationArtifactV1,
|
|
155
|
+
): ContentProvider {
|
|
156
|
+
return publication
|
|
157
|
+
? new PublicationSpaceContentProvider(sharedRoot, publication)
|
|
158
|
+
: sharedRoot;
|
|
159
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
openPublicationArtifactV1,
|
|
3
|
+
parsePublicationVersionStateV1Json,
|
|
4
|
+
publicationRoutePlanIsCurrentV1,
|
|
5
|
+
publicationVersionStateKey,
|
|
6
|
+
type OpenPublicationArtifactV1,
|
|
7
|
+
type PublicationArtifactBucketV1,
|
|
8
|
+
type PublicationArtifactOpenFailureCodeV1,
|
|
9
|
+
type PublicationChannelV1,
|
|
10
|
+
type PublicationRoutePlanV1,
|
|
11
|
+
type RouteTierIndexV1,
|
|
12
|
+
} from "@tot/public-runtime";
|
|
13
|
+
|
|
14
|
+
export interface PublicationStateStoreV1 {
|
|
15
|
+
get(key: string): Promise<string | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PublicationPageCacheScopeV1 {
|
|
19
|
+
spaceId: string;
|
|
20
|
+
channel: PublicationChannelV1;
|
|
21
|
+
pointerRevision: number;
|
|
22
|
+
versionId: string;
|
|
23
|
+
artifactDigest: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PublicationRuntimeSourceV1 =
|
|
27
|
+
| { status: "legacy-root" }
|
|
28
|
+
| {
|
|
29
|
+
status: "selected";
|
|
30
|
+
artifact: OpenPublicationArtifactV1;
|
|
31
|
+
cacheScope: PublicationPageCacheScopeV1;
|
|
32
|
+
}
|
|
33
|
+
| { status: "unavailable"; code: PublicationArtifactOpenFailureCodeV1 };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve one live request from the public KV state projection into its exact
|
|
37
|
+
* immutable artifact. A carried plan is checked against current route authority
|
|
38
|
+
* before state or object-store I/O. Only an unselected root keeps the legacy
|
|
39
|
+
* adapter; a mounted space never falls through to root content.
|
|
40
|
+
*/
|
|
41
|
+
export async function resolvePublicationRuntimeSourceV1(input: {
|
|
42
|
+
index: RouteTierIndexV1;
|
|
43
|
+
requestPath: string;
|
|
44
|
+
plan: PublicationRoutePlanV1;
|
|
45
|
+
channel: PublicationChannelV1;
|
|
46
|
+
store?: PublicationStateStoreV1;
|
|
47
|
+
bucket?: PublicationArtifactBucketV1;
|
|
48
|
+
}): Promise<PublicationRuntimeSourceV1> {
|
|
49
|
+
if (input.plan.action !== "render") return { status: "legacy-root" };
|
|
50
|
+
if (!publicationRoutePlanIsCurrentV1(input.index, input.requestPath, input.plan)) {
|
|
51
|
+
return { status: "unavailable", code: "publication_route_authority_mismatch" };
|
|
52
|
+
}
|
|
53
|
+
const isRoot = input.plan.manifest.routes.canonicalMount === "/";
|
|
54
|
+
if (!input.store) {
|
|
55
|
+
return isRoot
|
|
56
|
+
? { status: "legacy-root" }
|
|
57
|
+
: { status: "unavailable", code: "publication_channel_unselected" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let stateJson: string | null;
|
|
61
|
+
try {
|
|
62
|
+
stateJson = await input.store.get(publicationVersionStateKey(input.plan.manifest.tenant));
|
|
63
|
+
} catch {
|
|
64
|
+
return { status: "unavailable", code: "publication_artifact_source_unavailable" };
|
|
65
|
+
}
|
|
66
|
+
if (stateJson === null) {
|
|
67
|
+
return isRoot
|
|
68
|
+
? { status: "legacy-root" }
|
|
69
|
+
: { status: "unavailable", code: "publication_channel_unselected" };
|
|
70
|
+
}
|
|
71
|
+
const state = parsePublicationVersionStateV1Json(stateJson);
|
|
72
|
+
if (!state.ok) return { status: "unavailable", code: "publication_state_malformed" };
|
|
73
|
+
if (!input.bucket) {
|
|
74
|
+
return { status: "unavailable", code: "publication_artifact_source_unavailable" };
|
|
75
|
+
}
|
|
76
|
+
const opened = await openPublicationArtifactV1({
|
|
77
|
+
index: input.index,
|
|
78
|
+
requestPath: input.requestPath,
|
|
79
|
+
plan: input.plan,
|
|
80
|
+
state: state.value,
|
|
81
|
+
channel: input.channel,
|
|
82
|
+
bucket: input.bucket,
|
|
83
|
+
});
|
|
84
|
+
if (!opened.ok) return { status: "unavailable", code: opened.code };
|
|
85
|
+
const { selection } = opened.value;
|
|
86
|
+
return {
|
|
87
|
+
status: "selected",
|
|
88
|
+
artifact: opened.value,
|
|
89
|
+
cacheScope: {
|
|
90
|
+
spaceId: selection.manifest.id,
|
|
91
|
+
channel: selection.channel,
|
|
92
|
+
pointerRevision: selection.pointer.revision,
|
|
93
|
+
versionId: selection.publicationVersion.versionId,
|
|
94
|
+
artifactDigest: selection.publicationVersion.artifactDigest,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
renderIntegrationPhases,
|
|
20
20
|
INTERACTION_TRACKING_BOOT_JS,
|
|
21
21
|
type EffectivePlan,
|
|
22
|
+
type ConsentSnapshot,
|
|
22
23
|
} from "@tot/public-runtime";
|
|
23
24
|
import { reportTrackingChainHealth } from "./tracking/interactionHealth.js";
|
|
24
25
|
|
|
@@ -180,6 +181,13 @@ export function rawHtmlResponse(
|
|
|
180
181
|
* silently omit it. Nonces are stamped per response, never serialized (ADR 0018).
|
|
181
182
|
*/
|
|
182
183
|
plan: EffectivePlan;
|
|
184
|
+
/**
|
|
185
|
+
* The tenant's actual initial consent snapshot (see Layout.astro's identical
|
|
186
|
+
* prop). Optional: omitting it falls back to the fail-closed essential-only
|
|
187
|
+
* default, matching every render path that has not resolved a tenant consent
|
|
188
|
+
* config (candidate/preview renders).
|
|
189
|
+
*/
|
|
190
|
+
consentSnapshot?: ConsentSnapshot;
|
|
183
191
|
},
|
|
184
192
|
): Response {
|
|
185
193
|
let composed =
|
|
@@ -222,7 +230,7 @@ export function rawHtmlResponse(
|
|
|
222
230
|
// per-response nonce placeholder; the middleware substitutes a fresh nonce at serve
|
|
223
231
|
// time (ADR 0018), so the same engine output feeds both mounts identically.
|
|
224
232
|
composed = nonceInlineTags(composed, opts.nonce);
|
|
225
|
-
const phases = renderIntegrationPhases(opts.plan, opts.nonce);
|
|
233
|
+
const phases = renderIntegrationPhases(opts.plan, opts.nonce, opts.consentSnapshot);
|
|
226
234
|
composed = injectHeadStart(composed, phases.head);
|
|
227
235
|
composed = injectBodyStart(composed, phases.bodyStart);
|
|
228
236
|
composed = injectBodyEnd(composed, phases.bodyDeferred);
|