@tokenoftrust/storefront-runner 2.4.44 → 2.4.46

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.
@@ -20,7 +20,7 @@ interface Props {
20
20
  }
21
21
 
22
22
  const { collection, slug } = Astro.props;
23
- const { tenant, basePath, cspNonce } = Astro.locals;
23
+ const { tenant, apiBasePath, cspNonce } = Astro.locals;
24
24
 
25
25
  const META: Record<Reaction, { emoji: string; label: string }> = {
26
26
  like: { emoji: "👍", label: "Like" },
@@ -31,10 +31,13 @@ const META: Record<Reaction, { emoji: string; label: string }> = {
31
31
  const kv = await readKv("TENANT_CACHE");
32
32
  const counts = await getReactionCounts(kv, tenant.tenant_id, collection, slug);
33
33
 
34
- // The react endpoint is base-path-scoped like every other same-origin POST
35
- // (mirrors the newsletter form action); host-routed tenants get an empty prefix.
34
+ // The react endpoint is api-base-scoped like every other same-origin POST
35
+ // (mirrors the newsletter form action); host-routed tenants get an empty
36
+ // prefix. Uses apiBasePath, never basePath — basePath is overridden to a
37
+ // preview/candidate pin's own render prefix, which has no `/api/...` mirror
38
+ // (fb-1791905696516-ubw8a3's class).
36
39
  const endpoint = withBase(
37
- basePath,
40
+ apiBasePath,
38
41
  `/api/blog/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/react`,
39
42
  );
40
43
  ---
@@ -13,6 +13,13 @@ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
13
13
 
14
14
  interface Props {
15
15
  basePath: string;
16
+ /**
17
+ * The tenant's REAL api prefix (`Astro.locals.apiBasePath`), used ONLY for
18
+ * the newsletter form's `/api/...` action — never `basePath`, which is
19
+ * overridden to a preview/candidate pin's own render prefix with no
20
+ * `/api/...` mirror (fb-1791905696516-ubw8a3's class).
21
+ */
22
+ apiBasePath: string;
16
23
  canonicalBase: string;
17
24
  copy: TheBuildIndexCopy;
18
25
  presentation: BlogPresentation;
@@ -21,7 +28,8 @@ interface Props {
21
28
  totalPages: number;
22
29
  }
23
30
 
24
- const { basePath, canonicalBase, copy, presentation, pagePosts, page, totalPages } = Astro.props;
31
+ const { basePath, apiBasePath, canonicalBase, copy, presentation, pagePosts, page, totalPages } =
32
+ Astro.props;
25
33
  const buildPaths = collectionPathsV1(Astro.locals, "the-build");
26
34
 
27
35
  const href = (path: string) => `${basePath}${path}` || "/";
@@ -105,7 +113,11 @@ const itemList = {
105
113
  <section class="news-signup" aria-labelledby="news-signup-title">
106
114
  <h2 id="news-signup-title">Get {copy.title} in your inbox</h2>
107
115
  <p>A short note when something worth reading lands. Double opt-in — we'll email a link to confirm.</p>
108
- <form class="news-form" method="post" action={href("/api/newsletter/the-build/subscribe")}>
116
+ <form
117
+ class="news-form"
118
+ method="post"
119
+ action={`${apiBasePath}/api/newsletter/the-build/subscribe`}
120
+ >
109
121
  <label class="sr-only" for="news-email">Email address</label>
110
122
  <input id="news-email" name="email" type="email" inputmode="email" autocomplete="email"
111
123
  required placeholder="you@example.com" />
@@ -25,7 +25,7 @@ import AuthBadge from "@/components/auth/AuthBadge.astro";
25
25
  import { referenceTheme } from "@/themes/reference";
26
26
  import { mergeTheme } from "@/themes/schema";
27
27
  import { organizationLd, websiteLd } from "@/lib/jsonld";
28
- import { nonceInlineTags, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
28
+ import { nonceInlineTags, prefixApiFormActions, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
29
29
  import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/rawChrome";
30
30
  import { ASYNC_CSS_SWAP_JS } from "@/lib/asyncCss";
31
31
  import { reportTrackingChainHealth } from "@/lib/tracking/interactionHealth";
@@ -106,7 +106,7 @@ const {
106
106
  headerVariant,
107
107
  footerVariant,
108
108
  } = Astro.props;
109
- const { tenant, themeStyle, canonicalBase, basePath, cspNonce, features, capabilities } =
109
+ const { tenant, themeStyle, canonicalBase, basePath, apiBasePath, cspNonce, features, capabilities } =
110
110
  Astro.locals;
111
111
 
112
112
  // Fixed-phase integration engine (ext-kernel-04): ONE engine emits the
@@ -175,12 +175,18 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
175
175
  const bodyEnd = chromeHtml.lastIndexOf("</body>");
176
176
  sharedChrome = {
177
177
  header: nonceInlineTags(
178
- sanitizeSharedChromeFragment(chromeHtml.slice(bodyOpen, marker)),
178
+ prefixApiFormActions(
179
+ sanitizeSharedChromeFragment(chromeHtml.slice(bodyOpen, marker)),
180
+ apiBasePath,
181
+ ),
179
182
  cspNonce,
180
183
  ),
181
184
  footer: nonceInlineTags(
182
- sanitizeSharedChromeFragment(
183
- chromeHtml.slice(marker + CHROME_BODY_MARKER.length, bodyEnd >= 0 ? bodyEnd : undefined),
185
+ prefixApiFormActions(
186
+ sanitizeSharedChromeFragment(
187
+ chromeHtml.slice(marker + CHROME_BODY_MARKER.length, bodyEnd >= 0 ? bodyEnd : undefined),
188
+ ),
189
+ apiBasePath,
184
190
  ),
185
191
  cspNonce,
186
192
  ),
@@ -268,7 +274,11 @@ const footerWidgets = await renderWidgetSlot(
268
274
  ---
269
275
 
270
276
  <!doctype html>
271
- <html lang={lang ?? tenant.locale.split("-")[0] ?? "en"} data-base-path={basePath || undefined}>
277
+ <html
278
+ lang={lang ?? tenant.locale.split("-")[0] ?? "en"}
279
+ data-base-path={basePath || undefined}
280
+ data-api-base={apiBasePath || undefined}
281
+ >
272
282
  <head>
273
283
  <meta charset="utf-8" />
274
284
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -34,6 +34,7 @@ import {
34
34
  isPurposeEligible,
35
35
  parseConsentSnapshot,
36
36
  } from "@tot/public-runtime";
37
+ import { withClientApiBase } from "@/lib/basePath";
37
38
 
38
39
  /** The three field metrics we collect. INP is field-only (no lab equivalent). */
39
40
  export type WebVitalName = "lcp" | "inp" | "cls";
@@ -119,7 +120,7 @@ export const RUM_BEACON_PATH = "/api/rum/vitals";
119
120
  * `sendBeacon` is fire-and-forget and survives page unload — the moment CWV are
120
121
  * finalized. Falls back to a keepalive `fetch` where `sendBeacon` is absent.
121
122
  */
122
- export function beaconReporter(endpoint: string = RUM_BEACON_PATH): WebVitalsReporter {
123
+ export function beaconReporter(endpoint: string = withClientApiBase(RUM_BEACON_PATH)): WebVitalsReporter {
123
124
  return (sample) => {
124
125
  if (typeof navigator === "undefined") return;
125
126
  const body = JSON.stringify(sample);
@@ -80,6 +80,28 @@ export function withClientBase(href: string): string {
80
80
  return withBase(clientBasePath(), href);
81
81
  }
82
82
 
83
+ /**
84
+ * The active tenant's REAL api prefix as seen from the browser, read from the
85
+ * `<html data-api-base>` attribute Layout.astro emits (mirrors
86
+ * `locals.apiBasePath`, middleware/renderChain.ts — never a preview/candidate
87
+ * pin's base path). Returns "" on the server, for host-routed tenants, or
88
+ * before hydration.
89
+ */
90
+ export function clientApiBasePath(): string {
91
+ if (typeof document === "undefined") return "";
92
+ return document.documentElement.dataset.apiBase ?? "";
93
+ }
94
+
95
+ /**
96
+ * Convenience for client beacons that POST to a same-origin `/api/...`
97
+ * endpoint: prefix `path` with the client api base path so the request
98
+ * reaches the tenant's own routed prefix instead of 404ing at the platform
99
+ * host root (fb-1791984159097-jt5w3y's class).
100
+ */
101
+ export function withClientApiBase(path: string): string {
102
+ return withBase(clientApiBasePath(), path);
103
+ }
104
+
83
105
  /**
84
106
  * Keep same-origin root-absolute anchors inside the tenant prefix, so navigation on a
85
107
  * path-routed page stays on the tenant. Covers anchors in raw tenant HTML and chrome,
@@ -24,6 +24,7 @@ import type {
24
24
  IntegrationSiteSurface,
25
25
  } from "@tot/public-runtime";
26
26
  import { INTEGRATION_PLAN_GLOBAL } from "@tot/public-runtime";
27
+ import { withClientApiBase } from "@/lib/basePath";
27
28
 
28
29
  /** Where the browser posts a tamper beacon; the server receiver records + suspends. */
29
30
  export const TAMPER_BEACON_ENDPOINT = "/api/compliance/tamper";
@@ -91,14 +92,15 @@ function beaconSink(win: Window): TamperSink {
91
92
  const post = (beacon: TamperEvidenceBeacon): void => {
92
93
  try {
93
94
  const body = JSON.stringify(beacon);
95
+ const endpoint = withClientApiBase(TAMPER_BEACON_ENDPOINT);
94
96
  const nav = win.navigator as Navigator & {
95
97
  sendBeacon?: (url: string, data?: BodyInit) => boolean;
96
98
  };
97
99
  if (typeof nav.sendBeacon === "function") {
98
- nav.sendBeacon(TAMPER_BEACON_ENDPOINT, new Blob([body], { type: "application/json" }));
100
+ nav.sendBeacon(endpoint, new Blob([body], { type: "application/json" }));
99
101
  return;
100
102
  }
101
- void fetch(TAMPER_BEACON_ENDPOINT, {
103
+ void fetch(endpoint, {
102
104
  method: "POST",
103
105
  headers: { "content-type": "application/json" },
104
106
  body,
@@ -27,18 +27,19 @@ import type { StaffAdmission, TenantMembership } from "@/lib/dashboard/tenantSel
27
27
 
28
28
  /** What every viewer of an unpaid store is told in place of the delegate section. */
29
29
  export const UNPAID_DELEGATION_NOTE =
30
- "Developers with preview access can already add changes to the site. Ship-on-behalf " +
31
- "delegation becomes available once the live site is activated.";
30
+ "Developers you've invited can already add changes and publish them while the store is " +
31
+ "unpaid — no delegation needed. Ship-on-behalf delegation becomes available once the live " +
32
+ "site is activated.";
32
33
 
33
34
  /** The delegate section: an unpaid store's note, or the paid store's {@link DelegateGate}. */
34
35
  export type DelegateSection = { mode: "unpaid"; role: null; explanation: string } | DelegateGate;
35
36
 
36
37
  /**
37
- * Resolve the whole delegate section. Before a store is paid there is no live
38
- * site and preview-access developers can already add changes to the release
39
- * (the `merge-to-preview` admission class, lib/pipeline/admission.ts), so delegation has nothing to grant: every viewer, the
40
- * owner included, gets {@link UNPAID_DELEGATION_NOTE}. A paid store resolves
41
- * {@link resolveDelegateGate} unchanged.
38
+ * Resolve the whole delegate section. Before a store is paid, an invited developer can
39
+ * already add changes AND publish them (both `merge-to-preview` and `publish-live` admit the
40
+ * "unpaid-store-developer" basis, lib/pipeline/admission.ts), so delegation has nothing to
41
+ * grant: every viewer, the owner included, gets {@link UNPAID_DELEGATION_NOTE}. A paid store
42
+ * resolves {@link resolveDelegateGate} unchanged.
42
43
  */
43
44
  export function resolveDelegateSection(
44
45
  storefrontMode: string | null | undefined,
@@ -180,6 +180,31 @@ export function prefixTenantLinks(html: string, basePath: string): string {
180
180
  return html.replace(ROOT_SERVED_HREF_RE, `href="${basePath}/`);
181
181
  }
182
182
 
183
+ /**
184
+ * Matches `action="/api/…` and `formaction="/api/…`, excluding `/api/auth/` —
185
+ * that surface is platform-global and always host-root regardless of tenant
186
+ * routing, mirroring `loginGate.ts`'s `isAuthPath` carve-out (`/api/auth/`).
187
+ */
188
+ const API_FORM_ACTION_RE = /\b(action|formaction)="\/api\/(?!auth\/)/g;
189
+
190
+ /**
191
+ * Prefix root-absolute `/api/…` form targets (`action`/`formaction`) with the
192
+ * tenant's REAL api base path, so a raw/shared-chrome tenant's forms (footer
193
+ * newsletter signup, …) POST to the tenant's own path instead of 404ing at the
194
+ * platform host root under path-prefix routing (fb-1791984159097-jt5w3y).
195
+ *
196
+ * Mirrors {@link prefixTenantLinks}, but keyed on `apiBasePath`, never a
197
+ * preview/candidate pin's `basePath` — a pinned render's own `/api/...` POSTs
198
+ * have no mirror under that pin's prefix and would 404 there too (same class
199
+ * as fb-1791905696516-ubw8a3; see `middleware/renderChain.ts`'s
200
+ * `locals.apiBasePath`). No-op when apiBasePath is empty (host-routed prod,
201
+ * subdomain, custom domain).
202
+ */
203
+ export function prefixApiFormActions(html: string, apiBasePath: string): string {
204
+ if (!apiBasePath) return html;
205
+ return html.replace(API_FORM_ACTION_RE, (_match, attr: string) => `${attr}="${apiBasePath}/api/`);
206
+ }
207
+
183
208
  /** Raw pages with a robots `noindex` directive remain routable but stay out of discovery. */
184
209
  export function isIndexableHtml(html: string): boolean {
185
210
  for (const match of html.matchAll(/<meta\b([^>]*)>/gi)) {
@@ -275,6 +300,15 @@ export function rawHtmlResponse(
275
300
  opts: {
276
301
  nonce: string;
277
302
  basePath: string;
303
+ /**
304
+ * The tenant's REAL routed api prefix (`locals.apiBasePath`), used ONLY to
305
+ * rewrite `/api/...` form actions ({@link prefixApiFormActions}) — never
306
+ * the same value as `basePath` under a preview/candidate pin, where
307
+ * `basePath` is overridden to the pin's own render prefix. Omitted (the
308
+ * default, "") is a no-op, so every existing caller that hasn't threaded
309
+ * it through yet is unchanged.
310
+ */
311
+ apiBasePath?: string;
278
312
  chromeWrapper?: string | null;
279
313
  /**
280
314
  * The resolved effective plan for this request — the raw-mount half of the
@@ -363,7 +397,10 @@ export function rawHtmlResponse(
363
397
  composed = injectBodyEnd(composed, interactionTrackingScript(opts.nonce));
364
398
  composed = injectBodyEnd(composed, asyncCssSwapScript(opts.nonce));
365
399
  composed = resolveCanonicalHref(composed, opts.canonicalUrl);
366
- const doc = prefixTenantLinks(composed, opts.basePath);
400
+ const doc = prefixApiFormActions(
401
+ prefixTenantLinks(composed, opts.basePath),
402
+ opts.apiBasePath ?? "",
403
+ );
367
404
  return new Response(doc, {
368
405
  headers: { "content-type": "text/html; charset=utf-8" },
369
406
  });
@@ -26,6 +26,7 @@
26
26
  * violate either.
27
27
  */
28
28
  import type { TotInteraction } from "@tot/public-runtime";
29
+ import { withClientApiBase } from "@/lib/basePath";
29
30
 
30
31
  /** Where the browser beacon POSTs interaction batches. */
31
32
  export const INTERACTION_BEACON_PATH = "/api/tracking/interaction";
@@ -81,7 +82,9 @@ export type InteractionBatchSender = (events: InteractionWireEvent[]) => void;
81
82
  /** Default sender: one `sendBeacon` per flushed batch, falling back to a
82
83
  * keepalive `fetch` where `sendBeacon` is absent (mirrors
83
84
  * `analytics/webVitals.ts`'s `beaconReporter`). Never throws into the page. */
84
- export function beaconBatchSender(endpoint: string = INTERACTION_BEACON_PATH): InteractionBatchSender {
85
+ export function beaconBatchSender(
86
+ endpoint: string = withClientApiBase(INTERACTION_BEACON_PATH),
87
+ ): InteractionBatchSender {
85
88
  return (events) => {
86
89
  if (events.length === 0 || typeof navigator === "undefined") return;
87
90
  const body = JSON.stringify({ events });
@@ -16,7 +16,8 @@ import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/rawMa
16
16
  import { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
17
17
  import { styleGuideAliasRewrite } from "@/lib/styleGuidePaths";
18
18
 
19
- const { tenant, canonicalBase, content, basePath, capabilities, features } = Astro.locals;
19
+ const { tenant, canonicalBase, content, basePath, apiBasePath, capabilities, features } =
20
+ Astro.locals;
20
21
  const slug = (Astro.params.slug ?? "").replace(/^\/+|\/+$/g, "");
21
22
 
22
23
  // Defensive: never let the catch-all answer for first-class route namespaces.
@@ -60,6 +61,7 @@ if (rawHtml && rawPolicy.allowed) {
60
61
  return rawHtmlResponse(rawHtml, {
61
62
  nonce: Astro.locals.cspNonce,
62
63
  basePath,
64
+ apiBasePath,
63
65
  chromeWrapper,
64
66
  plan: Astro.locals.integrationPlan,
65
67
  consentSnapshot: Astro.locals.consentSnapshot,
@@ -76,6 +76,7 @@ if (rawHtml && rawPolicy.allowed) {
76
76
  return rawHtmlResponse(rawHtml, {
77
77
  nonce: Astro.locals.cspNonce,
78
78
  basePath,
79
+ apiBasePath,
79
80
  chromeWrapper,
80
81
  plan: Astro.locals.integrationPlan,
81
82
  consentSnapshot: Astro.locals.consentSnapshot,
@@ -10,7 +10,8 @@ import { getTheBuildSettings, listBuildPosts } from "@/lib/the-build/provider.js
10
10
  import { paginateBlogPosts } from "@/lib/blog/pagination.js";
11
11
  import { resolveTheBuildIndexCopy } from "@/lib/the-build/pagination.js";
12
12
 
13
- const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
13
+ const { tenant, basePath, apiBasePath, canonicalBase, content, publicationTimestamp } =
14
+ Astro.locals;
14
15
  const posts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
15
16
  if (posts.length === 0) return new Response(null, { status: 404 });
16
17
 
@@ -23,6 +24,7 @@ const { posts: pagePosts, totalPages } = paginateBlogPosts(posts, copy.pageSize,
23
24
 
24
25
  <TheBuildIndexView
25
26
  basePath={basePath}
27
+ apiBasePath={apiBasePath}
26
28
  canonicalBase={canonicalBase}
27
29
  copy={copy}
28
30
  presentation={resolveBlogPresentation(settings)}
@@ -12,7 +12,8 @@ import { parseTheBuildPageParam, resolveTheBuildIndexCopy } from "@/lib/the-buil
12
12
  const page = parseTheBuildPageParam(Astro.params.n);
13
13
  if (page === null) return new Response(null, { status: 404 });
14
14
 
15
- const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
15
+ const { tenant, basePath, apiBasePath, canonicalBase, content, publicationTimestamp } =
16
+ Astro.locals;
16
17
  const posts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
17
18
  if (posts.length === 0) return new Response(null, { status: 404 });
18
19
 
@@ -27,6 +28,7 @@ if (!sliced) return new Response(null, { status: 404 });
27
28
 
28
29
  <TheBuildIndexView
29
30
  basePath={basePath}
31
+ apiBasePath={apiBasePath}
30
32
  canonicalBase={canonicalBase}
31
33
  copy={copy}
32
34
  presentation={resolveBlogPresentation(settings)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.44",
3
+ "version": "2.4.46",
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",