@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.
- package/apps/storefront/env.d.ts +5 -1
- 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/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 +2 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
- package/apps/storefront/src/lib/the-build/provider.ts +2 -2
- package/apps/storefront/src/middleware/controlPlane.ts +1 -0
- package/apps/storefront/src/middleware/renderChain.ts +8 -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 +1 -0
package/apps/storefront/env.d.ts
CHANGED
|
@@ -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 +
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
|
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, {
|
|
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.
|
|
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",
|
|
@@ -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";
|