@tokenoftrust/storefront-runner 2.2.11 → 2.2.13
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-access-banner.js +53 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +15 -28
- package/apps/storefront/src/lib/http/mcpServiceCall.ts +85 -0
- package/apps/storefront/src/lib/shared/accessExplain.ts +20 -17
- package/apps/storefront/src/lib/shared/developerInvites.ts +44 -46
- package/apps/storefront/src/lib/shared/invitePreflight.ts +18 -31
- package/apps/storefront/src/lib/shared/viewerGrant.ts +11 -29
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +4 -3
- package/apps/storefront/{vitest.config.ts → vitest.config.cts} +9 -4
- package/package.json +1 -1
- package/packages/public-runtime/vitest.config.cts +13 -0
- package/packages/public-runtime/vitest.config.ts +0 -8
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The granter's "an invited developer can't use their access" banner, shared by the vendor
|
|
3
|
+
* dashboard home and the Team page.
|
|
4
|
+
*
|
|
5
|
+
* Served as a STATIC same-origin file (public/) and referenced via
|
|
6
|
+
* <script src="/js/dashboard-access-banner.js">, so the strict production CSP allows it via
|
|
7
|
+
* `script-src 'self'` (see the header of dashboard-team.js). The tenant appDomain is read
|
|
8
|
+
* from the `#access-alerts` element's `data-tenant` attribute; the element carries the
|
|
9
|
+
* `hidden` attribute until there is something to show. Any failure leaves it hidden.
|
|
10
|
+
*/
|
|
11
|
+
(function () {
|
|
12
|
+
const root = document.getElementById("access-alerts");
|
|
13
|
+
if (!root) return;
|
|
14
|
+
const tenant = root.dataset.tenant;
|
|
15
|
+
const teamHref = root.dataset.teamHref;
|
|
16
|
+
|
|
17
|
+
function render(broken) {
|
|
18
|
+
root.replaceChildren();
|
|
19
|
+
const n = broken.length;
|
|
20
|
+
const head = document.createElement("p");
|
|
21
|
+
head.className = "access-alerts-head";
|
|
22
|
+
head.textContent = n + (n === 1 ? " developer you invited can't" : " developers you invited can't") +
|
|
23
|
+
" use their access";
|
|
24
|
+
root.appendChild(head);
|
|
25
|
+
const tag = document.createElement("span");
|
|
26
|
+
tag.className = "advice";
|
|
27
|
+
tag.textContent = "tot-mcp advice";
|
|
28
|
+
root.appendChild(tag);
|
|
29
|
+
const list = document.createElement("ul");
|
|
30
|
+
for (const b of broken) {
|
|
31
|
+
const li = document.createElement("li");
|
|
32
|
+
li.textContent = b.developer + " — step: " + b.failedStep + ". " + b.reason + " Fix: " + b.fix;
|
|
33
|
+
list.appendChild(li);
|
|
34
|
+
}
|
|
35
|
+
root.appendChild(list);
|
|
36
|
+
if (teamHref) {
|
|
37
|
+
const a = document.createElement("a");
|
|
38
|
+
a.href = teamHref;
|
|
39
|
+
a.textContent = "Open the Team page →";
|
|
40
|
+
root.appendChild(a);
|
|
41
|
+
}
|
|
42
|
+
root.hidden = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fetch("/api/grants/team-access-alerts?tenant=" + encodeURIComponent(tenant), {
|
|
46
|
+
headers: { accept: "application/json" },
|
|
47
|
+
})
|
|
48
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
49
|
+
.then((body) => {
|
|
50
|
+
if (body && Array.isArray(body.broken) && body.broken.length) render(body.broken);
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {});
|
|
53
|
+
})();
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* transport / parse fault → { configured:true, ok:false } so a caller can distinguish
|
|
21
21
|
* "definitely not provisioned" (ok + provisioned:false) from "couldn't tell". Never throws.
|
|
22
22
|
*/
|
|
23
|
-
import {
|
|
23
|
+
import { callMcpService } from "@/lib/http/mcpServiceCall";
|
|
24
24
|
import { FetchHttpTransport, type HttpGetTransport } from "@/lib/http/fetchTransport";
|
|
25
25
|
|
|
26
26
|
export type { HttpGetTransport };
|
|
@@ -42,35 +42,22 @@ export async function readTenantPreviewStatus(
|
|
|
42
42
|
tenant: string | null,
|
|
43
43
|
deps: { transport?: HttpGetTransport } = {},
|
|
44
44
|
): Promise<TenantPreviewStatus> {
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return { configured: false, ok: false, reason: "bad-mcp-url" };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const transport = deps.transport ?? new FetchHttpTransport();
|
|
60
|
-
const full = `${endpoint}?tenant=${encodeURIComponent(tenant.toLowerCase())}`;
|
|
61
|
-
let res: { status: number; json(): Promise<unknown> };
|
|
62
|
-
try {
|
|
63
|
-
res = await transport.get(full, { "x-mcp-service-secret": secret });
|
|
64
|
-
} catch (cause) {
|
|
65
|
-
return {
|
|
66
|
-
configured: true,
|
|
67
|
-
ok: false,
|
|
68
|
-
reason: "mcp request failed: " + (cause instanceof Error ? cause.message : String(cause)),
|
|
69
|
-
};
|
|
45
|
+
if (!tenant) return { configured: false, ok: false };
|
|
46
|
+
const outcome = await callMcpService({
|
|
47
|
+
method: "GET",
|
|
48
|
+
path: "/internal/preview-status",
|
|
49
|
+
query: { tenant: tenant.toLowerCase() },
|
|
50
|
+
transport: deps.transport ?? new FetchHttpTransport(),
|
|
51
|
+
});
|
|
52
|
+
if (outcome.kind === "not-configured") return { configured: false, ok: false };
|
|
53
|
+
if (outcome.kind === "bad-url") return { configured: false, ok: false, reason: "bad-mcp-url" };
|
|
54
|
+
if (outcome.kind === "transport-error") {
|
|
55
|
+
return { configured: true, ok: false, reason: "mcp request failed: " + outcome.message };
|
|
70
56
|
}
|
|
71
|
-
if (
|
|
72
|
-
return { configured: true, ok: false, status:
|
|
57
|
+
if (outcome.kind === "misconfigured" || outcome.kind === "http-error") {
|
|
58
|
+
return { configured: true, ok: false, status: outcome.status, reason: `status ${outcome.status}` };
|
|
73
59
|
}
|
|
60
|
+
const res = outcome;
|
|
74
61
|
let payload: unknown;
|
|
75
62
|
try {
|
|
76
63
|
payload = await res.json();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one transport for storefront → tot-mcp service calls (`/internal/*`, keyless
|
|
3
|
+
* `x-mcp-service-secret` seam). Every caller builds its URL here from the ORIGIN of
|
|
4
|
+
* `MCP_APP_BINDING_URL` plus a literal contract path, so the paths and capabilities a caller
|
|
5
|
+
* sends are pinned by `contracts/tot-mcp-service-seam.json` (see `mcpServiceSeam.test.ts`).
|
|
6
|
+
*
|
|
7
|
+
* A 401 or 404 from a seam route is misconfiguration, never a business answer: tot-mcp's
|
|
8
|
+
* routes answer 200 for every absent/unknown case, so 401 means the secret or URL is wrong
|
|
9
|
+
* and 404 means the path is not served. Both log LOUDLY and come back as `misconfigured`
|
|
10
|
+
* (a non-skipped failure), while each caller keeps its own fail-open / fail-closed mapping
|
|
11
|
+
* for the outcome.
|
|
12
|
+
*/
|
|
13
|
+
import { readEnv } from "@/lib/env";
|
|
14
|
+
|
|
15
|
+
export interface McpServiceResponse {
|
|
16
|
+
status: number;
|
|
17
|
+
json(): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A transport implementing the method(s) the call uses; test stubs may implement only one. */
|
|
21
|
+
export interface McpServiceTransport {
|
|
22
|
+
get?(url: string, headers: Record<string, string>): Promise<McpServiceResponse>;
|
|
23
|
+
post?(url: string, headers: Record<string, string>, body: unknown): Promise<McpServiceResponse>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type McpServiceOutcome =
|
|
27
|
+
| { kind: "not-configured" }
|
|
28
|
+
| { kind: "bad-url" }
|
|
29
|
+
| { kind: "misconfigured"; status: 401 | 404; reason: string }
|
|
30
|
+
| { kind: "http-error"; status: number }
|
|
31
|
+
| { kind: "transport-error"; message: string }
|
|
32
|
+
| { kind: "ok"; status: number; json(): Promise<unknown> };
|
|
33
|
+
|
|
34
|
+
export interface McpServiceRequest {
|
|
35
|
+
method: "GET" | "POST";
|
|
36
|
+
/** A literal `/internal/...` contract path. */
|
|
37
|
+
path: string;
|
|
38
|
+
query?: Record<string, string>;
|
|
39
|
+
body?: unknown;
|
|
40
|
+
transport: McpServiceTransport;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function misconfigurationReason(status: 401 | 404): string {
|
|
44
|
+
return status === 401
|
|
45
|
+
? "tot-mcp rejected the service secret, or MCP_APP_BINDING_URL points at a session-guarded surface"
|
|
46
|
+
: "tot-mcp does not serve this path; storefront and tot-mcp disagree on the service-seam contract";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function callMcpService(req: McpServiceRequest): Promise<McpServiceOutcome> {
|
|
50
|
+
const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
|
|
51
|
+
const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
|
|
52
|
+
if (!bindingUrl || !secret) return { kind: "not-configured" };
|
|
53
|
+
|
|
54
|
+
let url: string;
|
|
55
|
+
try {
|
|
56
|
+
url = `${new URL(bindingUrl).origin}${req.path}`;
|
|
57
|
+
} catch {
|
|
58
|
+
return { kind: "bad-url" };
|
|
59
|
+
}
|
|
60
|
+
if (req.query) url += `?${new URLSearchParams(req.query).toString()}`;
|
|
61
|
+
|
|
62
|
+
const headers = { "x-mcp-service-secret": secret };
|
|
63
|
+
let res: McpServiceResponse;
|
|
64
|
+
try {
|
|
65
|
+
if (req.method === "GET") {
|
|
66
|
+
if (!req.transport.get) throw new Error("transport has no get()");
|
|
67
|
+
res = await req.transport.get(url, headers);
|
|
68
|
+
} else {
|
|
69
|
+
if (!req.transport.post) throw new Error("transport has no post()");
|
|
70
|
+
res = await req.transport.post(url, headers, req.body);
|
|
71
|
+
}
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
return { kind: "transport-error", message: cause instanceof Error ? cause.message : String(cause) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (res.status === 401 || res.status === 404) {
|
|
77
|
+
const reason = misconfigurationReason(res.status);
|
|
78
|
+
console.error(
|
|
79
|
+
`[mcp-service-seam] ${req.method} ${req.path} answered ${res.status}: ${reason}`,
|
|
80
|
+
);
|
|
81
|
+
return { kind: "misconfigured", status: res.status, reason };
|
|
82
|
+
}
|
|
83
|
+
if (res.status < 200 || res.status >= 300) return { kind: "http-error", status: res.status };
|
|
84
|
+
return { kind: "ok", status: res.status, json: () => res.json() };
|
|
85
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Nothing here is ever reported as verified unless tot-mcp said so: an unconfigured seam,
|
|
18
18
|
* a non-200 or a timeout is `unavailable`, which the UI shows as unverified.
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { callMcpService } from "@/lib/http/mcpServiceCall";
|
|
21
21
|
|
|
22
22
|
const EXPLAIN_TIMEOUT_MS = 8000;
|
|
23
23
|
|
|
@@ -120,27 +120,30 @@ export async function explainDeveloperAccess(
|
|
|
120
120
|
inviteStatus: "pending" | "accepted",
|
|
121
121
|
deps: { transport?: ExplainTransport } = {},
|
|
122
122
|
): Promise<AccessVerification> {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
const outcome = await callMcpService({
|
|
124
|
+
method: "POST",
|
|
125
|
+
path: "/internal/dev-access/explain",
|
|
126
|
+
body: { person: email.trim().toLowerCase(), tenant: tenant.trim().toLowerCase() },
|
|
127
|
+
transport: deps.transport ?? new FetchExplainTransport(),
|
|
128
|
+
});
|
|
129
|
+
if (outcome.kind === "not-configured") {
|
|
126
130
|
return { state: "unavailable", reason: "verification is not configured here", advice: "tot-mcp" };
|
|
127
131
|
}
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
endpoint = `${new URL(bindingUrl).origin}/internal/dev-access/explain`;
|
|
131
|
-
} catch {
|
|
132
|
+
if (outcome.kind === "bad-url") {
|
|
132
133
|
return { state: "unavailable", reason: "the tot-mcp address is malformed", advice: "tot-mcp" };
|
|
133
134
|
}
|
|
134
|
-
|
|
135
|
+
if (outcome.kind === "transport-error") {
|
|
136
|
+
return {
|
|
137
|
+
state: "unavailable",
|
|
138
|
+
reason: "tot-mcp did not answer: " + outcome.message,
|
|
139
|
+
advice: "tot-mcp",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (outcome.kind === "misconfigured" || outcome.kind === "http-error") {
|
|
143
|
+
return { state: "unavailable", reason: `tot-mcp answered ${outcome.status}`, advice: "tot-mcp" };
|
|
144
|
+
}
|
|
145
|
+
const res = outcome;
|
|
135
146
|
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
147
|
const explanation = (await res.json()) as AccessExplanation;
|
|
145
148
|
if (typeof explanation?.verified !== "boolean" || !Array.isArray(explanation.steps)) {
|
|
146
149
|
return { state: "unavailable", reason: "tot-mcp sent an unreadable answer", advice: "tot-mcp" };
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
* control-plane cluster stays self-contained (see `.dependency-cruiser.cjs`).
|
|
54
54
|
*/
|
|
55
55
|
import { readEnv } from "@/lib/env";
|
|
56
|
+
import { callMcpService } from "@/lib/http/mcpServiceCall";
|
|
56
57
|
import { diagEmail } from "@/lib/diag/redact";
|
|
57
58
|
import { checkStorePreviewReady, type PreflightDecision } from "@/lib/shared/invitePreflight";
|
|
58
59
|
import { explainDeveloperAccess, type AccessVerification } from "@/lib/shared/accessExplain";
|
|
@@ -1028,8 +1029,7 @@ export interface DeveloperAppBinding {
|
|
|
1028
1029
|
/** Best-effort outcome of the app-binding write (never fails the invite). */
|
|
1029
1030
|
export type AppBindingResult =
|
|
1030
1031
|
| { recorded: true }
|
|
1031
|
-
// Skipped
|
|
1032
|
-
// unconfigured OR the MCP route isn't mounted yet (401). Not an error.
|
|
1032
|
+
// Skipped: the write was not attempted because the seam is unconfigured. Not an error.
|
|
1033
1033
|
| { recorded: false; skipped: true; reason: string }
|
|
1034
1034
|
// Attempted but failed for a real reason (bad body / store fault / transport).
|
|
1035
1035
|
| { recorded: false; skipped: false; reason: string };
|
|
@@ -1039,17 +1039,16 @@ export type AppBindingResult =
|
|
|
1039
1039
|
* MCP store is the app-binding source of truth; the writer is this keyless flow.
|
|
1040
1040
|
*
|
|
1041
1041
|
* WIRED (confirmed contract):
|
|
1042
|
-
* POST `${MCP_APP_BINDING_URL}`
|
|
1043
|
-
* e.g. https://mcp.qa.tokenoftrust.com/internal/dev-access/subject-capability
|
|
1042
|
+
* POST `${origin(MCP_APP_BINDING_URL)}/internal/dev-access/subject-capability` — the keyless
|
|
1043
|
+
* subject-capability writer, e.g. https://mcp.qa.tokenoftrust.com/internal/dev-access/subject-capability
|
|
1044
1044
|
* Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (compared
|
|
1045
1045
|
* constant-time to the MCP's shared secret) — NOT an Authorization bearer.
|
|
1046
1046
|
* Body: { subject:<email>, tenant, capability:'preview-access', app:'storefront' }
|
|
1047
1047
|
* (capability/app are optional MCP defaults but sent explicitly). Idempotent.
|
|
1048
1048
|
* → 200 { status:'recorded', ... } | 401 bad/missing secret | 400 bad body | 502 store.
|
|
1049
1049
|
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
* `{ skipped: true }`, not a hard failure. A 400/502/transport fault is a real
|
|
1050
|
+
* An unconfigured seam (no URL/secret) is `{ skipped: true }`. A 401 (wrong secret, or a
|
|
1051
|
+
* URL under the MCP's session-guarded /api surface), 400, 502 or transport fault is a real
|
|
1053
1052
|
* (non-skipped) failure the caller surfaces as partial success.
|
|
1054
1053
|
*
|
|
1055
1054
|
* BEST-EFFORT by contract: this NEVER throws and NEVER blocks the invite. The
|
|
@@ -1059,48 +1058,47 @@ export async function recordDeveloperAppBinding(
|
|
|
1059
1058
|
binding: DeveloperAppBinding,
|
|
1060
1059
|
deps: { transport?: HttpTransport } = {},
|
|
1061
1060
|
): Promise<AppBindingResult> {
|
|
1062
|
-
const
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
//
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
//
|
|
1061
|
+
const outcome = await callMcpService({
|
|
1062
|
+
method: "POST",
|
|
1063
|
+
path: "/internal/dev-access/subject-capability",
|
|
1064
|
+
// Option B fixed fields — the subject-capability binding for storefront.
|
|
1065
|
+
body: {
|
|
1066
|
+
subject: binding.email.toLowerCase(),
|
|
1067
|
+
tenant: binding.tenant,
|
|
1068
|
+
capability: binding.capability ?? "preview-access",
|
|
1069
|
+
app: "storefront",
|
|
1070
|
+
},
|
|
1071
|
+
transport: deps.transport ?? new FetchHttpTransport(),
|
|
1072
|
+
});
|
|
1073
|
+
switch (outcome.kind) {
|
|
1074
|
+
case "ok":
|
|
1075
|
+
return { recorded: true };
|
|
1076
|
+
case "not-configured":
|
|
1077
|
+
// Seam not fully configured (the MCP route isn't mounted until the shared secret
|
|
1078
|
+
// exists). Skip cleanly rather than fail the invite.
|
|
1079
|
+
return { recorded: false, skipped: true, reason: "not-configured" };
|
|
1080
|
+
case "bad-url":
|
|
1081
|
+
return { recorded: false, skipped: false, reason: "mcp app-binding url is malformed" };
|
|
1082
|
+
case "misconfigured":
|
|
1083
|
+
// A route answered and refused (401), or does not exist (404): misconfiguration, so a
|
|
1084
|
+
// failure, never a skip.
|
|
1085
1085
|
return {
|
|
1086
1086
|
recorded: false,
|
|
1087
|
-
skipped:
|
|
1088
|
-
reason:
|
|
1087
|
+
skipped: false,
|
|
1088
|
+
reason: `${outcome.status === 401 ? "unauthorized" : "not found"}: ${outcome.reason}`,
|
|
1089
|
+
};
|
|
1090
|
+
case "http-error":
|
|
1091
|
+
return {
|
|
1092
|
+
recorded: false,
|
|
1093
|
+
skipped: false,
|
|
1094
|
+
reason: `mcp app-binding write returned status ${outcome.status}`,
|
|
1095
|
+
};
|
|
1096
|
+
case "transport-error":
|
|
1097
|
+
return {
|
|
1098
|
+
recorded: false,
|
|
1099
|
+
skipped: false,
|
|
1100
|
+
reason: "mcp app-binding write failed: " + outcome.message,
|
|
1089
1101
|
};
|
|
1090
|
-
}
|
|
1091
|
-
return {
|
|
1092
|
-
recorded: false,
|
|
1093
|
-
skipped: false,
|
|
1094
|
-
reason: `mcp app-binding write returned status ${res.status}`,
|
|
1095
|
-
};
|
|
1096
|
-
} catch (cause) {
|
|
1097
|
-
return {
|
|
1098
|
-
recorded: false,
|
|
1099
|
-
skipped: false,
|
|
1100
|
-
reason:
|
|
1101
|
-
"mcp app-binding write failed: " +
|
|
1102
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
1103
|
-
};
|
|
1104
1102
|
}
|
|
1105
1103
|
}
|
|
1106
1104
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* mirroring recordDeveloperAppBinding's "skipped when not-configured" stance — we
|
|
23
23
|
* never break invites just because the MCP is briefly unreachable.
|
|
24
24
|
*/
|
|
25
|
-
import {
|
|
25
|
+
import { callMcpService } from "@/lib/http/mcpServiceCall";
|
|
26
26
|
|
|
27
27
|
/** How long we'll wait on the preview-status read before failing open. */
|
|
28
28
|
const PREFLIGHT_TIMEOUT_MS = 4000;
|
|
@@ -71,39 +71,26 @@ export async function checkStorePreviewReady(
|
|
|
71
71
|
tenant: string,
|
|
72
72
|
deps: { transport?: PreflightTransport } = {},
|
|
73
73
|
): Promise<PreflightDecision> {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
const outcome = await callMcpService({
|
|
75
|
+
method: "GET",
|
|
76
|
+
path: "/internal/preview-status",
|
|
77
|
+
query: { tenant: tenant.toLowerCase(), app: "storefront", tag: "main" },
|
|
78
|
+
transport: deps.transport ?? new FetchPreflightTransport(),
|
|
79
|
+
});
|
|
80
|
+
// Only a clean 200 is a conclusive answer; every other outcome is inconclusive → allow
|
|
81
|
+
// (a 401/404 is logged loudly by the seam helper as misconfiguration).
|
|
82
|
+
if (outcome.kind === "not-configured") return { block: false, reason: "not-configured" };
|
|
83
|
+
if (outcome.kind === "bad-url") return { block: false, reason: "bad-mcp-url" };
|
|
84
|
+
if (outcome.kind === "transport-error") {
|
|
85
|
+
return { block: false, reason: "preflight request failed: " + outcome.message };
|
|
80
86
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
const origin = new URL(bindingUrl).origin;
|
|
85
|
-
const q = new URLSearchParams({ tenant: tenant.toLowerCase(), app: "storefront", tag: "main" });
|
|
86
|
-
// Canonical, namespace-neutral path (the read is generic, not dev-role-scoped).
|
|
87
|
-
endpoint = `${origin}/internal/preview-status?${q.toString()}`;
|
|
88
|
-
} catch {
|
|
89
|
-
return { block: false, reason: "bad-mcp-url" };
|
|
87
|
+
if (outcome.kind === "misconfigured") {
|
|
88
|
+
return { block: false, reason: `preflight misconfigured: status ${outcome.status}` };
|
|
90
89
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let res: { status: number; json(): Promise<unknown> };
|
|
94
|
-
try {
|
|
95
|
-
res = await transport.get(endpoint, { "x-mcp-service-secret": secret });
|
|
96
|
-
} catch (cause) {
|
|
97
|
-
return {
|
|
98
|
-
block: false,
|
|
99
|
-
reason: "preflight request failed: " + (cause instanceof Error ? cause.message : String(cause)),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Only a clean 200 is a conclusive answer; anything else is inconclusive → allow.
|
|
104
|
-
if (res.status < 200 || res.status >= 300) {
|
|
105
|
-
return { block: false, reason: `preflight returned status ${res.status}` };
|
|
90
|
+
if (outcome.kind === "http-error") {
|
|
91
|
+
return { block: false, reason: `preflight returned status ${outcome.status}` };
|
|
106
92
|
}
|
|
93
|
+
const res = outcome;
|
|
107
94
|
let payload: { provisioned?: unknown; previewReleased?: unknown };
|
|
108
95
|
try {
|
|
109
96
|
payload = (await res.json()) as typeof payload;
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* app-binding WRITE (`recordDeveloperAppBinding`) targets, so this READ and that
|
|
31
31
|
* WRITE always agree on which subject-capability store they speak to.
|
|
32
32
|
*/
|
|
33
|
-
import {
|
|
33
|
+
import { callMcpService } from "@/lib/http/mcpServiceCall";
|
|
34
34
|
import type { Capability } from "./capability.js";
|
|
35
35
|
|
|
36
36
|
/** Minimal GET transport (mirrors `dev/previewStatus.ts` — decoupled by design). */
|
|
@@ -58,9 +58,6 @@ export interface ViewerShipGrantDeps {
|
|
|
58
58
|
transport?: ViewerShipGrantGetTransport;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** The Step-1 subject-capability reader path (mounted OUTSIDE the session guard). */
|
|
62
|
-
const SUBJECT_CAPABILITY_PATH = "/internal/dev-access/subject-capability";
|
|
63
|
-
|
|
64
61
|
/** The viewer's blog-contribute capability — a NEW, blog-scoped projection kept OUT
|
|
65
62
|
* of the forge `Capability` union (which stays exactly two rungs). `ship-on-behalf`
|
|
66
63
|
* supersets contribute, so a ship grant also yields `"blog-contribute"` here. */
|
|
@@ -87,31 +84,16 @@ async function resolveRawViewerCapability(
|
|
|
87
84
|
const tenant = (appDomain ?? "").trim();
|
|
88
85
|
if (!subject || !tenant) return "preview-access";
|
|
89
86
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
const full =
|
|
102
|
-
`${endpoint}?subject=${encodeURIComponent(subject)}` +
|
|
103
|
-
`&tenant=${encodeURIComponent(tenant)}&app=storefront`;
|
|
104
|
-
|
|
105
|
-
const transport = deps.transport ?? new FetchShipGrantTransport();
|
|
106
|
-
let res: { status: number; json(): Promise<unknown> };
|
|
107
|
-
try {
|
|
108
|
-
res = await transport.get(full, { "x-mcp-service-secret": secret });
|
|
109
|
-
} catch {
|
|
110
|
-
// Transport / timeout fault — fail closed.
|
|
111
|
-
return "preview-access";
|
|
112
|
-
}
|
|
113
|
-
// Any non-2xx (incl. 502 store fault, 401 unmounted, 404) → deny.
|
|
114
|
-
if (res.status < 200 || res.status >= 300) return "preview-access";
|
|
87
|
+
const outcome = await callMcpService({
|
|
88
|
+
method: "GET",
|
|
89
|
+
path: "/internal/dev-access/subject-capability",
|
|
90
|
+
query: { subject, tenant, app: "storefront" },
|
|
91
|
+
transport: deps.transport ?? new FetchShipGrantTransport(),
|
|
92
|
+
});
|
|
93
|
+
// Unconfigured, misconfigured (401/404, logged loudly), any other non-2xx (incl. 502
|
|
94
|
+
// store fault) or a transport/timeout fault → deny.
|
|
95
|
+
if (outcome.kind !== "ok") return "preview-access";
|
|
96
|
+
const res = outcome;
|
|
115
97
|
|
|
116
98
|
let payload: unknown;
|
|
117
99
|
try {
|
|
@@ -159,9 +159,10 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
159
159
|
});
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
// The granter left at Send invite; re-prove the grant now that the invitee accepted
|
|
163
|
-
//
|
|
164
|
-
//
|
|
162
|
+
// The granter left at Send invite; re-prove the grant now that the invitee accepted. The
|
|
163
|
+
// granter is told by the red banner on their dashboard home and Team page
|
|
164
|
+
// (/api/grants/team-access-alerts re-runs this same check per invitee); this warn line is
|
|
165
|
+
// the log trace. Best-effort: never blocks the sign-in.
|
|
165
166
|
try {
|
|
166
167
|
const verification = await explainDeveloperAccess(tenant, derived.email, "accepted");
|
|
167
168
|
if (verification.state === "broken") {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// .cts so Vite compiles this in memory. An ESM config writes a sibling file, which the read-only
|
|
2
|
+
// verification workspace rejects. The results cache is off: that workspace cannot create
|
|
3
|
+
// node_modules/.vite/vitest.
|
|
4
|
+
process.env.VITE_CJS_IGNORE_WARNING = "1";
|
|
5
|
+
const { defineConfig } = require("vitest/config");
|
|
6
|
+
const path = require("node:path");
|
|
3
7
|
|
|
4
8
|
// Storefront unit tests — pure TS logic (tenant routing, search, query engine,
|
|
5
9
|
// image-attr resolution, edge cache, etc.). We deliberately do NOT render .astro
|
|
@@ -7,12 +11,12 @@ import { fileURLToPath } from "node:url";
|
|
|
7
11
|
// vite-plugin-head crashes under vitest@2 + astro@7 (configureServer reads an
|
|
8
12
|
// undefined `ssr`). Render *logic* is extracted into testable modules instead
|
|
9
13
|
// (e.g. lib/imageAttrs.ts), and rendered output is verified on a live preview.
|
|
10
|
-
|
|
14
|
+
module.exports = defineConfig({
|
|
11
15
|
// Mirror the `@` -> src alias from tsconfig so tests can import aliased modules
|
|
12
16
|
// (e.g. src/config/tenants.ts, which pulls the theme registry).
|
|
13
17
|
resolve: {
|
|
14
18
|
alias: {
|
|
15
|
-
"@":
|
|
19
|
+
"@": path.join(__dirname, "src"),
|
|
16
20
|
// `astro:middleware` is a virtual id Astro's own Vite plugin maps onto this
|
|
17
21
|
// real module. Mapping it the same way lets src/middleware/index.ts — the
|
|
18
22
|
// branch-to-handler dispatch — be invoked in a unit test instead of only
|
|
@@ -26,5 +30,6 @@ export default defineConfig({
|
|
|
26
30
|
// *.spec.ts matrix runs separately via playwright.a11y.config.ts).
|
|
27
31
|
include: ["src/**/*.test.ts", "e2e/a11y/**/*.test.ts"],
|
|
28
32
|
environment: "node",
|
|
33
|
+
cache: false,
|
|
29
34
|
},
|
|
30
35
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.13",
|
|
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,13 @@
|
|
|
1
|
+
// .cts so Vite compiles this in memory. An ESM config writes a sibling file, which the read-only
|
|
2
|
+
// verification workspace rejects. The results cache is off: that workspace cannot create
|
|
3
|
+
// node_modules/.vite/vitest.
|
|
4
|
+
process.env.VITE_CJS_IGNORE_WARNING = "1";
|
|
5
|
+
const { defineConfig } = require("vitest/config");
|
|
6
|
+
|
|
7
|
+
module.exports = defineConfig({
|
|
8
|
+
test: {
|
|
9
|
+
include: ["tests/**/*.test.ts"],
|
|
10
|
+
environment: "node",
|
|
11
|
+
cache: false,
|
|
12
|
+
},
|
|
13
|
+
});
|