@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, transport-free bridge from an uploaded GTM export to a REVIEWABLE
|
|
3
|
+
* migration-draft PROPOSAL — the connective tissue that makes the self-serve
|
|
4
|
+
* import→draft→review→install flow usable by a tenant developer.
|
|
5
|
+
*
|
|
6
|
+
* The analyze surface returns only a REDACTED report, so a developer can never
|
|
7
|
+
* reconstruct the `createMigrationDraft` body (proposedAdapterId /
|
|
8
|
+
* proposedConfiguration / findings) from it — those derive from the UNREDACTED
|
|
9
|
+
* container. This helper runs the same safe pipeline analyze uses
|
|
10
|
+
* (`parseGtmExport` → `buildMigrationGraph` → `classifyMigration`) and then the
|
|
11
|
+
* pure `generateDisabledMigrationDraft`, returning exactly the
|
|
12
|
+
* `CreateMigrationDraftBody` the developer submits, plus an inert review summary.
|
|
13
|
+
*
|
|
14
|
+
* SAFE-BY-CONSTRUCTION: the produced draft is `enabled:false, publishes:false`;
|
|
15
|
+
* the proposal carries only validator-safe values (the generator validates its
|
|
16
|
+
* own output against the pinned schema, and throws rather than emit an invalid
|
|
17
|
+
* one). Nothing here writes, enables, or publishes — it returns a plain data
|
|
18
|
+
* object, like the analyze builder.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
parseGtmExport,
|
|
22
|
+
buildMigrationGraph,
|
|
23
|
+
classifyMigration,
|
|
24
|
+
generateDisabledMigrationDraft,
|
|
25
|
+
NO_ADAPTER_SELECTED_ID,
|
|
26
|
+
type CreateMigrationDraftBody,
|
|
27
|
+
type GtmViolation,
|
|
28
|
+
type MigrationFindingCode,
|
|
29
|
+
} from "@tot/public-runtime";
|
|
30
|
+
|
|
31
|
+
/** The inert, review-friendly summary of what installing the proposal would stage. */
|
|
32
|
+
export interface GtmDraftProposalSummary {
|
|
33
|
+
/** Whether a managed-analytics adapter was matched (false = nothing auto-portable). */
|
|
34
|
+
readonly adapterSelected: boolean;
|
|
35
|
+
/** Count of GTM objects that mapped confidently into the managed adapter. */
|
|
36
|
+
readonly mappedCount: number;
|
|
37
|
+
/** Count of GTM objects that could not be automatically ported (needs review). */
|
|
38
|
+
readonly unresolvedCount: number;
|
|
39
|
+
/** Whether a destination id was carried into the proposed configuration. */
|
|
40
|
+
readonly destinationConfigured: boolean;
|
|
41
|
+
/** Stable redacted finding codes (no source text). */
|
|
42
|
+
readonly findings: MigrationFindingCode[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GtmProposeOk {
|
|
46
|
+
readonly ok: true;
|
|
47
|
+
readonly correlationId: string;
|
|
48
|
+
/** The exact body a caller POSTs to `createMigrationDraft`. */
|
|
49
|
+
readonly proposal: CreateMigrationDraftBody;
|
|
50
|
+
readonly summary: GtmDraftProposalSummary;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GtmProposeErr {
|
|
54
|
+
readonly ok: false;
|
|
55
|
+
readonly correlationId: string;
|
|
56
|
+
readonly error: string;
|
|
57
|
+
readonly violations: GtmViolation[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse + graph + classify an uploaded export into a disabled migration-draft
|
|
62
|
+
* PROPOSAL, or return the structured parse violations with a stable, safe error.
|
|
63
|
+
* NEVER throws on bad input, NEVER executes any part of the export.
|
|
64
|
+
*
|
|
65
|
+
* `draftId` is a placeholder the pure generator requires for its own provenance
|
|
66
|
+
* stamp; the authoritative draft id is assigned server-side by
|
|
67
|
+
* `createMigrationDraft`, so the caller only reads the returned `proposal`.
|
|
68
|
+
*/
|
|
69
|
+
export function proposeGtmMigrationDraft(
|
|
70
|
+
input: string | Uint8Array,
|
|
71
|
+
opts: { correlationId: string; generatedAt: string; draftId: string },
|
|
72
|
+
): GtmProposeOk | GtmProposeErr {
|
|
73
|
+
const parsed = parseGtmExport(input);
|
|
74
|
+
if (!parsed.ok) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
correlationId: opts.correlationId,
|
|
78
|
+
error: "The uploaded file is not a valid GTM container export.",
|
|
79
|
+
violations: parsed.violations,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const graph = buildMigrationGraph(parsed.container);
|
|
83
|
+
const disposition = classifyMigration(graph);
|
|
84
|
+
const result = generateDisabledMigrationDraft({
|
|
85
|
+
container: parsed.container,
|
|
86
|
+
graph,
|
|
87
|
+
disposition,
|
|
88
|
+
provenance: { draftId: opts.draftId, generatedAt: opts.generatedAt },
|
|
89
|
+
});
|
|
90
|
+
const { proposedAdapterId, proposedConfiguration, findings } = result.draft;
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
correlationId: opts.correlationId,
|
|
94
|
+
proposal: { proposedAdapterId, proposedConfiguration, findings },
|
|
95
|
+
summary: {
|
|
96
|
+
adapterSelected: proposedAdapterId !== NO_ADAPTER_SELECTED_ID,
|
|
97
|
+
mappedCount: result.mappedConcepts.length,
|
|
98
|
+
unresolvedCount: result.unresolvedInventory.length,
|
|
99
|
+
destinationConfigured: findings.includes("ga4-destination-id-configured"),
|
|
100
|
+
findings,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -244,6 +244,11 @@ export function isAdminPath(pathname: string): boolean {
|
|
|
244
244
|
* - every `/api/` route (tenant-state mutation). Genuinely-public API endpoints
|
|
245
245
|
* are already gate-exempt via {@link isPublicPath}/{@link isAuthPath}, so
|
|
246
246
|
* excluding all of `/api/` here costs nothing and fails safe.
|
|
247
|
+
*
|
|
248
|
+
* Every entry is matched in BOTH addressing forms — bare (`/api/...`) and
|
|
249
|
+
* tenant-prefixed (`/<tenant>/api/...`) — because the storefront serves the same
|
|
250
|
+
* route under both, so matching only one leaves the other reachable on a token or
|
|
251
|
+
* bypass header alone.
|
|
247
252
|
*/
|
|
248
253
|
export function isBypassPrivilegedPath(pathname: string): boolean {
|
|
249
254
|
if (isAdminPath(pathname)) return true;
|
|
@@ -252,6 +257,7 @@ export function isBypassPrivilegedPath(pathname: string): boolean {
|
|
|
252
257
|
if (pathname === "/dev" || pathname.startsWith("/dev/")) return true;
|
|
253
258
|
if (/^\/[^/]+\/dev(?:\/|$)/.test(pathname)) return true;
|
|
254
259
|
if (pathname === "/api" || pathname.startsWith("/api/")) return true;
|
|
260
|
+
if (/^\/[^/]+\/api(?:\/|$)/.test(pathname)) return true;
|
|
255
261
|
return false;
|
|
256
262
|
}
|
|
257
263
|
|
|
@@ -281,6 +287,19 @@ export interface LoginGateInput {
|
|
|
281
287
|
* (admin, cockpit, /api) are never bypassed regardless of this flag.
|
|
282
288
|
*/
|
|
283
289
|
qaBypass?: boolean;
|
|
290
|
+
/**
|
|
291
|
+
* True when the request presented a signed, unexpired customization-preview
|
|
292
|
+
* token (`?__preview=` or the `tot_preview` cookie) whose `tenantId` claim is
|
|
293
|
+
* THIS request's tenant. The token is HMAC-signed with
|
|
294
|
+
* `CUSTOMIZATION_PREVIEW_SECRET` and tenant-bound, so it is not client-forgeable
|
|
295
|
+
* and one tenant's token never admits a viewer to another's content.
|
|
296
|
+
*
|
|
297
|
+
* Available to EVERY tenant: previewing an unpublished storefront is platform
|
|
298
|
+
* functionality, not a property of which tenant is being viewed. Passes the gate
|
|
299
|
+
* for PUBLIC CONTENT paths only; {@link isBypassPrivilegedPath} paths (admin,
|
|
300
|
+
* cockpit, /api) still require a real session.
|
|
301
|
+
*/
|
|
302
|
+
previewTokenValid?: boolean;
|
|
284
303
|
}
|
|
285
304
|
|
|
286
305
|
/**
|
|
@@ -305,6 +324,12 @@ export function evaluateLoginGate(input: LoginGateInput): LoginGateDecision {
|
|
|
305
324
|
// public content only. NEVER for admin/cockpit/api — that guard lives here (not
|
|
306
325
|
// just in middleware) so /admin stays gated even if the flag is set upstream.
|
|
307
326
|
if (input.qaBypass && !isBypassPrivilegedPath(input.pathname)) return { action: "pass" };
|
|
327
|
+
// Customization preview: a signed, tenant-bound preview token admits a viewer to
|
|
328
|
+
// that tenant's public content on any tenant. Same privileged-path exclusion as
|
|
329
|
+
// the QA bypass — admin/cockpit/api are never reachable on a token alone.
|
|
330
|
+
if (input.previewTokenValid && !isBypassPrivilegedPath(input.pathname)) {
|
|
331
|
+
return { action: "pass" };
|
|
332
|
+
}
|
|
308
333
|
if (input.hasValidSession) return { action: "pass" };
|
|
309
334
|
const next = input.pathname + (input.search ?? "");
|
|
310
335
|
return { action: "redirect", location: `/auth/login?next=${encodeURIComponent(next)}` };
|
|
@@ -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);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { TenantConfig, ResolvedManagedCheckout } from "@tot/public-runtime";
|
|
16
16
|
import { resolveManagedCheckout } from "@tot/public-runtime";
|
|
17
|
-
import { readEnv } from "@/lib/
|
|
17
|
+
import { readEnv } from "@/lib/runtimeEnv";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Resolve the effective checkout facts for a tenant, or null when the tenant has
|
|
@@ -19,7 +19,7 @@ import type {
|
|
|
19
19
|
} from "@tot/public-runtime";
|
|
20
20
|
import { enforcementSignals } from "@tot/public-runtime";
|
|
21
21
|
import { readEnv } from "@/lib/env";
|
|
22
|
-
import {
|
|
22
|
+
import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
|
|
23
23
|
|
|
24
24
|
/** True when the tenant sells age/nicotine-restricted goods (drives fail-closed). */
|
|
25
25
|
export function isRegulatedTenant(
|
|
@@ -55,6 +55,26 @@ export function isSandboxVerificationProvider(
|
|
|
55
55
|
return !!provider && SANDBOX_VERIFICATION_PROVIDERS.has(provider.toLowerCase());
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The LIVE Token of Trust verification provider. When `TOT_VERIFICATION_PROVIDER`
|
|
60
|
+
* resolves to this, `/api/compliance/preflight` calls the real ToT verification
|
|
61
|
+
* API server-side (`@/lib/verification/totVerificationClient`) and derives the
|
|
62
|
+
* verification state from that call ALONE — never from a client-supplied
|
|
63
|
+
* `verification` field and never from the front-door affirmation.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately DISJOINT from {@link SANDBOX_VERIFICATION_PROVIDERS}: the
|
|
66
|
+
* affirmation-simulates-verification shortcut must be unreachable under the live
|
|
67
|
+
* provider (asserted in enforcement.test.ts).
|
|
68
|
+
*/
|
|
69
|
+
export const LIVE_TOT_VERIFICATION_PROVIDER = "tot";
|
|
70
|
+
|
|
71
|
+
/** True when the resolved provider is the live ToT verification integration. */
|
|
72
|
+
export function isLiveTotVerificationProvider(
|
|
73
|
+
provider: string | undefined | null,
|
|
74
|
+
): boolean {
|
|
75
|
+
return !!provider && provider.toLowerCase() === LIVE_TOT_VERIFICATION_PROVIDER;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
/**
|
|
59
79
|
* The PACT reporting CAPTURE sink status. The record BUILDER
|
|
60
80
|
* (`buildPactReportRecord`) ships in this unit and the capture point is wired at
|
|
@@ -73,8 +93,10 @@ const PACT_REPORTING_CAPTURE_WIRED = true;
|
|
|
73
93
|
export async function resolveTenantRuleset(
|
|
74
94
|
tenant: Pick<TenantConfig, "tenant_id" | "compliance">,
|
|
75
95
|
): Promise<ComplianceRuleset | null> {
|
|
76
|
-
const
|
|
77
|
-
|
|
96
|
+
const profile = tenant.compliance?.ruleProfile;
|
|
97
|
+
const buildRuleset = profile ? COMPLIANCE_RULE_PROFILES[profile] : undefined;
|
|
98
|
+
if (!buildRuleset) return null;
|
|
99
|
+
const base = buildRuleset(tenant.tenant_id);
|
|
78
100
|
const provider = (await readEnv("TOT_VERIFICATION_PROVIDER")) || undefined;
|
|
79
101
|
return {
|
|
80
102
|
...base,
|
|
@@ -206,6 +206,50 @@ export const trackingTenantDatabases = sqliteTable(
|
|
|
206
206
|
}),
|
|
207
207
|
);
|
|
208
208
|
|
|
209
|
+
// --- Managed-analytics test destinations -------------------------------------
|
|
210
|
+
// A tenant-owned, NON-LIVE-ONLY destination a developer registers to prove
|
|
211
|
+
// exactly-once delivery for a new marketing action before wiring a real vendor
|
|
212
|
+
// adapter (GA4/Meta/Amplitude fire directly to the vendor with no server-side
|
|
213
|
+
// readback). `environment` is never `live` — enforced by the managed-analytics
|
|
214
|
+
// service, not by this table. Deliberately its OWN table, not `app_installs` —
|
|
215
|
+
// this is not an integration install (no adapter, no manifest, no CSP/consent
|
|
216
|
+
// plan); it is a disposable proving fixture for the action registry.
|
|
217
|
+
export const testDestinations = sqliteTable(
|
|
218
|
+
"test_destinations",
|
|
219
|
+
{
|
|
220
|
+
destinationId: text("destination_id").primaryKey(),
|
|
221
|
+
tenantId: text("tenant_id").notNull(),
|
|
222
|
+
environment: text("environment").notNull(),
|
|
223
|
+
createdAt: text("created_at").notNull(),
|
|
224
|
+
},
|
|
225
|
+
(t) => ({
|
|
226
|
+
idxTestDestinationsTenant: index(
|
|
227
|
+
"idx_test_destinations_tenant",
|
|
228
|
+
).on(t.tenantId),
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// The delivered-event ledger a test destination reads back. Keyed by
|
|
233
|
+
// (destination_id, event_id): a retried/duplicate delivery of the same event id
|
|
234
|
+
// is `INSERT OR IGNORE`, so the row a caller reads back always reflects the
|
|
235
|
+
// FIRST delivery only — the proof that delivery landed exactly once.
|
|
236
|
+
export const testDestinationDeliveries = sqliteTable(
|
|
237
|
+
"test_destination_deliveries",
|
|
238
|
+
{
|
|
239
|
+
destinationId: text("destination_id").notNull(),
|
|
240
|
+
eventId: text("event_id").notNull(),
|
|
241
|
+
tenantId: text("tenant_id").notNull(),
|
|
242
|
+
actionName: text("action_name").notNull(),
|
|
243
|
+
deliveredAt: text("delivered_at").notNull(),
|
|
244
|
+
},
|
|
245
|
+
(t) => ({
|
|
246
|
+
pk: primaryKey({ columns: [t.destinationId, t.eventId] }),
|
|
247
|
+
idxTestDestinationDeliveriesTenant: index(
|
|
248
|
+
"idx_test_destination_deliveries_tenant",
|
|
249
|
+
).on(t.tenantId),
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
|
|
209
253
|
// --- Webhook event spine ----------------------------------------------------
|
|
210
254
|
// Immutable event log. `id` is the semantic event id we mint — distinct from
|
|
211
255
|
// any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
|
|
@@ -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
|
|