@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.
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +18 -7
- package/apps/storefront/src/lib/developer-experience/http/mcpServiceCall.ts +51 -15
- package/package.json +1 -1
- package/packages/public-runtime/src/integration/contact-sales-action-source.ts +33 -2
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +1 -0
|
@@ -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
|
|
30
|
-
* app-binding WRITE (`recordDeveloperAppBinding`) targets, so
|
|
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
|
-
|
|
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
|
|
114
|
-
* `200 { capability: <capability> }`; unconfigured
|
|
115
|
-
* any
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
|
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 (!
|
|
82
|
+
if (!authorityUrl || !secret) return { kind: "not-configured" };
|
|
53
83
|
|
|
54
|
-
let
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
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
|
|
73
|
-
return { kind: "transport-error", message:
|
|
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.
|
|
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
|
-
|
|
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),
|