@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.
Files changed (46) hide show
  1. package/apps/storefront/env.d.ts +5 -0
  2. package/apps/storefront/migrations-apps/0013_publication_space_editorial.sql +64 -0
  3. package/apps/storefront/src/config/publicationSpaceRegistry.ts +19 -0
  4. package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
  5. package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
  6. package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
  7. package/apps/storefront/src/lib/auth/session.ts +3 -0
  8. package/apps/storefront/src/lib/blog/collection.ts +24 -18
  9. package/apps/storefront/src/lib/blog/provider.ts +28 -5
  10. package/apps/storefront/src/lib/blog/reactions.ts +4 -2
  11. package/apps/storefront/src/lib/blog/types.ts +1 -1
  12. package/apps/storefront/src/lib/blog/viewCounts.ts +3 -1
  13. package/apps/storefront/src/lib/d1/catalog.ts +18 -1
  14. package/apps/storefront/src/lib/d1/schema-apps.ts +93 -0
  15. package/apps/storefront/src/lib/edgeCache.ts +19 -1
  16. package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
  17. package/apps/storefront/src/lib/publication/runtimeSource.ts +194 -0
  18. package/apps/storefront/src/lib/the-build/provider.ts +2 -2
  19. package/apps/storefront/src/middleware/controlPlane.ts +24 -3
  20. package/apps/storefront/src/middleware/index.ts +125 -22
  21. package/apps/storefront/src/middleware/renderChain.ts +109 -56
  22. package/apps/storefront/src/middleware/routeBranch.ts +39 -7
  23. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
  24. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
  25. package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
  26. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +4 -1
  27. package/apps/storefront/src/pages/api/rum/vitals.ts +4 -1
  28. package/apps/storefront/src/pages/blog/[slug].astro +5 -4
  29. package/apps/storefront/src/pages/blog/author/[author].astro +2 -2
  30. package/apps/storefront/src/pages/blog/category/[category].astro +2 -2
  31. package/apps/storefront/src/pages/blog/index.astro +2 -2
  32. package/apps/storefront/src/pages/blog/tag/[tag].astro +2 -2
  33. package/apps/storefront/src/pages/llms.txt.ts +2 -2
  34. package/apps/storefront/src/pages/sitemap.xml.ts +8 -4
  35. package/apps/storefront/src/pages/the-build/[slug].astro +6 -3
  36. package/apps/storefront/src/pages/the-build/index.astro +2 -2
  37. package/apps/storefront/src/pages/the-build/rss.xml.ts +2 -2
  38. package/package.json +1 -1
  39. package/packages/public-runtime/package.json +2 -1
  40. package/packages/public-runtime/src/blog-visibility.d.mts +8 -0
  41. package/packages/public-runtime/src/blog-visibility.mjs +32 -0
  42. package/packages/public-runtime/src/index.ts +3 -0
  43. package/packages/public-runtime/src/publication-artifact.ts +20 -3
  44. package/packages/public-runtime/src/publication-reader.ts +187 -0
  45. package/packages/public-runtime/src/publication-routing.ts +246 -0
  46. package/packages/public-runtime/src/publication-version.ts +15 -0
@@ -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: String(payload.sub ?? email),
125
+ sub: subject,
122
126
  aud: String(payload.aud ?? ""),
123
127
  iat: Number(payload.iat ?? 0),
124
128
  exp: Number(payload.exp ?? 0),
@@ -281,6 +285,8 @@ export async function getFederatedVerifier(
281
285
 
282
286
  /** The session fields derived from the VERIFIED token — the Phase-3 source of truth. */
283
287
  export interface DerivedSessionFields {
288
+ /** Opaque identity-provider subject, preserved exactly for live authorization. */
289
+ subject: string;
284
290
  email: string;
285
291
  resource: string;
286
292
  /** Coarse standing on THIS resource, surfaced as the display capability. */
@@ -307,6 +313,7 @@ export function sessionFromClaims(claims: IdentityClaims, resource: string): Der
307
313
  throw new Error(`verified assertion carries no membership for resource "${resource}"`);
308
314
  }
309
315
  return {
316
+ subject: claims.sub,
310
317
  email: claims.email,
311
318
  resource,
312
319
  capability: membership.role,
@@ -318,6 +325,8 @@ export function sessionFromClaims(claims: IdentityClaims, resource: string): Der
318
325
 
319
326
  /** The multi-tenant fields derived from a verified FEDERATED assertion. */
320
327
  export interface FederatedSessionFields {
328
+ /** Opaque identity-provider subject, preserved exactly for live authorization. */
329
+ subject: string;
321
330
  email: string;
322
331
  /** The verified `aud` — the storefront app identity this console session is for. */
323
332
  resource: string;
@@ -341,6 +350,7 @@ export interface FederatedSessionFields {
341
350
  */
342
351
  export function sessionFromFederatedClaims(claims: IdentityClaims): FederatedSessionFields {
343
352
  const fields: FederatedSessionFields = {
353
+ subject: claims.sub,
344
354
  email: claims.email,
345
355
  resource: claims.aud,
346
356
  tenants: claims.tenants,
@@ -0,0 +1,153 @@
1
+ import type { PublicationRoutePlanV1, ResolvedRouteTierV1 } from "@tot/public-runtime";
2
+ import type { SessionRecord, SessionStore } from "./session.js";
3
+ import { readSession } from "./session.js";
4
+
5
+ export const INTERNAL_CONTENT_CAPABILITY = "internal-content-reader" as const;
6
+ export const INTERNAL_CONTENT_AUTHORITY_TIMEOUT_MS = 5_000;
7
+
8
+ export type InternalContentAuthorityDecision =
9
+ | { allowed: true; tenant: string; spaceId: string; audience: "owner" | "developer" }
10
+ | { allowed: false; reason: "denied" | "unavailable" };
11
+
12
+ export type InternalContentGrant = Extract<InternalContentAuthorityDecision, { allowed: true }>;
13
+
14
+ interface BrokerContext {
15
+ appDomain: string;
16
+ audience: string;
17
+ }
18
+
19
+ export interface InternalContentAuthorityInput {
20
+ route: ResolvedRouteTierV1;
21
+ sessionStore?: SessionStore;
22
+ sessionId?: string | null;
23
+ coreUrl?: string | null;
24
+ mintAssertion: (subject: string) => Promise<string>;
25
+ fetchImpl?: typeof fetch;
26
+ nowSeconds?: number;
27
+ }
28
+
29
+ function routeIsDeclaredInternal(route: ResolvedRouteTierV1): route is ResolvedRouteTierV1 & {
30
+ space: NonNullable<ResolvedRouteTierV1["space"]>;
31
+ } {
32
+ return route.origin === "publication-space" &&
33
+ route.requiresAuthorization &&
34
+ route.visibility === "internal" &&
35
+ route.authorization.policy === "capability" &&
36
+ route.authorization.capability === INTERNAL_CONTENT_CAPABILITY &&
37
+ route.assets.access === "authorized" &&
38
+ !!route.space &&
39
+ route.space.tenant === route.tenant;
40
+ }
41
+
42
+ function brokerContexts(value: unknown): BrokerContext[] | null {
43
+ const unwrapped = value && typeof value === "object" && "content" in value
44
+ ? (value as { content?: unknown }).content
45
+ : value;
46
+ if (!unwrapped || typeof unwrapped !== "object") return null;
47
+ const contexts = (unwrapped as { contexts?: unknown }).contexts;
48
+ if (!Array.isArray(contexts)) return null;
49
+ const parsed: BrokerContext[] = [];
50
+ for (const context of contexts) {
51
+ if (!context || typeof context !== "object") return null;
52
+ const appDomain = (context as { appDomain?: unknown }).appDomain;
53
+ const audience = (context as { audience?: unknown }).audience;
54
+ if (typeof appDomain !== "string" || typeof audience !== "string") return null;
55
+ parsed.push({ appDomain, audience });
56
+ }
57
+ return parsed;
58
+ }
59
+
60
+ /**
61
+ * Resolve internal-content authority from fresh broker state. The session is
62
+ * identity proof only: cached memberships, roles, headers, and preview state do
63
+ * not grant access. Every store/read/signing fault is an explicit unavailable
64
+ * result and callers serve the same non-disclosing denial response.
65
+ */
66
+ export async function authorizeInternalContentRequest(
67
+ input: InternalContentAuthorityInput,
68
+ ): Promise<InternalContentAuthorityDecision> {
69
+ if (!routeIsDeclaredInternal(input.route)) return { allowed: false, reason: "denied" };
70
+ if (!input.sessionStore || !input.sessionId) return { allowed: false, reason: "denied" };
71
+
72
+ let session: SessionRecord | null;
73
+ try {
74
+ session = await readSession(
75
+ input.sessionStore,
76
+ input.sessionId,
77
+ input.nowSeconds ?? Math.floor(Date.now() / 1000),
78
+ );
79
+ } catch {
80
+ return { allowed: false, reason: "unavailable" };
81
+ }
82
+ const subject = session?.subject;
83
+ if (!subject || subject.trim().length === 0) {
84
+ return { allowed: false, reason: "denied" };
85
+ }
86
+
87
+ const coreUrl = (input.coreUrl ?? "").replace(/\/+$/, "");
88
+ if (!coreUrl) return { allowed: false, reason: "unavailable" };
89
+
90
+ let assertion: string;
91
+ try {
92
+ assertion = await input.mintAssertion(subject);
93
+ } catch {
94
+ return { allowed: false, reason: "unavailable" };
95
+ }
96
+
97
+ let response: Response;
98
+ try {
99
+ response = await (input.fetchImpl ?? fetch)(`${coreUrl}/api/broker/appDomains`, {
100
+ method: "GET",
101
+ headers: {
102
+ accept: "application/json",
103
+ "X-ToT-Broker-Assertion": assertion,
104
+ },
105
+ signal: AbortSignal.timeout(INTERNAL_CONTENT_AUTHORITY_TIMEOUT_MS),
106
+ });
107
+ } catch {
108
+ return { allowed: false, reason: "unavailable" };
109
+ }
110
+ if (!response.ok) return { allowed: false, reason: "unavailable" };
111
+
112
+ let contexts: BrokerContext[] | null;
113
+ try {
114
+ contexts = brokerContexts(await response.json());
115
+ } catch {
116
+ contexts = null;
117
+ }
118
+ if (!contexts) return { allowed: false, reason: "unavailable" };
119
+
120
+ const matching = contexts.filter((context) => context.appDomain === input.route.tenant);
121
+ if (matching.length !== 1) return { allowed: false, reason: "denied" };
122
+ const audience = matching[0]!.audience;
123
+ if (audience !== "owner" && audience !== "developer") {
124
+ return { allowed: false, reason: "denied" };
125
+ }
126
+ return {
127
+ allowed: true,
128
+ tenant: input.route.tenant,
129
+ spaceId: input.route.space.spaceId,
130
+ audience,
131
+ };
132
+ }
133
+
134
+ /** Prove the carried grant still names the exact server-resolved F2 space. */
135
+ export function internalContentGrantMatchesPlan(
136
+ grant: InternalContentGrant | undefined,
137
+ plan: PublicationRoutePlanV1,
138
+ ): boolean {
139
+ return !!grant && plan.action === "render" &&
140
+ plan.manifest.visibility === "internal" &&
141
+ plan.manifest.authorization.policy === "capability" &&
142
+ plan.manifest.authorization.capability === INTERNAL_CONTENT_CAPABILITY &&
143
+ plan.manifest.assets.access === "authorized" &&
144
+ grant.tenant === plan.route.tenant &&
145
+ grant.tenant === plan.manifest.tenant &&
146
+ grant.spaceId === plan.manifest.id;
147
+ }
148
+
149
+ /** Internal responses are never indexable or eligible for shared storage. */
150
+ export function applyInternalContentResponsePolicy(headers: Headers): void {
151
+ headers.set("Cache-Control", "private, no-store");
152
+ headers.set("X-Robots-Tag", "noindex, nofollow");
153
+ }
@@ -50,6 +50,8 @@ export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 3600;
50
50
  * are the multi-tenant claims carried in the verified token.
51
51
  */
52
52
  export interface SessionRecord {
53
+ /** Opaque identity-provider subject from the verified assertion. */
54
+ subject?: string;
53
55
  /** Verified email (lowercased) — the principal that passed the grant check. */
54
56
  email: string;
55
57
  /** The resource (appDomain/tenant, e.g. "sample-store.example") this session authorizes. */
@@ -166,6 +168,7 @@ export async function readSession(
166
168
  !record ||
167
169
  typeof record.email !== "string" ||
168
170
  typeof record.resource !== "string" ||
171
+ (record.subject !== undefined && typeof record.subject !== "string") ||
169
172
  typeof record.exp !== "number"
170
173
  ) {
171
174
  await store.delete(id);
@@ -1,6 +1,12 @@
1
- import { tenantDirSegment } from "@tot/public-runtime";
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). Because visibility is computed at build/request
54
- * time, a post committed ahead of time simply needs a build after its instant.
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: string = new Date().toISOString(),
71
+ now?: string | null,
72
72
  ): boolean {
73
- return p.status === "published" || p.status === "unlisted" || isDueScheduled(p, now);
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 ?? new Date().toISOString();
241
+ const now = await visibilityTimestamp(opts?.now);
237
242
  return posts
238
- .filter((p) => includeUnpublished || p.status === "published" || isDueScheduled(p, now))
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 { tenantDirSegment } from "@tot/public-runtime";
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
- return JSON.parse(raw) as BlogPost;
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
- return key ? postGlobs[key]!() : null;
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-scheduled) for this tenant —
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; when status === "scheduled", the post goes live once this instant has passed
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.map(({ sql, params = [] }) => db.prepare(sql).bind(...params)));
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
  }