@tokenoftrust/storefront-runner 2.2.85 → 2.2.87

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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The render-time read of a tenant's declared platform config (`.tot/config.json`
3
+ * → siteType/compliance/capabilities/features; see
4
+ * `@tot/public-runtime/declared-tenant-config`). Every render applies it the same
5
+ * way, on top of the resolved tenant record:
6
+ *
7
+ * 1. the checkout's own file in the build tree — what `tot dev` grafts in and
8
+ * what the static-publish build materializes from the tenant repo;
9
+ * 2. the per-environment artifact the reconcile derived from the same file at the
10
+ * reconciled commit — what the hosted Worker serves preview and live from.
11
+ *
12
+ * Both are the same file at the same commit, so a tenant renders identically on
13
+ * every surface; a field honored by one is honored by all.
14
+ */
15
+ import {
16
+ applyDeclaredTenantConfig,
17
+ declaredTenantConfig,
18
+ declaredTenantConfigArtifactPath,
19
+ type DeclaredTenantConfig,
20
+ } from "@tot/public-runtime/declared-tenant-config";
21
+ import {
22
+ readPublishedArtifact,
23
+ readVersionArtifact,
24
+ tenantIdFromPath,
25
+ type CustomizationEnv,
26
+ type TenantConfig,
27
+ } from "@tot/public-runtime";
28
+
29
+ // The checkout's own `.tot/config.json`, read eagerly (tiny JSON). From src/config/
30
+ // the repo root is four levels up; both tenant tiers are globbed, and the explicit
31
+ // `.tot` literal matches the dotdir.
32
+ const checkoutConfigFiles = import.meta.glob<Record<string, unknown>>(
33
+ ["../../../../tenants/*/.tot/config.json", "../../../../tenants/e2e/*/.tot/config.json"],
34
+ { eager: true, import: "default" },
35
+ );
36
+
37
+ /** dir id → that checkout's parsed `.tot/config.json`. */
38
+ export function checkoutTotConfigs(): Map<string, Record<string, unknown>> {
39
+ const map = new Map<string, Record<string, unknown>>();
40
+ for (const [key, config] of Object.entries(checkoutConfigFiles)) {
41
+ const id = tenantIdFromPath(key);
42
+ if (id && config && typeof config === "object") map.set(id, config);
43
+ }
44
+ return map;
45
+ }
46
+
47
+ /** The declaration in the checkout's own `.tot/config.json`, or `{}` when it has none. */
48
+ export function checkoutDeclaredTenantConfig(tenantId: string): DeclaredTenantConfig {
49
+ return declaredTenantConfig(checkoutConfigFiles[`../../../../tenants/${tenantId}/.tot/config.json`]
50
+ ?? checkoutConfigFiles[`../../../../tenants/e2e/${tenantId}/.tot/config.json`]);
51
+ }
52
+
53
+ /** The declaration the reconcile published for this environment, or `{}`. */
54
+ export async function publishedDeclaredTenantConfig(
55
+ kvGet: (key: string) => Promise<string | null>,
56
+ tenantId: string,
57
+ env: CustomizationEnv,
58
+ ): Promise<DeclaredTenantConfig> {
59
+ const raw = await readPublishedArtifact(kvGet, tenantId, env, declaredTenantConfigArtifactPath(tenantId));
60
+ if (!raw) return {};
61
+ try {
62
+ return declaredTenantConfig(JSON.parse(raw));
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ /** The declaration pinned to one immutable version (a candidate under review), or `{}`. */
69
+ export async function versionDeclaredTenantConfig(
70
+ kvGet: (key: string) => Promise<string | null>,
71
+ tenantId: string,
72
+ versionId: string,
73
+ ): Promise<DeclaredTenantConfig> {
74
+ const raw = await readVersionArtifact(kvGet, tenantId, versionId, declaredTenantConfigArtifactPath(tenantId));
75
+ if (!raw) return {};
76
+ try {
77
+ return declaredTenantConfig(JSON.parse(raw));
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ /** The tenant as it renders in `env`: its record with every declaration applied. */
84
+ export async function withDeclaredTenantConfig(
85
+ tenant: TenantConfig,
86
+ kvGet: (key: string) => Promise<string | null>,
87
+ env: CustomizationEnv,
88
+ ): Promise<TenantConfig> {
89
+ const fromCheckout = applyDeclaredTenantConfig(tenant, checkoutDeclaredTenantConfig(tenant.tenant_id));
90
+ return applyDeclaredTenantConfig(
91
+ fromCheckout,
92
+ await publishedDeclaredTenantConfig(kvGet, tenant.tenant_id, env),
93
+ );
94
+ }
@@ -97,6 +97,7 @@ import {
97
97
  import { recordActivity } from "@/lib/activity/recordActivity";
98
98
  import { middlewareRewriteTargetV1 } from "./routeBranch.js";
99
99
  import { declaredTenantOrigins } from "@/lib/tenantOrigins";
100
+ import { withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
100
101
  import {
101
102
  applyInternalContentResponsePolicy,
102
103
  internalContentGrantMatchesPlan,
@@ -147,7 +148,7 @@ export interface RenderChainInput {
147
148
  export const TENANT_ORIGINS_CAPTURE_HEADER = "x-tot-tenant-origins";
148
149
 
149
150
  export async function runRenderChain(input: RenderChainInput): Promise<Response> {
150
- const { context, next, host, url, tenant, basePath, renderPath, cache, kv } = input;
151
+ const { context, next, host, url, tenant: tenantRecord, basePath, renderPath, cache, kv } = input;
151
152
  const { request, locals } = context;
152
153
  const isInternalPublication = input.publicationRoute.action !== "fail-closed" &&
153
154
  input.publicationRoute.manifest.visibility === "internal";
@@ -177,7 +178,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
177
178
  // pin can only ever name its own version; a mismatched/expired/absent token
178
179
  // falls through to the normal channel-resolved read below, same fail-closed
179
180
  // posture as the `__preview` token.
180
- const candidatePin = await resolveCandidateContentPin(url, tenant.tenant_id);
181
+ const candidatePin = await resolveCandidateContentPin(url, tenantRecord.tenant_id);
181
182
  // Per-developer AI-edit draft: the signed
182
183
  // tot_dev_draft cookie's mere PRESENCE opts the request out of the shared page
183
184
  // cache below (a draft view must never be cached publicly); it's verified —
@@ -192,7 +193,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
192
193
  const nowSeconds = Math.floor(Date.now() / 1000);
193
194
  const customization = await resolveCustomizationEnv({
194
195
  isPreviewHost,
195
- tenantId: tenant.tenant_id,
196
+ tenantId: tenantRecord.tenant_id,
196
197
  token: previewToken,
197
198
  secret: previewSecret,
198
199
  nowSeconds,
@@ -230,6 +231,16 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
230
231
  if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
231
232
  return response;
232
233
  }
234
+
235
+ // The tenant as it renders in THIS environment: its record with the declared
236
+ // `.tot/config.json` fields applied (compliance, siteType, capabilities,
237
+ // features). Everything below — static bundle, notices, gates, features,
238
+ // capabilities — reads this, never the bare record.
239
+ const tenant = await withDeclaredTenantConfig(
240
+ tenantRecord,
241
+ kv ? (key: string) => kv.get(key) : async () => null,
242
+ customization.env,
243
+ );
233
244
  const publicationTimestamp = publicationSource.status === "selected"
234
245
  ? publicationSource.publicationTimestamp
235
246
  : exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.85",
3
+ "version": "2.2.87",
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",
@@ -19,6 +19,7 @@
19
19
  "./durable-assets": "./src/durable-assets.ts",
20
20
  "./retention-holds": "./src/retention-holds.ts",
21
21
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
22
+ "./declared-tenant-config": "./src/declared-tenant-config.mjs",
22
23
  "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
23
24
  "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs",
24
25
  "./integration/visitor-region": "./src/integration/visitor-region.mjs"
@@ -0,0 +1,22 @@
1
+ /** Types for the dependency-free declared-tenant-config module (declared-tenant-config.mjs). */
2
+ import type { TenantConfig } from "./tenant.js";
3
+
4
+ export type DeclaredTenantConfigField = "siteType" | "compliance" | "capabilities" | "features";
5
+ export type DeclaredTenantConfig = Partial<Pick<TenantConfig, DeclaredTenantConfigField>>;
6
+
7
+ export const DECLARED_TENANT_CONFIG_FIELDS: readonly DeclaredTenantConfigField[];
8
+ export const TOT_CONFIG_KEYS: readonly string[];
9
+ export const COMPLIANCE_KEYS: readonly string[];
10
+ export const FEATURE_KEYS: readonly string[];
11
+ export const SITE_TYPES_LIST: readonly string[];
12
+ export function declaredTenantConfig(config: unknown): DeclaredTenantConfig;
13
+ export function isEmptyDeclaredTenantConfig(declared: DeclaredTenantConfig | null | undefined): boolean;
14
+ export function applyDeclaredTenantConfig<T extends Partial<Pick<TenantConfig, DeclaredTenantConfigField>>>(
15
+ tenant: T,
16
+ declared: DeclaredTenantConfig | null | undefined,
17
+ ): T;
18
+ export function readDeclaredTenantConfigAt(
19
+ source: { readFile(path: string, ref: string): Promise<string | null> },
20
+ ref: string,
21
+ ): Promise<DeclaredTenantConfig>;
22
+ export function declaredTenantConfigArtifactPath(tenantId: string): string;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The tenant's declared platform config: the `.tot/config.json` fields that become
3
+ * TenantConfig fields on every surface that renders the tenant.
4
+ *
5
+ * `.tot/config.json` is the ONE declared source for these fields. Every reader goes
6
+ * through this module:
7
+ * - `tot dev` and the static-publish build read the checkout's own file from the
8
+ * build tree (apps/storefront/src/config/resolver.ts `declaredConfigFor`);
9
+ * - the hosted Worker reads the per-environment artifact the reconcile derives from
10
+ * the same file at the reconciled commit (`declaredTenantConfigArtifactPath`);
11
+ * - `tot validate` refuses any top-level key not in `TOT_CONFIG_KEYS`, so a field
12
+ * no surface reads cannot pass validation.
13
+ * All of them apply it with `applyDeclaredTenantConfig`, so a field honored on one
14
+ * surface is honored on all of them.
15
+ *
16
+ * Plain JS on purpose (same reason as embed-catalog.mjs): plain-`node` tooling and
17
+ * the CLI's parity test read it alongside the TypeScript runtime.
18
+ */
19
+
20
+ /** `.tot/config.json` keys that become TenantConfig fields. */
21
+ export const DECLARED_TENANT_CONFIG_FIELDS = Object.freeze([
22
+ "siteType",
23
+ "compliance",
24
+ "capabilities",
25
+ "features",
26
+ ]);
27
+
28
+ /**
29
+ * Every top-level `.tot/config.json` key the platform reads: the reconcile mapping
30
+ * keys, the CLI's host hint, provenance markers (`sample` from `tot dev --sample`,
31
+ * `seed` from the tot-mcp repo seed), an editor's `$schema`, and the declared
32
+ * TenantConfig fields.
33
+ */
34
+ export const TOT_CONFIG_KEYS = Object.freeze([
35
+ "$schema",
36
+ "tenant",
37
+ "scope",
38
+ "mappings",
39
+ "hostPlatform",
40
+ "sample",
41
+ "seed",
42
+ ...DECLARED_TENANT_CONFIG_FIELDS,
43
+ ]);
44
+
45
+ /** Every `compliance` key the platform renders or enforces (ComplianceConfig, tenant.ts). */
46
+ export const COMPLIANCE_KEYS = Object.freeze([
47
+ "ruleProfile",
48
+ "minAge",
49
+ "nicotineWarning",
50
+ "shippingRestriction",
51
+ "pactAct",
52
+ "adultSignature",
53
+ "prop65",
54
+ "purchaseLimit",
55
+ "stateEligibility",
56
+ "exciseTax",
57
+ ]);
58
+
59
+ /** Every `features` key (TenantFeatures, tenant.ts). */
60
+ export const FEATURE_KEYS = Object.freeze([
61
+ "catalog",
62
+ "productSearch",
63
+ "savedItems",
64
+ "quickView",
65
+ "newsletter",
66
+ "commerceSitemap",
67
+ "productJsonLd",
68
+ "ageGate",
69
+ ]);
70
+
71
+ /** Every `siteType` value (SiteType, tenant.ts). */
72
+ export const SITE_TYPES_LIST = Object.freeze(["commerce", "marketing"]);
73
+
74
+ const SITE_TYPES = new Set(SITE_TYPES_LIST);
75
+
76
+ const isPlainObject = (value) =>
77
+ value !== null && typeof value === "object" && !Array.isArray(value);
78
+
79
+ /**
80
+ * The declared TenantConfig fields from a parsed `.tot/config.json`. A field whose
81
+ * value has the wrong shape is omitted, never coerced (`tot validate` reports it).
82
+ *
83
+ * @param {unknown} config
84
+ */
85
+ export function declaredTenantConfig(config) {
86
+ /** @type {Record<string, unknown>} */
87
+ const out = {};
88
+ if (!isPlainObject(config)) return out;
89
+ const { siteType, compliance, capabilities, features } = /** @type {Record<string, unknown>} */ (config);
90
+ if (typeof siteType === "string" && SITE_TYPES.has(siteType)) out.siteType = siteType;
91
+ if (isPlainObject(compliance)) out.compliance = compliance;
92
+ if (isPlainObject(capabilities)) out.capabilities = capabilities;
93
+ if (isPlainObject(features)) out.features = features;
94
+ return out;
95
+ }
96
+
97
+ /** True when the declaration sets nothing. */
98
+ export function isEmptyDeclaredTenantConfig(declared) {
99
+ return !declared || Object.keys(declared).length === 0;
100
+ }
101
+
102
+ /**
103
+ * Overlay a declaration onto a resolved tenant. `siteType` is replaced; the object
104
+ * fields merge key by key, so a declaration adds to — and can set individual keys
105
+ * of — what the platform record already carries, but never erases a key it omits.
106
+ *
107
+ * @template T
108
+ * @param {T} tenant
109
+ * @param {Record<string, unknown> | null | undefined} declared
110
+ * @returns {T}
111
+ */
112
+ export function applyDeclaredTenantConfig(tenant, declared) {
113
+ if (isEmptyDeclaredTenantConfig(declared)) return tenant;
114
+ const out = /** @type {Record<string, unknown>} */ ({ ...tenant });
115
+ for (const field of DECLARED_TENANT_CONFIG_FIELDS) {
116
+ const value = declared[field];
117
+ if (value === undefined) continue;
118
+ out[field] = isPlainObject(value) && isPlainObject(out[field])
119
+ ? { ...out[field], ...value }
120
+ : value;
121
+ }
122
+ return /** @type {T} */ (out);
123
+ }
124
+
125
+ /**
126
+ * The declaration a tenant repo commits at `ref`, read through any source with the
127
+ * reconcile's `readFile(path, ref)` shape. `{}` when the file is absent or unreadable.
128
+ *
129
+ * @param {{ readFile(path: string, ref: string): Promise<string | null> }} source
130
+ * @param {string} ref
131
+ */
132
+ export async function readDeclaredTenantConfigAt(source, ref) {
133
+ try {
134
+ const raw = await source.readFile(".tot/config.json", ref);
135
+ return raw ? declaredTenantConfig(JSON.parse(raw)) : {};
136
+ } catch {
137
+ return {};
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Artifact path of the declaration the reconcile derives by reading `.tot/config.json`,
143
+ * in the same colocated `tenants/<id>/…` space as theme/capabilities so it is
144
+ * pinned per version and resolved per environment like every other artifact.
145
+ *
146
+ * @param {string} tenantId
147
+ */
148
+ export function declaredTenantConfigArtifactPath(tenantId) {
149
+ return `tenants/${tenantId}/tenant-config.json`;
150
+ }
@@ -1,14 +1,19 @@
1
- /** Types for the dependency-free redirect decision core (publication-redirects.mjs). */
1
+ /** Types for the redirect contract (agency-kit/tools/lib/redirect-declaration.mjs), re-exported here. */
2
2
 
3
3
  export const REDIRECT_STATUS: 308;
4
- export const EDGE_FUNCTION_CODE_LIMIT_BYTES: number;
5
- export const EDGE_FUNCTION_RESERVED_BYTES: number;
4
+ export const KVS_STORE_LIMIT_BYTES: number;
5
+ export const KVS_KEY_LIMIT_BYTES: number;
6
+ export const KVS_VALUE_LIMIT_BYTES: number;
7
+ export const KVS_RESERVED_BYTES: number;
6
8
  export const REDIRECT_MAP_BUDGET_BYTES: number;
9
+ export const REDIRECT_KEY_OVERHEAD_BYTES: number;
10
+ export const REDIRECT_RULE_MAX_DEPTHS: number;
7
11
  export const COLLECTION_REDIRECTS_PATH: RegExp;
8
12
  export const SITE_REDIRECTS_PATH: "content/redirects.json";
9
13
  export const WHOLE_SITE_DELIVERY_CONTRACT: string;
10
14
 
11
15
  export interface DeclaredRedirect {
16
+ /** An exact request path, or a `/<dir>/*` rule. */
12
17
  from: string;
13
18
  to: string;
14
19
  /** Which declaration produced it, named in any refusal. */
@@ -21,9 +26,24 @@ export interface RedirectSet {
21
26
  violations: string[];
22
27
  bytes: number;
23
28
  budget: number;
29
+ /** How many entries are `/<dir>/*` rules. */
30
+ rules: number;
24
31
  }
25
32
 
26
33
  export function normalizeRedirectPath(raw: string): string | null;
34
+ export function normalizeRedirectSource(raw: string): string | null;
35
+ export function ruleDirectory(raw: string): string | null;
36
+ export function isRedirectRule(from: string): boolean;
37
+ export function redirectRequestPath(pathname: string): string;
38
+ export function ruleLocation(to: string, capture: string): string | null;
39
+ export function resolveRedirect(
40
+ redirects: readonly { from: string; to: string }[],
41
+ pathname: string,
42
+ ): { from: string; to: string; location: string } | null;
43
+ export function redirectResolver(
44
+ entries: readonly { from: string; to: string }[],
45
+ ): (pathname: string) => { from: string; to: string; location: string } | null;
46
+ export function redirectRuleDepths(redirects: readonly { from: string }[]): number[];
27
47
  export function redirectsFromCollection(
28
48
  collection: string,
29
49
  rows: Record<string, unknown>,
@@ -38,7 +58,6 @@ export function redirectsFromAliases(
38
58
  source?: string,
39
59
  ): DeclaredRedirect[];
40
60
  export function buildRedirectSet(declared: DeclaredRedirect[], budget?: number): RedirectSet;
41
- export function serializeRedirectMap(redirects: { from: string; to: string }[]): string;
42
61
  export function aliasesFromRedirectSet(
43
62
  set: RedirectSet,
44
63
  ): { from: string; behavior: "redirect-308"; to: string }[];