@tokenoftrust/storefront-runner 2.0.18 → 2.0.20
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 +5 -0
- package/apps/storefront/migrations-apps/0013_publication_space_editorial.sql +64 -0
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +19 -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/blog/collection.ts +24 -18
- package/apps/storefront/src/lib/blog/provider.ts +28 -5
- package/apps/storefront/src/lib/blog/reactions.ts +4 -2
- package/apps/storefront/src/lib/blog/types.ts +1 -1
- package/apps/storefront/src/lib/blog/viewCounts.ts +3 -1
- package/apps/storefront/src/lib/d1/catalog.ts +18 -1
- package/apps/storefront/src/lib/d1/schema-apps.ts +93 -0
- package/apps/storefront/src/lib/edgeCache.ts +19 -1
- package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +194 -0
- package/apps/storefront/src/lib/the-build/provider.ts +2 -2
- 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 +109 -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/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +4 -1
- package/apps/storefront/src/pages/api/rum/vitals.ts +4 -1
- package/apps/storefront/src/pages/blog/[slug].astro +5 -4
- package/apps/storefront/src/pages/blog/author/[author].astro +2 -2
- package/apps/storefront/src/pages/blog/category/[category].astro +2 -2
- package/apps/storefront/src/pages/blog/index.astro +2 -2
- package/apps/storefront/src/pages/blog/tag/[tag].astro +2 -2
- package/apps/storefront/src/pages/llms.txt.ts +2 -2
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -4
- package/apps/storefront/src/pages/the-build/[slug].astro +6 -3
- package/apps/storefront/src/pages/the-build/index.astro +2 -2
- package/apps/storefront/src/pages/the-build/rss.xml.ts +2 -2
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -1
- package/packages/public-runtime/src/blog-visibility.d.mts +8 -0
- package/packages/public-runtime/src/blog-visibility.mjs +32 -0
- package/packages/public-runtime/src/index.ts +3 -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,10 @@ 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;
|
|
118
|
+
/** Audited selected aggregate transition instant; null means schedules stay hidden. */
|
|
119
|
+
publicationTimestamp: string | null;
|
|
115
120
|
/**
|
|
116
121
|
* Effective capability flags for the resolved tenant (commerce defaults
|
|
117
122
|
* unless siteType:"marketing"/explicit features). Chrome, layout islands,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
CREATE TABLE `publication_space_versions` (
|
|
2
|
+
`tenant_id` text NOT NULL,
|
|
3
|
+
`space_id` text NOT NULL,
|
|
4
|
+
`version_id` text NOT NULL,
|
|
5
|
+
`record` text NOT NULL,
|
|
6
|
+
PRIMARY KEY(`tenant_id`, `space_id`, `version_id`)
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
9
|
+
CREATE TABLE `publication_space_reviews` (
|
|
10
|
+
`tenant_id` text NOT NULL,
|
|
11
|
+
`space_id` text NOT NULL,
|
|
12
|
+
`version_id` text NOT NULL,
|
|
13
|
+
`record` text NOT NULL,
|
|
14
|
+
PRIMARY KEY(`tenant_id`, `space_id`, `version_id`)
|
|
15
|
+
);
|
|
16
|
+
--> statement-breakpoint
|
|
17
|
+
CREATE INDEX `idx_publication_space_reviews_tenant`
|
|
18
|
+
ON `publication_space_reviews` (`tenant_id`, `space_id`);
|
|
19
|
+
--> statement-breakpoint
|
|
20
|
+
CREATE TABLE `publication_space_pointers` (
|
|
21
|
+
`tenant_id` text NOT NULL,
|
|
22
|
+
`space_id` text NOT NULL,
|
|
23
|
+
`channel` text NOT NULL,
|
|
24
|
+
`revision` integer NOT NULL,
|
|
25
|
+
`record` text NOT NULL,
|
|
26
|
+
PRIMARY KEY(`tenant_id`, `space_id`, `channel`)
|
|
27
|
+
);
|
|
28
|
+
--> statement-breakpoint
|
|
29
|
+
CREATE TABLE `publication_space_transitions` (
|
|
30
|
+
`tenant_id` text NOT NULL,
|
|
31
|
+
`space_id` text NOT NULL,
|
|
32
|
+
`transition_id` text NOT NULL,
|
|
33
|
+
`channel` text NOT NULL,
|
|
34
|
+
`revision` integer NOT NULL,
|
|
35
|
+
`record` text NOT NULL,
|
|
36
|
+
PRIMARY KEY(`tenant_id`, `space_id`, `transition_id`)
|
|
37
|
+
);
|
|
38
|
+
--> statement-breakpoint
|
|
39
|
+
CREATE UNIQUE INDEX `uq_publication_space_transition_revision`
|
|
40
|
+
ON `publication_space_transitions` (`tenant_id`, `space_id`, `channel`, `revision`);
|
|
41
|
+
--> statement-breakpoint
|
|
42
|
+
CREATE INDEX `idx_publication_space_transitions_tenant`
|
|
43
|
+
ON `publication_space_transitions` (`tenant_id`, `space_id`, `channel`);
|
|
44
|
+
--> statement-breakpoint
|
|
45
|
+
CREATE TABLE `publication_space_projections` (
|
|
46
|
+
`tenant_id` text NOT NULL,
|
|
47
|
+
`space_id` text NOT NULL,
|
|
48
|
+
`channel` text NOT NULL,
|
|
49
|
+
`revision` integer NOT NULL,
|
|
50
|
+
`transition_id` text NOT NULL,
|
|
51
|
+
`operation` text NOT NULL,
|
|
52
|
+
`provider` text NOT NULL,
|
|
53
|
+
`status` text NOT NULL DEFAULT 'pending',
|
|
54
|
+
`attempts` integer NOT NULL DEFAULT 0,
|
|
55
|
+
`last_error` text,
|
|
56
|
+
`record` text NOT NULL,
|
|
57
|
+
PRIMARY KEY(`tenant_id`, `space_id`, `channel`, `revision`)
|
|
58
|
+
);
|
|
59
|
+
--> statement-breakpoint
|
|
60
|
+
CREATE UNIQUE INDEX `uq_publication_space_projection_transition`
|
|
61
|
+
ON `publication_space_projections` (`tenant_id`, `space_id`, `transition_id`);
|
|
62
|
+
--> statement-breakpoint
|
|
63
|
+
CREATE INDEX `idx_publication_space_projections_pending`
|
|
64
|
+
ON `publication_space_projections` (`tenant_id`, `status`, `revision`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PublicationSpaceManifestV1, PublicationSpaceSourceV1 } from "@tot/public-runtime";
|
|
2
|
+
import { rootPublicationSpaceConfig } from "./publicationSpaceConfig.js";
|
|
3
|
+
|
|
4
|
+
const ROOT_SOURCES: Readonly<Record<string, PublicationSpaceSourceV1>> = {
|
|
5
|
+
home: { forge: "fixture", repo: "home", defaultBranch: "main" },
|
|
6
|
+
"tokenoftrust.com": { forge: "gitea", repo: "tokenoftrust.com-main", defaultBranch: "main" },
|
|
7
|
+
"sample-store.com": { forge: "gitea", repo: "sample-store.com", defaultBranch: "main" },
|
|
8
|
+
"sample-store.e2e.test": { forge: "fixture", repo: "sample-store.e2e.test", defaultBranch: "main" },
|
|
9
|
+
"verify.e2e.test": { forge: "fixture", repo: "verify.e2e.test", defaultBranch: "main" },
|
|
10
|
+
"fixture.e2e.test": { forge: "fixture", repo: "fixture.e2e.test", defaultBranch: "main" },
|
|
11
|
+
"chrome.e2e.test": { forge: "fixture", repo: "chrome.e2e.test", defaultBranch: "main" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Node-safe publication topology authority shared by runtime config and CI composition. */
|
|
15
|
+
export function publicationSpacesForTenant(tenant: string): PublicationSpaceManifestV1[] {
|
|
16
|
+
const source = ROOT_SOURCES[tenant];
|
|
17
|
+
if (!source) return [];
|
|
18
|
+
return [rootPublicationSpaceConfig(tenant, source)];
|
|
19
|
+
}
|
|
@@ -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);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
exactPublicationTimestamp,
|
|
3
|
+
isBlogPostListableOnLive,
|
|
4
|
+
isBlogPostVisibleOnLive,
|
|
5
|
+
tenantDirSegment,
|
|
6
|
+
} from "@tot/public-runtime";
|
|
2
7
|
import type { AuthorBio, BlogPost, BlogPostSummary, PostStatus } from "./types.js";
|
|
3
8
|
import type { ContentProvider } from "../storyblok/provider.js";
|
|
9
|
+
import { readEnv } from "../env.js";
|
|
4
10
|
|
|
5
11
|
type ArtifactSource = Pick<ContentProvider, "readArtifact" | "listArtifactPaths">;
|
|
6
12
|
function canReadArtifacts(value: ArtifactSource | undefined): value is ArtifactSource {
|
|
@@ -8,7 +14,7 @@ function canReadArtifacts(value: ArtifactSource | undefined): value is ArtifactS
|
|
|
8
14
|
}
|
|
9
15
|
export interface CollectionReadOptions {
|
|
10
16
|
includeUnpublished?: boolean;
|
|
11
|
-
now?: string;
|
|
17
|
+
now?: string | null;
|
|
12
18
|
content?: ArtifactSource;
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -50,15 +56,9 @@ const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
|
50
56
|
* A "scheduled" post whose `scheduledAt` instant has passed behaves exactly like
|
|
51
57
|
* "published". Before that instant it is treated like a draft (invisible, unlisted).
|
|
52
58
|
* A scheduled post with a missing/unparseable `scheduledAt` is never due (safe: it
|
|
53
|
-
* stays hidden rather than leaking).
|
|
54
|
-
*
|
|
59
|
+
* stays hidden rather than leaking). Scheduled visibility is evaluated only against
|
|
60
|
+
* an explicit publication timestamp; wall-clock passage never changes a live release.
|
|
55
61
|
*/
|
|
56
|
-
function isDueScheduled(p: { status: PostStatus; scheduledAt?: string }, now: string): boolean {
|
|
57
|
-
if (p.status !== "scheduled" || typeof p.scheduledAt !== "string") return false;
|
|
58
|
-
const due = Date.parse(p.scheduledAt);
|
|
59
|
-
return !Number.isNaN(due) && due <= Date.parse(now);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
62
|
/**
|
|
63
63
|
* The single visibility chokepoint: is a post reachable at its URL on the live
|
|
64
64
|
* site? Published, unlisted, and due-scheduled resolve; drafts and not-yet-due
|
|
@@ -68,9 +68,9 @@ function isDueScheduled(p: { status: PostStatus; scheduledAt?: string }, now: st
|
|
|
68
68
|
*/
|
|
69
69
|
export function isVisibleOnLive(
|
|
70
70
|
p: { status: PostStatus; scheduledAt?: string },
|
|
71
|
-
now
|
|
71
|
+
now?: string | null,
|
|
72
72
|
): boolean {
|
|
73
|
-
return
|
|
73
|
+
return isBlogPostVisibleOnLive(p, now);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** "Category Insurance" -> "category-insurance" */
|
|
@@ -166,6 +166,11 @@ function toSummary(p: BlogPost): BlogPostSummary {
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
async function visibilityTimestamp(explicit?: string | null): Promise<string | null> {
|
|
170
|
+
if (explicit !== undefined) return exactPublicationTimestamp(explicit);
|
|
171
|
+
return exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
|
|
172
|
+
}
|
|
173
|
+
|
|
169
174
|
/**
|
|
170
175
|
* The collection ids that actually have post content for a tenant, derived from
|
|
171
176
|
* the same glob the index is built from (no per-tenant config needed). Sorted for
|
|
@@ -233,9 +238,9 @@ export async function listPosts(
|
|
|
233
238
|
.map((key) => postGlobs[key]!()),
|
|
234
239
|
)).map(normalize);
|
|
235
240
|
const includeUnpublished = opts?.includeUnpublished ?? false;
|
|
236
|
-
const now = opts?.now
|
|
241
|
+
const now = await visibilityTimestamp(opts?.now);
|
|
237
242
|
return posts
|
|
238
|
-
.filter((p) => includeUnpublished ||
|
|
243
|
+
.filter((p) => includeUnpublished || isBlogPostListableOnLive(p, now))
|
|
239
244
|
.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : a.publishedAt > b.publishedAt ? -1 : 0))
|
|
240
245
|
.map(toSummary);
|
|
241
246
|
}
|
|
@@ -258,7 +263,7 @@ export async function getPost(
|
|
|
258
263
|
if (raw) {
|
|
259
264
|
try {
|
|
260
265
|
const post = normalize(JSON.parse(raw) as BlogPost);
|
|
261
|
-
if (!(opts.includeUnpublished ?? false) && !isVisibleOnLive(post, opts.now)) return null;
|
|
266
|
+
if (!(opts.includeUnpublished ?? false) && !isVisibleOnLive(post, await visibilityTimestamp(opts.now))) return null;
|
|
262
267
|
return post;
|
|
263
268
|
} catch {
|
|
264
269
|
return null;
|
|
@@ -272,7 +277,7 @@ export async function getPost(
|
|
|
272
277
|
);
|
|
273
278
|
if (!key) return null;
|
|
274
279
|
const post = normalize(await postGlobs[key]!());
|
|
275
|
-
if (!(opts?.includeUnpublished ?? false) && !isVisibleOnLive(post, opts?.now)) return null;
|
|
280
|
+
if (!(opts?.includeUnpublished ?? false) && !isVisibleOnLive(post, await visibilityTimestamp(opts?.now))) return null;
|
|
276
281
|
return post;
|
|
277
282
|
}
|
|
278
283
|
|
|
@@ -325,9 +330,10 @@ export async function relatedPosts(
|
|
|
325
330
|
slug: string,
|
|
326
331
|
n = 3,
|
|
327
332
|
content?: ArtifactSource,
|
|
333
|
+
now?: string | null,
|
|
328
334
|
): Promise<BlogPostSummary[]> {
|
|
329
|
-
const pool = (await listPosts(tenantId, collection, { content })).filter((p) => p.slug !== slug);
|
|
330
|
-
const current = await getPost(tenantId, collection, slug, { content });
|
|
335
|
+
const pool = (await listPosts(tenantId, collection, { content, now })).filter((p) => p.slug !== slug);
|
|
336
|
+
const current = await getPost(tenantId, collection, slug, { content, now });
|
|
331
337
|
const category = current?.categories[0];
|
|
332
338
|
const result = category ? pool.filter((p) => p.categories.includes(category)) : [];
|
|
333
339
|
for (const p of pool) {
|
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
// collection, and backs `/blog` whenever that curated index is unusable;
|
|
5
5
|
// switching `/blog` onto it outright is a later migration once order is
|
|
6
6
|
// encoded as data (see B0.1 §"/blog migration caution").
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
exactPublicationTimestamp,
|
|
9
|
+
isBlogPostListableOnLive,
|
|
10
|
+
isBlogPostVisibleOnLive,
|
|
11
|
+
tenantDirSegment,
|
|
12
|
+
} from "@tot/public-runtime";
|
|
8
13
|
import { listPosts } from "./collection.js";
|
|
9
14
|
import type { BlogPost, BlogPostSummary } from "./types.js";
|
|
10
15
|
import type { ContentProvider } from "../storyblok/provider.js";
|
|
16
|
+
import { readEnv } from "../env.js";
|
|
11
17
|
|
|
12
18
|
/** The collection id `/blog` reads — the derived fallback's second argument. */
|
|
13
19
|
const BLOG_COLLECTION = "blog";
|
|
@@ -18,6 +24,11 @@ function canReadArtifacts(value: ArtifactSource | undefined): value is ArtifactS
|
|
|
18
24
|
return typeof value?.listArtifactPaths === "function" && typeof value.readArtifact === "function";
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
async function visibilityTimestamp(explicit?: string | null): Promise<string | null> {
|
|
28
|
+
if (explicit !== undefined) return exactPublicationTimestamp(explicit);
|
|
29
|
+
return exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
// Both tenant tiers: real at `tenants/<id>/`, platform test one level deeper at
|
|
22
33
|
// `tenants/e2e/<id>/`. `tenantDirSegment` is the one inverse used to look a
|
|
23
34
|
// tenant's keys back up.
|
|
@@ -69,6 +80,10 @@ async function readCuratedIndex(
|
|
|
69
80
|
if (canReadArtifacts(source)) {
|
|
70
81
|
const raw = await source.readArtifact("blog/index.json");
|
|
71
82
|
if (raw) return parseCuratedIndex(raw);
|
|
83
|
+
// A selected immutable publication artifact is authoritative. Missing or
|
|
84
|
+
// malformed curated metadata falls through to its own posts, never to stale
|
|
85
|
+
// build-time globs for the same tenant.
|
|
86
|
+
return null;
|
|
72
87
|
}
|
|
73
88
|
const key = tenantKey(Object.keys(indexGlobs), tenantId, "/index.json")
|
|
74
89
|
?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`${tenantDirSegment(tenantId)}content/blog/index.json`));
|
|
@@ -92,16 +107,19 @@ async function readCuratedIndex(
|
|
|
92
107
|
export async function listBlogPosts(
|
|
93
108
|
tenantId: string,
|
|
94
109
|
source?: ArtifactSource,
|
|
110
|
+
publicationTimestamp?: string | null,
|
|
95
111
|
): Promise<BlogPostSummary[]> {
|
|
112
|
+
const now = await visibilityTimestamp(publicationTimestamp);
|
|
96
113
|
const curated = await readCuratedIndex(tenantId, source);
|
|
97
|
-
if (curated && curated.length > 0) return curated;
|
|
98
|
-
return listPosts(tenantId, BLOG_COLLECTION, { content: source });
|
|
114
|
+
if (curated && curated.length > 0) return curated.filter((post) => isBlogPostListableOnLive(post, now));
|
|
115
|
+
return listPosts(tenantId, BLOG_COLLECTION, { content: source, now });
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
export async function getBlogPost(
|
|
102
119
|
tenantId: string,
|
|
103
120
|
slug: string,
|
|
104
121
|
source?: ArtifactSource,
|
|
122
|
+
publicationTimestamp?: string | null,
|
|
105
123
|
): Promise<BlogPost | null> {
|
|
106
124
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;
|
|
107
125
|
if (canReadArtifacts(source)) {
|
|
@@ -109,7 +127,9 @@ export async function getBlogPost(
|
|
|
109
127
|
const raw = await source.readArtifact(`blog/posts/${slug}.json`);
|
|
110
128
|
if (raw) {
|
|
111
129
|
try {
|
|
112
|
-
|
|
130
|
+
const post = JSON.parse(raw) as BlogPost;
|
|
131
|
+
const now = await visibilityTimestamp(publicationTimestamp);
|
|
132
|
+
return isBlogPostVisibleOnLive(post, now) ? post : null;
|
|
113
133
|
} catch {
|
|
114
134
|
return null;
|
|
115
135
|
}
|
|
@@ -117,7 +137,10 @@ export async function getBlogPost(
|
|
|
117
137
|
if (paths !== null) return null;
|
|
118
138
|
}
|
|
119
139
|
const key = tenantKey(Object.keys(postGlobs), tenantId, `/posts/${slug}.json`);
|
|
120
|
-
|
|
140
|
+
if (!key) return null;
|
|
141
|
+
const post = await postGlobs[key]!();
|
|
142
|
+
const now = await visibilityTimestamp(publicationTimestamp);
|
|
143
|
+
return isBlogPostVisibleOnLive(post, now) ? post : null;
|
|
121
144
|
}
|
|
122
145
|
|
|
123
146
|
export async function resolveBlogRedirect(
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* specific tenant or collection.
|
|
15
15
|
*/
|
|
16
16
|
import { getPost } from "./collection.js";
|
|
17
|
+
import type { ContentProvider } from "../storyblok/provider.js";
|
|
17
18
|
|
|
18
19
|
/** The fixed reaction vocabulary. Not free-form — a closed set like the pillar vocabulary. */
|
|
19
20
|
export const REACTIONS = ["like", "insightful", "celebrate"] as const;
|
|
@@ -42,7 +43,7 @@ async function readCount(kv: KVNamespace, key: string): Promise<number> {
|
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Increment the tally for `reaction` on a post IF the reaction is in the vocab
|
|
45
|
-
* AND the post is live (published/unlisted/due
|
|
46
|
+
* AND the post is live (published/unlisted/scheduled due at publication) for this tenant —
|
|
46
47
|
* `getPost` returns null on the live path for a draft/404/non-post. Best-effort:
|
|
47
48
|
* never throws (a reaction must never fail the reader's request).
|
|
48
49
|
*/
|
|
@@ -52,10 +53,11 @@ export async function recordReaction(
|
|
|
52
53
|
collection: string,
|
|
53
54
|
slug: string,
|
|
54
55
|
reaction: string,
|
|
56
|
+
visibility?: { content?: ContentProvider; now?: string | null },
|
|
55
57
|
): Promise<void> {
|
|
56
58
|
try {
|
|
57
59
|
if (!isReaction(reaction)) return;
|
|
58
|
-
const post = await getPost(tenantId, collection, slug);
|
|
60
|
+
const post = await getPost(tenantId, collection, slug, visibility);
|
|
59
61
|
if (!post) return; // not a live post (draft / 404 / non-blog) — do not count
|
|
60
62
|
const key = reactionsKey(tenantId, collection, slug, reaction);
|
|
61
63
|
const next = (await readCount(kv, key)) + 1;
|
|
@@ -29,7 +29,7 @@ export interface BlogPostSummary {
|
|
|
29
29
|
tags: string[];
|
|
30
30
|
featuredImage?: BlogImage;
|
|
31
31
|
status: PostStatus; // seeded/legacy posts without this field default to "published"
|
|
32
|
-
scheduledAt?: string; // ISO date;
|
|
32
|
+
scheduledAt?: string; // ISO date; eligible in an explicit publication at or after this instant
|
|
33
33
|
canonicalUrl?: string; // syndication override
|
|
34
34
|
inLanguage?: string; // default "en"
|
|
35
35
|
access?: BlogAccess; // entitlement gate; default "public". Orthogonal to `status`
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* `the-build` or `tokenoftrust.com`.
|
|
25
25
|
*/
|
|
26
26
|
import { getPost, listPosts } from "./collection.js";
|
|
27
|
+
import type { ContentProvider } from "../storyblok/provider.js";
|
|
27
28
|
|
|
28
29
|
// Mirror the engine's guards (collection.ts): a collection is one path segment;
|
|
29
30
|
// a slug is lower-kebab. Keeping them local avoids widening the engine's surface.
|
|
@@ -96,11 +97,12 @@ export async function recordBlogView(
|
|
|
96
97
|
kv: KVNamespace,
|
|
97
98
|
tenantId: string,
|
|
98
99
|
path: string,
|
|
100
|
+
visibility?: { content?: ContentProvider; now?: string | null },
|
|
99
101
|
): Promise<void> {
|
|
100
102
|
try {
|
|
101
103
|
const ref = parseBlogPostPath(path);
|
|
102
104
|
if (!ref) return;
|
|
103
|
-
const post = await getPost(tenantId, ref.collection, ref.slug);
|
|
105
|
+
const post = await getPost(tenantId, ref.collection, ref.slug, visibility);
|
|
104
106
|
if (!post) return; // not a live post (draft / 404 / non-blog) — do not count
|
|
105
107
|
const key = blogViewsKey(tenantId, ref.collection, ref.slug);
|
|
106
108
|
const dayKey = blogViewsDayKey(tenantId, ref.collection, ref.slug, dayStamp(new Date()));
|
|
@@ -29,6 +29,8 @@ export interface Queryable {
|
|
|
29
29
|
exec(sql: string): Promise<void>;
|
|
30
30
|
/** D1 executes a batch sequentially inside one implicit transaction. */
|
|
31
31
|
batch?(statements: readonly QueryStatement[]): Promise<void>;
|
|
32
|
+
/** D1 batch results, including SELECT rows, from that same implicit transaction. */
|
|
33
|
+
batchResults?(statements: readonly QueryStatement[]): Promise<readonly QueryBatchResult[]>;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export interface QueryStatement {
|
|
@@ -36,6 +38,10 @@ export interface QueryStatement {
|
|
|
36
38
|
params?: readonly unknown[];
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
export interface QueryBatchResult {
|
|
42
|
+
results: readonly Record<string, unknown>[];
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
/** The slice of the Cloudflare D1Database we use (kept local to avoid a types dep). */
|
|
40
46
|
export interface D1Like {
|
|
41
47
|
prepare(sql: string): {
|
|
@@ -51,6 +57,8 @@ export interface D1Like {
|
|
|
51
57
|
|
|
52
58
|
/** Adapt a Cloudflare D1Database to the Queryable seam. */
|
|
53
59
|
export function fromD1(db: D1Like): Queryable {
|
|
60
|
+
const prepareBatch = (statements: readonly QueryStatement[]) =>
|
|
61
|
+
statements.map(({ sql, params = [] }) => db.prepare(sql).bind(...params));
|
|
54
62
|
return {
|
|
55
63
|
all: async <T>(sql: string, ...params: unknown[]) =>
|
|
56
64
|
(await db.prepare(sql).bind(...params).all()).results as T[],
|
|
@@ -64,7 +72,16 @@ export function fromD1(db: D1Like): Queryable {
|
|
|
64
72
|
},
|
|
65
73
|
batch: async (statements) => {
|
|
66
74
|
if (!db.batch) throw new Error("D1 batch is unavailable");
|
|
67
|
-
await db.batch(statements
|
|
75
|
+
await db.batch(prepareBatch(statements));
|
|
76
|
+
},
|
|
77
|
+
batchResults: async (statements) => {
|
|
78
|
+
if (!db.batch) throw new Error("D1 batch is unavailable");
|
|
79
|
+
const raw = await db.batch(prepareBatch(statements));
|
|
80
|
+
return raw.map((result) => {
|
|
81
|
+
if (!result || typeof result !== "object") return { results: [] };
|
|
82
|
+
const rows = Reflect.get(result, "results") as unknown;
|
|
83
|
+
return { results: Array.isArray(rows) ? rows as Record<string, unknown>[] : [] };
|
|
84
|
+
});
|
|
68
85
|
},
|
|
69
86
|
};
|
|
70
87
|
}
|