@tokenoftrust/storefront-runner 2.2.8 → 2.2.10

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();
@@ -322,7 +322,7 @@ a.article-card__tag:hover { text-decoration: underline; }
322
322
  .footer-brand .brand .b-prime,
323
323
  .footer-brand .brand .b-vapor,
324
324
  .footer-brand .brand .b-club { color: var(--color-primary-contrast); }
325
- .footer-col h4 { color: var(--color-primary-contrast); font-size: .82rem; letter-spacing: .1em; text-transform: uppercase; margin-bottom: 14px; }
325
+ .footer-col .footer-col-title { color: var(--color-primary-contrast); font-size: .82rem; font-weight: bold; letter-spacing: .1em; text-transform: uppercase; margin: 0 0 14px; }
326
326
  .footer-col ul { display: flex; flex-direction: column; gap: 9px; }
327
327
  .footer-col a { color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .92rem; }
328
328
  .footer-col a:hover { color: var(--color-primary-contrast); text-decoration: underline; }
@@ -480,7 +480,7 @@ function renderFooter(footer: ChromeFooter): string {
480
480
  .join("");
481
481
  return collapsible
482
482
  ? ` <div class="footer-col"><details>\n <summary>${esc(c.title)}</summary>\n <ul>${links}</ul>\n </details></div>`
483
- : ` <div class="footer-col">\n <h4>${esc(c.title)}</h4>\n <ul>${links}</ul>\n </div>`;
483
+ : ` <div class="footer-col">\n <p class="footer-col-title">${esc(c.title)}</p>\n <ul>${links}</ul>\n </div>`;
484
484
  })
485
485
  .join("\n");
486
486
  const trust = footer.trustMarks?.length
@@ -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
  }
@@ -212,9 +212,9 @@ const componentVarsInline = Object.entries(componentVars)
212
212
  </div>
213
213
  <div class="pay-badges"><span>Visa</span><span>Mastercard</span><span>Amex</span><span>PayPal</span></div>
214
214
  </div>
215
- <div class="footer-col"><h4>Shop</h4><ul><li><a href="#">Disposables</a></li><li><a href="#">E-Liquid</a></li><li><a href="#">Devices</a></li></ul></div>
216
- <div class="footer-col"><h4>Support</h4><ul><li><a href="#">Contact</a></li><li><a href="#">Shipping</a></li><li><a href="#">Returns</a></li></ul></div>
217
- <div class="footer-col"><h4>Company</h4><ul><li><a href="#">About</a></li><li><a href="#">Blog</a></li></ul></div>
215
+ <div class="footer-col"><p class="footer-col-title">Shop</p><ul><li><a href="#">Disposables</a></li><li><a href="#">E-Liquid</a></li><li><a href="#">Devices</a></li></ul></div>
216
+ <div class="footer-col"><p class="footer-col-title">Support</p><ul><li><a href="#">Contact</a></li><li><a href="#">Shipping</a></li><li><a href="#">Returns</a></li></ul></div>
217
+ <div class="footer-col"><p class="footer-col-title">Company</p><ul><li><a href="#">About</a></li><li><a href="#">Blog</a></li></ul></div>
218
218
  </div>
219
219
  <div class="trust-marks"><span>🔒 Secure checkout</span><span>✓ Age-verified</span><span>★ 4.9 / 5</span></div>
220
220
  <div class="footer-warning"><strong>WARNING:</strong><p>This product contains nicotine. Nicotine is an addictive chemical.</p></div>
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.10",
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,131 @@
1
+ /**
2
+ * When the GA4 sandbox iframe may mount.
3
+ *
4
+ * The frame is same-process: once it loads, gtag.js runs on the page main
5
+ * thread. Mounting it on the first idle turn after `load` puts those long
6
+ * tasks inside the window Lighthouse scores as Total Blocking Time. This
7
+ * scheduler waits for the first of:
8
+ *
9
+ * - a real pointer or key input (`pointerdown` / `keydown`, capture), or
10
+ * - `load`, then a fixed delay long enough to outlast that quiet window.
11
+ *
12
+ * The delay is a minimum. Input before `load` still mounts immediately.
13
+ * The callback never runs synchronously on the call. Cancel is idempotent
14
+ * and drops both the listeners and the timer. Commands sent before the
15
+ * frame exists stay queued by the transport, not by this scheduler.
16
+ */
17
+
18
+ /** How long after `load` the sandbox waits when nobody has interacted. */
19
+ export const GA4_SANDBOX_POST_LOAD_DELAY_MS = 5_000;
20
+
21
+ /** The narrow slice of `window` this scheduler depends on. */
22
+ export interface Ga4SandboxScheduleWindow {
23
+ document?: {
24
+ readyState?: string;
25
+ addEventListener?(
26
+ type: string,
27
+ listener: (event: unknown) => void,
28
+ options?: { capture?: boolean },
29
+ ): void;
30
+ removeEventListener?(
31
+ type: string,
32
+ listener: (event: unknown) => void,
33
+ options?: { capture?: boolean },
34
+ ): void;
35
+ };
36
+ addEventListener(type: "load", listener: () => void, options?: { once?: boolean }): void;
37
+ removeEventListener?(type: "load", listener: () => void): void;
38
+ setTimeout(callback: () => void, ms: number): unknown;
39
+ clearTimeout(handle: unknown): void;
40
+ }
41
+
42
+ export interface Ga4SandboxScheduleOptions {
43
+ /** Minimum wait after `load` before the sandbox mounts without input. */
44
+ postLoadDelayMs?: number;
45
+ }
46
+
47
+ export interface Ga4SandboxScheduleHandle {
48
+ /** Idempotent. Prevents the callback from running if it has not run yet. */
49
+ cancel(): void;
50
+ }
51
+
52
+ const INPUT_EVENTS = ["pointerdown", "keydown"] as const;
53
+ const INPUT_CAPTURE = { capture: true } as const;
54
+
55
+ /**
56
+ * Schedule `callback` for the first input, or for `postLoadDelayMs` after
57
+ * `load`, whichever comes first. Never runs on the call itself.
58
+ */
59
+ export function scheduleGa4Sandbox(
60
+ win: Ga4SandboxScheduleWindow,
61
+ callback: () => void,
62
+ options: Ga4SandboxScheduleOptions = {},
63
+ ): Ga4SandboxScheduleHandle {
64
+ const delay = options.postLoadDelayMs ?? GA4_SANDBOX_POST_LOAD_DELAY_MS;
65
+ let cancelled = false;
66
+ let ran = false;
67
+ let timeoutHandle: unknown;
68
+ let loadListener: (() => void) | undefined;
69
+ const inputTarget = win.document;
70
+ const addInput = inputTarget?.addEventListener;
71
+ const removeInput = inputTarget?.removeEventListener;
72
+
73
+ const detachInput = (): void => {
74
+ if (typeof removeInput !== "function") return;
75
+ for (const type of INPUT_EVENTS) removeInput.call(inputTarget, type, onInput, INPUT_CAPTURE);
76
+ };
77
+
78
+ const finish = (): void => {
79
+ if (cancelled || ran) return;
80
+ ran = true;
81
+ detachInput();
82
+ if (loadListener) {
83
+ win.removeEventListener?.("load", loadListener);
84
+ loadListener = undefined;
85
+ }
86
+ if (timeoutHandle !== undefined) {
87
+ win.clearTimeout(timeoutHandle);
88
+ timeoutHandle = undefined;
89
+ }
90
+ callback();
91
+ };
92
+
93
+ function onInput(): void {
94
+ finish();
95
+ }
96
+
97
+ if (typeof addInput === "function") {
98
+ for (const type of INPUT_EVENTS) addInput.call(inputTarget, type, onInput, INPUT_CAPTURE);
99
+ }
100
+
101
+ const armDelay = (): void => {
102
+ if (cancelled || ran) return;
103
+ timeoutHandle = win.setTimeout(() => {
104
+ timeoutHandle = undefined;
105
+ finish();
106
+ }, delay);
107
+ };
108
+
109
+ if (win.document?.readyState === "complete") {
110
+ armDelay();
111
+ } else {
112
+ loadListener = () => {
113
+ loadListener = undefined;
114
+ armDelay();
115
+ };
116
+ win.addEventListener("load", loadListener, { once: true });
117
+ }
118
+
119
+ return {
120
+ cancel() {
121
+ if (cancelled || ran) {
122
+ cancelled = true;
123
+ return;
124
+ }
125
+ cancelled = true;
126
+ detachInput();
127
+ if (loadListener) win.removeEventListener?.("load", loadListener);
128
+ if (timeoutHandle !== undefined) win.clearTimeout(timeoutHandle);
129
+ },
130
+ };
131
+ }
@@ -49,6 +49,7 @@ import {
49
49
  type NativeGa4SandboxScheduler,
50
50
  } from "./native-ga4-sandbox.js";
51
51
  import { schedulePostPaint, type PostPaintWindow } from "./post-paint.js";
52
+ import { scheduleGa4Sandbox, type Ga4SandboxScheduleWindow } from "./ga4-sandbox-schedule.js";
52
53
  import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
53
54
  import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
54
55
  import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
@@ -124,14 +125,19 @@ export interface BootLifecycleOptions {
124
125
  /** Reviewed isolated-origin GA4 transport; injectable for DOM-free tests. */
125
126
  nativeGa4Transport?: NativeGa4Transport;
126
127
  /**
127
- * Defers non-essential platform-adapter work (the GA4 sandbox iframe mount and
128
- * the Amplitude HTTP capability) until after first paint and idle — see
129
- * `post-paint.ts`. Defaults to the real post-paint scheduler over `window`
130
- * (a guarded synchronous no-op off the browser). Injectable so a test can
131
- * assert nothing runs before the scheduler fires with a fake, controllable
132
- * scheduler instead of real paint/idle timing.
128
+ * Defers the Amplitude HTTP capability until after first paint and idle —
129
+ * see `post-paint.ts`. Defaults to that scheduler over `window` (synchronous
130
+ * off the browser). Injectable so a test can hold the flush.
133
131
  */
134
132
  schedulePlatformAdapterStart?: NativeGa4SandboxScheduler;
133
+ /**
134
+ * Mounts the GA4 sandbox iframe on the first pointer or key input, or a
135
+ * fixed delay after `load`, whichever comes first — see
136
+ * `ga4-sandbox-schedule.ts`. Defaults to that scheduler over `window`
137
+ * (synchronous off the browser). Commands sent before the frame exists stay
138
+ * queued in the transport. Injectable so a test can hold the mount.
139
+ */
140
+ scheduleGa4Sandbox?: NativeGa4SandboxScheduler;
135
141
  }
136
142
 
137
143
  /**
@@ -328,7 +334,8 @@ function defaultConsentGate(
328
334
  /**
329
335
  * The default `schedulePlatformAdapterStart`: the real post-paint scheduler over the
330
336
  * global `window`, or a synchronous immediate start off the browser (SSR, tests that
331
- * import this module without a DOM) — never a hang.
337
+ * import this module without a DOM) — never a hang. Amplitude uses this. The GA4
338
+ * sandbox does not.
332
339
  */
333
340
  function defaultPlatformAdapterScheduler(start: () => void): { cancel(): void } {
334
341
  if (typeof window === "undefined") {
@@ -338,12 +345,24 @@ function defaultPlatformAdapterScheduler(start: () => void): { cancel(): void }
338
345
  return schedulePostPaint(window as unknown as PostPaintWindow, start);
339
346
  }
340
347
 
348
+ /**
349
+ * The default GA4 sandbox scheduler. Off the browser it starts synchronously so
350
+ * SSR and DOM-free tests do not hang. In the browser it never starts on this
351
+ * turn: first input, or the post-load delay, whichever comes first.
352
+ */
353
+ function defaultGa4SandboxScheduler(start: () => void): { cancel(): void } {
354
+ if (typeof window === "undefined") {
355
+ start();
356
+ return { cancel() {} };
357
+ }
358
+ return scheduleGa4Sandbox(window as unknown as Ga4SandboxScheduleWindow, start);
359
+ }
360
+
341
361
  /**
342
362
  * Wrap a `fetch` capability so calls queue instead of running immediately, and are
343
- * all issued together once `schedule` fires. This is what keeps the Amplitude
344
- * adapter's HTTP collection off the pre-paint critical path exactly like the GA4
345
- * sandbox's deferred iframe mount, without touching `native-amplitude.ts` (which
346
- * stays paint-timing-agnostic — it just calls the `fetch` capability it was given).
363
+ * all issued together once `schedule` fires. This keeps the Amplitude adapter's
364
+ * HTTP collection off the pre-paint critical path. `native-amplitude.ts` stays
365
+ * paint-timing-agnostic — it just calls the `fetch` capability it was given.
347
366
  */
348
367
  function deferFetchCapability(
349
368
  fetchCapability: typeof fetch,
@@ -395,6 +414,7 @@ export function bootIntegrationLifecycle(
395
414
  (typeof win.fetch === "function" ? win.fetch.bind(win) : undefined);
396
415
  const schedulePlatformAdapterStart =
397
416
  options.schedulePlatformAdapterStart ?? defaultPlatformAdapterScheduler;
417
+ const scheduleGa4SandboxStart = options.scheduleGa4Sandbox ?? defaultGa4SandboxScheduler;
398
418
  const fallbackLoader = options.importModule
399
419
  ? makeSameOriginEntryLoader(options.importModule)
400
420
  : sameOriginEntryLoader;
@@ -419,7 +439,7 @@ export function bootIntegrationLifecycle(
419
439
  return createNativeGa4SandboxTransport(
420
440
  createBrowserNativeGa4SandboxHost(document, MessageChannel),
421
441
  resolveNativeGa4SandboxUrl(win.location.href),
422
- schedulePlatformAdapterStart,
442
+ scheduleGa4SandboxStart,
423
443
  );
424
444
  },
425
445
  });
@@ -111,10 +111,11 @@ const IMMEDIATE_SCHEDULER: NativeGa4SandboxScheduler = (start) => {
111
111
  * needs a real same-site origin. The exact origin, iframe window, and one-shot transfer
112
112
  * are the authority boundary. No general parent `message` listener is installed.
113
113
  *
114
- * The frame is not created or appended until `schedule` fires (default: immediately,
115
- * i.e. unchanged from before) — that append is what starts gtag.js loading, so gating
116
- * it behind a paint-aware scheduler (see `post-paint.ts`) keeps it off the LCP path.
117
- * `configure`/`send` calls before the frame mounts queue exactly as they always have.
114
+ * The frame is not created or appended until `schedule` fires. Boot passes the
115
+ * GA4 sandbox schedule (`ga4-sandbox-schedule.ts`); a caller that omits
116
+ * `schedule` still mounts immediately. That append is what starts gtag.js.
117
+ * `configure`/`send` before the frame mounts stay queued and flush in order
118
+ * when the frame loads.
118
119
  */
119
120
  export function createNativeGa4SandboxTransport(
120
121
  host: NativeGa4SandboxHost,
@@ -1,19 +1,18 @@
1
1
  /**
2
- * Paint-aware scheduling for non-essential platform work (ext-kernel-05 follow-on).
3
- *
4
- * Nothing in the lifecycle boot chain waited for a real paint before starting
5
- * heavy adapter transports (GA4's isolated-origin iframe, Amplitude's HTTP
6
- * collection), so Lighthouse's simulated dependency graph charged their bytes
7
- * and main-thread time straight to LCP. This scheduler defers a callback until
8
- * AFTER the document has painted and the main thread is otherwise idle:
2
+ * Paint-aware scheduling for platform adapter work that starts once the
3
+ * document has painted and the main thread is idle (Amplitude's HTTP
4
+ * collection). The GA4 sandbox uses `ga4-sandbox-schedule.ts` instead: its
5
+ * iframe shares the page process, so an idle start still runs gtag.js inside
6
+ * the Total Blocking Time window.
9
7
  *
10
8
  * - if the document has already finished loading, schedule on idle immediately;
11
9
  * - otherwise wait for `load`, then schedule on idle;
12
10
  * - "idle" is `requestIdleCallback` with a bounded timeout, falling back to a
13
11
  * macrotask (`setTimeout(0)`) where `requestIdleCallback` does not exist.
14
12
  *
15
- * Cancellable at every stage (before `load`, and while idle-queued), so a
16
- * document torn down before the deferred work starts never runs it.
13
+ * The idle timeout is a maximum, not a minimum. Cancellable at every stage
14
+ * (before `load`, and while idle-queued), so a document torn down before the
15
+ * deferred work starts never runs it.
17
16
  */
18
17
 
19
18
  /** The narrow slice of `window` this scheduler depends on — structural, edge-safe. */