@tokenoftrust/storefront-runner 2.2.12 → 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";
@@ -1038,8 +1039,8 @@ export type AppBindingResult =
1038
1039
  * MCP store is the app-binding source of truth; the writer is this keyless flow.
1039
1040
  *
1040
1041
  * WIRED (confirmed contract):
1041
- * POST `${MCP_APP_BINDING_URL}` — the keyless subject-capability writer,
1042
- * 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
1043
1044
  * Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (compared
1044
1045
  * constant-time to the MCP's shared secret) — NOT an Authorization bearer.
1045
1046
  * Body: { subject:<email>, tenant, capability:'preview-access', app:'storefront' }
@@ -1057,49 +1058,47 @@ export async function recordDeveloperAppBinding(
1057
1058
  binding: DeveloperAppBinding,
1058
1059
  deps: { transport?: HttpTransport } = {},
1059
1060
  ): Promise<AppBindingResult> {
1060
- const url = await readEnv("MCP_APP_BINDING_URL");
1061
- const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
1062
- if (!url || !secret) {
1063
- // Seam not fully configured (the MCP route isn't mounted until the shared
1064
- // secret exists). Skip cleanly rather than fail the invite.
1065
- return { recorded: false, skipped: true, reason: "not-configured" };
1066
- }
1067
- const transport = deps.transport ?? new FetchHttpTransport();
1068
- try {
1069
- const res = await transport.post(
1070
- url,
1071
- { "x-mcp-service-secret": secret },
1072
- {
1073
- // Option B fixed fields — the subject-capability binding for storefront.
1074
- subject: binding.email.toLowerCase(),
1075
- tenant: binding.tenant,
1076
- capability: binding.capability ?? "preview-access",
1077
- app: "storefront",
1078
- },
1079
- );
1080
- if (res.status >= 200 && res.status < 300) return { recorded: true };
1081
- if (res.status === 401) {
1082
- // A route answered and refused: the secret does not match, or the URL sits under the
1083
- // MCP's session-guarded /api surface. Both are misconfiguration, so it is a failure.
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.
1084
1085
  return {
1085
1086
  recorded: false,
1086
1087
  skipped: false,
1087
- reason: "unauthorized: the mcp service secret was rejected, or MCP_APP_BINDING_URL is not the /internal path",
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,
1088
1101
  };
1089
- }
1090
- return {
1091
- recorded: false,
1092
- skipped: false,
1093
- reason: `mcp app-binding write returned status ${res.status}`,
1094
- };
1095
- } catch (cause) {
1096
- return {
1097
- recorded: false,
1098
- skipped: false,
1099
- reason:
1100
- "mcp app-binding write failed: " +
1101
- (cause instanceof Error ? cause.message : String(cause)),
1102
- };
1103
1102
  }
1104
1103
  }
1105
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") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.12",
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",