@tokenoftrust/storefront-runner 4.11.8 → 4.11.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-contact-capture.js +52 -0
- package/apps/storefront/public/js/dashboard-delegate.js +52 -19
- package/apps/storefront/src/lib/access/dashboard/delegateGate.ts +1 -1
- package/apps/storefront/src/lib/access/shared/principalGrants.ts +118 -16
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +8 -3
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client behavior for the staff "Customer contact access" card on /dashboard/<appDomain>/team:
|
|
3
|
+
* a ToT staff member records the owner's out-of-band consent, which grants that staff member
|
|
4
|
+
* customer contact access through POST /api/grants/contact-capture.
|
|
5
|
+
*
|
|
6
|
+
* Served as a STATIC same-origin file (public/), same CSP rationale as dashboard-team.js. Only
|
|
7
|
+
* wires up when the card is present (ToT staff viewer only). The server decides everything; this
|
|
8
|
+
* file only collects the channel, ticket and expiry and shows the answer.
|
|
9
|
+
*/
|
|
10
|
+
(function () {
|
|
11
|
+
const $ = (id) => document.getElementById(id);
|
|
12
|
+
const card = $("contactCapture");
|
|
13
|
+
if (!card) return;
|
|
14
|
+
const tenant = card.dataset.tenant;
|
|
15
|
+
|
|
16
|
+
function showMsg(kind, text) {
|
|
17
|
+
const el = $("captureMsg");
|
|
18
|
+
el.className = "msg " + kind;
|
|
19
|
+
el.textContent = text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function capture() {
|
|
23
|
+
const channel = $("captureChannel").value;
|
|
24
|
+
const ticket = $("captureTicket").value.trim();
|
|
25
|
+
if (!ticket) { showMsg("err", "Enter the support ticket reference."); return; }
|
|
26
|
+
const ttlMs = Number($("captureTtl").value);
|
|
27
|
+
const btn = $("captureBtn");
|
|
28
|
+
btn.disabled = true;
|
|
29
|
+
showMsg("", "Recording…");
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch("/api/grants/contact-capture", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
34
|
+
body: JSON.stringify({ tenant, channel, ticket, ttlMs }),
|
|
35
|
+
});
|
|
36
|
+
const data = await res.json().catch(() => ({}));
|
|
37
|
+
if (res.ok) {
|
|
38
|
+
const until = data.expiresAt ? new Date(data.expiresAt).toLocaleString() : "";
|
|
39
|
+
showMsg("ok", "Recorded. You can read customer contact details on this store" + (until ? " until " + until : "") + ". The owner has been notified.");
|
|
40
|
+
$("captureTicket").value = "";
|
|
41
|
+
} else {
|
|
42
|
+
showMsg("err", data.message || data.error || ("Couldn't record consent (HTTP " + res.status + ")."));
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
showMsg("err", "Couldn't record consent. Try again.");
|
|
46
|
+
} finally {
|
|
47
|
+
btn.disabled = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
$("captureBtn").addEventListener("click", capture);
|
|
52
|
+
})();
|
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client behavior for the "
|
|
3
|
-
*
|
|
2
|
+
* Client behavior for the owner's "Grant access" section of /dashboard/<appDomain>/team:
|
|
3
|
+
* ship-on-behalf or customer contact access, granted to a person by email, time-limited and
|
|
4
|
+
* revocable, through POST/GET/DELETE /api/grants/principal.
|
|
4
5
|
*
|
|
5
6
|
* Served as a STATIC same-origin file (public/), same CSP rationale as
|
|
6
|
-
* dashboard-team.js — see that file's header. Only wires up when the
|
|
7
|
-
*
|
|
7
|
+
* dashboard-team.js — see that file's header. Only wires up when the section is present
|
|
8
|
+
* (store owner only).
|
|
8
9
|
*/
|
|
9
10
|
(function () {
|
|
10
11
|
const $ = (id) => document.getElementById(id);
|
|
11
12
|
const body = $("delegations");
|
|
12
|
-
if (!body) return; // section not rendered (
|
|
13
|
+
if (!body) return; // section not rendered (not the owner) — nothing to wire up
|
|
13
14
|
const tenant = body.dataset.tenant;
|
|
14
15
|
|
|
16
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
17
|
+
/** Expiry choices per capability; the server enforces the same bounds. */
|
|
18
|
+
const TTL_CHOICES = {
|
|
19
|
+
"ship-on-behalf": [[7 * DAY, "In 7 days"], [30 * DAY, "In 30 days", true], [90 * DAY, "In 90 days"]],
|
|
20
|
+
"customer-contact-read": [[1 * DAY, "In 1 day", true], [3 * DAY, "In 3 days"], [7 * DAY, "In 7 days"]],
|
|
21
|
+
};
|
|
22
|
+
const LABELS = { "ship-on-behalf": "Ship on behalf", "customer-contact-read": "Customer contacts" };
|
|
23
|
+
|
|
24
|
+
function capability() {
|
|
25
|
+
const el = $("delegateCapability");
|
|
26
|
+
return el && TTL_CHOICES[el.value] ? el.value : "ship-on-behalf";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fillTtl() {
|
|
30
|
+
const ttlEl = $("delegateTtl");
|
|
31
|
+
if (!ttlEl) return;
|
|
32
|
+
ttlEl.replaceChildren();
|
|
33
|
+
for (const [ms, label, selected] of TTL_CHOICES[capability()]) {
|
|
34
|
+
const opt = document.createElement("option");
|
|
35
|
+
opt.value = String(ms);
|
|
36
|
+
opt.textContent = label;
|
|
37
|
+
if (selected) opt.selected = true;
|
|
38
|
+
ttlEl.appendChild(opt);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
function showMsg(kind, text) {
|
|
16
43
|
const el = $("delegateMsg");
|
|
17
44
|
el.className = "msg " + kind;
|
|
@@ -23,8 +50,8 @@
|
|
|
23
50
|
if (!grants || !grants.length) {
|
|
24
51
|
const tr = document.createElement("tr");
|
|
25
52
|
const td = document.createElement("td");
|
|
26
|
-
td.colSpan =
|
|
27
|
-
td.textContent = "No active
|
|
53
|
+
td.colSpan = 4; td.className = "muted";
|
|
54
|
+
td.textContent = "No active grants.";
|
|
28
55
|
tr.appendChild(td); body.appendChild(tr);
|
|
29
56
|
return;
|
|
30
57
|
}
|
|
@@ -32,6 +59,8 @@
|
|
|
32
59
|
const tr = document.createElement("tr");
|
|
33
60
|
const dev = document.createElement("td");
|
|
34
61
|
dev.textContent = (g.principal && g.principal.email) || "";
|
|
62
|
+
const cap = document.createElement("td");
|
|
63
|
+
cap.textContent = LABELS[g.capability] || g.capability || "";
|
|
35
64
|
const exp = document.createElement("td");
|
|
36
65
|
exp.textContent = g.expiresAt ? new Date(g.expiresAt).toLocaleString() : "";
|
|
37
66
|
const act = document.createElement("td");
|
|
@@ -40,9 +69,9 @@
|
|
|
40
69
|
revokeBtn.type = "button";
|
|
41
70
|
revokeBtn.className = "rowbtn";
|
|
42
71
|
revokeBtn.textContent = "Revoke";
|
|
43
|
-
revokeBtn.addEventListener("click", () => revoke(g.ref, dev.textContent, revokeBtn));
|
|
72
|
+
revokeBtn.addEventListener("click", () => revoke(g.ref, dev.textContent, cap.textContent, revokeBtn));
|
|
44
73
|
act.appendChild(revokeBtn);
|
|
45
|
-
tr.appendChild(dev); tr.appendChild(exp); tr.appendChild(act);
|
|
74
|
+
tr.appendChild(dev); tr.appendChild(cap); tr.appendChild(exp); tr.appendChild(act);
|
|
46
75
|
body.appendChild(tr);
|
|
47
76
|
}
|
|
48
77
|
}
|
|
@@ -51,7 +80,7 @@
|
|
|
51
80
|
body.replaceChildren();
|
|
52
81
|
const tr = document.createElement("tr");
|
|
53
82
|
const td = document.createElement("td");
|
|
54
|
-
td.colSpan =
|
|
83
|
+
td.colSpan = 4; td.className = "muted";
|
|
55
84
|
td.textContent = msg;
|
|
56
85
|
tr.appendChild(td); body.appendChild(tr);
|
|
57
86
|
}
|
|
@@ -63,15 +92,15 @@
|
|
|
63
92
|
});
|
|
64
93
|
const data = await res.json().catch(() => ({}));
|
|
65
94
|
if (res.ok) renderRows(data.grants);
|
|
66
|
-
else renderError(
|
|
95
|
+
else renderError("Couldn't load grants (HTTP " + res.status + "). Reload to try again.");
|
|
67
96
|
} catch (e) {
|
|
68
|
-
renderError("Couldn't load
|
|
97
|
+
renderError("Couldn't load grants. Reload to try again.");
|
|
69
98
|
}
|
|
70
99
|
}
|
|
71
100
|
|
|
72
|
-
async function revoke(ref, email, btn) {
|
|
101
|
+
async function revoke(ref, email, label, btn) {
|
|
73
102
|
if (!ref) return;
|
|
74
|
-
if (!confirm("Revoke " + (email || "this
|
|
103
|
+
if (!confirm("Revoke " + (email || "this person") + "'s " + (label || "access") + " access?")) return;
|
|
75
104
|
if (btn) btn.disabled = true;
|
|
76
105
|
try {
|
|
77
106
|
const res = await fetch("/api/grants/principal", {
|
|
@@ -95,9 +124,10 @@
|
|
|
95
124
|
|
|
96
125
|
async function grant() {
|
|
97
126
|
const email = $("delegateEmail").value.trim();
|
|
98
|
-
if (!email) { showMsg("err", "Enter
|
|
127
|
+
if (!email) { showMsg("err", "Enter an email."); return; }
|
|
128
|
+
const cap = capability();
|
|
99
129
|
const ttlEl = $("delegateTtl");
|
|
100
|
-
const ttlMs = ttlEl ? Number(ttlEl.value) :
|
|
130
|
+
const ttlMs = ttlEl ? Number(ttlEl.value) : TTL_CHOICES[cap][0][0];
|
|
101
131
|
const btn = $("delegateBtn");
|
|
102
132
|
btn.disabled = true;
|
|
103
133
|
showMsg("", "Granting…");
|
|
@@ -105,15 +135,15 @@
|
|
|
105
135
|
const res = await fetch("/api/grants/principal", {
|
|
106
136
|
method: "POST",
|
|
107
137
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
108
|
-
body: JSON.stringify({ tenant, email, ttlMs }),
|
|
138
|
+
body: JSON.stringify({ tenant, email, capability: cap, ttlMs }),
|
|
109
139
|
});
|
|
110
140
|
const data = await res.json().catch(() => ({}));
|
|
111
141
|
if (res.ok) {
|
|
112
|
-
showMsg("ok", "Granted
|
|
142
|
+
showMsg("ok", "Granted " + LABELS[cap].toLowerCase() + " access to " + email + ".");
|
|
113
143
|
$("delegateEmail").value = "";
|
|
114
144
|
loadDelegations();
|
|
115
145
|
} else {
|
|
116
|
-
showMsg("err", data.error || ("Grant failed (HTTP " + res.status + ")."));
|
|
146
|
+
showMsg("err", data.message || data.error || ("Grant failed (HTTP " + res.status + ")."));
|
|
117
147
|
}
|
|
118
148
|
} catch (e) {
|
|
119
149
|
showMsg("err", "Grant failed: " + (e && e.message ? e.message : e));
|
|
@@ -128,5 +158,8 @@
|
|
|
128
158
|
delegateBtn.addEventListener("click", grant);
|
|
129
159
|
emailInput.addEventListener("keydown", (e) => { if (e.key === "Enter") grant(); });
|
|
130
160
|
}
|
|
161
|
+
const capEl = $("delegateCapability");
|
|
162
|
+
if (capEl) capEl.addEventListener("change", fillTtl);
|
|
163
|
+
fillTtl();
|
|
131
164
|
loadDelegations();
|
|
132
165
|
})();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The "
|
|
2
|
+
* The owner "Grant access" gate on /dashboard/<appDomain>/team, as a PURE
|
|
3
3
|
* decision — who sees the control, and what everyone else is told instead.
|
|
4
4
|
*
|
|
5
5
|
* The gate itself is unchanged and stays OWNER-ONLY: `/api/grants/principal`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PrincipalGrantClient — the storefront's seam to tot20's principal-keyed grant
|
|
3
|
-
* writer (ADR-0003, #8425): a scoped, revocable `ship-on-behalf`
|
|
4
|
-
* issued to a HUMAN (by email), never a team role.
|
|
3
|
+
* writer (ADR-0003, #8425): a scoped, revocable, expiring capability (`ship-on-behalf`
|
|
4
|
+
* or `customer-contact-read`) issued to a HUMAN (by email), never a team role.
|
|
5
5
|
*
|
|
6
6
|
* This is the AUTHORIZATION (L2) surface — see docs/architecture/grant-surfaces.md
|
|
7
7
|
* ("Let an operator app act on a store" row's owner-consent shape, generalized to
|
|
@@ -20,9 +20,13 @@
|
|
|
20
20
|
* human/service principal). `ref` is opaque (never the raw email) and is what
|
|
21
21
|
* `revoke`/list rows key on.
|
|
22
22
|
*
|
|
23
|
-
* GRANTOR
|
|
24
|
-
*
|
|
25
|
-
* provenances, and this client drives
|
|
23
|
+
* GRANTOR MODES: BROKERED SESSION. Merchants hold no ToT API keys — they sign into
|
|
24
|
+
* storefront's dashboard. tot20's `resolveGrantorContext` accepts three grantor
|
|
25
|
+
* provenances, and this client drives the two brokered ones. The assertion's SUBJECT
|
|
26
|
+
* decides which: the store owner gives `owner-brokered-session` (/api/grants/principal);
|
|
27
|
+
* storefront-line ToT staff give `staff-consent-capture`, which tot20 audits with a
|
|
28
|
+
* `staff:<hash>` actor and notifies the owner of (/api/grants/contact-capture). Other
|
|
29
|
+
* staff roles are refused `staffScopeInsufficient`, and tot20 never lets staff list.
|
|
26
30
|
*
|
|
27
31
|
* - Naming a `tenant` on the request switches tot20 OFF the owner-key path (where
|
|
28
32
|
* the grantor is the CALLER's own store — i.e. storefront itself) and onto the
|
|
@@ -30,9 +34,10 @@
|
|
|
30
34
|
* - The brokered path requires a VERIFIED broker assertion whose signed `tenant`
|
|
31
35
|
* claim matches the request's tenant EXACTLY (an unnormalized string compare, so
|
|
32
36
|
* both are built from the same value here) and whose SUBJECT is the signed-in
|
|
33
|
-
*
|
|
34
|
-
* `AccessCodeService.resolveEligibility`, strictly `capability === 'owner'`)
|
|
35
|
-
* the assertion proves WHO the subject is, never
|
|
37
|
+
* person. tot20 then resolves ownership (`ownsTenant` →
|
|
38
|
+
* `AccessCodeService.resolveEligibility`, strictly `capability === 'owner'`) or
|
|
39
|
+
* staff role from its OWN state — the assertion proves WHO the subject is, never
|
|
40
|
+
* WHAT they own or which role they hold.
|
|
36
41
|
* - Transport auth stays storefront's OWN operator credential (`TOT_API_KEY` /
|
|
37
42
|
* `TOT_SECRET_KEY`; the route is `requireSecretKey`), the same credential every
|
|
38
43
|
* sibling client here presents. It authenticates the BROKER; it does not choose
|
|
@@ -84,9 +89,102 @@ export interface UserPrincipal {
|
|
|
84
89
|
email: string;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
/**
|
|
88
|
-
*
|
|
89
|
-
export type PrincipalGrantCapability = "ship-on-behalf" | (string & {});
|
|
92
|
+
/** Kept as a string so a future tot20 vocabulary addition (see
|
|
93
|
+
* `grantCapabilities.CAPABILITIES` in tot20) doesn't need a type change here. */
|
|
94
|
+
export type PrincipalGrantCapability = "ship-on-behalf" | "customer-contact-read" | (string & {});
|
|
95
|
+
|
|
96
|
+
// --- Route guards shared by the grant routes ---------------------------------
|
|
97
|
+
|
|
98
|
+
/** The capabilities storefront's grant routes may issue. Anything else is refused before tot20. */
|
|
99
|
+
export const GRANTABLE_CAPABILITIES = ["ship-on-behalf", "customer-contact-read"] as const;
|
|
100
|
+
export type GrantableCapability = (typeof GRANTABLE_CAPABILITIES)[number];
|
|
101
|
+
|
|
102
|
+
export function isGrantableCapability(value: unknown): value is GrantableCapability {
|
|
103
|
+
return typeof value === "string" && (GRANTABLE_CAPABILITIES as readonly string[]).includes(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
107
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Expiry bounds per capability. Customer contact data is order and customer PII, so its grants are
|
|
111
|
+
* short (at most 7 days); ship-on-behalf keeps its longer delegation window.
|
|
112
|
+
*/
|
|
113
|
+
export const GRANT_TTL_BOUNDS: Record<GrantableCapability, { minMs: number; maxMs: number; label: string }> = {
|
|
114
|
+
"ship-on-behalf": { minMs: HOUR_MS, maxMs: 90 * DAY_MS, label: "between 1 hour and 90 days" },
|
|
115
|
+
"customer-contact-read": { minMs: HOUR_MS, maxMs: 7 * DAY_MS, label: "between 1 hour and 7 days" },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The expiry for a grant request: `expiresAt` (epoch-ms) wins, else `ttlMs` from now. Refused when
|
|
120
|
+
* absent, not finite, or outside the capability's bounds.
|
|
121
|
+
*/
|
|
122
|
+
export function grantExpiry(
|
|
123
|
+
capability: GrantableCapability,
|
|
124
|
+
body: { expiresAt?: unknown; ttlMs?: unknown },
|
|
125
|
+
now: number,
|
|
126
|
+
): { ok: true; expiresAt: number } | { ok: false; error: string } {
|
|
127
|
+
let expiresAt: number;
|
|
128
|
+
if (typeof body.expiresAt === "number" && Number.isFinite(body.expiresAt)) expiresAt = body.expiresAt;
|
|
129
|
+
else if (typeof body.ttlMs === "number" && Number.isFinite(body.ttlMs)) expiresAt = now + body.ttlMs;
|
|
130
|
+
else return { ok: false, error: "expiresAt (epoch-ms) or ttlMs is required — a human grant is never standing authority" };
|
|
131
|
+
const { minMs, maxMs, label } = GRANT_TTL_BOUNDS[capability];
|
|
132
|
+
const ttl = expiresAt - now;
|
|
133
|
+
if (ttl < minMs || ttl > maxMs) return { ok: false, error: `expiry must be ${label} from now` };
|
|
134
|
+
return { ok: true, expiresAt };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Cross-site request refusal for the cookie-authenticated grant mutations. The session cookie is
|
|
139
|
+
* SameSite=Lax and the admin entry checks do not look at Origin, so a sibling host under the shared
|
|
140
|
+
* store domain could otherwise post here. Requires `Origin` equal to the request URL's origin and,
|
|
141
|
+
* for a body-carrying method, a JSON content type. Returns a 403 response to send, or null to proceed.
|
|
142
|
+
*/
|
|
143
|
+
export function crossOriginRefusal(request: Request): Response | null {
|
|
144
|
+
const origin = request.headers.get("origin");
|
|
145
|
+
let expected: string;
|
|
146
|
+
try {
|
|
147
|
+
expected = new URL(request.url).origin;
|
|
148
|
+
} catch {
|
|
149
|
+
expected = "";
|
|
150
|
+
}
|
|
151
|
+
const sameOrigin = !!origin && !!expected && origin === expected;
|
|
152
|
+
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
153
|
+
const jsonBody = request.method === "GET" || request.method === "HEAD" || contentType.startsWith("application/json");
|
|
154
|
+
if (sameOrigin && jsonBody) return null;
|
|
155
|
+
return new Response(JSON.stringify({ error: "cross-origin" }), {
|
|
156
|
+
status: 403,
|
|
157
|
+
headers: { "content-type": "application/json" },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** How a staff member heard the owner consent. A fixed list, so no free text reaches a grant. */
|
|
162
|
+
export const CONSENT_CHANNELS = ["phone", "email", "chat", "meeting"] as const;
|
|
163
|
+
export type ConsentChannel = (typeof CONSENT_CHANNELS)[number];
|
|
164
|
+
|
|
165
|
+
/** The support-ticket reference shape the contact support-read already accepts. */
|
|
166
|
+
export const CONSENT_TICKET_REF = /^[A-Za-z0-9._-]{1,64}$/;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The grant purpose recorded for a staff consent capture, from validated fields only, so a purpose
|
|
170
|
+
* can never carry customer detail.
|
|
171
|
+
*/
|
|
172
|
+
export function consentCapturePurpose(
|
|
173
|
+
body: { channel?: unknown; ticket?: unknown },
|
|
174
|
+
): { ok: true; purpose: string; channel: ConsentChannel; ticket: string } | { ok: false; error: string } {
|
|
175
|
+
const channel = body.channel;
|
|
176
|
+
if (typeof channel !== "string" || !(CONSENT_CHANNELS as readonly string[]).includes(channel)) {
|
|
177
|
+
return { ok: false, error: `channel must be one of ${CONSENT_CHANNELS.join(", ")}` };
|
|
178
|
+
}
|
|
179
|
+
const ticket = typeof body.ticket === "string" ? body.ticket.trim() : "";
|
|
180
|
+
if (!CONSENT_TICKET_REF.test(ticket)) return { ok: false, error: "ticket must be a support ticket reference" };
|
|
181
|
+
return {
|
|
182
|
+
ok: true,
|
|
183
|
+
purpose: `staff-consent-capture channel=${channel} ticket=${ticket}`,
|
|
184
|
+
channel: channel as ConsentChannel,
|
|
185
|
+
ticket,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
90
188
|
|
|
91
189
|
export interface CreatePrincipalGrantInput {
|
|
92
190
|
/** Target tenant this grant is issued on (session-derived; never trust a body field). */
|
|
@@ -121,6 +219,7 @@ export type PrincipalGrantFailureReason =
|
|
|
121
219
|
| "assertionTenantMismatch" // 422 — assertion names a different tenant than the request
|
|
122
220
|
| "tenantNotRegistered" // 422 — the named appDomain is not a ToT client
|
|
123
221
|
| "subjectNotTenantOwner" // 403 — the signed-in subject does not OWN this tenant
|
|
222
|
+
| "staffScopeInsufficient" // 403 — a staff subject whose role may not capture consent here
|
|
124
223
|
| "brokerNotConfigured" // LOCAL 503 — this deploy holds no broker signing key
|
|
125
224
|
| "error"; // transport / unexpected upstream fault
|
|
126
225
|
|
|
@@ -431,11 +530,11 @@ function reasonFor(code: string | undefined, status: number): PrincipalGrantFail
|
|
|
431
530
|
case "operatorGrants:tenantNotRegistered":
|
|
432
531
|
return "tenantNotRegistered";
|
|
433
532
|
case "operatorGrants:subjectNotTenantOwner":
|
|
434
|
-
// Storefront never drives the staff-consent-capture branch, so a staff-scope
|
|
435
|
-
// refusal here can only mean the subject reached tot20 as staff rather than as
|
|
436
|
-
// the owner — the same operator-facing story as "you don't own this store".
|
|
437
|
-
case "operatorGrants:staffScopeInsufficient":
|
|
438
533
|
return "subjectNotTenantOwner";
|
|
534
|
+
// The staff-consent-capture branch (POST /api/grants/contact-capture): tot20 decided this
|
|
535
|
+
// staff role may not capture consent on the tenant.
|
|
536
|
+
case "operatorGrants:staffScopeInsufficient":
|
|
537
|
+
return "staffScopeInsufficient";
|
|
439
538
|
default:
|
|
440
539
|
if (status === 403 || status === 401) return "notAuthenticated";
|
|
441
540
|
return "error";
|
|
@@ -474,7 +573,9 @@ export function principalGrantFailureMessage(reason: PrincipalGrantFailureReason
|
|
|
474
573
|
case "principalTypeNotSupported":
|
|
475
574
|
return "this endpoint grants a human (email) principal only";
|
|
476
575
|
case "subjectNotTenantOwner":
|
|
477
|
-
return "only an owner of this store can
|
|
576
|
+
return "only an owner of this store can grant access";
|
|
577
|
+
case "staffScopeInsufficient":
|
|
578
|
+
return "your Token of Trust staff role can't capture owner consent on this store";
|
|
478
579
|
case "tenantNotRegistered":
|
|
479
580
|
return "this store isn't registered with Token of Trust";
|
|
480
581
|
case "brokerAssertionInvalid":
|
|
@@ -551,6 +652,7 @@ export function principalGrantHttpStatus(reason: PrincipalGrantFailureReason, up
|
|
|
551
652
|
case "brokerAssertionInvalid":
|
|
552
653
|
return 401;
|
|
553
654
|
case "subjectNotTenantOwner":
|
|
655
|
+
case "staffScopeInsufficient":
|
|
554
656
|
return 403;
|
|
555
657
|
case "brokerNotConfigured":
|
|
556
658
|
return 503;
|
|
@@ -50,14 +50,19 @@ export class FetchShipGrantTransport implements ViewerShipGrantGetTransport {
|
|
|
50
50
|
// every admin tab), so an unbounded hang stalls the whole document stream. A
|
|
51
51
|
// timeout aborts to the documented deny-default (the resolver maps any transport/
|
|
52
52
|
// timeout fault to `preview-access`) — never a render-blocking wait on a slow MCP.
|
|
53
|
-
// `redirect: "
|
|
54
|
-
// so
|
|
53
|
+
// `redirect: "manual"`, then a 3xx throws: a followed redirect would carry the service-secret
|
|
54
|
+
// header to another origin, so a redirect is a transport fault (a deny) and nothing is sent to
|
|
55
|
+
// its target. Not `redirect: "error"`: the Workers runtime rejects that value before the request
|
|
56
|
+
// leaves, so every lookup would deny without ever reaching tot-mcp.
|
|
55
57
|
const res = await fetch(url, {
|
|
56
58
|
method: "GET",
|
|
57
59
|
headers,
|
|
58
|
-
redirect: "
|
|
60
|
+
redirect: "manual",
|
|
59
61
|
signal: AbortSignal.timeout(2500),
|
|
60
62
|
});
|
|
63
|
+
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
64
|
+
throw new Error("grant authority answered a redirect; refused");
|
|
65
|
+
}
|
|
61
66
|
return { status: res.status, json: () => res.json() };
|
|
62
67
|
}
|
|
63
68
|
}
|
|
@@ -95,7 +95,9 @@ export function addressLines(address: unknown): string[] {
|
|
|
95
95
|
export const CONTACT_NONE =
|
|
96
96
|
"No customer contact details are on file for this order. They are kept for a limited time and are gone once expired or erased.";
|
|
97
97
|
export const CONTACT_DENIED = "You cannot view customer contact details for this order.";
|
|
98
|
-
export const CONTACT_GRANT_REQUIRED =
|
|
98
|
+
export const CONTACT_GRANT_REQUIRED =
|
|
99
|
+
"The store owner has not granted ToT support access to customer contact details. Ask the owner to grant it on the " +
|
|
100
|
+
"store's Team page, or, if the owner already agreed, record their consent there under Customer contact access.";
|
|
99
101
|
export const CONTACT_QUOTA = "The hourly limit for reading customer contact details has been reached. Try again later.";
|
|
100
102
|
export const CONTACT_UNAVAILABLE = "Customer contact details are temporarily unavailable. Try again shortly.";
|
|
101
103
|
export const CONTACT_FAILED = "The contact details could not be loaded. Try again.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.11.
|
|
3
|
+
"version": "4.11.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",
|