@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.
- package/apps/storefront/public/js/dashboard-team.js +102 -5
- package/apps/storefront/public/shared/commerce-chrome.css +1 -1
- package/apps/storefront/src/lib/rawChrome.ts +1 -1
- package/apps/storefront/src/lib/shared/accessExplain.ts +156 -0
- package/apps/storefront/src/lib/shared/developerInvites.ts +27 -2
- package/apps/storefront/src/lib/shared/viewerGrant.ts +3 -2
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +16 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +3 -3
- package/package.json +1 -1
- package/packages/public-runtime/src/integration/ga4-sandbox-schedule.ts +131 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +32 -12
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +5 -4
- package/packages/public-runtime/src/integration/post-paint.ts +8 -9
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 <
|
|
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/
|
|
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
|
-
|
|
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}/
|
|
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 = "/
|
|
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"><
|
|
216
|
-
<div class="footer-col"><
|
|
217
|
-
<div class="footer-col"><
|
|
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.
|
|
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
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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
|
-
|
|
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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* `configure`/`send`
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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. */
|