@tokenoftrust/storefront-runner 2.0.18 → 2.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/apps/storefront/env.d.ts +5 -0
  2. package/apps/storefront/migrations-apps/0013_publication_space_editorial.sql +64 -0
  3. package/apps/storefront/src/config/publicationSpaceRegistry.ts +19 -0
  4. package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
  5. package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
  6. package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
  7. package/apps/storefront/src/lib/auth/session.ts +3 -0
  8. package/apps/storefront/src/lib/blog/collection.ts +24 -18
  9. package/apps/storefront/src/lib/blog/provider.ts +28 -5
  10. package/apps/storefront/src/lib/blog/reactions.ts +4 -2
  11. package/apps/storefront/src/lib/blog/types.ts +1 -1
  12. package/apps/storefront/src/lib/blog/viewCounts.ts +3 -1
  13. package/apps/storefront/src/lib/d1/catalog.ts +18 -1
  14. package/apps/storefront/src/lib/d1/schema-apps.ts +93 -0
  15. package/apps/storefront/src/lib/edgeCache.ts +19 -1
  16. package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
  17. package/apps/storefront/src/lib/publication/runtimeSource.ts +194 -0
  18. package/apps/storefront/src/lib/the-build/provider.ts +2 -2
  19. package/apps/storefront/src/middleware/controlPlane.ts +24 -3
  20. package/apps/storefront/src/middleware/index.ts +125 -22
  21. package/apps/storefront/src/middleware/renderChain.ts +109 -56
  22. package/apps/storefront/src/middleware/routeBranch.ts +39 -7
  23. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
  24. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
  25. package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
  26. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +4 -1
  27. package/apps/storefront/src/pages/api/rum/vitals.ts +4 -1
  28. package/apps/storefront/src/pages/blog/[slug].astro +5 -4
  29. package/apps/storefront/src/pages/blog/author/[author].astro +2 -2
  30. package/apps/storefront/src/pages/blog/category/[category].astro +2 -2
  31. package/apps/storefront/src/pages/blog/index.astro +2 -2
  32. package/apps/storefront/src/pages/blog/tag/[tag].astro +2 -2
  33. package/apps/storefront/src/pages/llms.txt.ts +2 -2
  34. package/apps/storefront/src/pages/sitemap.xml.ts +8 -4
  35. package/apps/storefront/src/pages/the-build/[slug].astro +6 -3
  36. package/apps/storefront/src/pages/the-build/index.astro +2 -2
  37. package/apps/storefront/src/pages/the-build/rss.xml.ts +2 -2
  38. package/package.json +1 -1
  39. package/packages/public-runtime/package.json +2 -1
  40. package/packages/public-runtime/src/blog-visibility.d.mts +8 -0
  41. package/packages/public-runtime/src/blog-visibility.mjs +32 -0
  42. package/packages/public-runtime/src/index.ts +3 -0
  43. package/packages/public-runtime/src/publication-artifact.ts +20 -3
  44. package/packages/public-runtime/src/publication-reader.ts +187 -0
  45. package/packages/public-runtime/src/publication-routing.ts +246 -0
  46. package/packages/public-runtime/src/publication-version.ts +15 -0
@@ -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.18",
3
+ "version": "2.0.20",
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,8 +16,11 @@
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";
20
+ export * from "./publication-routing.js";
19
21
  export * from "./publication-version.js";
20
22
  export * from "./publication-artifact.js";
23
+ export * from "./publication-reader.js";
21
24
  // The ONE route-tier authority: route -> mount -> publication space ->
22
25
  // {visibility, authorization} -> an ADR 0013 tier. No consumer derives its own.
23
26
  export * from "./route-tier.js";
@@ -1,6 +1,9 @@
1
1
  import type { PublicationSpaceManifestV1 } from "./publication-space.js";
2
2
  import { sha256Hex } from "./hash.js";
3
3
 
4
+ const PUBLICATION_ARTIFACT_IDENTIFIER_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
5
+ const PUBLICATION_ARTIFACT_VERSION_RE = /^pv_[a-z0-9](?:[a-z0-9._:-]{4,122}[a-z0-9])$/;
6
+
4
7
  /** Renderer-safe description of one immutable publication artifact file. */
5
8
  export interface PublicationArtifactFileV1 {
6
9
  lane: "content" | "public-asset" | "authorized-asset";
@@ -188,9 +191,9 @@ export function publicationArtifactKeyV1(
188
191
  lane: PublicationArtifactFileV1["lane"],
189
192
  relativePath: string,
190
193
  ): string {
191
- const identifier = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
192
- const version = /^pv_[a-z0-9](?:[a-z0-9._:-]{4,122}[a-z0-9])$/;
193
- if (!identifier.test(tenant) || !identifier.test(spaceId) || !version.test(versionId)) {
194
+ if (!PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(tenant) ||
195
+ !PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(spaceId) ||
196
+ !PUBLICATION_ARTIFACT_VERSION_RE.test(versionId)) {
194
197
  throw new Error("publication artifact identity is unsafe");
195
198
  }
196
199
  if (!["content", "public-asset", "authorized-asset"].includes(lane) ||
@@ -201,3 +204,17 @@ export function publicationArtifactKeyV1(
201
204
  }
202
205
  return `publications/${tenant}/${spaceId}/${versionId}/${lane}/${relativePath}`;
203
206
  }
207
+
208
+ /** Marker address for one immutable tenant+space+version bundle. */
209
+ export function publicationArtifactBundleKeyV1(
210
+ tenant: string,
211
+ spaceId: string,
212
+ versionId: string,
213
+ ): string {
214
+ if (!PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(tenant) ||
215
+ !PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(spaceId) ||
216
+ !PUBLICATION_ARTIFACT_VERSION_RE.test(versionId)) {
217
+ throw new Error("publication artifact identity is unsafe");
218
+ }
219
+ return `publications/${tenant}/${spaceId}/${versionId}/bundle.json`;
220
+ }
@@ -0,0 +1,187 @@
1
+ import {
2
+ publicationArtifactBundleKeyV1,
3
+ publicationArtifactFileBytesV1,
4
+ publicationArtifactKeyV1,
5
+ validatePublicationArtifactBundleV1,
6
+ type PublicationArtifactBundleV1,
7
+ type PublicationArtifactFileV1,
8
+ } from "./publication-artifact.js";
9
+ import {
10
+ selectPublicationRouteVersionV1,
11
+ type PublicationRouteFailureCodeV1,
12
+ type PublicationRoutePlanV1,
13
+ type SelectedPublicationRouteV1,
14
+ } from "./publication-routing.js";
15
+ import type {
16
+ PublicationChannelV1,
17
+ PublicationVersionStateV1,
18
+ } from "./publication-version.js";
19
+ import type { RouteTierIndexV1 } from "./route-tier.js";
20
+
21
+ export interface PublicationStoredObjectV1 {
22
+ text(): Promise<string>;
23
+ arrayBuffer(): Promise<ArrayBuffer>;
24
+ }
25
+
26
+ /** Provider-neutral object-store read surface; R2 and test stores both satisfy it. */
27
+ export interface PublicationArtifactBucketV1 {
28
+ get(key: string): Promise<PublicationStoredObjectV1 | null>;
29
+ }
30
+
31
+ export type PublicationArtifactOpenFailureCodeV1 =
32
+ | PublicationRouteFailureCodeV1
33
+ | "publication_artifact_marker_missing"
34
+ | "publication_artifact_marker_malformed"
35
+ | "publication_artifact_file_missing"
36
+ | "publication_artifact_file_mismatch"
37
+ | "publication_artifact_source_unavailable";
38
+
39
+ export interface OpenPublicationArtifactV1 {
40
+ selection: SelectedPublicationRouteV1;
41
+ bundle: PublicationArtifactBundleV1;
42
+ /** Read a UTF-8 artifact relative to the selected space's content/ root. */
43
+ readContent(relativePath: string): string | null;
44
+ /** List paths relative to the selected space's content/ root. */
45
+ listContentPaths(prefix?: string): string[];
46
+ /** Read bytes relative to this space's declared asset namespace. */
47
+ readAsset(relativePath: string): Uint8Array | null;
48
+ }
49
+
50
+ export type OpenPublicationArtifactResultV1 =
51
+ | { ok: true; value: OpenPublicationArtifactV1 }
52
+ | { ok: false; code: PublicationArtifactOpenFailureCodeV1 };
53
+
54
+ function safeRelativePath(path: string, allowEmpty = false): boolean {
55
+ if (path === "") return allowEmpty;
56
+ return !path.startsWith("/") && !path.includes("\\") && !path.includes("\0") &&
57
+ path.split("/").every((part) => !!part && part !== "." && part !== "..");
58
+ }
59
+
60
+ function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
61
+ return left.byteLength === right.byteLength &&
62
+ left.every((value, index) => value === right[index]);
63
+ }
64
+
65
+ /**
66
+ * Open exactly one selected publication version and validate every physical
67
+ * object before rendering from it. A route/state/provenance/object failure is
68
+ * terminal for this source; callers must not fall through to another space.
69
+ */
70
+ export async function openPublicationArtifactV1(input: {
71
+ index: RouteTierIndexV1;
72
+ requestPath: string;
73
+ plan: PublicationRoutePlanV1;
74
+ state: PublicationVersionStateV1;
75
+ channel: PublicationChannelV1;
76
+ bucket: PublicationArtifactBucketV1;
77
+ }): Promise<OpenPublicationArtifactResultV1> {
78
+ const selected = selectPublicationRouteVersionV1(
79
+ input.index,
80
+ input.requestPath,
81
+ input.plan,
82
+ input.state,
83
+ input.channel,
84
+ );
85
+ if (!selected.ok) return { ok: false, code: selected.code };
86
+ return openSelectedPublicationArtifactV1(selected.value, input.bucket);
87
+ }
88
+
89
+ async function openSelectedPublicationArtifactV1(
90
+ selection: SelectedPublicationRouteV1,
91
+ bucket: PublicationArtifactBucketV1,
92
+ ): Promise<OpenPublicationArtifactResultV1> {
93
+ const { manifest, publicationVersion } = selection;
94
+ let marker: PublicationStoredObjectV1 | null;
95
+ try {
96
+ marker = await bucket.get(publicationArtifactBundleKeyV1(
97
+ manifest.tenant,
98
+ manifest.id,
99
+ publicationVersion.versionId,
100
+ ));
101
+ } catch {
102
+ return { ok: false, code: "publication_artifact_source_unavailable" };
103
+ }
104
+ if (!marker) return { ok: false, code: "publication_artifact_marker_missing" };
105
+
106
+ let markerValue: unknown;
107
+ try {
108
+ markerValue = JSON.parse(await marker.text());
109
+ } catch {
110
+ return { ok: false, code: "publication_artifact_marker_malformed" };
111
+ }
112
+ const checked = await validatePublicationArtifactBundleV1(markerValue, {
113
+ tenant: manifest.tenant,
114
+ spaceId: manifest.id,
115
+ versionId: publicationVersion.versionId,
116
+ sourceSha: publicationVersion.source.commitSha,
117
+ artifactDigest: publicationVersion.artifactDigest,
118
+ forge: manifest.source.forge,
119
+ repo: manifest.source.repo,
120
+ visibility: manifest.visibility,
121
+ assetNamespace: manifest.assets.namespace,
122
+ assetAccess: manifest.assets.access,
123
+ });
124
+ if (!checked.ok) return { ok: false, code: "publication_artifact_marker_malformed" };
125
+
126
+ const physical = new Map<string, Uint8Array>();
127
+ for (const file of checked.value.files) {
128
+ const expected = publicationArtifactFileBytesV1(file);
129
+ if (!expected) return { ok: false, code: "publication_artifact_marker_malformed" };
130
+ let stored: PublicationStoredObjectV1 | null;
131
+ try {
132
+ stored = await bucket.get(publicationArtifactKeyV1(
133
+ manifest.tenant,
134
+ manifest.id,
135
+ publicationVersion.versionId,
136
+ file.lane,
137
+ file.path,
138
+ ));
139
+ } catch {
140
+ return { ok: false, code: "publication_artifact_source_unavailable" };
141
+ }
142
+ if (!stored) return { ok: false, code: "publication_artifact_file_missing" };
143
+ let actual: Uint8Array;
144
+ try {
145
+ actual = new Uint8Array(await stored.arrayBuffer());
146
+ } catch {
147
+ return { ok: false, code: "publication_artifact_file_missing" };
148
+ }
149
+ if (!equalBytes(actual, expected)) {
150
+ return { ok: false, code: "publication_artifact_file_mismatch" };
151
+ }
152
+ physical.set(`${file.lane}\0${file.path}`, actual);
153
+ }
154
+
155
+ const contentFiles = checked.value.files.filter(
156
+ (file): file is PublicationArtifactFileV1 & { encoding: "utf8" } =>
157
+ file.lane === "content" && file.encoding === "utf8" && file.path.startsWith("content/"),
158
+ );
159
+ const assetLane = manifest.assets.access === "public" ? "public-asset" : "authorized-asset";
160
+ const assetPrefix = `${manifest.assets.namespace}/`;
161
+ return {
162
+ ok: true,
163
+ value: {
164
+ selection,
165
+ bundle: checked.value,
166
+ readContent(relativePath) {
167
+ if (!safeRelativePath(relativePath)) return null;
168
+ const path = `content/${relativePath}`;
169
+ const file = contentFiles.find((candidate) => candidate.path === path);
170
+ if (!file) return null;
171
+ const bytes = physical.get(`content\0${path}`);
172
+ return bytes ? new TextDecoder().decode(bytes) : null;
173
+ },
174
+ listContentPaths(prefix = "") {
175
+ if (!safeRelativePath(prefix.replace(/\/$/, ""), true)) return [];
176
+ return contentFiles
177
+ .map((file) => file.path.slice("content/".length))
178
+ .filter((path) => path.startsWith(prefix))
179
+ .sort();
180
+ },
181
+ readAsset(relativePath) {
182
+ if (!safeRelativePath(relativePath)) return null;
183
+ return physical.get(`${assetLane}\0${assetPrefix}${relativePath}`) ?? null;
184
+ },
185
+ },
186
+ };
187
+ }