@tokenoftrust/storefront-runner 4.7.8 → 4.7.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.
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ToT-staff principal ENTERING a store: the one audited act behind every staff
|
|
3
|
+
* cross-store admission (#8425 staff access).
|
|
4
|
+
*
|
|
5
|
+
* Shared by the staff console (`POST /api/dashboard/enter-vendor`) and the preview
|
|
6
|
+
* token mint (`POST /api/preview/token`, what `tot preview token` calls), so a staff
|
|
7
|
+
* CLI principal records exactly the selection the console does:
|
|
8
|
+
*
|
|
9
|
+
* 1. prove the staff actor to tot20 with a fresh storefront-signed broker assertion
|
|
10
|
+
* minted from the VERIFIED session email (no plaintext email on the wire);
|
|
11
|
+
* 2. tot20 re-checks staff scope, scopes the capability, and writes the AUDIT row —
|
|
12
|
+
* it is the authority, and nothing is admitted without its ok;
|
|
13
|
+
* 3. stamp the short-lived {@link StaffSelection} on the session (never a `tenants[]`
|
|
14
|
+
* entry), so re-entering after it lapses re-audits;
|
|
15
|
+
* 4. hand every exit's outcome to the caller's `emit`, which records it as
|
|
16
|
+
* `dashboard.vendor_entered` against its opaque admin actor id (the email is
|
|
17
|
+
* never stored).
|
|
18
|
+
*/
|
|
19
|
+
import type { SessionRecord, SessionStore, StaffSelection } from "@/lib/access/auth/session";
|
|
20
|
+
import { updateSession } from "@/lib/access/auth/session";
|
|
21
|
+
import { isStaffSession, buildStaffSelection } from "./staffAdmission";
|
|
22
|
+
import {
|
|
23
|
+
createStaffVendorAccessClient,
|
|
24
|
+
staffAccessHttpStatus,
|
|
25
|
+
StaffVendorAccessClientError,
|
|
26
|
+
} from "./staffVendorAccess";
|
|
27
|
+
import { mintBrokerAssertion, BrokerAssertionError } from "@/lib/access/auth/brokerAssertion";
|
|
28
|
+
/** One `dashboard.vendor_entered` outcome, for the caller to record against its actor. */
|
|
29
|
+
export interface StaffEntryEvent {
|
|
30
|
+
outcome: { status: "succeeded" | "failed" | "refused"; errorClass?: string };
|
|
31
|
+
scope: { tenantId: string };
|
|
32
|
+
payload: { args: { resource: string; capability?: string } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StaffStoreEntryInput {
|
|
36
|
+
/** The verified viewer session. Must carry ToT-staff scope. */
|
|
37
|
+
session: SessionRecord;
|
|
38
|
+
/** The store to enter, by appDomain. */
|
|
39
|
+
vendorAppDomain: string;
|
|
40
|
+
/** Records each entry outcome; the route binds it to its opaque admin actor. */
|
|
41
|
+
emit: (event: StaffEntryEvent) => void;
|
|
42
|
+
/** The persisted session to stamp the selection on; null when there is no store. */
|
|
43
|
+
sessionHandle: () => Promise<{ store: SessionStore; id: string } | null>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type StaffStoreEntryResult =
|
|
47
|
+
| { ok: true; selection: StaffSelection; capability: string }
|
|
48
|
+
| {
|
|
49
|
+
ok: false;
|
|
50
|
+
status: number;
|
|
51
|
+
body: { error: string; reason?: string; code?: string };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Enter `vendorAppDomain` as ToT staff: audit with tot20, then persist the selection. */
|
|
55
|
+
export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<StaffStoreEntryResult> {
|
|
56
|
+
const { session, emit } = input;
|
|
57
|
+
const vendorAppDomain = input.vendorAppDomain.trim();
|
|
58
|
+
if (!isStaffSession(session)) {
|
|
59
|
+
return { ok: false, status: 403, body: { error: "staff access required", reason: "notStaff" } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let client;
|
|
63
|
+
try {
|
|
64
|
+
client = await createStaffVendorAccessClient();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof StaffVendorAccessClientError) {
|
|
67
|
+
return { ok: false, status: 503, body: { error: "staff-access integration not configured" } };
|
|
68
|
+
}
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let brokerAssertion: string;
|
|
73
|
+
try {
|
|
74
|
+
brokerAssertion = await mintBrokerAssertion(session.email);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err instanceof BrokerAssertionError) {
|
|
77
|
+
return { ok: false, status: 503, body: { error: "staff-proof signing not configured" } };
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = await client.grantStaffAccess(brokerAssertion, vendorAppDomain);
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
emit({
|
|
85
|
+
outcome: { status: "refused", errorClass: result.reason ?? `http_${result.status}` },
|
|
86
|
+
scope: { tenantId: vendorAppDomain },
|
|
87
|
+
payload: { args: { resource: vendorAppDomain } },
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
status: staffAccessHttpStatus(result.reason, result.status),
|
|
92
|
+
body: { error: result.message, reason: result.reason, code: result.code },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
97
|
+
const selection = buildStaffSelection({
|
|
98
|
+
resource: vendorAppDomain,
|
|
99
|
+
capability: result.capability,
|
|
100
|
+
nowSeconds,
|
|
101
|
+
});
|
|
102
|
+
const handle = await input.sessionHandle();
|
|
103
|
+
if (!handle) {
|
|
104
|
+
// The audit row is written but the marker cannot persist (no SESSION KV / no
|
|
105
|
+
// cookie): fail closed so the caller is never told it is admitted.
|
|
106
|
+
emit({
|
|
107
|
+
outcome: { status: "failed", errorClass: "session_store_unavailable" },
|
|
108
|
+
scope: { tenantId: vendorAppDomain },
|
|
109
|
+
payload: { args: { resource: vendorAppDomain, capability: result.capability } },
|
|
110
|
+
});
|
|
111
|
+
return { ok: false, status: 503, body: { error: "session store unavailable; cannot persist admission" } };
|
|
112
|
+
}
|
|
113
|
+
const updated = await updateSession(
|
|
114
|
+
handle.store,
|
|
115
|
+
handle.id,
|
|
116
|
+
(rec) => ({ ...rec, staffSelection: selection }),
|
|
117
|
+
nowSeconds,
|
|
118
|
+
);
|
|
119
|
+
if (!updated) {
|
|
120
|
+
emit({
|
|
121
|
+
outcome: { status: "failed", errorClass: "session_expired" },
|
|
122
|
+
scope: { tenantId: vendorAppDomain },
|
|
123
|
+
payload: { args: { resource: vendorAppDomain, capability: result.capability } },
|
|
124
|
+
});
|
|
125
|
+
return { ok: false, status: 401, body: { error: "session expired; sign in again" } };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
emit({
|
|
129
|
+
outcome: { status: "succeeded" },
|
|
130
|
+
scope: { tenantId: vendorAppDomain },
|
|
131
|
+
payload: { args: { resource: vendorAppDomain, capability: result.capability } },
|
|
132
|
+
});
|
|
133
|
+
return { ok: true, selection, capability: result.capability };
|
|
134
|
+
}
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
* carries only the reason, the cause, elapsed ms, whether the action was held,
|
|
36
36
|
* whether `FC.client` existed, Foxy's own full-page and bypass decisions, the
|
|
37
37
|
* document ready state and viewport/screen/body widths — never a URL or session.
|
|
38
|
+
* A CSP refusal raised by the drawer's own scripts is the cause when nothing else
|
|
39
|
+
* failed (`csp-eval`, `csp-inline`, `csp-resource`): the page policy admits no
|
|
40
|
+
* eval, so a store template that carries an inline script breaks the drawer.
|
|
38
41
|
*
|
|
39
42
|
* Every cart open waits for this store's Foxy session. The id comes from the
|
|
40
43
|
* same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
|
|
@@ -271,6 +274,20 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
271
274
|
failure = cause;
|
|
272
275
|
runHeld();
|
|
273
276
|
}
|
|
277
|
+
// A drawer can report ready and still fail to render: the page CSP has no
|
|
278
|
+
// 'unsafe-eval', so a store template carrying an inline <script> (which the
|
|
279
|
+
// drawer's jQuery runs through eval) throws inside cart.render. A refusal raised
|
|
280
|
+
// by the drawer's own scripts is kept as the cause of a later fall-through.
|
|
281
|
+
var drawerOrigins = [cartOrigin];
|
|
282
|
+
try { drawerOrigins.push(new URL(src, document.baseURI).origin); } catch (e) {}
|
|
283
|
+
var cspRefusal = null;
|
|
284
|
+
document.addEventListener('securitypolicyviolation', function (event) {
|
|
285
|
+
var from = null;
|
|
286
|
+
try { from = new URL(event.sourceFile).origin; } catch (e) {}
|
|
287
|
+
if (!from || drawerOrigins.indexOf(from) === -1) return;
|
|
288
|
+
var blocked = event.blockedURI;
|
|
289
|
+
cspRefusal = 'csp-' + (blocked === 'eval' || blocked === 'inline' ? blocked : 'resource');
|
|
290
|
+
});
|
|
274
291
|
// A cart action reaching the window unhandled after the drawer settled navigates
|
|
275
292
|
// to the full cart page. Record why, with no URL or session in the record.
|
|
276
293
|
function fallthrough(kind, el) {
|
|
@@ -284,9 +301,10 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
284
301
|
return !!(node && node.classList && (node.classList.contains('fc-sidecart--bypass') || node.hasAttribute('data-fc-sidecart-bypass')));
|
|
285
302
|
}
|
|
286
303
|
var reason = state === 'ready' ? 'drawer-declined' : 'drawer-unavailable';
|
|
304
|
+
var cause = failure || cspRefusal;
|
|
287
305
|
var detail = {
|
|
288
306
|
reason: reason,
|
|
289
|
-
cause:
|
|
307
|
+
cause: cause,
|
|
290
308
|
kind: kind,
|
|
291
309
|
held: replaying === el,
|
|
292
310
|
elapsedMs: armedAt ? Date.now() - armedAt : null,
|
|
@@ -298,7 +316,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
298
316
|
screenWidth: window.screen ? window.screen.availWidth : null,
|
|
299
317
|
bodyWidth: body ? body.clientWidth : null
|
|
300
318
|
};
|
|
301
|
-
document.documentElement.setAttribute('data-cart-fallthrough', reason + (
|
|
319
|
+
document.documentElement.setAttribute('data-cart-fallthrough', reason + (cause ? ':' + cause : ''));
|
|
302
320
|
try { console.warn('[tot-cart] cart fell through to the cart page', detail); } catch (e) {}
|
|
303
321
|
try { window.dispatchEvent(new window.CustomEvent('tot:cart-fallthrough', { detail: detail })); } catch (e) {}
|
|
304
322
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.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",
|