@tokenoftrust/storefront-runner 2.2.8 → 2.2.9

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.
@@ -17,12 +17,95 @@
17
17
  if (!membersBody) return;
18
18
  const tenant = membersBody.dataset.tenant;
19
19
 
20
+ // One verdict from tot-mcp's access_explain, rendered the same everywhere: the Team row,
21
+ // the invite result and the resend result. `verified` is `true` or the failure object.
22
+ function verdictParts(verdict) {
23
+ if (verdict === true || (verdict && verdict.state === "verified")) {
24
+ return { cls: "ok", label: "Active", why: "" };
25
+ }
26
+ const v = verdict || { state: "unavailable", reason: "not verified" };
27
+ if (v.state === "awaiting-acceptance") {
28
+ return v.inviteStatus === "accepted"
29
+ ? { cls: "pend", label: "Accepted — not signed in", why: v.reason + " " + v.fix }
30
+ : { cls: "pend", label: "Pending", why: "Waiting for the developer to accept and sign in." };
31
+ }
32
+ if (v.state === "broken") {
33
+ return { cls: "bad", label: "Not resolving", why: v.reason + " Fix: " + v.fix };
34
+ }
35
+ return { cls: "pend", label: "Unverified", why: "Couldn't check: " + (v.reason || "unknown") + "." };
36
+ }
37
+
38
+ function renderAccessCell(td, verdict) {
39
+ const parts = verdictParts(verdict);
40
+ td.replaceChildren();
41
+ const label = document.createElement("span");
42
+ label.className = "access " + parts.cls;
43
+ label.textContent = parts.label;
44
+ td.appendChild(label);
45
+ if (parts.why) {
46
+ const why = document.createElement("span");
47
+ why.className = "access-why";
48
+ why.textContent = parts.why;
49
+ td.appendChild(why);
50
+ }
51
+ }
52
+
53
+ async function checkAccess(email, td, btn) {
54
+ if (btn) btn.disabled = true;
55
+ td.replaceChildren();
56
+ td.textContent = "Checking…";
57
+ td.className = "muted";
58
+ try {
59
+ const q = "tenant=" + encodeURIComponent(tenant) + "&email=" + encodeURIComponent(email);
60
+ const res = await fetch("/api/grants/team-access?" + q, { headers: { accept: "application/json" } });
61
+ const body = await res.json().catch(() => ({}));
62
+ td.className = "";
63
+ renderAccessCell(td, res.ok ? body.access
64
+ : { state: "unavailable", reason: body.error || ("HTTP " + res.status) });
65
+ } catch (e) {
66
+ td.className = "";
67
+ renderAccessCell(td, { state: "unavailable", reason: e && e.message ? e.message : String(e) });
68
+ } finally {
69
+ if (btn) btn.disabled = false;
70
+ }
71
+ }
72
+
73
+ // The invite / resend result: green only when tot-mcp proved the grant resolves; a
74
+ // broken grant is red with its step and fix and never reads as "invited".
75
+ function showVerdict(email, verdict, sentLine) {
76
+ const parts = verdictParts(verdict);
77
+ const wrap = document.createElement("div");
78
+ const tag = document.createElement("span");
79
+ tag.className = "advice";
80
+ tag.textContent = "tot-mcp advice";
81
+ wrap.appendChild(tag);
82
+ const line = document.createElement("p");
83
+ line.style.margin = ".2rem 0 0";
84
+ if (parts.cls === "ok") {
85
+ line.textContent = sentLine + " Access verified — " +
86
+ ((verdict && verdict.capability) || "preview-access") + " resolves for " + email + ".";
87
+ wrap.appendChild(line);
88
+ showMsg("ok", wrap);
89
+ return;
90
+ }
91
+ if (parts.cls === "pend") {
92
+ line.textContent = sentLine + " " + parts.why;
93
+ wrap.appendChild(line);
94
+ showMsg("ok", wrap);
95
+ return;
96
+ }
97
+ line.textContent = "Access for " + email + " will not work yet (step: " + verdict.failedStep +
98
+ "). " + verdict.reason + " Fix: " + verdict.fix + " No invite email was sent.";
99
+ wrap.appendChild(line);
100
+ showMsg("err", wrap);
101
+ }
102
+
20
103
  function renderMembers(members) {
21
104
  membersBody.replaceChildren();
22
105
  if (!members || !members.length) {
23
106
  const tr = document.createElement("tr");
24
107
  const td = document.createElement("td");
25
- td.colSpan = 3; td.className = "muted";
108
+ td.colSpan = 4; td.className = "muted";
26
109
  td.textContent = "No developers invited yet.";
27
110
  tr.appendChild(td); membersBody.appendChild(tr);
28
111
  return;
@@ -33,8 +116,17 @@
33
116
  dev.textContent = m.developer;
34
117
  const roles = document.createElement("td");
35
118
  roles.textContent = (m.roles || []).join(", ") || "developer";
119
+ const access = document.createElement("td");
36
120
  const act = document.createElement("td");
37
121
  act.className = "act";
122
+ const checkBtn = document.createElement("button");
123
+ checkBtn.type = "button";
124
+ checkBtn.className = "rowbtn";
125
+ checkBtn.textContent = "Check access";
126
+ checkBtn.title = "Check whether " + m.developer + "'s access resolves right now";
127
+ checkBtn.style.marginRight = ".4rem";
128
+ checkBtn.addEventListener("click", () => checkAccess(m.developer, access, checkBtn));
129
+ act.appendChild(checkBtn);
38
130
  const btn = document.createElement("button");
39
131
  btn.type = "button";
40
132
  btn.className = "rowbtn";
@@ -52,8 +144,9 @@
52
144
  revokeBtn.addEventListener("click", () => revoke(m.developer, m.inviteId, revokeBtn));
53
145
  act.appendChild(revokeBtn);
54
146
  }
55
- tr.appendChild(dev); tr.appendChild(roles); tr.appendChild(act);
147
+ tr.appendChild(dev); tr.appendChild(roles); tr.appendChild(access); tr.appendChild(act);
56
148
  membersBody.appendChild(tr);
149
+ checkAccess(m.developer, access, checkBtn);
57
150
  }
58
151
  }
59
152
 
@@ -92,7 +185,9 @@
92
185
  });
93
186
  const body = await res.json().catch(() => ({}));
94
187
  if (res.ok) {
95
- showMsg("ok", "Re-sent the sign-in email to " + (body.developer || email) + ".");
188
+ const who = body.developer || email;
189
+ showVerdict(who, body.verified, "Re-sent the sign-in email to " + who + ".");
190
+ loadMembers();
96
191
  } else {
97
192
  showMsg("err", body.error || ("Couldn't resend (HTTP " + res.status + ")."));
98
193
  }
@@ -107,7 +202,7 @@
107
202
  membersBody.replaceChildren();
108
203
  const tr = document.createElement("tr");
109
204
  const td = document.createElement("td");
110
- td.colSpan = 2; td.className = "muted";
205
+ td.colSpan = 4; td.className = "muted";
111
206
  td.textContent = msg;
112
207
  tr.appendChild(td); membersBody.appendChild(tr);
113
208
  }
@@ -206,7 +301,9 @@
206
301
  const body = await res.json().catch(() => ({}));
207
302
  if (res.ok) {
208
303
  const already = body.changed === false;
209
- showMsg("ok", (already ? "Already a developer — re-sent the sign-in email to " : "Invited ") + (body.developer || email) + ".");
304
+ const who = body.developer || email;
305
+ showVerdict(who, body.verified,
306
+ (already ? "Already a developer — re-sent the sign-in email to " : "Invited ") + who + ".");
210
307
  $("email").value = "";
211
308
  if (noteEl) noteEl.value = "";
212
309
  loadMembers();
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Developer-access verification — asks tot-mcp whether an invited developer's access to a
3
+ * store actually resolves, walking the same chain their sign-in and checkout use, and
4
+ * classifies the answer for the granter.
5
+ *
6
+ * POST {origin(MCP_APP_BINDING_URL)}/internal/dev-access/explain
7
+ * header x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>
8
+ * body { person: <email>, tenant: <appDomain> }
9
+ * → 200 { verified, failedStep?, reason?, fix?, capability?, expiry?, app?, steps[] }
10
+ * Contract source of truth: tot-mcp src/modules/mcp/access/access-explain.ts.
11
+ *
12
+ * A failure in a step that can only pass once the invitee accepts and signs in (identity,
13
+ * the store context, a fresh session, checkout) reads as "awaiting acceptance" while the
14
+ * invite is pending. Any other failure — the repo is not provisioned, the invite record
15
+ * and sign-in disagree — is broken now, and the granter is told before they leave.
16
+ *
17
+ * Nothing here is ever reported as verified unless tot-mcp said so: an unconfigured seam,
18
+ * a non-200 or a timeout is `unavailable`, which the UI shows as unverified.
19
+ */
20
+ import { readEnv } from "@/lib/env";
21
+
22
+ const EXPLAIN_TIMEOUT_MS = 8000;
23
+
24
+ export interface ExplainStep {
25
+ step: string;
26
+ status: "pass" | "fail" | "skip" | "info";
27
+ dependsOnAcceptance: boolean;
28
+ detail: string;
29
+ }
30
+
31
+ /** tot-mcp's explain result, as sent. */
32
+ export interface AccessExplanation {
33
+ verified: boolean;
34
+ tenant: string;
35
+ failedStep?: string;
36
+ reason?: string;
37
+ fix?: string;
38
+ capability?: string;
39
+ expiry?: "none";
40
+ app?: string;
41
+ steps: ExplainStep[];
42
+ }
43
+
44
+ /** The granter-facing verdict. */
45
+ export type AccessVerification =
46
+ | { state: "verified"; capability: string; expiry: "none"; advice: "tot-mcp" }
47
+ | {
48
+ state: "awaiting-acceptance";
49
+ inviteStatus: "pending" | "accepted";
50
+ failedStep: string;
51
+ reason: string;
52
+ fix: string;
53
+ advice: "tot-mcp";
54
+ }
55
+ | { state: "broken"; failedStep: string; reason: string; fix: string; advice: "tot-mcp" }
56
+ | { state: "unavailable"; reason: string; advice: "tot-mcp" };
57
+
58
+ /** POST transport seam (injectable for tests). */
59
+ export interface ExplainTransport {
60
+ post(
61
+ url: string,
62
+ headers: Record<string, string>,
63
+ body: unknown,
64
+ ): Promise<{ status: number; json(): Promise<unknown> }>;
65
+ }
66
+
67
+ export class FetchExplainTransport implements ExplainTransport {
68
+ async post(url: string, headers: Record<string, string>, body: unknown) {
69
+ const ctrl = new AbortController();
70
+ const timer = setTimeout(() => ctrl.abort(), EXPLAIN_TIMEOUT_MS);
71
+ try {
72
+ const res = await fetch(url, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json", ...headers },
75
+ body: JSON.stringify(body),
76
+ signal: ctrl.signal,
77
+ });
78
+ return { status: res.status, json: () => res.json() };
79
+ } finally {
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Classify an explanation against what the storefront knows about the invite. */
86
+ export function classifyExplanation(
87
+ explanation: AccessExplanation,
88
+ inviteStatus: "pending" | "accepted",
89
+ ): AccessVerification {
90
+ if (explanation.verified) {
91
+ return {
92
+ state: "verified",
93
+ capability: explanation.capability ?? "preview-access",
94
+ expiry: "none",
95
+ advice: "tot-mcp",
96
+ };
97
+ }
98
+ const failed = explanation.steps.find((s) => s.step === explanation.failedStep);
99
+ const verdict = {
100
+ failedStep: explanation.failedStep ?? "unknown",
101
+ reason: explanation.reason ?? "Access does not resolve.",
102
+ fix: explanation.fix ?? "Run access_explain for this developer and store.",
103
+ advice: "tot-mcp" as const,
104
+ };
105
+ // Accepting on the storefront does not enroll the tot identity — the developer's first
106
+ // tot sign-in does — so an unenrolled identity is still waiting on them, accepted or not.
107
+ if (
108
+ explanation.failedStep === "identity"
109
+ || (inviteStatus === "pending" && failed?.dependsOnAcceptance)
110
+ ) {
111
+ return { state: "awaiting-acceptance", inviteStatus, ...verdict };
112
+ }
113
+ return { state: "broken", ...verdict };
114
+ }
115
+
116
+ /** Ask tot-mcp to explain `email`'s access to `tenant`. Never throws. */
117
+ export async function explainDeveloperAccess(
118
+ tenant: string,
119
+ email: string,
120
+ inviteStatus: "pending" | "accepted",
121
+ deps: { transport?: ExplainTransport } = {},
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) {
126
+ return { state: "unavailable", reason: "verification is not configured here", advice: "tot-mcp" };
127
+ }
128
+ let endpoint: string;
129
+ try {
130
+ endpoint = `${new URL(bindingUrl).origin}/internal/dev-access/explain`;
131
+ } catch {
132
+ return { state: "unavailable", reason: "the tot-mcp address is malformed", advice: "tot-mcp" };
133
+ }
134
+ const transport = deps.transport ?? new FetchExplainTransport();
135
+ 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
+ const explanation = (await res.json()) as AccessExplanation;
145
+ if (typeof explanation?.verified !== "boolean" || !Array.isArray(explanation.steps)) {
146
+ return { state: "unavailable", reason: "tot-mcp sent an unreadable answer", advice: "tot-mcp" };
147
+ }
148
+ return classifyExplanation(explanation, inviteStatus);
149
+ } catch (cause) {
150
+ return {
151
+ state: "unavailable",
152
+ reason: "tot-mcp did not answer: " + (cause instanceof Error ? cause.message : String(cause)),
153
+ advice: "tot-mcp",
154
+ };
155
+ }
156
+ }
@@ -55,6 +55,7 @@
55
55
  import { readEnv } from "@/lib/env";
56
56
  import { diagEmail } from "@/lib/diag/redact";
57
57
  import { checkStorePreviewReady, type PreflightDecision } from "@/lib/shared/invitePreflight";
58
+ import { explainDeveloperAccess, type AccessVerification } from "@/lib/shared/accessExplain";
58
59
 
59
60
  /**
60
61
  * The header carrying the storefront-signed staff BROKER ASSERTION to tot20
@@ -244,6 +245,8 @@ export interface DeveloperInviteSummary {
244
245
  tenant: string;
245
246
  developer: string;
246
247
  roles: string[];
248
+ /** tot20's invite state; an entry predating the field is an active member (`accepted`). */
249
+ status: "pending" | "accepted";
247
250
  }
248
251
 
249
252
  /** Result of listing a tenant's developer invites (typed failure, fail-closed). */
@@ -729,6 +732,7 @@ export function normalizeInvitesListResponse(
729
732
  roles: Array.isArray(r.roles)
730
733
  ? r.roles.filter((x): x is string => typeof x === "string")
731
734
  : [],
735
+ status: r.status === "pending" ? "pending" : "accepted",
732
736
  };
733
737
  });
734
738
  return { ok: true, tenant, invites };
@@ -945,6 +949,7 @@ export class MockDeveloperInviteClient implements DeveloperInviteClient {
945
949
  tenant: t,
946
950
  developer: email,
947
951
  roles: ["developer"],
952
+ status: "pending" as const,
948
953
  };
949
954
  });
950
955
  return { ok: true, tenant: t, invites };
@@ -1035,7 +1040,7 @@ export type AppBindingResult =
1035
1040
  *
1036
1041
  * WIRED (confirmed contract):
1037
1042
  * POST `${MCP_APP_BINDING_URL}` — the keyless subject-capability writer,
1038
- * e.g. https://mcp.qa.tokenoftrust.com/api/internal/dev-access/subject-capability
1043
+ * e.g. https://mcp.qa.tokenoftrust.com/internal/dev-access/subject-capability
1039
1044
  * Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (compared
1040
1045
  * constant-time to the MCP's shared secret) — NOT an Authorization bearer.
1041
1046
  * Body: { subject:<email>, tenant, capability:'preview-access', app:'storefront' }
@@ -1191,6 +1196,8 @@ export interface InviteDeveloperOutcome {
1191
1196
  * (a failed invite has nothing to bind); undefined otherwise.
1192
1197
  */
1193
1198
  appBinding?: AppBindingResult;
1199
+ /** Whether the grant resolves for the invitee (tot-mcp access_explain); success only. */
1200
+ verification?: AccessVerification;
1194
1201
  }
1195
1202
 
1196
1203
  /** Injectable collaborators (tests); both default to the env-configured impls. */
@@ -1207,6 +1214,12 @@ export interface InviteDeveloperDeps {
1207
1214
  * any inconclusive answer fails open (see invitePreflight).
1208
1215
  */
1209
1216
  preflight?: (tenant: string) => Promise<PreflightDecision>;
1217
+ /** The post-write verification. Defaults to {@link explainDeveloperAccess}. */
1218
+ explain?: (
1219
+ tenant: string,
1220
+ email: string,
1221
+ inviteStatus: "pending" | "accepted",
1222
+ ) => Promise<AccessVerification>;
1210
1223
  }
1211
1224
 
1212
1225
  /**
@@ -1291,5 +1304,17 @@ export async function inviteDeveloperToTenant(
1291
1304
  tenant: invite.tenant,
1292
1305
  capability,
1293
1306
  });
1294
- return { invite, appBinding };
1307
+
1308
+ // Prove the grant resolves before the granter leaves: tot-mcp walks the invitee's real
1309
+ // sign-in + checkout chain. The invite's own state decides whether an acceptance-bound
1310
+ // failure is "awaiting acceptance" or broken — a re-invite of an accepted developer must
1311
+ // resolve now.
1312
+ const listed = await client.listInvites(invite.tenant, input.brokerAssertion);
1313
+ const inviteStatus = listed.ok
1314
+ ? listed.invites.find((i) => i.developer.toLowerCase() === invite.developer.toLowerCase())
1315
+ ?.status ?? "pending"
1316
+ : "pending";
1317
+ const explain = deps.explain ?? explainDeveloperAccess;
1318
+ const verification = await explain(invite.tenant, invite.developer, inviteStatus);
1319
+ return { invite, appBinding, verification };
1295
1320
  }
@@ -3,7 +3,8 @@
3
3
  * VIEWER's REAL, owner-revocable ship capability over a tenant, LIVE from the MCP
4
4
  * subject-capability store. This is the near-side read of the Step-1 tot-mcp seam:
5
5
  *
6
- * GET `${origin}/api/internal/dev-access/subject-capability?subject=<email>&tenant=<appDomain>&app=storefront`
6
+ * GET `${origin}/internal/dev-access/subject-capability?subject=<email>&tenant=<appDomain>&app=storefront`
7
+ * (outside tot-mcp's session-guarded `/api` surface, which answers this caller 401)
7
8
  * Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (NOT a bearer)
8
9
  * → 200 { capability: "ship-on-behalf" | "preview-access" }
9
10
  * · an ABSENT / REVOKED grant answers `preview-access`
@@ -58,7 +59,7 @@ export interface ViewerShipGrantDeps {
58
59
  }
59
60
 
60
61
  /** The Step-1 subject-capability reader path (mounted OUTSIDE the session guard). */
61
- const SUBJECT_CAPABILITY_PATH = "/api/internal/dev-access/subject-capability";
62
+ const SUBJECT_CAPABILITY_PATH = "/internal/dev-access/subject-capability";
62
63
 
63
64
  /** The viewer's blog-contribute capability — a NEW, blog-scoped projection kept OUT
64
65
  * of the forge `Capability` union (which stays exactly two rungs). `ship-on-behalf`
@@ -26,6 +26,7 @@ import {
26
26
  import { createSession, DEFAULT_SESSION_TTL_SECONDS } from "@/lib/auth/session";
27
27
  import { normalizeEmailHint } from "@/lib/privacy/emailHint";
28
28
  import { acceptDeveloperInvite } from "@/lib/shared/developerInvites";
29
+ import { explainDeveloperAccess } from "@/lib/shared/accessExplain";
29
30
 
30
31
  export const prerender = false;
31
32
 
@@ -158,5 +159,20 @@ export async function POST(context: APIContext): Promise<Response> {
158
159
  });
159
160
  }
160
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.
165
+ try {
166
+ const verification = await explainDeveloperAccess(tenant, derived.email, "accepted");
167
+ if (verification.state === "broken") {
168
+ console.warn("auth/magic-exchange: accepted invite does not resolve", {
169
+ tenant,
170
+ failedStep: verification.failedStep,
171
+ });
172
+ }
173
+ } catch {
174
+ /* verification must never break the sign-in */
175
+ }
176
+
161
177
  return context.redirect(next, 303);
162
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.8",
3
+ "version": "2.2.9",
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",