@tokenoftrust/storefront-runner 2.0.18 → 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.
@@ -12,6 +12,7 @@ import type {
12
12
  EffectivePlan,
13
13
  ConsentSnapshot,
14
14
  ResolvedRouteTierV1,
15
+ PublicationRoutePlanV1,
15
16
  } from "@tot/public-runtime";
16
17
  import type { ToTClient } from "@/lib/tot/ToTClient";
17
18
  import type { ContentProvider } from "@/lib/storyblok/provider";
@@ -112,6 +113,8 @@ declare global {
112
113
  * served it. Downstream code reads this rather than re-deriving a tier.
113
114
  */
114
115
  routeTier: ResolvedRouteTierV1;
116
+ /** Normalized publication mount/canonical/content plan for this request. */
117
+ publicationRoute: PublicationRoutePlanV1;
115
118
  /**
116
119
  * Effective capability flags for the resolved tenant (commerce defaults
117
120
  * unless siteType:"marketing"/explicit features). Chrome, layout islands,
@@ -79,6 +79,17 @@ export interface MintBrokerAssertionOverrides {
79
79
  issuer?: string;
80
80
  }
81
81
 
82
+ /** Mint a broker assertion whose subject is an opaque verified identity id. */
83
+ export async function mintOpaqueBrokerAssertion(
84
+ opaqueSubject: string,
85
+ overrides: MintBrokerAssertionOverrides = {},
86
+ ): Promise<string> {
87
+ if (!opaqueSubject || opaqueSubject.trim().length === 0) {
88
+ throw new BrokerAssertionError("broker assertion: an opaque subject is required");
89
+ }
90
+ return mintBrokerAssertionSubject(opaqueSubject, overrides);
91
+ }
92
+
82
93
  // Import the signing key once per distinct PEM (importPKCS8 is not free). Keyed on
83
94
  // the base64 string so a key rotation (new env value) transparently re-imports.
84
95
  const keyCache = new Map<string, CryptoKey>();
@@ -137,6 +148,13 @@ export async function mintBrokerAssertion(
137
148
  if (!sub) {
138
149
  throw new BrokerAssertionError("broker assertion: a subject email is required");
139
150
  }
151
+ return mintBrokerAssertionSubject(sub, overrides);
152
+ }
153
+
154
+ async function mintBrokerAssertionSubject(
155
+ sub: string,
156
+ overrides: MintBrokerAssertionOverrides,
157
+ ): Promise<string> {
140
158
  const privateKeyB64 = overrides.privateKeyB64 ?? (await readEnv(BROKER_PRIVATE_KEY_ENV));
141
159
  if (!privateKeyB64) {
142
160
  throw new BrokerAssertionError(
@@ -104,6 +104,10 @@ export function toIdentityClaims(payload: Record<string, unknown>): IdentityClai
104
104
  if (typeof email !== "string" || !email) {
105
105
  throw new Error("identity assertion missing `email` claim");
106
106
  }
107
+ const subject = payload.sub;
108
+ if (typeof subject !== "string" || subject.trim().length === 0) {
109
+ throw new Error("identity assertion missing `sub` claim");
110
+ }
107
111
  const tenantsRaw = Array.isArray(payload.tenants) ? payload.tenants : [];
108
112
  const tenants: TenantMembership[] = tenantsRaw.map((t): TenantMembership => {
109
113
  const entry = t as { resource?: unknown; role?: unknown };
@@ -118,7 +122,7 @@ export function toIdentityClaims(payload: Record<string, unknown>): IdentityClai
118
122
 
119
123
  const claims: IdentityClaims = {
120
124
  iss: String(payload.iss ?? ""),
121
- sub: String(payload.sub ?? email),
125
+ sub: subject,
122
126
  aud: String(payload.aud ?? ""),
123
127
  iat: Number(payload.iat ?? 0),
124
128
  exp: Number(payload.exp ?? 0),
@@ -281,6 +285,8 @@ export async function getFederatedVerifier(
281
285
 
282
286
  /** The session fields derived from the VERIFIED token — the Phase-3 source of truth. */
283
287
  export interface DerivedSessionFields {
288
+ /** Opaque identity-provider subject, preserved exactly for live authorization. */
289
+ subject: string;
284
290
  email: string;
285
291
  resource: string;
286
292
  /** Coarse standing on THIS resource, surfaced as the display capability. */
@@ -307,6 +313,7 @@ export function sessionFromClaims(claims: IdentityClaims, resource: string): Der
307
313
  throw new Error(`verified assertion carries no membership for resource "${resource}"`);
308
314
  }
309
315
  return {
316
+ subject: claims.sub,
310
317
  email: claims.email,
311
318
  resource,
312
319
  capability: membership.role,
@@ -318,6 +325,8 @@ export function sessionFromClaims(claims: IdentityClaims, resource: string): Der
318
325
 
319
326
  /** The multi-tenant fields derived from a verified FEDERATED assertion. */
320
327
  export interface FederatedSessionFields {
328
+ /** Opaque identity-provider subject, preserved exactly for live authorization. */
329
+ subject: string;
321
330
  email: string;
322
331
  /** The verified `aud` — the storefront app identity this console session is for. */
323
332
  resource: string;
@@ -341,6 +350,7 @@ export interface FederatedSessionFields {
341
350
  */
342
351
  export function sessionFromFederatedClaims(claims: IdentityClaims): FederatedSessionFields {
343
352
  const fields: FederatedSessionFields = {
353
+ subject: claims.sub,
344
354
  email: claims.email,
345
355
  resource: claims.aud,
346
356
  tenants: claims.tenants,
@@ -0,0 +1,153 @@
1
+ import type { PublicationRoutePlanV1, ResolvedRouteTierV1 } from "@tot/public-runtime";
2
+ import type { SessionRecord, SessionStore } from "./session.js";
3
+ import { readSession } from "./session.js";
4
+
5
+ export const INTERNAL_CONTENT_CAPABILITY = "internal-content-reader" as const;
6
+ export const INTERNAL_CONTENT_AUTHORITY_TIMEOUT_MS = 5_000;
7
+
8
+ export type InternalContentAuthorityDecision =
9
+ | { allowed: true; tenant: string; spaceId: string; audience: "owner" | "developer" }
10
+ | { allowed: false; reason: "denied" | "unavailable" };
11
+
12
+ export type InternalContentGrant = Extract<InternalContentAuthorityDecision, { allowed: true }>;
13
+
14
+ interface BrokerContext {
15
+ appDomain: string;
16
+ audience: string;
17
+ }
18
+
19
+ export interface InternalContentAuthorityInput {
20
+ route: ResolvedRouteTierV1;
21
+ sessionStore?: SessionStore;
22
+ sessionId?: string | null;
23
+ coreUrl?: string | null;
24
+ mintAssertion: (subject: string) => Promise<string>;
25
+ fetchImpl?: typeof fetch;
26
+ nowSeconds?: number;
27
+ }
28
+
29
+ function routeIsDeclaredInternal(route: ResolvedRouteTierV1): route is ResolvedRouteTierV1 & {
30
+ space: NonNullable<ResolvedRouteTierV1["space"]>;
31
+ } {
32
+ return route.origin === "publication-space" &&
33
+ route.requiresAuthorization &&
34
+ route.visibility === "internal" &&
35
+ route.authorization.policy === "capability" &&
36
+ route.authorization.capability === INTERNAL_CONTENT_CAPABILITY &&
37
+ route.assets.access === "authorized" &&
38
+ !!route.space &&
39
+ route.space.tenant === route.tenant;
40
+ }
41
+
42
+ function brokerContexts(value: unknown): BrokerContext[] | null {
43
+ const unwrapped = value && typeof value === "object" && "content" in value
44
+ ? (value as { content?: unknown }).content
45
+ : value;
46
+ if (!unwrapped || typeof unwrapped !== "object") return null;
47
+ const contexts = (unwrapped as { contexts?: unknown }).contexts;
48
+ if (!Array.isArray(contexts)) return null;
49
+ const parsed: BrokerContext[] = [];
50
+ for (const context of contexts) {
51
+ if (!context || typeof context !== "object") return null;
52
+ const appDomain = (context as { appDomain?: unknown }).appDomain;
53
+ const audience = (context as { audience?: unknown }).audience;
54
+ if (typeof appDomain !== "string" || typeof audience !== "string") return null;
55
+ parsed.push({ appDomain, audience });
56
+ }
57
+ return parsed;
58
+ }
59
+
60
+ /**
61
+ * Resolve internal-content authority from fresh broker state. The session is
62
+ * identity proof only: cached memberships, roles, headers, and preview state do
63
+ * not grant access. Every store/read/signing fault is an explicit unavailable
64
+ * result and callers serve the same non-disclosing denial response.
65
+ */
66
+ export async function authorizeInternalContentRequest(
67
+ input: InternalContentAuthorityInput,
68
+ ): Promise<InternalContentAuthorityDecision> {
69
+ if (!routeIsDeclaredInternal(input.route)) return { allowed: false, reason: "denied" };
70
+ if (!input.sessionStore || !input.sessionId) return { allowed: false, reason: "denied" };
71
+
72
+ let session: SessionRecord | null;
73
+ try {
74
+ session = await readSession(
75
+ input.sessionStore,
76
+ input.sessionId,
77
+ input.nowSeconds ?? Math.floor(Date.now() / 1000),
78
+ );
79
+ } catch {
80
+ return { allowed: false, reason: "unavailable" };
81
+ }
82
+ const subject = session?.subject;
83
+ if (!subject || subject.trim().length === 0) {
84
+ return { allowed: false, reason: "denied" };
85
+ }
86
+
87
+ const coreUrl = (input.coreUrl ?? "").replace(/\/+$/, "");
88
+ if (!coreUrl) return { allowed: false, reason: "unavailable" };
89
+
90
+ let assertion: string;
91
+ try {
92
+ assertion = await input.mintAssertion(subject);
93
+ } catch {
94
+ return { allowed: false, reason: "unavailable" };
95
+ }
96
+
97
+ let response: Response;
98
+ try {
99
+ response = await (input.fetchImpl ?? fetch)(`${coreUrl}/api/broker/appDomains`, {
100
+ method: "GET",
101
+ headers: {
102
+ accept: "application/json",
103
+ "X-ToT-Broker-Assertion": assertion,
104
+ },
105
+ signal: AbortSignal.timeout(INTERNAL_CONTENT_AUTHORITY_TIMEOUT_MS),
106
+ });
107
+ } catch {
108
+ return { allowed: false, reason: "unavailable" };
109
+ }
110
+ if (!response.ok) return { allowed: false, reason: "unavailable" };
111
+
112
+ let contexts: BrokerContext[] | null;
113
+ try {
114
+ contexts = brokerContexts(await response.json());
115
+ } catch {
116
+ contexts = null;
117
+ }
118
+ if (!contexts) return { allowed: false, reason: "unavailable" };
119
+
120
+ const matching = contexts.filter((context) => context.appDomain === input.route.tenant);
121
+ if (matching.length !== 1) return { allowed: false, reason: "denied" };
122
+ const audience = matching[0]!.audience;
123
+ if (audience !== "owner" && audience !== "developer") {
124
+ return { allowed: false, reason: "denied" };
125
+ }
126
+ return {
127
+ allowed: true,
128
+ tenant: input.route.tenant,
129
+ spaceId: input.route.space.spaceId,
130
+ audience,
131
+ };
132
+ }
133
+
134
+ /** Prove the carried grant still names the exact server-resolved F2 space. */
135
+ export function internalContentGrantMatchesPlan(
136
+ grant: InternalContentGrant | undefined,
137
+ plan: PublicationRoutePlanV1,
138
+ ): boolean {
139
+ return !!grant && plan.action === "render" &&
140
+ plan.manifest.visibility === "internal" &&
141
+ plan.manifest.authorization.policy === "capability" &&
142
+ plan.manifest.authorization.capability === INTERNAL_CONTENT_CAPABILITY &&
143
+ plan.manifest.assets.access === "authorized" &&
144
+ grant.tenant === plan.route.tenant &&
145
+ grant.tenant === plan.manifest.tenant &&
146
+ grant.spaceId === plan.manifest.id;
147
+ }
148
+
149
+ /** Internal responses are never indexable or eligible for shared storage. */
150
+ export function applyInternalContentResponsePolicy(headers: Headers): void {
151
+ headers.set("Cache-Control", "private, no-store");
152
+ headers.set("X-Robots-Tag", "noindex, nofollow");
153
+ }
@@ -50,6 +50,8 @@ export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 3600;
50
50
  * are the multi-tenant claims carried in the verified token.
51
51
  */
52
52
  export interface SessionRecord {
53
+ /** Opaque identity-provider subject from the verified assertion. */
54
+ subject?: string;
53
55
  /** Verified email (lowercased) — the principal that passed the grant check. */
54
56
  email: string;
55
57
  /** The resource (appDomain/tenant, e.g. "sample-store.example") this session authorizes. */
@@ -166,6 +168,7 @@ export async function readSession(
166
168
  !record ||
167
169
  typeof record.email !== "string" ||
168
170
  typeof record.resource !== "string" ||
171
+ (record.subject !== undefined && typeof record.subject !== "string") ||
169
172
  typeof record.exp !== "number"
170
173
  ) {
171
174
  await store.delete(id);
@@ -64,6 +64,13 @@ export function pageCacheKey(
64
64
  search: string,
65
65
  version: number,
66
66
  planRevision?: string,
67
+ publication?: {
68
+ spaceId: string;
69
+ channel: "preview" | "live";
70
+ pointerRevision: number;
71
+ versionId: string;
72
+ artifactDigest: string;
73
+ },
67
74
  ): Request {
68
75
  // A stored page carries the CSP header it was rendered with, INCLUDING any
69
76
  // version-scoped integration origins. Folding the resolved `planRevision` into
@@ -73,8 +80,17 @@ export function pageCacheKey(
73
80
  // integration plane resolved) the key is byte-identical to the non-integration
74
81
  // form, so a plain tenant pays nothing and its cache is never busted.
75
82
  const planSegment = planRevision ? `/pr:${planRevision}` : "";
83
+ const publicationSegment = publication
84
+ ? `/publication:${encodeURIComponent([
85
+ publication.spaceId,
86
+ publication.channel,
87
+ publication.pointerRevision,
88
+ publication.versionId,
89
+ publication.artifactDigest,
90
+ ].join(":"))}`
91
+ : "";
76
92
  return new Request(
77
- `${CACHE_ORIGIN}/${tenantId}/v${version}${planSegment}${pathname}${search}`,
93
+ `${CACHE_ORIGIN}/${tenantId}/v${version}${planSegment}${publicationSegment}${pathname}${search}`,
78
94
  );
79
95
  }
80
96
 
@@ -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
+ }
@@ -12,7 +12,14 @@ import { fromD1, type D1Like } from "@/lib/d1/catalog";
12
12
  import { readCurrentTenantConsentConfig } from "@/lib/apps/consentConfigRead";
13
13
  import { loadActiveManagedInstallDeclarations } from "@/lib/integration/managedInstallDeclarations";
14
14
  import { buildTenantConsentBroker, verifyPreviewToken } from "@tot/public-runtime";
15
- import type { ConsentSnapshot, IntegrationDeclarationInput, TenantConfig } from "@tot/public-runtime";
15
+ import type {
16
+ ConsentSnapshot,
17
+ IntegrationDeclarationInput,
18
+ PublicationArtifactBucketV1,
19
+ PublicationRoutePlanV1,
20
+ RouteTierIndexV1,
21
+ TenantConfig,
22
+ } from "@tot/public-runtime";
16
23
  import {
17
24
  evaluateLoginGate,
18
25
  isAuthPath,
@@ -27,6 +34,7 @@ import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
27
34
  import { gatedHoldingResponse } from "@/lib/auth/gatePage";
28
35
  import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
29
36
  import { runRenderChain } from "./renderChain.js";
37
+ import type { InternalContentGrant } from "@/lib/auth/internalContentAuthority";
30
38
 
31
39
  export interface ControlPlaneInput {
32
40
  context: APIContext;
@@ -38,11 +46,17 @@ export interface ControlPlaneInput {
38
46
  renderPath: string;
39
47
  cache: Cache | undefined;
40
48
  kv: KVNamespace | undefined;
49
+ routeTierIndex: RouteTierIndexV1;
50
+ publicationRequestPath: string;
51
+ publicationRoute: PublicationRoutePlanV1;
52
+ publicationBucket: PublicationArtifactBucketV1 | undefined;
41
53
  gateMode: GateMode;
42
54
  /** `DEMO_GATE_HOSTS`, raw. */
43
55
  demoGateHosts: string | null | undefined;
44
56
  /** True when this host is withheld behind sign-in — see `resolveMiddlewareBranchV1`. */
45
57
  hostLoginGated: boolean;
58
+ /** Exact per-request authorization for an F2-selected internal space. */
59
+ internalContentGrant?: InternalContentGrant;
46
60
  }
47
61
 
48
62
  export async function serveControlPlane(input: ControlPlaneInput): Promise<Response> {
@@ -57,7 +71,7 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
57
71
  // otherwise an unauthenticated visitor gets a "Coming soon" holding page with
58
72
  // a Sign-in button (auth paths are exempt so sign-in itself is reachable). The
59
73
  // grant `resource` is the tenant's appDomain — never input.
60
- if (input.hostLoginGated) {
74
+ if (input.hostLoginGated && !input.internalContentGrant) {
61
75
  const tenantExempt = tenant.tenant_id === PUBLIC_HOME_TENANT_ID;
62
76
  // UNIFIED on the federated session: read the FULL session (no resource filter —
63
77
  // federated sign-in's aud is the storefront app, not this tenant) and admit the
@@ -232,8 +246,14 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
232
246
  tenant,
233
247
  basePath,
234
248
  renderPath,
235
- cache,
249
+ // Internal output never receives a shared-cache handle. The render chain
250
+ // also refuses its page-cache predicate, making this a two-sided invariant.
251
+ cache: input.internalContentGrant ? undefined : cache,
236
252
  kv,
253
+ routeTierIndex: input.routeTierIndex,
254
+ publicationRequestPath: input.publicationRequestPath,
255
+ publicationRoute: input.publicationRoute,
256
+ publicationBucket: input.publicationBucket,
237
257
  managedIntegrationDeclarations,
238
258
  readTenantConsentSnapshot: () => readSavedConsentSnapshot(tenant.tenant_id),
239
259
  // The D1 catalog edge read replica, when this deploy opts into it. Deferred to
@@ -242,6 +262,7 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
242
262
  // — see `publicRender`.
243
263
  readCatalogD1: async (): Promise<D1Like | undefined> =>
244
264
  (await readEnv("USE_D1_CATALOG")) === "1" ? await readD1("CATALOG_DB") : undefined,
265
+ internalContentGrant: input.internalContentGrant,
245
266
  });
246
267
  }
247
268