@tokenoftrust/storefront-runner 2.0.6 → 2.0.8
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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client behavior for the "Delegate ship-on-behalf" section of
|
|
3
|
+
* /dashboard/<appDomain>/team (handoff 2026-09-03-storefront-owner-grants-a-developer).
|
|
4
|
+
*
|
|
5
|
+
* 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 delegation
|
|
7
|
+
* section is present (real member only; absent for a staff-viewed tenant).
|
|
8
|
+
*/
|
|
9
|
+
(function () {
|
|
10
|
+
const $ = (id) => document.getElementById(id);
|
|
11
|
+
const body = $("delegations");
|
|
12
|
+
if (!body) return; // section not rendered (staff view) — nothing to wire up
|
|
13
|
+
const tenant = body.dataset.tenant;
|
|
14
|
+
|
|
15
|
+
function showMsg(kind, text) {
|
|
16
|
+
const el = $("delegateMsg");
|
|
17
|
+
el.className = "msg " + kind;
|
|
18
|
+
el.textContent = text;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderRows(grants) {
|
|
22
|
+
body.replaceChildren();
|
|
23
|
+
if (!grants || !grants.length) {
|
|
24
|
+
const tr = document.createElement("tr");
|
|
25
|
+
const td = document.createElement("td");
|
|
26
|
+
td.colSpan = 3; td.className = "muted";
|
|
27
|
+
td.textContent = "No active delegations.";
|
|
28
|
+
tr.appendChild(td); body.appendChild(tr);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const g of grants) {
|
|
32
|
+
const tr = document.createElement("tr");
|
|
33
|
+
const dev = document.createElement("td");
|
|
34
|
+
dev.textContent = (g.principal && g.principal.email) || "";
|
|
35
|
+
const exp = document.createElement("td");
|
|
36
|
+
exp.textContent = g.expiresAt ? new Date(g.expiresAt).toLocaleString() : "";
|
|
37
|
+
const act = document.createElement("td");
|
|
38
|
+
act.className = "act";
|
|
39
|
+
const revokeBtn = document.createElement("button");
|
|
40
|
+
revokeBtn.type = "button";
|
|
41
|
+
revokeBtn.className = "rowbtn";
|
|
42
|
+
revokeBtn.textContent = "Revoke";
|
|
43
|
+
revokeBtn.addEventListener("click", () => revoke(g.ref, dev.textContent, revokeBtn));
|
|
44
|
+
act.appendChild(revokeBtn);
|
|
45
|
+
tr.appendChild(dev); tr.appendChild(exp); tr.appendChild(act);
|
|
46
|
+
body.appendChild(tr);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderError(msg) {
|
|
51
|
+
body.replaceChildren();
|
|
52
|
+
const tr = document.createElement("tr");
|
|
53
|
+
const td = document.createElement("td");
|
|
54
|
+
td.colSpan = 3; td.className = "muted";
|
|
55
|
+
td.textContent = msg;
|
|
56
|
+
tr.appendChild(td); body.appendChild(tr);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function loadDelegations() {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch("/api/grants/principal?tenant=" + encodeURIComponent(tenant), {
|
|
62
|
+
headers: { accept: "application/json" },
|
|
63
|
+
});
|
|
64
|
+
const data = await res.json().catch(() => ({}));
|
|
65
|
+
if (res.ok) renderRows(data.grants);
|
|
66
|
+
else renderError(data.error || ("Couldn't load delegations (HTTP " + res.status + ")."));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
renderError("Couldn't load delegations: " + (e && e.message ? e.message : e));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function revoke(ref, email, btn) {
|
|
73
|
+
if (!ref) return;
|
|
74
|
+
if (!confirm("Revoke " + (email || "this developer") + "'s ship-on-behalf access?")) return;
|
|
75
|
+
if (btn) btn.disabled = true;
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch("/api/grants/principal", {
|
|
78
|
+
method: "DELETE",
|
|
79
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
80
|
+
body: JSON.stringify({ tenant, ref }),
|
|
81
|
+
});
|
|
82
|
+
const data = await res.json().catch(() => ({}));
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
showMsg("ok", "Revoked " + (email || "") + "'s access.");
|
|
85
|
+
loadDelegations();
|
|
86
|
+
} else {
|
|
87
|
+
showMsg("err", data.error || ("Couldn't revoke (HTTP " + res.status + ")."));
|
|
88
|
+
if (btn) btn.disabled = false;
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
showMsg("err", "Couldn't revoke: " + (e && e.message ? e.message : e));
|
|
92
|
+
if (btn) btn.disabled = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function grant() {
|
|
97
|
+
const email = $("delegateEmail").value.trim();
|
|
98
|
+
if (!email) { showMsg("err", "Enter a developer email."); return; }
|
|
99
|
+
const ttlEl = $("delegateTtl");
|
|
100
|
+
const ttlMs = ttlEl ? Number(ttlEl.value) : 2592000000;
|
|
101
|
+
const btn = $("delegateBtn");
|
|
102
|
+
btn.disabled = true;
|
|
103
|
+
showMsg("", "Granting…");
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch("/api/grants/principal", {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
108
|
+
body: JSON.stringify({ tenant, email, ttlMs }),
|
|
109
|
+
});
|
|
110
|
+
const data = await res.json().catch(() => ({}));
|
|
111
|
+
if (res.ok) {
|
|
112
|
+
showMsg("ok", "Granted ship-on-behalf to " + email + ".");
|
|
113
|
+
$("delegateEmail").value = "";
|
|
114
|
+
loadDelegations();
|
|
115
|
+
} else {
|
|
116
|
+
showMsg("err", data.error || ("Grant failed (HTTP " + res.status + ")."));
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
showMsg("err", "Grant failed: " + (e && e.message ? e.message : e));
|
|
120
|
+
} finally {
|
|
121
|
+
btn.disabled = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const delegateBtn = $("delegateBtn");
|
|
126
|
+
const emailInput = $("delegateEmail");
|
|
127
|
+
if (delegateBtn && emailInput) {
|
|
128
|
+
delegateBtn.addEventListener("click", grant);
|
|
129
|
+
emailInput.addEventListener("keydown", (e) => { if (e.key === "Enter") grant(); });
|
|
130
|
+
}
|
|
131
|
+
loadDelegations();
|
|
132
|
+
})();
|
|
@@ -21,11 +21,25 @@
|
|
|
21
21
|
* → 422 developerInvites:{tenantRequired|invalidEmail|tenantNotFound}
|
|
22
22
|
*
|
|
23
23
|
* AUTH WIRE (mirrors `@/lib/auth/totAccessClient`): tot20's swagger
|
|
24
|
-
* `normalizeSecureUserRequest`
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* `normalizeSecureUserRequest` accepts machine creds three ways — request BODY
|
|
25
|
+
* fields `totApiKey`/`totSecretKey`, the same fields as QUERY params, or an
|
|
26
|
+
* `Authorization` header carrying a base64 "license key"
|
|
27
|
+
* (`base64("<apiKey>;<secretKey>")`, expanded by tot20's `expandLicenseKey`, which
|
|
28
|
+
* takes PRECEDENCE over the body/query values). Custom `x-tot-*` headers are NOT
|
|
29
|
+
* read. Those are the only integration-sensitive spots
|
|
30
|
+
* ({@link credentialFields}, {@link credentialAuthHeader}).
|
|
27
31
|
* tot20 wraps every response in a `{ content, metadata }` envelope.
|
|
28
32
|
*
|
|
33
|
+
* ⚠ CREDENTIALS NEVER GO IN A URL. `POST` carries them in the body; `GET`/`DELETE`
|
|
34
|
+
* have no body, so they use the `Authorization` license-key header — NOT the query
|
|
35
|
+
* string, even though tot20 would accept it there. A query string lands in access
|
|
36
|
+
* logs, proxy logs, browser history and error-tracking payloads, so a credential
|
|
37
|
+
* there is a disclosure with a long tail. tot20 holds this same line deliberately
|
|
38
|
+
* (its staff broker assertions are header-only "so the staff subject never lands in
|
|
39
|
+
* URLs/access logs"); this client must not be the thing that breaks it.
|
|
40
|
+
* `developerInvites.test.ts` asserts no credential value ever appears in a request
|
|
41
|
+
* URL, for any method — keep that guard.
|
|
42
|
+
*
|
|
29
43
|
* TIER: the host + operator credential are per-DEPLOY env (`TOT_CORE_URL` +
|
|
30
44
|
* `TOT_API_KEY`/`TOT_SECRET_KEY`) — each tier's deploy is configured with its
|
|
31
45
|
* tier-matching values (test/qa → https://qa.tokenoftrust.com, prod →
|
|
@@ -372,6 +386,29 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
372
386
|
return f;
|
|
373
387
|
}
|
|
374
388
|
|
|
389
|
+
/**
|
|
390
|
+
* The body-less counterpart to {@link credentialFields}: the `Authorization`
|
|
391
|
+
* header tot20 accepts for GET/DELETE, so the credential never reaches the URL.
|
|
392
|
+
* tot20's `expandLicenseKey` base64-decodes this value and splits it on `;` into
|
|
393
|
+
* `apiKey;secretKey[;appDomain]`, and `normalizeSecureUserRequest` prefers it over
|
|
394
|
+
* any body/query credential.
|
|
395
|
+
*
|
|
396
|
+
* The encoding is not optional: `expandLicenseKey` returns `undefined` for a PLAIN
|
|
397
|
+
* `a;b` string (it only decodes values that don't already split into 2-3 fields),
|
|
398
|
+
* so the base64 wrapper is what makes tot20 read it at all. Base64's alphabet
|
|
399
|
+
* contains no `;`, so the wrapper can never be mistaken for the plain form.
|
|
400
|
+
*
|
|
401
|
+
* Deliberately duplicated from the sibling `@/lib/shared/principalGrants`
|
|
402
|
+
* (`credentialAuthHeader`) rather than imported, for the same reason the HTTP
|
|
403
|
+
* transport seam is: this client stays self-contained, and enrollment (L1) does not
|
|
404
|
+
* take a dependency on the authorization (L2) client. Each copy is guarded by its
|
|
405
|
+
* own no-credential-in-URL test.
|
|
406
|
+
*/
|
|
407
|
+
private credentialAuthHeader(): Record<string, string> {
|
|
408
|
+
const licenseKey = `${this.apiKey};${this.secretKey ?? ""}`;
|
|
409
|
+
return { authorization: Buffer.from(licenseKey, "utf8").toString("base64") };
|
|
410
|
+
}
|
|
411
|
+
|
|
375
412
|
async createInvite(
|
|
376
413
|
input: DeveloperInviteInput,
|
|
377
414
|
): Promise<DeveloperInviteResult> {
|
|
@@ -474,12 +511,13 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
474
511
|
message: "developerInvites: transport does not support GET",
|
|
475
512
|
};
|
|
476
513
|
}
|
|
477
|
-
// GET
|
|
478
|
-
//
|
|
479
|
-
|
|
514
|
+
// GET has no body, so the credential rides the Authorization license-key header
|
|
515
|
+
// — NEVER the query string (see the ⚠ note in this file's header). `tenant` is a
|
|
516
|
+
// normal, non-secret query param and stays where it is.
|
|
517
|
+
const q = new URLSearchParams({ tenant });
|
|
480
518
|
// #8453: the staff actor is proven by the signed
|
|
481
519
|
// assertion header — NEVER a plaintext email query param.
|
|
482
|
-
const headers: Record<string, string> = {};
|
|
520
|
+
const headers: Record<string, string> = { ...this.credentialAuthHeader() };
|
|
483
521
|
if (brokerAssertion) headers[BROKER_ASSERTION_HEADER] = brokerAssertion;
|
|
484
522
|
let res: HttpResponseLike;
|
|
485
523
|
try {
|
|
@@ -523,14 +561,15 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
523
561
|
message: "developerInvites: transport does not support DELETE",
|
|
524
562
|
};
|
|
525
563
|
}
|
|
526
|
-
// DELETE has no body —
|
|
527
|
-
|
|
528
|
-
|
|
564
|
+
// DELETE has no body — the credential rides the Authorization license-key header,
|
|
565
|
+
// matching listInvites. Nothing secret goes in the URL, so there is no query
|
|
566
|
+
// string here at all (inviteId is an opaque id, already path-encoded).
|
|
567
|
+
const headers: Record<string, string> = { ...this.credentialAuthHeader() };
|
|
529
568
|
if (brokerAssertion) headers[BROKER_ASSERTION_HEADER] = brokerAssertion;
|
|
530
569
|
let res: HttpResponseLike;
|
|
531
570
|
try {
|
|
532
571
|
res = await this.transport.delete(
|
|
533
|
-
`${this.baseUrl}/api/clients/developerInvites/${encodeURIComponent(inviteId)}
|
|
572
|
+
`${this.baseUrl}/api/clients/developerInvites/${encodeURIComponent(inviteId)}`,
|
|
534
573
|
headers,
|
|
535
574
|
);
|
|
536
575
|
} catch (cause) {
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrincipalGrantClient — the storefront's seam to tot20's principal-keyed grant
|
|
3
|
+
* writer (ADR-0003, #8425): a scoped, revocable `ship-on-behalf` capability
|
|
4
|
+
* issued to a HUMAN (by email), never a team role.
|
|
5
|
+
*
|
|
6
|
+
* This is the AUTHORIZATION (L2) surface — see docs/architecture/grant-surfaces.md
|
|
7
|
+
* ("Let an operator app act on a store" row's owner-consent shape, generalized to
|
|
8
|
+
* a human grantee). It is NOT the enrollment path (`@/lib/shared/developerInvites`,
|
|
9
|
+
* which only ever emits `preview-access`/`blog-contribute` sign-in eligibility) —
|
|
10
|
+
* the two revoke independently and must never be merged.
|
|
11
|
+
*
|
|
12
|
+
* POST /api/clients/principalGrants { principal:{type:'user',email}, capability, expiresAt }
|
|
13
|
+
* → 200 { code:'operatorGrants:principalGrantCreated', tenant, principal, ref, capability, expiresAt }
|
|
14
|
+
* → 422 operatorGrants:{expiresAtRequired|unknownCapability|invalidPrincipal|principalTypeNotSupported}
|
|
15
|
+
* → 403 operatorGrants:notAuthenticated (bad/absent owner credential)
|
|
16
|
+
* GET /api/clients/principalGrants → 200 { code:'operatorGrants:principalGrantsOk', grants:[...] }
|
|
17
|
+
* DELETE /api/clients/principalGrants/{ref} → 200 { code:'operatorGrants:principalGrantRevoked', tenant }
|
|
18
|
+
*
|
|
19
|
+
* `expiresAt` (epoch-ms) is REQUIRED for a human grantee — tot20 fails closed
|
|
20
|
+
* with `expiresAtRequired` if omitted (ADR-0003 D4: no standing authority for a
|
|
21
|
+
* human/service principal). `ref` is opaque (never the raw email) and is what
|
|
22
|
+
* `revoke`/list rows key on.
|
|
23
|
+
*
|
|
24
|
+
* ⚠ KNOWN GAP (2026-09-03, see handoff 2026-09-03-storefront-owner-grants-a-developer,
|
|
25
|
+
* Part 1): unlike every other tot20 seam in this repo, this route is
|
|
26
|
+
* OWNER-authenticated — `req.getApiClient()` must resolve to the TARGET TENANT's
|
|
27
|
+
* own credential, not storefront's own operator identity. Storefront holds NO
|
|
28
|
+
* per-tenant owner secret key anywhere (confirmed: no such credential exists in
|
|
29
|
+
* this codebase — every other tot20 call here, `developerInvites`/`teamGrants`,
|
|
30
|
+
* authenticates as storefront ITSELF via `TOT_API_KEY`/`TOT_SECRET_KEY`, which
|
|
31
|
+
* resolves to storefront's OWN app domain, not a hosted tenant's). Presenting
|
|
32
|
+
* storefront's operator credential here would not grant on behalf of the target
|
|
33
|
+
* tenant — it would either self-grant on storefront's own domain or be rejected.
|
|
34
|
+
* `createPrincipalGrantClient` therefore FAILS CLOSED (throws
|
|
35
|
+
* {@link PrincipalGrantClientError}) until one of the following lands:
|
|
36
|
+
* (a) tot20 accepts the signed-in owner's verified id_token as bearer auth on
|
|
37
|
+
* this route, or
|
|
38
|
+
* (b) tot20 adds a storefront-proxy grantor mode (operator credential + a
|
|
39
|
+
* verified owner id_token/session claim, scoped to storefront's own hosted
|
|
40
|
+
* tenants) — the same shape as the ToT-staff-broker-assertion widening
|
|
41
|
+
* requested separately for this same route.
|
|
42
|
+
* The client/route/UI below are otherwise complete and ready to light up the
|
|
43
|
+
* moment a real credential resolver exists — see {@link OwnerCredentialResolver}.
|
|
44
|
+
*
|
|
45
|
+
* AUTH WIRE: tot20's swagger `normalizeSecureUserRequest` accepts machine creds
|
|
46
|
+
* three ways — request BODY fields `totApiKey`/`totSecretKey`, the same fields as
|
|
47
|
+
* QUERY params, or an `Authorization` header carrying a base64 "license key"
|
|
48
|
+
* (`base64("<apiKey>;<secretKey>")`, expanded by tot20's `expandLicenseKey`,
|
|
49
|
+
* which takes PRECEDENCE over the body/query values). Custom `x-tot-*` headers
|
|
50
|
+
* are NOT read. This is the only integration-sensitive spot
|
|
51
|
+
* ({@link OwnerCredentialResolver}, {@link credentialAuthHeader}).
|
|
52
|
+
*
|
|
53
|
+
* ⚠ CREDENTIALS NEVER GO IN A URL. `POST` carries them in the body; `GET`/`DELETE`
|
|
54
|
+
* have no body, so they use the `Authorization` license-key header — NOT the query
|
|
55
|
+
* string, even though tot20 would accept it there. A query string lands in access
|
|
56
|
+
* logs, proxy logs, browser history and error-tracking payloads, so a credential
|
|
57
|
+
* there is a disclosure with a long tail. tot20 holds this same line deliberately
|
|
58
|
+
* (its staff broker assertions are header-only "so the staff subject never lands
|
|
59
|
+
* in URLs/access logs", and its revoke addresses a grant by an opaque `ref`
|
|
60
|
+
* precisely so an email never becomes a path segment); this client must not be the
|
|
61
|
+
* thing that breaks it. `principalGrants.test.ts` asserts no credential value ever
|
|
62
|
+
* appears in a request URL, for any method — keep that guard.
|
|
63
|
+
*
|
|
64
|
+
* TIER: host + credentials are per-DEPLOY env, same `TOT_CORE_URL` the sibling
|
|
65
|
+
* developerInvites/forge clients read (qa tier is what's live today for this route).
|
|
66
|
+
*
|
|
67
|
+
* Built like `@/lib/shared/developerInvites` (interface + Http impl + Mock + env
|
|
68
|
+
* factory) so the route develops/tests against `MockPrincipalGrantClient` and
|
|
69
|
+
* swaps in the Http client with no call-site change.
|
|
70
|
+
*/
|
|
71
|
+
import { readEnv } from "@/lib/env";
|
|
72
|
+
import { diagEmail } from "@/lib/diag/redact";
|
|
73
|
+
|
|
74
|
+
/** A human grantee — the only principal type this HTTP surface accepts. */
|
|
75
|
+
export interface UserPrincipal {
|
|
76
|
+
type: "user";
|
|
77
|
+
email: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `ship-on-behalf` today; kept as a string so a future tot20 vocabulary addition
|
|
81
|
+
* (see `grantCapabilities.CAPABILITIES` in tot20) doesn't need a type change here. */
|
|
82
|
+
export type PrincipalGrantCapability = "ship-on-behalf" | (string & {});
|
|
83
|
+
|
|
84
|
+
export interface CreatePrincipalGrantInput {
|
|
85
|
+
/** Target tenant this grant is issued on (session-derived; never trust a body field). */
|
|
86
|
+
tenant: string;
|
|
87
|
+
principal: UserPrincipal;
|
|
88
|
+
capability: PrincipalGrantCapability;
|
|
89
|
+
/** Epoch-ms; REQUIRED (ADR-0003 D4 — a human grant is never standing authority). */
|
|
90
|
+
expiresAt: number;
|
|
91
|
+
scope?: string;
|
|
92
|
+
purpose?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PrincipalGrantCreated {
|
|
96
|
+
ok: true;
|
|
97
|
+
tenant: string;
|
|
98
|
+
principal: UserPrincipal;
|
|
99
|
+
/** Opaque revoke handle — never the raw email. */
|
|
100
|
+
ref: string;
|
|
101
|
+
capability: string;
|
|
102
|
+
expiresAt: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type PrincipalGrantFailureReason =
|
|
106
|
+
| "notAuthenticated" // 403/422 — owner credential missing/rejected
|
|
107
|
+
| "expiresAtRequired" // 422 — ADR-0003 D4
|
|
108
|
+
| "unknownCapability" // 422
|
|
109
|
+
| "invalidPrincipal" // 422
|
|
110
|
+
| "principalTypeNotSupported" // 422 — non-'user' principal at this HTTP layer
|
|
111
|
+
| "ownerCredentialNotConfigured" // LOCAL: no owner-credential resolver wired yet (see file header)
|
|
112
|
+
| "error"; // transport / unexpected upstream fault
|
|
113
|
+
|
|
114
|
+
export interface PrincipalGrantFailed {
|
|
115
|
+
ok: false;
|
|
116
|
+
status: number;
|
|
117
|
+
reason: PrincipalGrantFailureReason;
|
|
118
|
+
message: string;
|
|
119
|
+
code?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type PrincipalGrantResult = PrincipalGrantCreated | PrincipalGrantFailed;
|
|
123
|
+
|
|
124
|
+
export interface PrincipalGrantSummary {
|
|
125
|
+
principal: UserPrincipal;
|
|
126
|
+
capability: string;
|
|
127
|
+
expiresAt: number;
|
|
128
|
+
ref: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type PrincipalGrantListResult =
|
|
132
|
+
| { ok: true; tenant: string; grants: PrincipalGrantSummary[] }
|
|
133
|
+
| { ok: false; status: number; reason: PrincipalGrantFailureReason; message: string; code?: string };
|
|
134
|
+
|
|
135
|
+
export type PrincipalGrantRevokeResult =
|
|
136
|
+
| { ok: true; tenant: string; ref: string }
|
|
137
|
+
| { ok: false; status: number; reason: PrincipalGrantFailureReason; message: string; code?: string };
|
|
138
|
+
|
|
139
|
+
export interface PrincipalGrantClient {
|
|
140
|
+
createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult>;
|
|
141
|
+
listGrants(tenant: string): Promise<PrincipalGrantListResult>;
|
|
142
|
+
revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- HTTP transport seam (injected by tests) --------------------------------
|
|
146
|
+
|
|
147
|
+
export interface HttpResponseLike {
|
|
148
|
+
status: number;
|
|
149
|
+
json(): Promise<unknown>;
|
|
150
|
+
}
|
|
151
|
+
export interface HttpTransport {
|
|
152
|
+
post(url: string, headers: Record<string, string>, body: unknown): Promise<HttpResponseLike>;
|
|
153
|
+
get(url: string, headers: Record<string, string>): Promise<HttpResponseLike>;
|
|
154
|
+
delete(url: string, headers: Record<string, string>): Promise<HttpResponseLike>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class FetchHttpTransport implements HttpTransport {
|
|
158
|
+
async post(url: string, headers: Record<string, string>, body: unknown) {
|
|
159
|
+
return fetch(url, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "content-type": "application/json", accept: "application/json", ...headers },
|
|
162
|
+
body: JSON.stringify(body),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async get(url: string, headers: Record<string, string>) {
|
|
166
|
+
return fetch(url, { method: "GET", headers: { accept: "application/json", ...headers } });
|
|
167
|
+
}
|
|
168
|
+
async delete(url: string, headers: Record<string, string>) {
|
|
169
|
+
return fetch(url, { method: "DELETE", headers: { accept: "application/json", ...headers } });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class PrincipalGrantClientError extends Error {}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolves the tot20 credential fields to merge into a request body FOR A GIVEN
|
|
177
|
+
* TENANT — the seam the known gap above is about. Returns `null` when no owner
|
|
178
|
+
* credential can be resolved for that tenant (fails closed; never falls back to
|
|
179
|
+
* storefront's own operator credential, which would silently misdirect the grant).
|
|
180
|
+
*/
|
|
181
|
+
export type OwnerCredentialResolver = (
|
|
182
|
+
tenant: string,
|
|
183
|
+
) => Promise<{ totApiKey: string; totSecretKey?: string } | null>;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build the `Authorization` header tot20 accepts for a body-less request (GET /
|
|
187
|
+
* DELETE), so the credential never reaches the URL. tot20's `expandLicenseKey`
|
|
188
|
+
* base64-decodes this value and splits it on `;` into `apiKey;secretKey[;appDomain]`,
|
|
189
|
+
* and `normalizeSecureUserRequest` prefers it over any body/query credential.
|
|
190
|
+
*
|
|
191
|
+
* Note the encoding is not optional: `expandLicenseKey` returns `undefined` for a
|
|
192
|
+
* PLAIN `a;b` string (it only decodes values that don't already split into 2-3
|
|
193
|
+
* fields), so the base64 wrapper is what makes tot20 read it at all. Base64's
|
|
194
|
+
* alphabet contains no `;`, so the wrapper can never be mistaken for the plain form.
|
|
195
|
+
*/
|
|
196
|
+
export function credentialAuthHeader(cred: {
|
|
197
|
+
totApiKey: string;
|
|
198
|
+
totSecretKey?: string;
|
|
199
|
+
}): { authorization: string } {
|
|
200
|
+
const licenseKey = `${cred.totApiKey};${cred.totSecretKey ?? ""}`;
|
|
201
|
+
return { authorization: Buffer.from(licenseKey, "utf8").toString("base64") };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface HttpPrincipalGrantClientConfig {
|
|
205
|
+
/** tot20 core API base (no trailing slash), e.g. https://qa.tokenoftrust.com */
|
|
206
|
+
baseUrl: string;
|
|
207
|
+
resolveOwnerCredential: OwnerCredentialResolver;
|
|
208
|
+
transport?: HttpTransport;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
212
|
+
private readonly baseUrl: string;
|
|
213
|
+
private readonly resolveOwnerCredential: OwnerCredentialResolver;
|
|
214
|
+
private readonly transport: HttpTransport;
|
|
215
|
+
|
|
216
|
+
constructor(config: HttpPrincipalGrantClientConfig) {
|
|
217
|
+
if (!config.baseUrl) {
|
|
218
|
+
throw new PrincipalGrantClientError("principalGrants: TOT_CORE_URL is not configured");
|
|
219
|
+
}
|
|
220
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
221
|
+
this.resolveOwnerCredential = config.resolveOwnerCredential;
|
|
222
|
+
this.transport = config.transport ?? new FetchHttpTransport();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult> {
|
|
226
|
+
const cred = await this.resolveOwnerCredential(input.tenant);
|
|
227
|
+
if (!cred) return notConfigured();
|
|
228
|
+
|
|
229
|
+
const body: Record<string, unknown> = {
|
|
230
|
+
principal: { type: "user", email: input.principal.email.toLowerCase() },
|
|
231
|
+
capability: input.capability,
|
|
232
|
+
expiresAt: input.expiresAt,
|
|
233
|
+
totApiKey: cred.totApiKey,
|
|
234
|
+
...(cred.totSecretKey ? { totSecretKey: cred.totSecretKey } : {}),
|
|
235
|
+
};
|
|
236
|
+
if (input.scope) body.scope = input.scope;
|
|
237
|
+
if (input.purpose) body.purpose = input.purpose;
|
|
238
|
+
|
|
239
|
+
logGrantDiag("request", {
|
|
240
|
+
endpoint: `${this.baseUrl}/api/clients/principalGrants`,
|
|
241
|
+
tenant: input.tenant,
|
|
242
|
+
email: diagEmail(input.principal.email.toLowerCase()),
|
|
243
|
+
capability: input.capability,
|
|
244
|
+
expiresAt: input.expiresAt,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
let res: HttpResponseLike;
|
|
248
|
+
try {
|
|
249
|
+
res = await this.transport.post(`${this.baseUrl}/api/clients/principalGrants`, {}, body);
|
|
250
|
+
} catch (cause) {
|
|
251
|
+
return transportFailure("creating principal grant", cause);
|
|
252
|
+
}
|
|
253
|
+
const parsed = await safeJson(res, "creating principal grant");
|
|
254
|
+
if ("failed" in parsed) return parsed.failed;
|
|
255
|
+
const result = normalizeCreateResponse(res.status, parsed.body);
|
|
256
|
+
logGrantDiag("response", {
|
|
257
|
+
tenant: input.tenant,
|
|
258
|
+
httpStatus: res.status,
|
|
259
|
+
ok: result.ok,
|
|
260
|
+
...(result.ok ? { ref: result.ref } : { reason: result.reason, code: result.code }),
|
|
261
|
+
});
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async listGrants(tenant: string): Promise<PrincipalGrantListResult> {
|
|
266
|
+
const cred = await this.resolveOwnerCredential(tenant);
|
|
267
|
+
if (!cred) return notConfigured();
|
|
268
|
+
let res: HttpResponseLike;
|
|
269
|
+
try {
|
|
270
|
+
// Credential travels in the Authorization header, never the query string.
|
|
271
|
+
res = await this.transport.get(
|
|
272
|
+
`${this.baseUrl}/api/clients/principalGrants`,
|
|
273
|
+
credentialAuthHeader(cred),
|
|
274
|
+
);
|
|
275
|
+
} catch (cause) {
|
|
276
|
+
return transportFailure("listing principal grants", cause);
|
|
277
|
+
}
|
|
278
|
+
const parsed = await safeJson(res, "listing principal grants");
|
|
279
|
+
if ("failed" in parsed) return parsed.failed;
|
|
280
|
+
return normalizeListResponse(res.status, tenant, parsed.body);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult> {
|
|
284
|
+
const cred = await this.resolveOwnerCredential(tenant);
|
|
285
|
+
if (!cred) return notConfigured();
|
|
286
|
+
let res: HttpResponseLike;
|
|
287
|
+
try {
|
|
288
|
+
// Credential travels in the Authorization header, never the query string.
|
|
289
|
+
// `ref` is tot20's opaque grant handle (never a raw email), so it is safe
|
|
290
|
+
// as a path segment — that is exactly why tot20 addresses revoke by ref.
|
|
291
|
+
res = await this.transport.delete(
|
|
292
|
+
`${this.baseUrl}/api/clients/principalGrants/${encodeURIComponent(ref)}`,
|
|
293
|
+
credentialAuthHeader(cred),
|
|
294
|
+
);
|
|
295
|
+
} catch (cause) {
|
|
296
|
+
return transportFailure("revoking principal grant", cause);
|
|
297
|
+
}
|
|
298
|
+
const parsed = await safeJson(res, "revoking principal grant");
|
|
299
|
+
if ("failed" in parsed) return parsed.failed;
|
|
300
|
+
return normalizeRevokeResponse(res.status, tenant, parsed.body);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function notConfigured(): PrincipalGrantFailed {
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
status: 503,
|
|
308
|
+
reason: "ownerCredentialNotConfigured",
|
|
309
|
+
message:
|
|
310
|
+
"storefront has no owner credential to present to tot20 for this tenant yet " +
|
|
311
|
+
"(see the KNOWN GAP note in lib/shared/principalGrants.ts)",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function transportFailure(action: string, cause: unknown): Promise<PrincipalGrantFailed> {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
status: 0,
|
|
319
|
+
reason: "error",
|
|
320
|
+
message: `principalGrants: transport failure ${action} ` + (cause instanceof Error ? cause.message : String(cause)),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function safeJson(
|
|
325
|
+
res: HttpResponseLike,
|
|
326
|
+
action: string,
|
|
327
|
+
): Promise<{ body: unknown } | { failed: PrincipalGrantFailed }> {
|
|
328
|
+
try {
|
|
329
|
+
return { body: await res.json() };
|
|
330
|
+
} catch {
|
|
331
|
+
return {
|
|
332
|
+
failed: {
|
|
333
|
+
ok: false,
|
|
334
|
+
status: res.status,
|
|
335
|
+
reason: "error",
|
|
336
|
+
message: `principalGrants: tot20 returned a non-JSON body ${action} (status ${res.status})`,
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function logGrantDiag(phase: "request" | "response", fields: Record<string, unknown>): void {
|
|
343
|
+
try {
|
|
344
|
+
console.info(`[diag:principalGrants:${phase}]`, JSON.stringify(fields));
|
|
345
|
+
} catch {
|
|
346
|
+
/* diagnostics must never break the call */
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function unwrapEnvelope(body: unknown): Record<string, unknown> {
|
|
351
|
+
const raw = (body ?? {}) as Record<string, unknown>;
|
|
352
|
+
return (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function reasonFor(code: string | undefined, status: number): PrincipalGrantFailureReason {
|
|
356
|
+
switch (code) {
|
|
357
|
+
case "operatorGrants:notAuthenticated":
|
|
358
|
+
return "notAuthenticated";
|
|
359
|
+
case "operatorGrants:expiresAtRequired":
|
|
360
|
+
case "operatorGrants:invalidExpiresAt":
|
|
361
|
+
return "expiresAtRequired";
|
|
362
|
+
case "operatorGrants:unknownCapability":
|
|
363
|
+
return "unknownCapability";
|
|
364
|
+
case "operatorGrants:invalidPrincipal":
|
|
365
|
+
case "operatorGrants:principalNotFound":
|
|
366
|
+
return "invalidPrincipal";
|
|
367
|
+
case "operatorGrants:principalTypeNotSupported":
|
|
368
|
+
return "principalTypeNotSupported";
|
|
369
|
+
default:
|
|
370
|
+
if (status === 403 || status === 401) return "notAuthenticated";
|
|
371
|
+
return "error";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function defaultMessage(reason: PrincipalGrantFailureReason, status: number): string {
|
|
376
|
+
switch (reason) {
|
|
377
|
+
case "notAuthenticated":
|
|
378
|
+
return "storefront isn't authorized to grant on this tenant";
|
|
379
|
+
case "expiresAtRequired":
|
|
380
|
+
return "a future expiresAt is required for a human grant";
|
|
381
|
+
case "unknownCapability":
|
|
382
|
+
return "unknown capability";
|
|
383
|
+
case "invalidPrincipal":
|
|
384
|
+
return "a valid grantee email is required";
|
|
385
|
+
case "principalTypeNotSupported":
|
|
386
|
+
return "this endpoint grants a human (email) principal only";
|
|
387
|
+
case "ownerCredentialNotConfigured":
|
|
388
|
+
return "storefront has no owner credential configured for this tenant";
|
|
389
|
+
default:
|
|
390
|
+
return `principalGrants: tot20 returned status ${status}`;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function normalizeCreateResponse(status: number, body: unknown): PrincipalGrantResult {
|
|
395
|
+
const c = unwrapEnvelope(body);
|
|
396
|
+
const code = typeof c.code === "string" ? c.code : undefined;
|
|
397
|
+
const isCreated = code === "operatorGrants:principalGrantCreated" || (status >= 200 && status < 300 && typeof c.ref === "string");
|
|
398
|
+
if (isCreated) {
|
|
399
|
+
return {
|
|
400
|
+
ok: true,
|
|
401
|
+
tenant: String(c.tenant ?? ""),
|
|
402
|
+
principal: (c.principal as UserPrincipal) ?? { type: "user", email: "" },
|
|
403
|
+
ref: String(c.ref ?? ""),
|
|
404
|
+
capability: String(c.capability ?? ""),
|
|
405
|
+
expiresAt: Number(c.expiresAt ?? 0),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const reason = reasonFor(code, status);
|
|
409
|
+
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
410
|
+
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function normalizeListResponse(status: number, tenant: string, body: unknown): PrincipalGrantListResult {
|
|
414
|
+
const c = unwrapEnvelope(body);
|
|
415
|
+
const code = typeof c.code === "string" ? c.code : undefined;
|
|
416
|
+
const isOk = code === "operatorGrants:principalGrantsOk" || (status >= 200 && status < 300 && Array.isArray(c.grants));
|
|
417
|
+
if (isOk) {
|
|
418
|
+
const grants: PrincipalGrantSummary[] = (Array.isArray(c.grants) ? c.grants : []).map((raw) => {
|
|
419
|
+
const g = (raw ?? {}) as Record<string, unknown>;
|
|
420
|
+
return {
|
|
421
|
+
principal: (g.principal as UserPrincipal) ?? { type: "user", email: "" },
|
|
422
|
+
capability: String(g.capability ?? ""),
|
|
423
|
+
expiresAt: Number(g.expiresAt ?? 0),
|
|
424
|
+
ref: String(g.ref ?? ""),
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
return { ok: true, tenant, grants };
|
|
428
|
+
}
|
|
429
|
+
const reason = reasonFor(code, status);
|
|
430
|
+
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
431
|
+
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function normalizeRevokeResponse(status: number, tenant: string, body: unknown): PrincipalGrantRevokeResult {
|
|
435
|
+
const c = unwrapEnvelope(body);
|
|
436
|
+
const code = typeof c.code === "string" ? c.code : undefined;
|
|
437
|
+
const isOk = code === "operatorGrants:principalGrantRevoked" || (status >= 200 && status < 300);
|
|
438
|
+
if (isOk) {
|
|
439
|
+
return { ok: true, tenant: String(c.tenant ?? tenant), ref: "" };
|
|
440
|
+
}
|
|
441
|
+
const reason = reasonFor(code, status);
|
|
442
|
+
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
443
|
+
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Map a typed failure onto the HTTP status a storefront route should return. */
|
|
447
|
+
export function principalGrantHttpStatus(reason: PrincipalGrantFailureReason, upstreamStatus: number): number {
|
|
448
|
+
switch (reason) {
|
|
449
|
+
case "notAuthenticated":
|
|
450
|
+
return 403;
|
|
451
|
+
case "expiresAtRequired":
|
|
452
|
+
case "unknownCapability":
|
|
453
|
+
case "invalidPrincipal":
|
|
454
|
+
case "principalTypeNotSupported":
|
|
455
|
+
return 422;
|
|
456
|
+
case "ownerCredentialNotConfigured":
|
|
457
|
+
return 503;
|
|
458
|
+
default:
|
|
459
|
+
return upstreamStatus >= 400 ? upstreamStatus : 502;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- Mock (tests + local dev) -----------------------------------------------
|
|
464
|
+
|
|
465
|
+
/** In-memory PrincipalGrantClient — deterministic for route/UI tests. */
|
|
466
|
+
export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
467
|
+
private grants = new Map<string, { principal: UserPrincipal; capability: string; expiresAt: number }>();
|
|
468
|
+
private seq = 0;
|
|
469
|
+
/** Tenants with NO owner credential configured (simulates the known gap). */
|
|
470
|
+
private readonly unconfigured: Set<string>;
|
|
471
|
+
|
|
472
|
+
constructor(unconfiguredTenants: string[] = []) {
|
|
473
|
+
this.unconfigured = new Set(unconfiguredTenants.map((t) => t.toLowerCase()));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult> {
|
|
477
|
+
const tenant = input.tenant.toLowerCase();
|
|
478
|
+
if (this.unconfigured.has(tenant)) return notConfigured();
|
|
479
|
+
if (!input.expiresAt || input.expiresAt <= Date.now()) {
|
|
480
|
+
return { ok: false, status: 422, reason: "expiresAtRequired", message: "a future expiresAt is required", code: "operatorGrants:expiresAtRequired" };
|
|
481
|
+
}
|
|
482
|
+
const email = input.principal.email.trim().toLowerCase();
|
|
483
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
484
|
+
return { ok: false, status: 422, reason: "invalidPrincipal", message: "a valid grantee email is required", code: "operatorGrants:invalidPrincipal" };
|
|
485
|
+
}
|
|
486
|
+
const ref = `mock-ref-${tenant}-${++this.seq}`;
|
|
487
|
+
this.grants.set(ref, { principal: { type: "user", email }, capability: input.capability, expiresAt: input.expiresAt });
|
|
488
|
+
return { ok: true, tenant, principal: { type: "user", email }, ref, capability: input.capability, expiresAt: input.expiresAt };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async listGrants(tenant: string): Promise<PrincipalGrantListResult> {
|
|
492
|
+
const t = tenant.toLowerCase();
|
|
493
|
+
if (this.unconfigured.has(t)) return notConfigured();
|
|
494
|
+
const grants = [...this.grants.entries()]
|
|
495
|
+
.filter(([ref]) => ref.startsWith(`mock-ref-${t}-`))
|
|
496
|
+
.map(([ref, g]) => ({ ...g, ref }));
|
|
497
|
+
return { ok: true, tenant: t, grants };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult> {
|
|
501
|
+
const t = tenant.toLowerCase();
|
|
502
|
+
if (this.unconfigured.has(t)) return notConfigured();
|
|
503
|
+
if (!ref.startsWith(`mock-ref-${t}-`) || !this.grants.has(ref)) {
|
|
504
|
+
return { ok: false, status: 404, reason: "error", message: "no such grant" };
|
|
505
|
+
}
|
|
506
|
+
this.grants.delete(ref);
|
|
507
|
+
return { ok: true, tenant: t, ref };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// --- Factory ----------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Build the env-configured client. `resolveOwnerCredential` defaults to a
|
|
515
|
+
* fail-closed stub (see the KNOWN GAP note at the top of this file) — pass an
|
|
516
|
+
* override once a real owner-credential resolver exists.
|
|
517
|
+
*/
|
|
518
|
+
export async function createPrincipalGrantClient(
|
|
519
|
+
overrides: Partial<HttpPrincipalGrantClientConfig> = {},
|
|
520
|
+
): Promise<HttpPrincipalGrantClient> {
|
|
521
|
+
const baseUrl = overrides.baseUrl ?? (await readEnv("TOT_CORE_URL")) ?? "";
|
|
522
|
+
return new HttpPrincipalGrantClient({
|
|
523
|
+
baseUrl,
|
|
524
|
+
resolveOwnerCredential: overrides.resolveOwnerCredential ?? (async () => null),
|
|
525
|
+
...(overrides.transport ? { transport: overrides.transport } : {}),
|
|
526
|
+
});
|
|
527
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
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",
|