@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.
- package/apps/storefront/env.d.ts +3 -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/session.ts +3 -0
- package/apps/storefront/src/lib/edgeCache.ts +17 -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/middleware/controlPlane.ts +24 -3
- package/apps/storefront/src/middleware/index.ts +125 -22
- package/apps/storefront/src/middleware/renderChain.ts +101 -56
- package/apps/storefront/src/middleware/routeBranch.ts +39 -7
- 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/package.json +1 -1
- package/packages/public-runtime/src/index.ts +2 -0
- package/packages/public-runtime/src/publication-artifact.ts +20 -3
- 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-version.ts +15 -0
package/apps/storefront/env.d.ts
CHANGED
|
@@ -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:
|
|
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 {
|
|
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
|
|