@tokenoftrust/storefront-runner 2.0.19 → 2.0.21

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 (32) hide show
  1. package/apps/storefront/env.d.ts +5 -1
  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/blog/collection.ts +24 -18
  5. package/apps/storefront/src/lib/blog/provider.ts +28 -5
  6. package/apps/storefront/src/lib/blog/reactions.ts +4 -2
  7. package/apps/storefront/src/lib/blog/types.ts +1 -1
  8. package/apps/storefront/src/lib/blog/viewCounts.ts +3 -1
  9. package/apps/storefront/src/lib/d1/catalog.ts +18 -1
  10. package/apps/storefront/src/lib/d1/schema-apps.ts +93 -0
  11. package/apps/storefront/src/lib/edgeCache.ts +2 -0
  12. package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
  13. package/apps/storefront/src/lib/the-build/provider.ts +2 -2
  14. package/apps/storefront/src/middleware/controlPlane.ts +1 -0
  15. package/apps/storefront/src/middleware/renderChain.ts +8 -0
  16. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +4 -1
  17. package/apps/storefront/src/pages/api/rum/vitals.ts +4 -1
  18. package/apps/storefront/src/pages/blog/[slug].astro +5 -4
  19. package/apps/storefront/src/pages/blog/author/[author].astro +2 -2
  20. package/apps/storefront/src/pages/blog/category/[category].astro +2 -2
  21. package/apps/storefront/src/pages/blog/index.astro +2 -2
  22. package/apps/storefront/src/pages/blog/tag/[tag].astro +2 -2
  23. package/apps/storefront/src/pages/llms.txt.ts +2 -2
  24. package/apps/storefront/src/pages/sitemap.xml.ts +8 -4
  25. package/apps/storefront/src/pages/the-build/[slug].astro +6 -3
  26. package/apps/storefront/src/pages/the-build/index.astro +2 -2
  27. package/apps/storefront/src/pages/the-build/rss.xml.ts +2 -2
  28. package/package.json +1 -1
  29. package/packages/public-runtime/package.json +2 -1
  30. package/packages/public-runtime/src/blog-visibility.d.mts +8 -0
  31. package/packages/public-runtime/src/blog-visibility.mjs +32 -0
  32. package/packages/public-runtime/src/index.ts +1 -0
@@ -115,6 +115,8 @@ declare global {
115
115
  routeTier: ResolvedRouteTierV1;
116
116
  /** Normalized publication mount/canonical/content plan for this request. */
117
117
  publicationRoute: PublicationRoutePlanV1;
118
+ /** Audited selected aggregate transition instant; null means schedules stay hidden. */
119
+ publicationTimestamp: string | null;
118
120
  /**
119
121
  * Effective capability flags for the resolved tenant (commerce defaults
120
122
  * unless siteType:"marketing"/explicit features). Chrome, layout islands,
@@ -195,11 +197,13 @@ declare global {
195
197
  /**
196
198
  * Signed-in viewer for a login-gated preview host (set by the demo login
197
199
  * gate once a valid, resource-bound `tot_session` cookie is present). Carries
198
- * the email + the role(s)/capability tot20 resolved on this tenant, so chrome
200
+ * the opaque subject, email + role(s)/capability tot20 resolved on this tenant, so chrome
199
201
  * can show "signed in as … — owner/developer/staff". Absent on open or
200
202
  * password-gated hosts.
201
203
  */
202
204
  viewer?: {
205
+ /** Opaque identity-provider subject from the verified session assertion. */
206
+ subject?: string;
203
207
  email: string;
204
208
  roles: string[];
205
209
  capability: string;
@@ -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
+ }
@@ -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
  }
@@ -250,6 +250,99 @@ export const testDestinationDeliveries = sqliteTable(
250
250
  }),
251
251
  );
252
252
 
253
+ // --- Publication-space editorial control -----------------------------------
254
+ // Immutable reviewed versions plus one CAS pointer/audit chain per space. The
255
+ // API always derives tenant+space from its authenticated route; the composite
256
+ // keys make cross-tenant aliases impossible in persistence.
257
+ export const publicationSpaceVersions = sqliteTable(
258
+ "publication_space_versions",
259
+ {
260
+ tenantId: text("tenant_id").notNull(),
261
+ spaceId: text("space_id").notNull(),
262
+ versionId: text("version_id").notNull(),
263
+ record: text("record").notNull(),
264
+ },
265
+ (t) => ({ pk: primaryKey({ columns: [t.tenantId, t.spaceId, t.versionId] }) }),
266
+ );
267
+
268
+ export const publicationSpaceReviews = sqliteTable(
269
+ "publication_space_reviews",
270
+ {
271
+ tenantId: text("tenant_id").notNull(),
272
+ spaceId: text("space_id").notNull(),
273
+ versionId: text("version_id").notNull(),
274
+ record: text("record").notNull(),
275
+ },
276
+ (t) => ({
277
+ pk: primaryKey({ columns: [t.tenantId, t.spaceId, t.versionId] }),
278
+ idxTenant: index("idx_publication_space_reviews_tenant").on(t.tenantId, t.spaceId),
279
+ }),
280
+ );
281
+
282
+ export const publicationSpacePointers = sqliteTable(
283
+ "publication_space_pointers",
284
+ {
285
+ tenantId: text("tenant_id").notNull(),
286
+ spaceId: text("space_id").notNull(),
287
+ channel: text("channel").notNull(),
288
+ revision: integer("revision").notNull(),
289
+ record: text("record").notNull(),
290
+ },
291
+ (t) => ({ pk: primaryKey({ columns: [t.tenantId, t.spaceId, t.channel] }) }),
292
+ );
293
+
294
+ export const publicationSpaceTransitions = sqliteTable(
295
+ "publication_space_transitions",
296
+ {
297
+ tenantId: text("tenant_id").notNull(),
298
+ spaceId: text("space_id").notNull(),
299
+ transitionId: text("transition_id").notNull(),
300
+ channel: text("channel").notNull(),
301
+ revision: integer("revision").notNull(),
302
+ record: text("record").notNull(),
303
+ },
304
+ (t) => ({
305
+ pk: primaryKey({ columns: [t.tenantId, t.spaceId, t.transitionId] }),
306
+ uqRevision: uniqueIndex("uq_publication_space_transition_revision").on(
307
+ t.tenantId,
308
+ t.spaceId,
309
+ t.channel,
310
+ t.revision,
311
+ ),
312
+ idxTenant: index("idx_publication_space_transitions_tenant").on(
313
+ t.tenantId,
314
+ t.spaceId,
315
+ t.channel,
316
+ ),
317
+ }),
318
+ );
319
+
320
+ export const publicationSpaceProjections = sqliteTable(
321
+ "publication_space_projections",
322
+ {
323
+ tenantId: text("tenant_id").notNull(),
324
+ spaceId: text("space_id").notNull(),
325
+ channel: text("channel").notNull(),
326
+ revision: integer("revision").notNull(),
327
+ transitionId: text("transition_id").notNull(),
328
+ operation: text("operation").notNull(),
329
+ provider: text("provider").notNull(),
330
+ status: text("status").notNull().default("pending"),
331
+ attempts: integer("attempts").notNull().default(0),
332
+ lastError: text("last_error"),
333
+ record: text("record").notNull(),
334
+ },
335
+ (t) => ({
336
+ pk: primaryKey({ columns: [t.tenantId, t.spaceId, t.channel, t.revision] }),
337
+ uqTransition: uniqueIndex("uq_publication_space_projection_transition").on(
338
+ t.tenantId, t.spaceId, t.transitionId,
339
+ ),
340
+ idxPending: index("idx_publication_space_projections_pending").on(
341
+ t.tenantId, t.status, t.revision,
342
+ ),
343
+ }),
344
+ );
345
+
253
346
  // --- Webhook event spine ----------------------------------------------------
254
347
  // Immutable event log. `id` is the semantic event id we mint — distinct from
255
348
  // any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
@@ -70,6 +70,7 @@ export function pageCacheKey(
70
70
  pointerRevision: number;
71
71
  versionId: string;
72
72
  artifactDigest: string;
73
+ publicationTimestamp: string | null;
73
74
  },
74
75
  ): Request {
75
76
  // A stored page carries the CSP header it was rendered with, INCLUDING any
@@ -87,6 +88,7 @@ export function pageCacheKey(
87
88
  publication.pointerRevision,
88
89
  publication.versionId,
89
90
  publication.artifactDigest,
91
+ publication.publicationTimestamp ?? "unclocked",
90
92
  ].join(":"))}`
91
93
  : "";
92
94
  return new Request(
@@ -1,4 +1,5 @@
1
1
  import {
2
+ exactPublicationTimestamp,
2
3
  openPublicationArtifactV1,
3
4
  parsePublicationVersionStateV1Json,
4
5
  publicationRoutePlanIsCurrentV1,
@@ -7,6 +8,7 @@ import {
7
8
  type PublicationArtifactBucketV1,
8
9
  type PublicationArtifactOpenFailureCodeV1,
9
10
  type PublicationChannelV1,
11
+ type PublicationChannelPointerV1,
10
12
  type PublicationRoutePlanV1,
11
13
  type RouteTierIndexV1,
12
14
  } from "@tot/public-runtime";
@@ -21,6 +23,76 @@ export interface PublicationPageCacheScopeV1 {
21
23
  pointerRevision: number;
22
24
  versionId: string;
23
25
  artifactDigest: string;
26
+ publicationTimestamp: string | null;
27
+ }
28
+
29
+ export interface PublicationRuntimeClockV1 {
30
+ version: 1;
31
+ tenant: string;
32
+ spaceId: string;
33
+ channel: PublicationChannelV1;
34
+ revision: number;
35
+ versionId: string;
36
+ sourceSha: string;
37
+ artifactDigest: string;
38
+ publicationTimestamp: string;
39
+ }
40
+
41
+ export function publicationRuntimeClockKeyV1(
42
+ tenant: string,
43
+ spaceId: string,
44
+ channel: PublicationChannelV1,
45
+ revision: number,
46
+ ): string {
47
+ return `cust:publication-clock:${tenant}:${spaceId}:${channel}:${revision}`;
48
+ }
49
+
50
+ export function publicationRuntimeClockV1(input: {
51
+ pointer: PublicationChannelPointerV1;
52
+ publicationTimestamp: string;
53
+ }): PublicationRuntimeClockV1 | null {
54
+ const publicationTimestamp = exactPublicationTimestamp(input.publicationTimestamp);
55
+ const pointer = input.pointer;
56
+ if (!publicationTimestamp || !Number.isSafeInteger(pointer.revision) || pointer.revision < 1) return null;
57
+ return {
58
+ version: 1,
59
+ tenant: pointer.tenant,
60
+ spaceId: pointer.spaceId,
61
+ channel: pointer.channel,
62
+ revision: pointer.revision,
63
+ versionId: pointer.versionId,
64
+ sourceSha: pointer.sourceSha,
65
+ artifactDigest: pointer.artifactDigest,
66
+ publicationTimestamp,
67
+ };
68
+ }
69
+
70
+ export function parsePublicationRuntimeClockV1(json: string): PublicationRuntimeClockV1 | null {
71
+ try {
72
+ const value = JSON.parse(json) as Record<string, unknown>;
73
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
74
+ Object.keys(value).sort().join("\0") !==
75
+ ["artifactDigest", "channel", "publicationTimestamp", "revision", "sourceSha", "spaceId", "tenant", "version", "versionId"].sort().join("\0") ||
76
+ value.version !== 1 || typeof value.tenant !== "string" || typeof value.spaceId !== "string" ||
77
+ (value.channel !== "live" && value.channel !== "preview") ||
78
+ !Number.isSafeInteger(value.revision) || Number(value.revision) < 1 ||
79
+ typeof value.versionId !== "string" || typeof value.sourceSha !== "string" ||
80
+ typeof value.artifactDigest !== "string" || !exactPublicationTimestamp(value.publicationTimestamp)) return null;
81
+ return value as unknown as PublicationRuntimeClockV1;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ export function publicationRuntimeClockMatchesStateV1(input: {
88
+ clock: PublicationRuntimeClockV1;
89
+ pointer: PublicationChannelPointerV1;
90
+ }): boolean {
91
+ const expected = publicationRuntimeClockV1({
92
+ pointer: input.pointer,
93
+ publicationTimestamp: input.clock.publicationTimestamp,
94
+ });
95
+ return !!expected && JSON.stringify(expected) === JSON.stringify(input.clock);
24
96
  }
25
97
 
26
98
  export type PublicationRuntimeSourceV1 =
@@ -29,6 +101,8 @@ export type PublicationRuntimeSourceV1 =
29
101
  status: "selected";
30
102
  artifact: OpenPublicationArtifactV1;
31
103
  cacheScope: PublicationPageCacheScopeV1;
104
+ /** Exact audited transition instant bound to this selected space revision. */
105
+ publicationTimestamp: string | null;
32
106
  }
33
107
  | { status: "unavailable"; code: PublicationArtifactOpenFailureCodeV1 };
34
108
 
@@ -83,15 +157,38 @@ export async function resolvePublicationRuntimeSourceV1(input: {
83
157
  });
84
158
  if (!opened.ok) return { status: "unavailable", code: opened.code };
85
159
  const { selection } = opened.value;
160
+ let clock: PublicationRuntimeClockV1 | null = null;
161
+ if (selection.manifest.visibility === "public") {
162
+ try {
163
+ const clockJson = await input.store.get(publicationRuntimeClockKeyV1(
164
+ selection.pointer.tenant,
165
+ selection.pointer.spaceId,
166
+ selection.channel,
167
+ selection.pointer.revision,
168
+ ));
169
+ const candidate = clockJson ? parsePublicationRuntimeClockV1(clockJson) : null;
170
+ if (candidate && publicationRuntimeClockMatchesStateV1({
171
+ clock: candidate,
172
+ pointer: selection.pointer,
173
+ })) clock = candidate;
174
+ } catch {
175
+ // The content remains selectable, but scheduled visibility fails closed.
176
+ }
177
+ }
178
+ const publicationTimestamp = selection.manifest.visibility === "public"
179
+ ? clock?.publicationTimestamp ?? null
180
+ : exactPublicationTimestamp(selection.pointer.updatedAt);
86
181
  return {
87
182
  status: "selected",
88
183
  artifact: opened.value,
184
+ publicationTimestamp,
89
185
  cacheScope: {
90
186
  spaceId: selection.manifest.id,
91
187
  channel: selection.channel,
92
188
  pointerRevision: selection.pointer.revision,
93
189
  versionId: selection.publicationVersion.versionId,
94
190
  artifactDigest: selection.publicationVersion.artifactDigest,
191
+ publicationTimestamp,
95
192
  },
96
193
  };
97
194
  }
@@ -10,7 +10,7 @@ export const THE_BUILD: Collection = { id: "the-build", base: "/the-build" };
10
10
 
11
11
  export function listBuildPosts(
12
12
  tenantId: string,
13
- opts?: { includeUnpublished?: boolean; now?: string; content?: ContentProvider },
13
+ opts?: { includeUnpublished?: boolean; now?: string | null; content?: ContentProvider },
14
14
  ): Promise<BlogPostSummary[]> {
15
15
  return listPosts(tenantId, THE_BUILD.id, opts);
16
16
  }
@@ -18,7 +18,7 @@ export function listBuildPosts(
18
18
  export function getBuildPost(
19
19
  tenantId: string,
20
20
  slug: string,
21
- opts?: { includeUnpublished?: boolean; now?: string; content?: ContentProvider },
21
+ opts?: { includeUnpublished?: boolean; now?: string | null; content?: ContentProvider },
22
22
  ): Promise<BlogPost | null> {
23
23
  return getPost(tenantId, THE_BUILD.id, slug, opts);
24
24
  }
@@ -206,6 +206,7 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
206
206
  : tenant.appDomain;
207
207
  const hostCapability = sessionHostCapability(viewerRecord, capabilityHost, nowSeconds);
208
208
  locals.viewer = {
209
+ ...(viewerRecord.subject ? { subject: viewerRecord.subject } : {}),
209
210
  email: viewerRecord.email,
210
211
  roles: hostCapability ? [hostCapability] : viewerRecord.roles,
211
212
  capability: hostCapability ?? viewerRecord.capability,
@@ -54,6 +54,7 @@ import {
54
54
  collectDeclaredCspProfiles,
55
55
  INTEGRATION_NONCE_PLACEHOLDER,
56
56
  defaultConsentSnapshot,
57
+ exactPublicationTimestamp,
57
58
  parseConsentSnapshot,
58
59
  CONSENT_SNAPSHOT_INPUT_HEADER,
59
60
  CONSENT_SNAPSHOT_SIG_HEADER,
@@ -214,6 +215,9 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
214
215
  if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
215
216
  return response;
216
217
  }
218
+ const publicationTimestamp = publicationSource.status === "selected"
219
+ ? publicationSource.publicationTimestamp
220
+ : exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
217
221
 
218
222
  const managedIntegrationDeclarations = input.managedIntegrationDeclarations;
219
223
 
@@ -503,6 +507,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
503
507
  const cspNonce = INTEGRATION_NONCE_PLACEHOLDER;
504
508
 
505
509
  locals.tenant = tenant;
510
+ // One immutable clock for every visibility consumer in this render. Selected
511
+ // Worker content uses its audited pointer transition; materialization uses the
512
+ // signed build input; an ordinary legacy request has no clock and schedules stay hidden.
513
+ locals.publicationTimestamp = publicationTimestamp;
506
514
  // Effective capability flags for this tenant (commerce defaults unless the
507
515
  // tenant declares siteType:"marketing" / explicit features). Pages and chrome
508
516
  // read these instead of hard-coding tenant identity checks.
@@ -98,7 +98,10 @@ export const POST: APIRoute = async (context) => {
98
98
  // Best-effort: no-ops for a non-live post; never throws. When there is no KV
99
99
  // binding (local dev) the increment can't persist, but we still return the
100
100
  // (all-zero) tallies so the client has a consistent shape.
101
- if (kv) await recordReaction(kv, tenantId, collection, slug, reaction);
101
+ if (kv) await recordReaction(kv, tenantId, collection, slug, reaction, {
102
+ content: context.locals.content,
103
+ now: context.locals.publicationTimestamp,
104
+ });
102
105
 
103
106
  return json({ ok: true, counts: await getReactionCounts(kv, tenantId, collection, slug) });
104
107
  };
@@ -61,7 +61,10 @@ export const POST: APIRoute = async (context) => {
61
61
  if (tenantId && kv) {
62
62
  const cfContext =
63
63
  (context.locals as { cfContext?: CfExecutionContextLike })?.cfContext ?? null;
64
- runAfterResponse(recordBlogView(kv, tenantId, sample.path), cfContext);
64
+ runAfterResponse(recordBlogView(kv, tenantId, sample.path, {
65
+ content: context.locals.content,
66
+ now: context.locals.publicationTimestamp,
67
+ }), cfContext);
65
68
  }
66
69
  }
67
70
  }
@@ -11,9 +11,9 @@ import { buildAlternates } from "@/lib/seo/alternates.js";
11
11
  import { languageLabel } from "@/lib/i18n.js";
12
12
  import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
13
13
 
14
- const { tenant, canonicalBase, basePath, content } = Astro.locals;
14
+ const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
15
15
  const slug = Astro.params.slug ?? "";
16
- const post = await getBlogPost(tenant.tenant_id, slug, content);
16
+ const post = await getBlogPost(tenant.tenant_id, slug, content, publicationTimestamp);
17
17
  if (!post) {
18
18
  const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
19
19
  if (redirect) return Astro.redirect(`${basePath}/blog/${redirect}/`, 301);
@@ -38,7 +38,7 @@ const canonical = new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString()
38
38
  // (the invariant `blog/author/[author].astro` documents). Same slug + same source
39
39
  // the archive itself filters on, so the two can't disagree.
40
40
  const authorArchiveSlug = authorSlug(post.author);
41
- const authorHasArchive = (await listBlogPosts(tenant.tenant_id, content)).some(
41
+ const authorHasArchive = (await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)).some(
42
42
  (p) => authorSlug(p.author) === authorArchiveSlug,
43
43
  );
44
44
  const authorUrl = authorHasArchive
@@ -63,7 +63,7 @@ const authorHref = authorHasArchive
63
63
  // I2 — hreflang alternates + per-variant <html lang>. Singleton ⇒ [] alternates
64
64
  // and no `lang` override, so a non-translated post is byte-identical to today.
65
65
  const translations = post.translationGroupId
66
- ? await getTranslations(tenant.tenant_id, "blog", post.slug, { content })
66
+ ? await getTranslations(tenant.tenant_id, "blog", post.slug, { content, now: publicationTimestamp })
67
67
  : [];
68
68
  const alternates = buildAlternates(translations, (s) =>
69
69
  new URL(`/blog/${s}/`, canonicalBase + "/").toString(),
@@ -77,6 +77,7 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
77
77
  path={`/blog/${post.slug}/`}
78
78
  ogType="article"
79
79
  ogImage={post.featuredImage?.src}
80
+ noindex={post.status === "unlisted"}
80
81
  jsonLd={[crumbs, articleLd]}
81
82
  article={{
82
83
  publishedTime: post.publishedAt,
@@ -8,10 +8,10 @@ import { breadcrumbLd } from "@/lib/jsonld.js";
8
8
  // Author archive for the `/blog` collection. Lists exactly the posts whose
9
9
  // bylines link here (same source as /blog/index.astro + /blog/[slug].astro),
10
10
  // so a byline never points at a page missing its post.
11
- const { tenant, basePath, canonicalBase, content } = Astro.locals;
11
+ const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
12
12
  const slug = Astro.params.author ?? "";
13
13
 
14
- const allPosts = await listBlogPosts(tenant.tenant_id, content);
14
+ const allPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
15
15
  const posts = allPosts.filter((post) => authorSlug(post.author) === slug);
16
16
  if (posts.length === 0) return new Response(null, { status: 404 });
17
17
 
@@ -5,12 +5,12 @@ import { listBlogPosts } from "@/lib/blog/provider.js";
5
5
  import { collectPostsByTerm } from "@/lib/blog/collection.js";
6
6
  import { breadcrumbLd } from "@/lib/jsonld.js";
7
7
 
8
- const { tenant, basePath, canonicalBase, content } = Astro.locals;
8
+ const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
9
9
  const param = Astro.params.category ?? "";
10
10
 
11
11
  // Same source as the /blog index (the collection's curated list), filtered to
12
12
  // one category — so a post can never appear here that isn't already live on /blog.
13
- const all = await listBlogPosts(tenant.tenant_id, content);
13
+ const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
14
14
  const match = collectPostsByTerm(all, "categories", param);
15
15
  if (!match) return new Response(null, { status: 404 });
16
16
  const { label, posts } = match;
@@ -3,8 +3,8 @@ import Layout from "@/layouts/Layout.astro";
3
3
  import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
4
4
  import { listBlogPosts } from "@/lib/blog/provider.js";
5
5
 
6
- const { tenant, basePath, canonicalBase, content } = Astro.locals;
7
- const posts = await listBlogPosts(tenant.tenant_id, content);
6
+ const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
7
+ const posts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
8
8
  // A tenant with no blog content has no blog index, so this is still a 404 — but
9
9
  // it is a DIFFERENT 404 from "no such route", and telling them apart from the
10
10
  // outside used to be impossible (both were bare and byte-identical). The header
@@ -5,10 +5,10 @@ import { listBlogPosts } from "@/lib/blog/provider.js";
5
5
  import { collectPostsByTerm } from "@/lib/blog/collection.js";
6
6
  import { breadcrumbLd } from "@/lib/jsonld.js";
7
7
 
8
- const { tenant, basePath, canonicalBase, content } = Astro.locals;
8
+ const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
9
9
  const param = Astro.params.tag ?? "";
10
10
 
11
- const all = await listBlogPosts(tenant.tenant_id, content);
11
+ const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
12
12
  const match = collectPostsByTerm(all, "tags", param);
13
13
  if (!match) return new Response(null, { status: 404 });
14
14
  const { label, posts } = match;
@@ -22,7 +22,7 @@ function oneLine(s: string | undefined, max = 140): string | undefined {
22
22
  }
23
23
 
24
24
  export async function GET(context: APIContext): Promise<Response> {
25
- const { tot, tenant, canonicalBase, content } = context.locals;
25
+ const { tot, tenant, canonicalBase, content, publicationTimestamp } = context.locals;
26
26
  const base = canonicalBase.replace(/\/$/, "");
27
27
 
28
28
  // Use the request-resolved ContentProvider (locals.content) so any published /
@@ -34,7 +34,7 @@ export async function GET(context: APIContext): Promise<Response> {
34
34
  content.listPages(),
35
35
  tot.getFeatured(12),
36
36
  content.getHome().catch(() => null),
37
- listBlogPosts(tenant.tenant_id, content),
37
+ listBlogPosts(tenant.tenant_id, content, publicationTimestamp),
38
38
  ]);
39
39
  // Empty synthesized collections (no active members) never make the guide —
40
40
  // an LLM shouldn't be pointed at a URL that resolves to zero products.
@@ -11,7 +11,7 @@
11
11
  import type { APIContext } from "astro";
12
12
  import { nonEmptyCollections } from "@/lib/tot/query.js";
13
13
  import { listBlogPosts } from "@/lib/blog/provider.js";
14
- import { authorSlug } from "@/lib/blog/collection.js";
14
+ import { authorSlug, categorySlug } from "@/lib/blog/collection.js";
15
15
  import { listBuildPosts } from "@/lib/the-build/provider.js";
16
16
 
17
17
  interface SitemapUrl {
@@ -30,7 +30,7 @@ function escapeXml(value: string): string {
30
30
  }
31
31
 
32
32
  export async function GET(context: APIContext): Promise<Response> {
33
- const { tot, tenant, canonicalBase, content, features } = context.locals;
33
+ const { tot, tenant, canonicalBase, content, features, publicationTimestamp } = context.locals;
34
34
  const base = canonicalBase.replace(/\/$/, "");
35
35
 
36
36
  const urls: SitemapUrl[] = [{ loc: `${base}/` }];
@@ -67,7 +67,7 @@ export async function GET(context: APIContext): Promise<Response> {
67
67
  urls.push({ loc: `${base}/${page.slug}` });
68
68
  }
69
69
 
70
- const blogPosts = await listBlogPosts(tenant.tenant_id, content);
70
+ const blogPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
71
71
  if (blogPosts.length > 0) {
72
72
  urls.push({ loc: `${base}/blog/` });
73
73
  for (const post of blogPosts) {
@@ -83,9 +83,13 @@ export async function GET(context: APIContext): Promise<Response> {
83
83
  for (const slug of authorSlugs) {
84
84
  urls.push({ loc: `${base}/blog/author/${slug}/` });
85
85
  }
86
+ const categorySlugs = [...new Set(blogPosts.flatMap((post) => post.categories.map(categorySlug)).filter(Boolean))].sort();
87
+ const tagSlugs = [...new Set(blogPosts.flatMap((post) => post.tags.map(categorySlug)).filter(Boolean))].sort();
88
+ for (const slug of categorySlugs) urls.push({ loc: `${base}/blog/category/${slug}/` });
89
+ for (const slug of tagSlugs) urls.push({ loc: `${base}/blog/tag/${slug}/` });
86
90
  }
87
91
 
88
- const buildPosts = await listBuildPosts(tenant.tenant_id, { content });
92
+ const buildPosts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
89
93
  if (buildPosts.length > 0) {
90
94
  urls.push({ loc: `${base}/the-build/` });
91
95
  for (const post of buildPosts) {
@@ -11,9 +11,9 @@ import { getTranslations } from "@/lib/blog/collection.js";
11
11
  import { buildAlternates } from "@/lib/seo/alternates.js";
12
12
  import { breadcrumbLd } from "@/lib/jsonld.js";
13
13
 
14
- const { tenant, canonicalBase, basePath, content } = Astro.locals;
14
+ const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
15
15
  const slug = Astro.params.slug ?? "";
16
- const post = await getBuildPost(tenant.tenant_id, slug, { content });
16
+ const post = await getBuildPost(tenant.tenant_id, slug, { content, now: publicationTimestamp });
17
17
  if (!post) {
18
18
  const redirect = await resolveBuildRedirect(tenant.tenant_id, slug, content);
19
19
  if (redirect) return Astro.redirect(`${basePath}/the-build/${redirect}/`, 301);
@@ -45,7 +45,10 @@ const buildHref = `${basePath}/the-build/`;
45
45
  // the live-visible variant set (self + siblings); `buildAlternates` yields [] for
46
46
  // a singleton, so a non-translated post gets no alternates and no `lang` override
47
47
  // (byte-identical to today).
48
- const translations = await getTranslations(tenant.tenant_id, THE_BUILD.id, post.slug, { content });
48
+ const translations = await getTranslations(tenant.tenant_id, THE_BUILD.id, post.slug, {
49
+ content,
50
+ now: publicationTimestamp,
51
+ });
49
52
  const alternates = buildAlternates(translations, (s) =>
50
53
  new URL(`/the-build/${s}/`, canonicalBase + "/").toString(),
51
54
  );
@@ -8,8 +8,8 @@ import Layout from "@/layouts/Layout.astro";
8
8
  import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
9
9
  import { listBuildPosts } from "@/lib/the-build/provider.js";
10
10
 
11
- const { tenant, basePath, canonicalBase, content } = Astro.locals;
12
- const posts = await listBuildPosts(tenant.tenant_id, { content });
11
+ const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
12
+ const posts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
13
13
  if (posts.length === 0) return new Response(null, { status: 404 });
14
14
 
15
15
  const href = (path: string) => `${basePath}${path}` || "/";
@@ -11,8 +11,8 @@ import { listBuildPosts, THE_BUILD } from "@/lib/the-build/provider.js";
11
11
  import { renderRssFeed } from "@/lib/blog/rss.js";
12
12
 
13
13
  export async function GET(context: APIContext): Promise<Response> {
14
- const { tenant, canonicalBase, content } = context.locals;
15
- const posts = await listBuildPosts(tenant.tenant_id, { content });
14
+ const { tenant, canonicalBase, content, publicationTimestamp } = context.locals;
15
+ const posts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
16
16
  if (posts.length === 0) return new Response(null, { status: 404 });
17
17
 
18
18
  const body = renderRssFeed({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -8,7 +8,8 @@
8
8
  "main": "./src/index.ts",
9
9
  "types": "./src/index.ts",
10
10
  "exports": {
11
- ".": "./src/index.ts"
11
+ ".": "./src/index.ts",
12
+ "./blog-visibility": "./src/blog-visibility.mjs"
12
13
  },
13
14
  "scripts": {
14
15
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,8 @@
1
+ export interface BlogVisibilityInput {
2
+ status?: unknown;
3
+ scheduledAt?: unknown;
4
+ }
5
+
6
+ export declare function exactPublicationTimestamp(value: unknown): string | null;
7
+ export declare function isBlogPostVisibleOnLive(post: BlogVisibilityInput, now?: string | null): boolean;
8
+ export declare function isBlogPostListableOnLive(post: BlogVisibilityInput, now?: string | null): boolean;
@@ -0,0 +1,32 @@
1
+ // @ts-check
2
+
3
+ /** @param {unknown} value */
4
+ export function exactPublicationTimestamp(value) {
5
+ if (typeof value !== "string") return null;
6
+ try {
7
+ return new Date(value).toISOString() === value ? value : null;
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ /** @param {{status?: unknown, scheduledAt?: unknown}} post @param {unknown} now */
14
+ function scheduledIsDue(post, now) {
15
+ const publicationTimestamp = exactPublicationTimestamp(now);
16
+ if (post.status !== "scheduled" || typeof post.scheduledAt !== "string" ||
17
+ !publicationTimestamp) return false;
18
+ const due = Date.parse(post.scheduledAt);
19
+ return !Number.isNaN(due) && due <= Date.parse(publicationTimestamp);
20
+ }
21
+
22
+ /** Canonical direct-route visibility: unlisted resolves but never enters a listing. */
23
+ export function isBlogPostVisibleOnLive(post, now) {
24
+ const status = post.status ?? "published";
25
+ return status === "published" || status === "unlisted" || scheduledIsDue({ ...post, status }, now);
26
+ }
27
+
28
+ /** Canonical archive/feed/sitemap visibility. Missing legacy status means published. */
29
+ export function isBlogPostListableOnLive(post, now) {
30
+ const status = post.status ?? "published";
31
+ return status === "published" || scheduledIsDue({ ...post, status }, now);
32
+ }
@@ -16,6 +16,7 @@
16
16
  export * from "./product.js";
17
17
  export * from "./tenant.js";
18
18
  export * from "./publication-space.js";
19
+ export * from "./blog-visibility.mjs";
19
20
  export * from "./publication-routing.js";
20
21
  export * from "./publication-version.js";
21
22
  export * from "./publication-artifact.js";