@tokenoftrust/storefront-runner 2.2.11 → 2.2.13

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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The granter's "an invited developer can't use their access" banner, shared by the vendor
3
+ * dashboard home and the Team page.
4
+ *
5
+ * Served as a STATIC same-origin file (public/) and referenced via
6
+ * <script src="/js/dashboard-access-banner.js">, so the strict production CSP allows it via
7
+ * `script-src 'self'` (see the header of dashboard-team.js). The tenant appDomain is read
8
+ * from the `#access-alerts` element's `data-tenant` attribute; the element carries the
9
+ * `hidden` attribute until there is something to show. Any failure leaves it hidden.
10
+ */
11
+ (function () {
12
+ const root = document.getElementById("access-alerts");
13
+ if (!root) return;
14
+ const tenant = root.dataset.tenant;
15
+ const teamHref = root.dataset.teamHref;
16
+
17
+ function render(broken) {
18
+ root.replaceChildren();
19
+ const n = broken.length;
20
+ const head = document.createElement("p");
21
+ head.className = "access-alerts-head";
22
+ head.textContent = n + (n === 1 ? " developer you invited can't" : " developers you invited can't") +
23
+ " use their access";
24
+ root.appendChild(head);
25
+ const tag = document.createElement("span");
26
+ tag.className = "advice";
27
+ tag.textContent = "tot-mcp advice";
28
+ root.appendChild(tag);
29
+ const list = document.createElement("ul");
30
+ for (const b of broken) {
31
+ const li = document.createElement("li");
32
+ li.textContent = b.developer + " — step: " + b.failedStep + ". " + b.reason + " Fix: " + b.fix;
33
+ list.appendChild(li);
34
+ }
35
+ root.appendChild(list);
36
+ if (teamHref) {
37
+ const a = document.createElement("a");
38
+ a.href = teamHref;
39
+ a.textContent = "Open the Team page →";
40
+ root.appendChild(a);
41
+ }
42
+ root.hidden = false;
43
+ }
44
+
45
+ fetch("/api/grants/team-access-alerts?tenant=" + encodeURIComponent(tenant), {
46
+ headers: { accept: "application/json" },
47
+ })
48
+ .then((res) => (res.ok ? res.json() : null))
49
+ .then((body) => {
50
+ if (body && Array.isArray(body.broken) && body.broken.length) render(body.broken);
51
+ })
52
+ .catch(() => {});
53
+ })();
@@ -20,7 +20,7 @@
20
20
  * transport / parse fault → { configured:true, ok:false } so a caller can distinguish
21
21
  * "definitely not provisioned" (ok + provisioned:false) from "couldn't tell". Never throws.
22
22
  */
23
- import { readEnv } from "@/lib/env";
23
+ import { callMcpService } from "@/lib/http/mcpServiceCall";
24
24
  import { FetchHttpTransport, type HttpGetTransport } from "@/lib/http/fetchTransport";
25
25
 
26
26
  export type { HttpGetTransport };
@@ -42,35 +42,22 @@ export async function readTenantPreviewStatus(
42
42
  tenant: string | null,
43
43
  deps: { transport?: HttpGetTransport } = {},
44
44
  ): Promise<TenantPreviewStatus> {
45
- const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
46
- const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
47
- if (!bindingUrl || !secret || !tenant) return { configured: false, ok: false };
48
-
49
- let endpoint: string;
50
- try {
51
- // Mounted OUTSIDE /api on the MCP (its /api surface is session-guarded); use the
52
- // ORIGIN of MCP_APP_BINDING_URL, same as the cli-signin-code mint. Canonical path
53
- // (namespace-neutral) — the MCP still serves the /internal/dev-access alias too.
54
- endpoint = `${new URL(bindingUrl).origin}/internal/preview-status`;
55
- } catch {
56
- return { configured: false, ok: false, reason: "bad-mcp-url" };
57
- }
58
-
59
- const transport = deps.transport ?? new FetchHttpTransport();
60
- const full = `${endpoint}?tenant=${encodeURIComponent(tenant.toLowerCase())}`;
61
- let res: { status: number; json(): Promise<unknown> };
62
- try {
63
- res = await transport.get(full, { "x-mcp-service-secret": secret });
64
- } catch (cause) {
65
- return {
66
- configured: true,
67
- ok: false,
68
- reason: "mcp request failed: " + (cause instanceof Error ? cause.message : String(cause)),
69
- };
45
+ if (!tenant) return { configured: false, ok: false };
46
+ const outcome = await callMcpService({
47
+ method: "GET",
48
+ path: "/internal/preview-status",
49
+ query: { tenant: tenant.toLowerCase() },
50
+ transport: deps.transport ?? new FetchHttpTransport(),
51
+ });
52
+ if (outcome.kind === "not-configured") return { configured: false, ok: false };
53
+ if (outcome.kind === "bad-url") return { configured: false, ok: false, reason: "bad-mcp-url" };
54
+ if (outcome.kind === "transport-error") {
55
+ return { configured: true, ok: false, reason: "mcp request failed: " + outcome.message };
70
56
  }
71
- if (res.status < 200 || res.status >= 300) {
72
- return { configured: true, ok: false, status: res.status, reason: `status ${res.status}` };
57
+ if (outcome.kind === "misconfigured" || outcome.kind === "http-error") {
58
+ return { configured: true, ok: false, status: outcome.status, reason: `status ${outcome.status}` };
73
59
  }
60
+ const res = outcome;
74
61
  let payload: unknown;
75
62
  try {
76
63
  payload = await res.json();
@@ -0,0 +1,85 @@
1
+ /**
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`).
6
+ *
7
+ * A 401 or 404 from a seam route is misconfiguration, never a business answer: tot-mcp's
8
+ * routes answer 200 for every absent/unknown case, so 401 means the secret or URL is wrong
9
+ * and 404 means the path is not served. Both log LOUDLY and come back as `misconfigured`
10
+ * (a non-skipped failure), while each caller keeps its own fail-open / fail-closed mapping
11
+ * for the outcome.
12
+ */
13
+ import { readEnv } from "@/lib/env";
14
+
15
+ export interface McpServiceResponse {
16
+ status: number;
17
+ json(): Promise<unknown>;
18
+ }
19
+
20
+ /** A transport implementing the method(s) the call uses; test stubs may implement only one. */
21
+ export interface McpServiceTransport {
22
+ get?(url: string, headers: Record<string, string>): Promise<McpServiceResponse>;
23
+ post?(url: string, headers: Record<string, string>, body: unknown): Promise<McpServiceResponse>;
24
+ }
25
+
26
+ export type McpServiceOutcome =
27
+ | { kind: "not-configured" }
28
+ | { kind: "bad-url" }
29
+ | { kind: "misconfigured"; status: 401 | 404; reason: string }
30
+ | { kind: "http-error"; status: number }
31
+ | { kind: "transport-error"; message: string }
32
+ | { kind: "ok"; status: number; json(): Promise<unknown> };
33
+
34
+ export interface McpServiceRequest {
35
+ method: "GET" | "POST";
36
+ /** A literal `/internal/...` contract path. */
37
+ path: string;
38
+ query?: Record<string, string>;
39
+ body?: unknown;
40
+ transport: McpServiceTransport;
41
+ }
42
+
43
+ function misconfigurationReason(status: 401 | 404): string {
44
+ return status === 401
45
+ ? "tot-mcp rejected the service secret, or MCP_APP_BINDING_URL points at a session-guarded surface"
46
+ : "tot-mcp does not serve this path; storefront and tot-mcp disagree on the service-seam contract";
47
+ }
48
+
49
+ export async function callMcpService(req: McpServiceRequest): Promise<McpServiceOutcome> {
50
+ const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
51
+ const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
52
+ if (!bindingUrl || !secret) return { kind: "not-configured" };
53
+
54
+ let url: string;
55
+ try {
56
+ url = `${new URL(bindingUrl).origin}${req.path}`;
57
+ } catch {
58
+ return { kind: "bad-url" };
59
+ }
60
+ if (req.query) url += `?${new URLSearchParams(req.query).toString()}`;
61
+
62
+ const headers = { "x-mcp-service-secret": secret };
63
+ let res: McpServiceResponse;
64
+ try {
65
+ if (req.method === "GET") {
66
+ if (!req.transport.get) throw new Error("transport has no get()");
67
+ res = await req.transport.get(url, headers);
68
+ } else {
69
+ if (!req.transport.post) throw new Error("transport has no post()");
70
+ res = await req.transport.post(url, headers, req.body);
71
+ }
72
+ } catch (cause) {
73
+ return { kind: "transport-error", message: cause instanceof Error ? cause.message : String(cause) };
74
+ }
75
+
76
+ if (res.status === 401 || res.status === 404) {
77
+ const reason = misconfigurationReason(res.status);
78
+ console.error(
79
+ `[mcp-service-seam] ${req.method} ${req.path} answered ${res.status}: ${reason}`,
80
+ );
81
+ return { kind: "misconfigured", status: res.status, reason };
82
+ }
83
+ if (res.status < 200 || res.status >= 300) return { kind: "http-error", status: res.status };
84
+ return { kind: "ok", status: res.status, json: () => res.json() };
85
+ }
@@ -17,7 +17,7 @@
17
17
  * Nothing here is ever reported as verified unless tot-mcp said so: an unconfigured seam,
18
18
  * a non-200 or a timeout is `unavailable`, which the UI shows as unverified.
19
19
  */
20
- import { readEnv } from "@/lib/env";
20
+ import { callMcpService } from "@/lib/http/mcpServiceCall";
21
21
 
22
22
  const EXPLAIN_TIMEOUT_MS = 8000;
23
23
 
@@ -120,27 +120,30 @@ export async function explainDeveloperAccess(
120
120
  inviteStatus: "pending" | "accepted",
121
121
  deps: { transport?: ExplainTransport } = {},
122
122
  ): Promise<AccessVerification> {
123
- const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
124
- const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
125
- if (!bindingUrl || !secret) {
123
+ const outcome = await callMcpService({
124
+ method: "POST",
125
+ path: "/internal/dev-access/explain",
126
+ body: { person: email.trim().toLowerCase(), tenant: tenant.trim().toLowerCase() },
127
+ transport: deps.transport ?? new FetchExplainTransport(),
128
+ });
129
+ if (outcome.kind === "not-configured") {
126
130
  return { state: "unavailable", reason: "verification is not configured here", advice: "tot-mcp" };
127
131
  }
128
- let endpoint: string;
129
- try {
130
- endpoint = `${new URL(bindingUrl).origin}/internal/dev-access/explain`;
131
- } catch {
132
+ if (outcome.kind === "bad-url") {
132
133
  return { state: "unavailable", reason: "the tot-mcp address is malformed", advice: "tot-mcp" };
133
134
  }
134
- const transport = deps.transport ?? new FetchExplainTransport();
135
+ if (outcome.kind === "transport-error") {
136
+ return {
137
+ state: "unavailable",
138
+ reason: "tot-mcp did not answer: " + outcome.message,
139
+ advice: "tot-mcp",
140
+ };
141
+ }
142
+ if (outcome.kind === "misconfigured" || outcome.kind === "http-error") {
143
+ return { state: "unavailable", reason: `tot-mcp answered ${outcome.status}`, advice: "tot-mcp" };
144
+ }
145
+ const res = outcome;
135
146
  try {
136
- const res = await transport.post(
137
- endpoint,
138
- { "x-mcp-service-secret": secret },
139
- { person: email.trim().toLowerCase(), tenant: tenant.trim().toLowerCase() },
140
- );
141
- if (res.status < 200 || res.status >= 300) {
142
- return { state: "unavailable", reason: `tot-mcp answered ${res.status}`, advice: "tot-mcp" };
143
- }
144
147
  const explanation = (await res.json()) as AccessExplanation;
145
148
  if (typeof explanation?.verified !== "boolean" || !Array.isArray(explanation.steps)) {
146
149
  return { state: "unavailable", reason: "tot-mcp sent an unreadable answer", advice: "tot-mcp" };
@@ -53,6 +53,7 @@
53
53
  * control-plane cluster stays self-contained (see `.dependency-cruiser.cjs`).
54
54
  */
55
55
  import { readEnv } from "@/lib/env";
56
+ import { callMcpService } from "@/lib/http/mcpServiceCall";
56
57
  import { diagEmail } from "@/lib/diag/redact";
57
58
  import { checkStorePreviewReady, type PreflightDecision } from "@/lib/shared/invitePreflight";
58
59
  import { explainDeveloperAccess, type AccessVerification } from "@/lib/shared/accessExplain";
@@ -1028,8 +1029,7 @@ export interface DeveloperAppBinding {
1028
1029
  /** Best-effort outcome of the app-binding write (never fails the invite). */
1029
1030
  export type AppBindingResult =
1030
1031
  | { recorded: true }
1031
- // Skipped fail-closed: the write was not attempted/counted because the seam is
1032
- // unconfigured OR the MCP route isn't mounted yet (401). Not an error.
1032
+ // Skipped: the write was not attempted because the seam is unconfigured. Not an error.
1033
1033
  | { recorded: false; skipped: true; reason: string }
1034
1034
  // Attempted but failed for a real reason (bad body / store fault / transport).
1035
1035
  | { recorded: false; skipped: false; reason: string };
@@ -1039,17 +1039,16 @@ export type AppBindingResult =
1039
1039
  * MCP store is the app-binding source of truth; the writer is this keyless flow.
1040
1040
  *
1041
1041
  * WIRED (confirmed contract):
1042
- * POST `${MCP_APP_BINDING_URL}` — the keyless subject-capability writer,
1043
- * e.g. https://mcp.qa.tokenoftrust.com/internal/dev-access/subject-capability
1042
+ * POST `${origin(MCP_APP_BINDING_URL)}/internal/dev-access/subject-capability` — the keyless
1043
+ * subject-capability writer, e.g. https://mcp.qa.tokenoftrust.com/internal/dev-access/subject-capability
1044
1044
  * Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (compared
1045
1045
  * constant-time to the MCP's shared secret) — NOT an Authorization bearer.
1046
1046
  * Body: { subject:<email>, tenant, capability:'preview-access', app:'storefront' }
1047
1047
  * (capability/app are optional MCP defaults but sent explicitly). Idempotent.
1048
1048
  * → 200 { status:'recorded', ... } | 401 bad/missing secret | 400 bad body | 502 store.
1049
1049
  *
1050
- * FAIL-CLOSED: the MCP route is only mounted once the shared secret is set on the
1051
- * MCP deploy, so an unconfigured seam (no URL/secret) OR a 401 is treated as
1052
- * `{ skipped: true }`, not a hard failure. A 400/502/transport fault is a real
1050
+ * An unconfigured seam (no URL/secret) is `{ skipped: true }`. A 401 (wrong secret, or a
1051
+ * URL under the MCP's session-guarded /api surface), 400, 502 or transport fault is a real
1053
1052
  * (non-skipped) failure the caller surfaces as partial success.
1054
1053
  *
1055
1054
  * BEST-EFFORT by contract: this NEVER throws and NEVER blocks the invite. The
@@ -1059,48 +1058,47 @@ export async function recordDeveloperAppBinding(
1059
1058
  binding: DeveloperAppBinding,
1060
1059
  deps: { transport?: HttpTransport } = {},
1061
1060
  ): Promise<AppBindingResult> {
1062
- const url = await readEnv("MCP_APP_BINDING_URL");
1063
- const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
1064
- if (!url || !secret) {
1065
- // Seam not fully configured (the MCP route isn't mounted until the shared
1066
- // secret exists). Skip cleanly rather than fail the invite.
1067
- return { recorded: false, skipped: true, reason: "not-configured" };
1068
- }
1069
- const transport = deps.transport ?? new FetchHttpTransport();
1070
- try {
1071
- const res = await transport.post(
1072
- url,
1073
- { "x-mcp-service-secret": secret },
1074
- {
1075
- // Option B fixed fields — the subject-capability binding for storefront.
1076
- subject: binding.email.toLowerCase(),
1077
- tenant: binding.tenant,
1078
- capability: binding.capability ?? "preview-access",
1079
- app: "storefront",
1080
- },
1081
- );
1082
- if (res.status >= 200 && res.status < 300) return { recorded: true };
1083
- if (res.status === 401) {
1084
- // Bad/missing secret or the MCP route isn't mounted yet — fail-closed as skipped.
1061
+ const outcome = await callMcpService({
1062
+ method: "POST",
1063
+ path: "/internal/dev-access/subject-capability",
1064
+ // Option B fixed fields — the subject-capability binding for storefront.
1065
+ body: {
1066
+ subject: binding.email.toLowerCase(),
1067
+ tenant: binding.tenant,
1068
+ capability: binding.capability ?? "preview-access",
1069
+ app: "storefront",
1070
+ },
1071
+ transport: deps.transport ?? new FetchHttpTransport(),
1072
+ });
1073
+ switch (outcome.kind) {
1074
+ case "ok":
1075
+ return { recorded: true };
1076
+ case "not-configured":
1077
+ // Seam not fully configured (the MCP route isn't mounted until the shared secret
1078
+ // exists). Skip cleanly rather than fail the invite.
1079
+ return { recorded: false, skipped: true, reason: "not-configured" };
1080
+ case "bad-url":
1081
+ return { recorded: false, skipped: false, reason: "mcp app-binding url is malformed" };
1082
+ case "misconfigured":
1083
+ // A route answered and refused (401), or does not exist (404): misconfiguration, so a
1084
+ // failure, never a skip.
1085
1085
  return {
1086
1086
  recorded: false,
1087
- skipped: true,
1088
- reason: "unauthorized (mcp service secret rejected or route unmounted)",
1087
+ skipped: false,
1088
+ reason: `${outcome.status === 401 ? "unauthorized" : "not found"}: ${outcome.reason}`,
1089
+ };
1090
+ case "http-error":
1091
+ return {
1092
+ recorded: false,
1093
+ skipped: false,
1094
+ reason: `mcp app-binding write returned status ${outcome.status}`,
1095
+ };
1096
+ case "transport-error":
1097
+ return {
1098
+ recorded: false,
1099
+ skipped: false,
1100
+ reason: "mcp app-binding write failed: " + outcome.message,
1089
1101
  };
1090
- }
1091
- return {
1092
- recorded: false,
1093
- skipped: false,
1094
- reason: `mcp app-binding write returned status ${res.status}`,
1095
- };
1096
- } catch (cause) {
1097
- return {
1098
- recorded: false,
1099
- skipped: false,
1100
- reason:
1101
- "mcp app-binding write failed: " +
1102
- (cause instanceof Error ? cause.message : String(cause)),
1103
- };
1104
1102
  }
1105
1103
  }
1106
1104
 
@@ -22,7 +22,7 @@
22
22
  * mirroring recordDeveloperAppBinding's "skipped when not-configured" stance — we
23
23
  * never break invites just because the MCP is briefly unreachable.
24
24
  */
25
- import { readEnv } from "@/lib/env";
25
+ import { callMcpService } from "@/lib/http/mcpServiceCall";
26
26
 
27
27
  /** How long we'll wait on the preview-status read before failing open. */
28
28
  const PREFLIGHT_TIMEOUT_MS = 4000;
@@ -71,39 +71,26 @@ export async function checkStorePreviewReady(
71
71
  tenant: string,
72
72
  deps: { transport?: PreflightTransport } = {},
73
73
  ): Promise<PreflightDecision> {
74
- const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
75
- const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
76
- if (!bindingUrl || !secret) {
77
- // Seam not configured (the MCP route isn't even mounted without the secret) —
78
- // inconclusive, so allow rather than break every invite.
79
- return { block: false, reason: "not-configured" };
74
+ const outcome = await callMcpService({
75
+ method: "GET",
76
+ path: "/internal/preview-status",
77
+ query: { tenant: tenant.toLowerCase(), app: "storefront", tag: "main" },
78
+ transport: deps.transport ?? new FetchPreflightTransport(),
79
+ });
80
+ // Only a clean 200 is a conclusive answer; every other outcome is inconclusive → allow
81
+ // (a 401/404 is logged loudly by the seam helper as misconfiguration).
82
+ if (outcome.kind === "not-configured") return { block: false, reason: "not-configured" };
83
+ if (outcome.kind === "bad-url") return { block: false, reason: "bad-mcp-url" };
84
+ if (outcome.kind === "transport-error") {
85
+ return { block: false, reason: "preflight request failed: " + outcome.message };
80
86
  }
81
-
82
- let endpoint: string;
83
- try {
84
- const origin = new URL(bindingUrl).origin;
85
- const q = new URLSearchParams({ tenant: tenant.toLowerCase(), app: "storefront", tag: "main" });
86
- // Canonical, namespace-neutral path (the read is generic, not dev-role-scoped).
87
- endpoint = `${origin}/internal/preview-status?${q.toString()}`;
88
- } catch {
89
- return { block: false, reason: "bad-mcp-url" };
87
+ if (outcome.kind === "misconfigured") {
88
+ return { block: false, reason: `preflight misconfigured: status ${outcome.status}` };
90
89
  }
91
-
92
- const transport = deps.transport ?? new FetchPreflightTransport();
93
- let res: { status: number; json(): Promise<unknown> };
94
- try {
95
- res = await transport.get(endpoint, { "x-mcp-service-secret": secret });
96
- } catch (cause) {
97
- return {
98
- block: false,
99
- reason: "preflight request failed: " + (cause instanceof Error ? cause.message : String(cause)),
100
- };
101
- }
102
-
103
- // Only a clean 200 is a conclusive answer; anything else is inconclusive → allow.
104
- if (res.status < 200 || res.status >= 300) {
105
- return { block: false, reason: `preflight returned status ${res.status}` };
90
+ if (outcome.kind === "http-error") {
91
+ return { block: false, reason: `preflight returned status ${outcome.status}` };
106
92
  }
93
+ const res = outcome;
107
94
  let payload: { provisioned?: unknown; previewReleased?: unknown };
108
95
  try {
109
96
  payload = (await res.json()) as typeof payload;
@@ -30,7 +30,7 @@
30
30
  * app-binding WRITE (`recordDeveloperAppBinding`) targets, so this READ and that
31
31
  * WRITE always agree on which subject-capability store they speak to.
32
32
  */
33
- import { readEnv } from "@/lib/env";
33
+ import { callMcpService } from "@/lib/http/mcpServiceCall";
34
34
  import type { Capability } from "./capability.js";
35
35
 
36
36
  /** Minimal GET transport (mirrors `dev/previewStatus.ts` — decoupled by design). */
@@ -58,9 +58,6 @@ export interface ViewerShipGrantDeps {
58
58
  transport?: ViewerShipGrantGetTransport;
59
59
  }
60
60
 
61
- /** The Step-1 subject-capability reader path (mounted OUTSIDE the session guard). */
62
- const SUBJECT_CAPABILITY_PATH = "/internal/dev-access/subject-capability";
63
-
64
61
  /** The viewer's blog-contribute capability — a NEW, blog-scoped projection kept OUT
65
62
  * of the forge `Capability` union (which stays exactly two rungs). `ship-on-behalf`
66
63
  * supersets contribute, so a ship grant also yields `"blog-contribute"` here. */
@@ -87,31 +84,16 @@ async function resolveRawViewerCapability(
87
84
  const tenant = (appDomain ?? "").trim();
88
85
  if (!subject || !tenant) return "preview-access";
89
86
 
90
- const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
91
- const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
92
- // Unconfigured seam → deny (the MCP route is only mounted once the secret exists).
93
- if (!bindingUrl || !secret) return "preview-access";
94
-
95
- let endpoint: string;
96
- try {
97
- endpoint = `${new URL(bindingUrl).origin}${SUBJECT_CAPABILITY_PATH}`;
98
- } catch {
99
- return "preview-access";
100
- }
101
- const full =
102
- `${endpoint}?subject=${encodeURIComponent(subject)}` +
103
- `&tenant=${encodeURIComponent(tenant)}&app=storefront`;
104
-
105
- const transport = deps.transport ?? new FetchShipGrantTransport();
106
- let res: { status: number; json(): Promise<unknown> };
107
- try {
108
- res = await transport.get(full, { "x-mcp-service-secret": secret });
109
- } catch {
110
- // Transport / timeout fault — fail closed.
111
- return "preview-access";
112
- }
113
- // Any non-2xx (incl. 502 store fault, 401 unmounted, 404) → deny.
114
- if (res.status < 200 || res.status >= 300) return "preview-access";
87
+ const outcome = await callMcpService({
88
+ method: "GET",
89
+ path: "/internal/dev-access/subject-capability",
90
+ query: { subject, tenant, app: "storefront" },
91
+ transport: deps.transport ?? new FetchShipGrantTransport(),
92
+ });
93
+ // Unconfigured, misconfigured (401/404, logged loudly), any other non-2xx (incl. 502
94
+ // store fault) or a transport/timeout fault → deny.
95
+ if (outcome.kind !== "ok") return "preview-access";
96
+ const res = outcome;
115
97
 
116
98
  let payload: unknown;
117
99
  try {
@@ -159,9 +159,10 @@ export async function POST(context: APIContext): Promise<Response> {
159
159
  });
160
160
  }
161
161
 
162
- // The granter left at Send invite; re-prove the grant now that the invitee accepted, so a
163
- // break shows on the granter's Team page (team-access re-runs the same check) and in the
164
- // logs, not only in the invitee's terminal. Best-effort: never blocks the sign-in.
162
+ // The granter left at Send invite; re-prove the grant now that the invitee accepted. The
163
+ // granter is told by the red banner on their dashboard home and Team page
164
+ // (/api/grants/team-access-alerts re-runs this same check per invitee); this warn line is
165
+ // the log trace. Best-effort: never blocks the sign-in.
165
166
  try {
166
167
  const verification = await explainDeveloperAccess(tenant, derived.email, "accepted");
167
168
  if (verification.state === "broken") {
@@ -1,5 +1,9 @@
1
- import { defineConfig } from "vitest/config";
2
- import { fileURLToPath } from "node:url";
1
+ // .cts so Vite compiles this in memory. An ESM config writes a sibling file, which the read-only
2
+ // verification workspace rejects. The results cache is off: that workspace cannot create
3
+ // node_modules/.vite/vitest.
4
+ process.env.VITE_CJS_IGNORE_WARNING = "1";
5
+ const { defineConfig } = require("vitest/config");
6
+ const path = require("node:path");
3
7
 
4
8
  // Storefront unit tests — pure TS logic (tenant routing, search, query engine,
5
9
  // image-attr resolution, edge cache, etc.). We deliberately do NOT render .astro
@@ -7,12 +11,12 @@ import { fileURLToPath } from "node:url";
7
11
  // vite-plugin-head crashes under vitest@2 + astro@7 (configureServer reads an
8
12
  // undefined `ssr`). Render *logic* is extracted into testable modules instead
9
13
  // (e.g. lib/imageAttrs.ts), and rendered output is verified on a live preview.
10
- export default defineConfig({
14
+ module.exports = defineConfig({
11
15
  // Mirror the `@` -> src alias from tsconfig so tests can import aliased modules
12
16
  // (e.g. src/config/tenants.ts, which pulls the theme registry).
13
17
  resolve: {
14
18
  alias: {
15
- "@": fileURLToPath(new URL("./src", import.meta.url)),
19
+ "@": path.join(__dirname, "src"),
16
20
  // `astro:middleware` is a virtual id Astro's own Vite plugin maps onto this
17
21
  // real module. Mapping it the same way lets src/middleware/index.ts — the
18
22
  // branch-to-handler dispatch — be invoked in a unit test instead of only
@@ -26,5 +30,6 @@ export default defineConfig({
26
30
  // *.spec.ts matrix runs separately via playwright.a11y.config.ts).
27
31
  include: ["src/**/*.test.ts", "e2e/a11y/**/*.test.ts"],
28
32
  environment: "node",
33
+ cache: false,
29
34
  },
30
35
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.11",
3
+ "version": "2.2.13",
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,13 @@
1
+ // .cts so Vite compiles this in memory. An ESM config writes a sibling file, which the read-only
2
+ // verification workspace rejects. The results cache is off: that workspace cannot create
3
+ // node_modules/.vite/vitest.
4
+ process.env.VITE_CJS_IGNORE_WARNING = "1";
5
+ const { defineConfig } = require("vitest/config");
6
+
7
+ module.exports = defineConfig({
8
+ test: {
9
+ include: ["tests/**/*.test.ts"],
10
+ environment: "node",
11
+ cache: false,
12
+ },
13
+ });
@@ -1,8 +0,0 @@
1
- import { defineConfig } from "vitest/config";
2
-
3
- export default defineConfig({
4
- test: {
5
- include: ["tests/**/*.test.ts"],
6
- environment: "node",
7
- },
8
- });