@tokenoftrust/cli 4.7.1 → 4.7.3
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/package.json +1 -1
- package/src/viewer-session.mjs +130 -36
- package/src/visual-parity.mjs +2 -1
- package/template/sample-store/content/chrome.html +2 -2
- package/template/sample-store/content/pages/contact-us.json +22 -0
- package/template/sample-store/content/pages-html/pages/contact-us.html +0 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.3",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/viewer-session.mjs
CHANGED
|
@@ -54,6 +54,29 @@ function originOf(url) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The hosted storefront Worker's own public origins (`[env.preview]`). Only a
|
|
58
|
+
* storefront called at one of these EXACT origins may steer the CLI to present a
|
|
59
|
+
* session for a different MCP issuer: anything reached through `--url` /
|
|
60
|
+
* `TOT_STOREFRONT_URL` is user input, and a hostile one could otherwise name the
|
|
61
|
+
* prod issuer and collect that session. */
|
|
62
|
+
export const FIRST_PARTY_STOREFRONT_ORIGINS = Object.freeze(new Set([
|
|
63
|
+
DEFAULT_STOREFRONT_ORIGIN,
|
|
64
|
+
"https://storefront.tokenoftrust.workers.dev",
|
|
65
|
+
]));
|
|
66
|
+
|
|
67
|
+
/** The Token of Trust MCP issuers a first-party storefront may name. Exact origins. */
|
|
68
|
+
export const TOKEN_OF_TRUST_MCP_ORIGINS = Object.freeze(new Set([
|
|
69
|
+
"https://mcp.tokenoftrust.com",
|
|
70
|
+
"https://mcp.qa.tokenoftrust.com",
|
|
71
|
+
]));
|
|
72
|
+
|
|
73
|
+
/** True only when BOTH sides are declared first-party, by exact origin. */
|
|
74
|
+
function mayPresentSessionFor(storefrontBase, issuer) {
|
|
75
|
+
const storefront = originOf(storefrontBase);
|
|
76
|
+
return Boolean(storefront && issuer &&
|
|
77
|
+
FIRST_PARTY_STOREFRONT_ORIGINS.has(storefront) && TOKEN_OF_TRUST_MCP_ORIGINS.has(issuer));
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
/**
|
|
58
81
|
* Turn a non-2xx `/api/dev/cli-session` response into one clean, actionable
|
|
59
82
|
* `{ message, hint }` — the ONE place this CLI interprets that endpoint's refusal
|
|
@@ -91,9 +114,13 @@ export function describeCliSessionRefusal(status, body, signedInMcpUrl) {
|
|
|
91
114
|
if (error === "unresolved_token" && trustedMcp) {
|
|
92
115
|
const signedInOrigin = originOf(signedInMcpUrl);
|
|
93
116
|
if (signedInOrigin && signedInOrigin !== trustedMcp) {
|
|
117
|
+
// `TOT_SELECTED_MCP_URL=… tot login` adds a session for the storefront's MCP
|
|
118
|
+
// WITHOUT rebinding this checkout (`tot login --mcp` inside a checkout would
|
|
119
|
+
// move every command there to the other tier). Once that session exists,
|
|
120
|
+
// `resolveViewerTransport` uses it for the hosted storefront on its own.
|
|
94
121
|
return {
|
|
95
|
-
message: `you're signed in to ${signedInOrigin}, but
|
|
96
|
-
hint: `
|
|
122
|
+
message: `you're signed in to ${signedInOrigin}, but the hosted storefront trusts ${trustedMcp}.`,
|
|
123
|
+
hint: `sign in to it once with \`TOT_SELECTED_MCP_URL=${trustedMcp} tot login\` (this checkout stays on ${signedInOrigin}), then re-run.`,
|
|
97
124
|
};
|
|
98
125
|
}
|
|
99
126
|
}
|
|
@@ -150,42 +177,48 @@ export async function resolveViewerTransport({
|
|
|
150
177
|
}
|
|
151
178
|
|
|
152
179
|
const base = tenantBase(tenant, storefrontUrl);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
+
const first = await mintSession(base, dev.token, fetchImpl, vlog);
|
|
181
|
+
if (!first.ok) return first.refusal;
|
|
182
|
+
/** @type {any} */
|
|
183
|
+
let res = first.response;
|
|
184
|
+
|
|
185
|
+
// The hosted storefront trusts ONE MCP issuer, independent of the tier this
|
|
186
|
+
// checkout is bound to (a QA-registry store is still served by the shared
|
|
187
|
+
// storefront). When it names a different issuer, the session to present is the
|
|
188
|
+
// one for THAT issuer — the checkout's own binding is for the registry, not for
|
|
189
|
+
// this transport. Use it when the developer holds one; otherwise fall through
|
|
190
|
+
// to the refusal, which says how to get one without rebinding the checkout.
|
|
191
|
+
if (res.status === 401) {
|
|
192
|
+
const body = await readJson(res);
|
|
193
|
+
const trustedMcp = typeof body?.trustedMcp === "string" ? body.trustedMcp : null;
|
|
194
|
+
const signedInOrigin = originOf(dev.mcpUrl);
|
|
195
|
+
if (
|
|
196
|
+
body?.error === "unresolved_token" &&
|
|
197
|
+
mayPresentSessionFor(base, trustedMcp) &&
|
|
198
|
+
signedInOrigin && signedInOrigin !== trustedMcp
|
|
199
|
+
) {
|
|
200
|
+
let trustedDev = null;
|
|
201
|
+
try {
|
|
202
|
+
trustedDev = await resolveDev(null, { ...env, TOT_SELECTED_MCP_URL: trustedMcp }, { fetchImpl });
|
|
203
|
+
} catch {
|
|
204
|
+
/* no usable session for the storefront's issuer — report the mismatch below */
|
|
205
|
+
}
|
|
206
|
+
if (trustedDev?.token && originOf(trustedDev.mcpUrl) === trustedMcp) {
|
|
207
|
+
vlog(`cli-session: retrying with your ${trustedMcp} session`);
|
|
208
|
+
const retry = await mintSession(base, trustedDev.token, fetchImpl, vlog);
|
|
209
|
+
if (!retry.ok) return retry.refusal;
|
|
210
|
+
res = retry.response;
|
|
211
|
+
dev = trustedDev;
|
|
212
|
+
} else {
|
|
213
|
+
res = replayJson(res, body);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
res = replayJson(res, body);
|
|
217
|
+
}
|
|
180
218
|
}
|
|
181
|
-
|
|
219
|
+
|
|
182
220
|
if (res.status === 401 || res.status === 502 || res.status === 503) {
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
body = await res.json();
|
|
186
|
-
} catch {
|
|
187
|
-
/* non-JSON error body — describeCliSessionRefusal falls back cleanly */
|
|
188
|
-
}
|
|
221
|
+
const body = await readJson(res);
|
|
189
222
|
if (body?.error || body?.code) vlog(`cli-session error: ${[body.error, body.code].filter(Boolean).join(" / ")}`);
|
|
190
223
|
const { message, hint } = describeCliSessionRefusal(res.status, body, dev.mcpUrl);
|
|
191
224
|
return { ok: false, message, hint };
|
|
@@ -218,3 +251,64 @@ export async function resolveViewerTransport({
|
|
|
218
251
|
},
|
|
219
252
|
};
|
|
220
253
|
}
|
|
254
|
+
|
|
255
|
+
/** The refusal body, or null when the body is not JSON (describeCliSessionRefusal
|
|
256
|
+
* falls back cleanly). Any other failure — a body already consumed, a stream
|
|
257
|
+
* error — is a bug or a fault, not "no body", and propagates.
|
|
258
|
+
* @param {{ json: () => Promise<any> }} res */
|
|
259
|
+
async function readJson(res) {
|
|
260
|
+
try {
|
|
261
|
+
return await res.json();
|
|
262
|
+
} catch (e) {
|
|
263
|
+
if (!(e instanceof SyntaxError)) throw e;
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A response whose body was already read, re-readable once more. */
|
|
269
|
+
function replayJson(res, body) {
|
|
270
|
+
return { ok: res.ok, status: res.status, headers: res.headers, json: async () => body };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* POST the bearer to `<base>/api/dev/cli-session`.
|
|
275
|
+
* @returns {Promise<{ ok: true, response: any } | { ok: false, refusal: { ok: false, message: string, hint: string } }>}
|
|
276
|
+
*/
|
|
277
|
+
async function mintSession(base, token, fetchImpl, vlog) {
|
|
278
|
+
let res;
|
|
279
|
+
try {
|
|
280
|
+
// `content-type: application/json` is REQUIRED, not decoration. Astro's origin
|
|
281
|
+
// check rejects any unsafe-method request that is cross-origin AND either
|
|
282
|
+
// form-like (`application/x-www-form-urlencoded`, `multipart/form-data`,
|
|
283
|
+
// `text/plain`) or carries no content-type at all. Node's `fetch` sends no
|
|
284
|
+
// `Origin`, so every request from here is "cross-origin" — declaring JSON is what
|
|
285
|
+
// takes it out of the form-POST class the check guards. It is an honest
|
|
286
|
+
// declaration, not a bypass: a browser cannot send `application/json`
|
|
287
|
+
// cross-origin without a CORS preflight this worker never answers, so the
|
|
288
|
+
// anti-CSRF property is preserved exactly. Spoofing an `Origin` header instead
|
|
289
|
+
// would assert a browser context that does not exist and protect nothing.
|
|
290
|
+
vlog(`POST ${base}/api/dev/cli-session`);
|
|
291
|
+
res = await fetchImpl(`${base}/api/dev/cli-session`, {
|
|
292
|
+
method: "POST",
|
|
293
|
+
headers: {
|
|
294
|
+
authorization: `Bearer ${token}`,
|
|
295
|
+
"content-type": "application/json",
|
|
296
|
+
},
|
|
297
|
+
body: "{}",
|
|
298
|
+
// Never follow a redirect with the bearer attached: a 3xx is a refusal here
|
|
299
|
+
// (non-2xx), so the token only ever reaches the origin it was addressed to.
|
|
300
|
+
redirect: "manual",
|
|
301
|
+
});
|
|
302
|
+
} catch (e) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
refusal: {
|
|
306
|
+
ok: false,
|
|
307
|
+
message: `couldn't reach ${base} to start a session: ${e?.message || e}`,
|
|
308
|
+
hint: "check the --tenant domain / your network, then re-run.",
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
vlog(`cli-session → HTTP ${res.status}`);
|
|
313
|
+
return { ok: true, response: res };
|
|
314
|
+
}
|
package/src/visual-parity.mjs
CHANGED
|
@@ -255,8 +255,9 @@ function pageShape(value, file) {
|
|
|
255
255
|
case "definitions": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !item || !string(item.term) || !rich(item.description))) findings.push(finding("parity-section-shape", at, "definitions require string terms and RichText descriptions", "supply valid definition items")); break;
|
|
256
256
|
case "callout": if (!rich(section.body) || !["info", "warning", "legal", "success"].includes(section.tone) || section.title !== undefined && !string(section.title)) findings.push(finding("parity-section-shape", at, "callout requires supported tone, RichText body, and optional string title", "supply a valid callout")); break;
|
|
257
257
|
case "contact": if (section.heading !== undefined && !string(section.heading) || section.email !== undefined && !string(section.email) || section.phone !== undefined && !string(section.phone) || section.address !== undefined && (!Array.isArray(section.address) || section.address.some((line) => !string(line))) || !string(section.email) && !string(section.phone) && !(Array.isArray(section.address) && section.address.length)) findings.push(finding("parity-section-shape", at, "contact requires string members and email, phone, or address", "supply valid contact details")); break;
|
|
258
|
+
case "contact_form": if (["heading", "messageLabel", "submitLabel", "successMessage"].some((key) => section[key] !== undefined && !string(section[key])) || section.fields !== undefined && (!Array.isArray(section.fields) || section.fields.some((field) => field !== "name" && field !== "phone"))) findings.push(finding("parity-section-shape", at, "contact_form requires optional string copy and fields limited to name or phone", "supply a valid contact_form section")); break;
|
|
258
259
|
case "needs_review": if (!string(section.reason) || section.severity !== undefined && !["blocker", "note"].includes(section.severity) || section.owner !== undefined && !string(section.owner) || section.href !== undefined && !string(section.href)) findings.push(finding("parity-section-shape", at, "needs_review requires reason and valid optional severity/owner/href", "supply a valid needs_review section")); break;
|
|
259
|
-
default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, or needs_review"));
|
|
260
|
+
default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, contact_form, or needs_review"));
|
|
260
261
|
}
|
|
261
262
|
});
|
|
262
263
|
const bodyHtml = typeof value.body_html === "string" && value.body_html.trim() !== "";
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
<ul>
|
|
29
29
|
<li><a href="/blogs/news">News</a></li>
|
|
30
30
|
<li><a href="/pages/about-us">About</a></li>
|
|
31
|
-
<li><a href="/
|
|
31
|
+
<li><a href="/contact-us">Contact</a></li>
|
|
32
32
|
</ul>
|
|
33
33
|
<div class="utility-nav__right">
|
|
34
34
|
<span class="sel">English</span>
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
<ul>
|
|
99
99
|
<li><a href="/search">Search</a></li>
|
|
100
100
|
<li><a href="/pages/about-us">About Us</a></li>
|
|
101
|
-
<li><a href="/
|
|
101
|
+
<li><a href="/contact-us">Contact Us</a></li>
|
|
102
102
|
<li><a href="/blogs/news">Blog</a></li>
|
|
103
103
|
</ul>
|
|
104
104
|
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "contact-us",
|
|
3
|
+
"title": "Contact Sample Vapor Co.",
|
|
4
|
+
"seoTitle": "Contact | Sample Vapor Co.",
|
|
5
|
+
"seoDescription": "Sample contact page for the fictional Sample Vapor Co. demo store.",
|
|
6
|
+
"eyebrow": "Sample content",
|
|
7
|
+
"intro": "This is a placeholder contact page for a fictional demo store.",
|
|
8
|
+
"isPolicy": false,
|
|
9
|
+
"sections": [
|
|
10
|
+
{ "kind": "heading", "text": "Support hours" },
|
|
11
|
+
{ "kind": "paragraph", "text": "Sample copy: set your real support hours here." },
|
|
12
|
+
{ "kind": "heading", "text": "Customer support" },
|
|
13
|
+
{ "kind": "paragraph", "text": "Sample copy: describe how customers reach you." },
|
|
14
|
+
{
|
|
15
|
+
"kind": "contact_form",
|
|
16
|
+
"heading": "Send us a message",
|
|
17
|
+
"fields": ["name", "phone"],
|
|
18
|
+
"messageLabel": "How can we help?",
|
|
19
|
+
"submitLabel": "Send message"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
<head>
|
|
2
|
-
<title>Contact | Sample Vapor Co.</title>
|
|
3
|
-
<meta name="description" content="Sample contact page for the fictional Sample Vapor Co. demo store.">
|
|
4
|
-
<link rel="canonical" href="https://sample-store.example/pages/contact-us">
|
|
5
|
-
<meta name="robots" content="noindex,nofollow">
|
|
6
|
-
<link rel="stylesheet" href="/tenants/sample-store.example/pages/page.css">
|
|
7
|
-
</head>
|
|
8
|
-
<main id="main">
|
|
9
|
-
|
|
10
|
-
<section class="page-hero page-hero--center section--tight">
|
|
11
|
-
<div class="container">
|
|
12
|
-
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> / Contact Us</nav>
|
|
13
|
-
<h1>Contact Sample Vapor Co.</h1>
|
|
14
|
-
<p>This is a placeholder contact page for a fictional demo store. Nothing here submits anywhere.</p>
|
|
15
|
-
</div>
|
|
16
|
-
</section>
|
|
17
|
-
|
|
18
|
-
<section class="section" aria-labelledby="contact-h">
|
|
19
|
-
<h2 id="contact-h" class="sr-only">Get in touch</h2>
|
|
20
|
-
<div class="container">
|
|
21
|
-
<div class="contact-grid">
|
|
22
|
-
<div class="contact-card">
|
|
23
|
-
<h3>Support hours</h3>
|
|
24
|
-
<p>Sample copy — set your real support hours here.</p>
|
|
25
|
-
</div>
|
|
26
|
-
<div class="contact-card">
|
|
27
|
-
<h3>Customer support</h3>
|
|
28
|
-
<p>Sample copy — describe how customers reach you.</p>
|
|
29
|
-
</div>
|
|
30
|
-
<div class="contact-card">
|
|
31
|
-
<h3>Track your order</h3>
|
|
32
|
-
<p>Sample copy — link your order-tracking flow here.</p>
|
|
33
|
-
</div>
|
|
34
|
-
</div>
|
|
35
|
-
|
|
36
|
-
<form class="contact-form" action="/api/contact" method="post">
|
|
37
|
-
<div class="row2">
|
|
38
|
-
<input type="text" name="name" aria-label="Name" placeholder="Name" required>
|
|
39
|
-
<input type="email" name="email" aria-label="Email" placeholder="Email" required>
|
|
40
|
-
</div>
|
|
41
|
-
<input type="text" name="order" aria-label="Order number (optional)" placeholder="Order number (optional)">
|
|
42
|
-
<textarea name="comment" aria-label="How can we help?" placeholder="How can we help?"></textarea>
|
|
43
|
-
<button type="submit" class="btn btn-primary">Send Message</button>
|
|
44
|
-
</form>
|
|
45
|
-
</div>
|
|
46
|
-
</section>
|
|
47
|
-
|
|
48
|
-
</main>
|