@tokenoftrust/storefront-runner 4.11.2 → 4.11.4

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.
@@ -26,9 +26,11 @@
26
26
  * it keeps its OWN small transport rather than importing another cluster's copy so
27
27
  * the two stay decoupled (the same reason previewStatus duplicates its transport).
28
28
  *
29
- * The MCP HOST is the ORIGIN of `MCP_APP_BINDING_URL` — the same tier the invite
30
- * app-binding WRITE (`recordDeveloperAppBinding`) targets, so this READ and that
31
- * WRITE always agree on which subject-capability store they speak to.
29
+ * The ship and contribute reads go to the ORIGIN of `MCP_APP_BINDING_URL` — the same tier
30
+ * the invite app-binding WRITE (`recordDeveloperAppBinding`) targets, so those READs and
31
+ * that WRITE always agree on which subject-capability store they speak to. A named grant
32
+ * (`resolveNamedSubjectGrant`) always goes to the code-owned grant authority,
33
+ * `MCP_GRANT_AUTHORITY_URL`, with no fallback to the app binding.
32
34
  */
33
35
  import { callMcpService } from "@/lib/developer-experience/http/mcpServiceCall";
34
36
  import type { Capability } from "./capability.js";
@@ -48,7 +50,14 @@ export class FetchShipGrantTransport implements ViewerShipGrantGetTransport {
48
50
  // every admin tab), so an unbounded hang stalls the whole document stream. A
49
51
  // timeout aborts to the documented deny-default (the resolver maps any transport/
50
52
  // timeout fault to `preview-access`) — never a render-blocking wait on a slow MCP.
51
- const res = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(2500) });
53
+ // `redirect: "error"`: a redirect would carry the service-secret header to another origin,
54
+ // so it rejects (a transport fault, so a deny) and nothing is sent to the redirect target.
55
+ const res = await fetch(url, {
56
+ method: "GET",
57
+ headers,
58
+ redirect: "error",
59
+ signal: AbortSignal.timeout(2500),
60
+ });
52
61
  return { status: res.status, json: () => res.json() };
53
62
  }
54
63
  }
@@ -110,9 +119,10 @@ async function resolveRawViewerCapability(
110
119
 
111
120
  /**
112
121
  * Whether `email` holds a LIVE owner-consented grant of exactly `capability` over `appDomain`,
113
- * asked of the same subject-capability seam with the capability named. True only on an explicit
114
- * `200 { capability: <capability> }`; unconfigured, any non-2xx, a timeout, an unparseable body or
115
- * any other answer is false. No cache: a revoke takes effect on the next call. Never throws.
122
+ * asked of the grant authority's subject-capability seam with the capability named. True only on
123
+ * an explicit `200 { capability: <capability> }`; an unconfigured or malformed grant authority,
124
+ * any non-2xx, a redirect, a timeout, an unparseable body or any other answer is false. No cache:
125
+ * a revoke takes effect on the next call. Never throws.
116
126
  */
117
127
  export async function resolveNamedSubjectGrant(
118
128
  email: string | null | undefined,
@@ -128,6 +138,7 @@ export async function resolveNamedSubjectGrant(
128
138
  method: "GET",
129
139
  path: "/internal/dev-access/subject-capability",
130
140
  query: { subject, tenant, app: "storefront", capability },
141
+ authority: "grant",
131
142
  transport: deps.transport ?? new FetchShipGrantTransport(),
132
143
  });
133
144
  if (outcome.kind !== "ok") return false;
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * The one transport for storefront → tot-mcp service calls (`/internal/*`, keyless
3
- * `x-mcp-service-secret` seam). Every caller builds its URL here from the ORIGIN of
4
- * `MCP_APP_BINDING_URL` plus a literal contract path, so the paths and capabilities a caller
5
- * sends are pinned by `contracts/tot-mcp-service-seam.json` (see `mcpServiceSeam.test.ts`).
3
+ * `x-mcp-service-secret` seam). Every caller builds its URL here from a code-owned authority
4
+ * origin plus a literal contract path, so the paths and capabilities a caller sends are pinned
5
+ * by `contracts/tot-mcp-service-seam.json` (see `mcpServiceSeam.test.ts`).
6
+ *
7
+ * The authority is chosen by the caller's code, never by a request:
8
+ * - omitted → the ORIGIN of `MCP_APP_BINDING_URL` (the registry/identity tier).
9
+ * - `"grant"` → `MCP_GRANT_AUTHORITY_URL`, which must be a bare HTTPS origin. Missing is
10
+ * `not-configured` and malformed is `bad-url`; neither falls back to the app binding.
11
+ *
12
+ * A thrown transport failure comes back as one fixed diagnostic, never the thrown value: a
13
+ * fetch error can carry the request URL (and so its subject query) to every downstream sink.
6
14
  *
7
15
  * A 401 or 404 from a seam route is misconfiguration, never a business answer: tot-mcp's
8
16
  * routes answer 200 for every absent/unknown case, so 401 means the secret or URL is wrong
@@ -37,26 +45,54 @@ export interface McpServiceRequest {
37
45
  path: string;
38
46
  query?: Record<string, string>;
39
47
  body?: unknown;
48
+ /** `"grant"` selects `MCP_GRANT_AUTHORITY_URL`; omitted selects `MCP_APP_BINDING_URL`. */
49
+ authority?: "grant";
40
50
  transport: McpServiceTransport;
41
51
  }
42
52
 
43
- function misconfigurationReason(status: 401 | 404): string {
53
+ /** The only diagnostic a thrown transport failure produces. */
54
+ export const MCP_SERVICE_TRANSPORT_FAILURE = "tot-mcp service request failed";
55
+
56
+ const AUTHORITY_ENV = {
57
+ binding: "MCP_APP_BINDING_URL",
58
+ grant: "MCP_GRANT_AUTHORITY_URL",
59
+ } as const;
60
+
61
+ /**
62
+ * The grant authority's origin, or null unless `raw` is exactly a bare HTTPS origin (an optional
63
+ * trailing `/` allowed): no credentials, port spelling, path, query or fragment.
64
+ */
65
+ export function grantAuthorityOrigin(raw: string): string | null {
66
+ if (!URL.canParse(raw)) return null;
67
+ const parsed = new URL(raw);
68
+ if (parsed.protocol !== "https:") return null;
69
+ return raw === parsed.origin || raw === `${parsed.origin}/` ? parsed.origin : null;
70
+ }
71
+
72
+ function misconfigurationReason(status: 401 | 404, urlEnv: string): string {
44
73
  return status === 401
45
- ? "tot-mcp rejected the service secret, or MCP_APP_BINDING_URL points at a session-guarded surface"
74
+ ? `tot-mcp rejected the service secret, or ${urlEnv} points at a session-guarded surface`
46
75
  : "tot-mcp does not serve this path; storefront and tot-mcp disagree on the service-seam contract";
47
76
  }
48
77
 
49
78
  export async function callMcpService(req: McpServiceRequest): Promise<McpServiceOutcome> {
50
- const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
79
+ const urlEnv = AUTHORITY_ENV[req.authority ?? "binding"];
80
+ const authorityUrl = await readEnv(urlEnv);
51
81
  const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
52
- if (!bindingUrl || !secret) return { kind: "not-configured" };
82
+ if (!authorityUrl || !secret) return { kind: "not-configured" };
53
83
 
54
- let url: string;
55
- try {
56
- url = `${new URL(bindingUrl).origin}${req.path}`;
57
- } catch {
58
- return { kind: "bad-url" };
84
+ let origin: string | null;
85
+ if (req.authority === "grant") {
86
+ origin = grantAuthorityOrigin(authorityUrl);
87
+ } else {
88
+ try {
89
+ origin = new URL(authorityUrl).origin;
90
+ } catch {
91
+ origin = null;
92
+ }
59
93
  }
94
+ if (!origin) return { kind: "bad-url" };
95
+ let url = `${origin}${req.path}`;
60
96
  if (req.query) url += `?${new URLSearchParams(req.query).toString()}`;
61
97
 
62
98
  const headers = { "x-mcp-service-secret": secret };
@@ -69,12 +105,12 @@ export async function callMcpService(req: McpServiceRequest): Promise<McpService
69
105
  if (!req.transport.post) throw new Error("transport has no post()");
70
106
  res = await req.transport.post(url, headers, req.body);
71
107
  }
72
- } catch (cause) {
73
- return { kind: "transport-error", message: cause instanceof Error ? cause.message : String(cause) };
108
+ } catch {
109
+ return { kind: "transport-error", message: MCP_SERVICE_TRANSPORT_FAILURE };
74
110
  }
75
111
 
76
112
  if (res.status === 401 || res.status === 404) {
77
- const reason = misconfigurationReason(res.status);
113
+ const reason = misconfigurationReason(res.status, urlEnv);
78
114
  console.error(
79
115
  `[mcp-service-seam] ${req.method} ${req.path} answered ${res.status}: ${reason}`,
80
116
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.11.2",
3
+ "version": "4.11.4",
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",
@@ -55,6 +55,8 @@ export interface ContactSalesActionSourceOptions {
55
55
  now?: () => number;
56
56
  navigate?: (href: string) => void;
57
57
  schedule?: (callback: () => void, delayMs: number) => void;
58
+ /** Current document URL. Same-document targets are not cancelled. */
59
+ pageUrl?: string | (() => string);
58
60
  }
59
61
 
60
62
  function deferredNavigationHref(node: ContactSalesActionElement, event: ContactSalesClickEvent): string | undefined {
@@ -68,10 +70,37 @@ function deferredNavigationHref(node: ContactSalesActionElement, event: ContactS
68
70
  return node.href;
69
71
  }
70
72
 
73
+ function currentPageUrl(pageUrl: ContactSalesActionSourceOptions["pageUrl"]): string | undefined {
74
+ if (typeof pageUrl === "function") {
75
+ const value = pageUrl();
76
+ return typeof value === "string" && value ? value : undefined;
77
+ }
78
+ return typeof pageUrl === "string" && pageUrl ? pageUrl : undefined;
79
+ }
80
+
81
+ /** True when href stays on this document and only the fragment can change. */
82
+ function isSameDocumentNavigation(href: string, pageUrl: string | undefined): boolean {
83
+ if (pageUrl) {
84
+ try {
85
+ const current = new URL(pageUrl);
86
+ const target = new URL(href, pageUrl);
87
+ return current.origin === target.origin
88
+ && current.pathname === target.pathname
89
+ && current.search === target.search;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ return href.startsWith("#");
95
+ }
96
+
71
97
  /**
72
98
  * Bind the fixed reviewed DOM identities to a typed source for the managed-analytics
73
99
  * bridge. A keyboard activation of an anchor/button produces its native `click`, so
74
100
  * it follows this same semantic path without a separate key listener.
101
+ *
102
+ * A same-document target is recorded and then left to the browser. Cancelling it and
103
+ * assigning the href updates the hash without scrolling.
75
104
  */
76
105
  export function createContactSalesActionSource(
77
106
  options: ContactSalesActionSourceOptions,
@@ -98,7 +127,9 @@ export function createContactSalesActionSource(
98
127
  bound.add(node);
99
128
  const emit = (event: ContactSalesClickEvent = {}): void => {
100
129
  const navigationHref = options.navigate ? deferredNavigationHref(node, event) : undefined;
101
- if (navigationHref) event.preventDefault?.();
130
+ const deferNavigation = typeof navigationHref === "string"
131
+ && !isSameDocumentNavigation(navigationHref, currentPageUrl(options.pageUrl));
132
+ if (deferNavigation) event.preventDefault?.();
102
133
  sequence += 1;
103
134
  const at = now();
104
135
  const activation: PlatformManagedActionActivation = {
@@ -113,7 +144,7 @@ export function createContactSalesActionSource(
113
144
  listener(activation);
114
145
  // A full-document navigation destroys the isolated GA4 iframe. Give its
115
146
  // MessagePort one bounded turn to receive the reviewed action first.
116
- if (navigationHref) {
147
+ if (deferNavigation && navigationHref) {
117
148
  schedule(() => { if (live) options.navigate?.(navigationHref); }, CONTACT_SALES_NAVIGATION_DEFER_MS);
118
149
  }
119
150
  };
@@ -584,6 +584,7 @@ function createDocumentLifecycle(
584
584
  if (typeof win.location.assign === "function") win.location.assign(href);
585
585
  else win.location.href = href;
586
586
  },
587
+ pageUrl: () => win.location.href,
587
588
  }),
588
589
  }),
589
590
  pageView: () => documentPageView(win, descriptor, options.now ?? Date.now),