@tokenoftrust/cli 1.3.4-rc.3 → 1.3.4-rc.5
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/bin/tot.cjs +44 -16
- package/bin/tot.mjs +8 -0
- package/package.json +2 -1
- package/src/app-scaffold.mjs +84 -0
- package/src/banner.mjs +46 -0
- package/src/commands/app/dev.mjs +268 -0
- package/src/commands/app/index.mjs +35 -0
- package/src/commands/app/scaffold.mjs +57 -0
- package/src/commands/checkout.mjs +5 -0
- package/src/commands/dev.mjs +61 -14
- package/src/commands/start.mjs +76 -17
- package/src/commands/validate.mjs +4 -0
- package/src/dev-logs.mjs +34 -7
- package/src/obstacle-beacon.cjs +111 -0
- package/src/obstacle.mjs +35 -0
- package/src/validate.mjs +77 -8
- package/src/vendor/private-apps-devkit.mjs +490 -0
- package/template/private-app/.env.example +10 -0
- package/template/private-app/Dockerfile +12 -0
- package/template/private-app/README.md +45 -0
- package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
- package/template/private-app/server.js +81 -0
- package/template/private-app/tot-app.json +29 -0
- package/template/sample-store/content/chrome.html +140 -0
- package/template/sample-store/content/chrome.json +86 -0
- package/template/sample-store/content/home.html +121 -0
- package/template/sample-store/content/home.json +50 -0
- package/template/sample-store/content/pages/about.json +10 -0
- package/template/sample-store/content/pages/privacy.json +10 -0
- package/template/sample-store/content/pages/shipping-returns.json +10 -0
- package/template/sample-store/content/pages-html/blogs/news.html +26 -0
- package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
- package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
- package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
- package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
- package/template/sample-store/public/logo.svg +6 -0
- package/template/sample-store/public/pages/home.css +120 -0
- package/template/sample-store/public/pages/mkt.css +185 -0
- package/template/sample-store/public/pages/page.css +155 -0
- package/template/sample-store/public/themes/sample.css +76 -0
- package/template/sample-store/theme.json +38 -0
package/src/validate.mjs
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* mirror the reconcile pipeline's own gate (@tot/private-controlplane
|
|
8
8
|
* customization-reconcile.ts `mapToArtifact` / `validateRawHtml`). Kept in step
|
|
9
9
|
* with the in-repo source of truth `scripts/tenant/validate.mjs` (which the CI
|
|
10
|
-
* regression test imports)
|
|
11
|
-
*
|
|
10
|
+
* regression test imports) — including its platform-route awareness (commerce
|
|
11
|
+
* tenants own /collections, /products/<handle>, /search, /saved, /account, /cart,
|
|
12
|
+
* so links into those are not false `link-dangling` warnings). FOLLOW-UP: collapse
|
|
13
|
+
* the two onto this module as the single source once the package is the install surface.
|
|
12
14
|
*
|
|
13
15
|
* Pure + dependency-free (readFileSync only). `validateTenant(dir)` is the entry
|
|
14
16
|
* point; `validateConfigShape` is exported for focused testing.
|
|
@@ -333,10 +335,72 @@ const HREF_RE = /\bhref\s*=\s*"([^"]*)"/gi;
|
|
|
333
335
|
const SRC_RE = /\b(?:src|srcset)\s*=\s*"([^"]*)"/gi;
|
|
334
336
|
const STYLE_OPEN_WITH_ATTRS_RE = /<style\s+[^>]*>/i;
|
|
335
337
|
|
|
338
|
+
// --- platform-owned routes (framework surfaces, NOT tenant pages) ------------
|
|
339
|
+
/**
|
|
340
|
+
* When the tenant is served BY this ToT storefront platform, the platform itself
|
|
341
|
+
* owns a set of framework routes — catalog (`/collections`, `/collections/<handle>`,
|
|
342
|
+
* `/products/<handle>`), `/search`, `/saved`, the auth/account route (`/account`),
|
|
343
|
+
* and `/cart` when cart/checkout is on. A COMMERCE tenant that links into those is
|
|
344
|
+
* linking to a live platform surface, so such links must NOT be flagged
|
|
345
|
+
* `link-dangling` — only genuinely-missing tenant pages/assets should warn.
|
|
346
|
+
*
|
|
347
|
+
* MARKETING tenants get none of these (their commerce affordances are off), so they
|
|
348
|
+
* stay strict. Standalone / unresolved hosts also stay conservative — they can't
|
|
349
|
+
* assume the eventual host is this platform — UNLESS the config resolves the host
|
|
350
|
+
* (`"hostPlatform": "tot-storefront"`).
|
|
351
|
+
*
|
|
352
|
+
* The route→feature mapping mirrors `resolveTenantFeatures` in
|
|
353
|
+
* `@tot/public-runtime` tenant.ts (catalog / productSearch / savedItems) — keep in step.
|
|
354
|
+
*/
|
|
355
|
+
const COMMERCE_ROUTE_FEATURES = { catalog: true, productSearch: true, savedItems: true };
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {any} config parsed `.tot/config.json` (or null)
|
|
359
|
+
* @param {boolean} hostIsPlatform is the serving host this ToT storefront platform?
|
|
360
|
+
* @returns {{active:boolean, owns:(path:string)=>boolean}}
|
|
361
|
+
*/
|
|
362
|
+
function resolvePlatformRouteOwnership(config, hostIsPlatform) {
|
|
363
|
+
const inert = { active: false, owns: () => false };
|
|
364
|
+
if (!hostIsPlatform) return inert; // standalone / unresolved host → conservative
|
|
365
|
+
const siteType = config?.siteType ?? "commerce"; // unset == commerce (platform default)
|
|
366
|
+
if (siteType === "marketing") return inert; // marketing owns no commerce routes
|
|
367
|
+
// commerce feature defaults, with any explicit per-flag overrides layered on
|
|
368
|
+
const features = { ...COMMERCE_ROUTE_FEATURES };
|
|
369
|
+
const overrides = config?.features;
|
|
370
|
+
if (overrides && typeof overrides === "object") {
|
|
371
|
+
for (const [k, v] of Object.entries(overrides)) if (v !== undefined && k in features) features[k] = v;
|
|
372
|
+
}
|
|
373
|
+
const cartEnabled = config?.capabilities?.cartCheckout?.enabled ?? true; // commerce default: on
|
|
374
|
+
|
|
375
|
+
const exact = new Set(["/account"]); // the auth/account route the platform currently serves
|
|
376
|
+
const prefixes = ["/account/"];
|
|
377
|
+
if (features.catalog) {
|
|
378
|
+
exact.add("/collections");
|
|
379
|
+
prefixes.push("/collections/", "/products/");
|
|
380
|
+
}
|
|
381
|
+
if (features.productSearch) exact.add("/search");
|
|
382
|
+
if (features.savedItems) exact.add("/saved");
|
|
383
|
+
if (cartEnabled) {
|
|
384
|
+
exact.add("/cart");
|
|
385
|
+
prefixes.push("/cart/");
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
active: true,
|
|
389
|
+
owns(path) {
|
|
390
|
+
const norm = path.replace(/\/+$/, "") || "/";
|
|
391
|
+
if (exact.has(norm)) return true;
|
|
392
|
+
return prefixes.some((p) => path.startsWith(p));
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
336
397
|
/**
|
|
337
398
|
* Full static validation of a tenant directory (content/ public/ theme.json [.tot/]).
|
|
338
399
|
* @param {string} tenantDir absolute path to the tenant dir
|
|
339
|
-
* @param {{tenantId?:string, scope?:string}} [opts]
|
|
400
|
+
* @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace"}} [opts]
|
|
401
|
+
* `mode` — "monorepo" (default): served by this platform, so commerce tenants own
|
|
402
|
+
* the framework routes. "workspace": a standalone checkout, conservative about
|
|
403
|
+
* platform-route ownership unless the config resolves the host (`hostPlatform`).
|
|
340
404
|
* @returns {{ok:boolean, findings:Finding[]}}
|
|
341
405
|
*/
|
|
342
406
|
export function validateTenant(tenantDir, opts = {}) {
|
|
@@ -370,6 +434,10 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
370
434
|
);
|
|
371
435
|
}
|
|
372
436
|
const scope = opts.scope || config?.scope;
|
|
437
|
+
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
438
|
+
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
439
|
+
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
440
|
+
const platformRoutes = resolvePlatformRouteOwnership(config, hostIsPlatform);
|
|
373
441
|
if (config?.capabilities) {
|
|
374
442
|
findings.push(...validateCapabilitiesDoc(config.capabilities, ".tot/config.json capabilities", config));
|
|
375
443
|
}
|
|
@@ -456,7 +524,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
456
524
|
}
|
|
457
525
|
|
|
458
526
|
for (const m of html.matchAll(HREF_RE)) {
|
|
459
|
-
findings.push(...checkLink(m[1].trim(), r, scope, pageTargets));
|
|
527
|
+
findings.push(...checkLink(m[1].trim(), r, scope, pageTargets, platformRoutes.owns));
|
|
460
528
|
}
|
|
461
529
|
for (const m of html.matchAll(SRC_RE)) {
|
|
462
530
|
const f = checkAsset(m[1].trim(), r, publicDir, opts.tenantId || config?.tenant);
|
|
@@ -480,7 +548,7 @@ function buildPageTargetSet(contentDir, pagesDir) {
|
|
|
480
548
|
return set;
|
|
481
549
|
}
|
|
482
550
|
|
|
483
|
-
function checkLink(href, file, scope, pageTargets) {
|
|
551
|
+
function checkLink(href, file, scope, pageTargets, ownsPlatformRoute = () => false) {
|
|
484
552
|
const out = [];
|
|
485
553
|
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return out;
|
|
486
554
|
if (/^https?:\/\/localhost(?::\d+)?/i.test(href)) {
|
|
@@ -501,9 +569,10 @@ function checkLink(href, file, scope, pageTargets) {
|
|
|
501
569
|
if (href.startsWith("/tenants/")) return out;
|
|
502
570
|
const path = href.split(/[?#]/)[0];
|
|
503
571
|
const norm = path.replace(/\/+$/, "") || "/";
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
-
|
|
572
|
+
if (pageTargets.has(path) || pageTargets.has(norm) || pageTargets.has(norm + "/")) return out;
|
|
573
|
+
// a framework route the host platform owns (e.g. /collections, /account) is not a dangling tenant page
|
|
574
|
+
if (ownsPlatformRoute(path)) return out;
|
|
575
|
+
out.push(mk(WARN, "link-dangling", file, `internal link ${path} does not resolve to a known page (would 404)`));
|
|
507
576
|
return out;
|
|
508
577
|
}
|
|
509
578
|
out.push(mk(WARN, "link-relative", file, `relative link "${href}" — internal links should be root-absolute (/foo/)`));
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored subset of `@tokenoftrust/private-apps-devkit` (PrivateApps epic D6
|
|
3
|
+
* Chunk B, `packages/private-apps-devkit/src/{signing,jwt,manifest}.ts`) — for
|
|
4
|
+
* `tot app` (D6 Chunk C+D) to sign/verify webhook deliveries, mint dev JWTs,
|
|
5
|
+
* and validate `tot-app.json` manifests WITHOUT a package.json dependency on
|
|
6
|
+
* that package.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS IS A COPY, NOT AN IMPORT (the devkit's own module doc explicitly
|
|
9
|
+
* hoped for the opposite — "without forking security-critical crypto into a
|
|
10
|
+
* second, drifting copy"): the devkit is `"private": true` and ships ONLY
|
|
11
|
+
* TypeScript source (`main`/`exports` both point at `src/index.ts`, no build
|
|
12
|
+
* output committed). That's fine for its other consumer
|
|
13
|
+
* (`apps/storefront`, an Astro/Vite app that transpiles TS at build time) but
|
|
14
|
+
* `@tokenoftrust/cli` is a *published, dependency-free* npm package
|
|
15
|
+
* (`.github/workflows/publish-cli.yml` runs `node --test` then a bare
|
|
16
|
+
* `npm publish` — no install step, no bundler, Node 20 — and the CLI's own
|
|
17
|
+
* `engines` promises `>=20`, well below the Node 22.6+/23.6-default needed to
|
|
18
|
+
* import `.ts` sources directly). A `workspace:*` dependency would publish
|
|
19
|
+
* literally as the string `"workspace:*"` in the tarball's package.json —
|
|
20
|
+
* unresolvable by any installer outside this pnpm workspace. So the two
|
|
21
|
+
* packages cannot share one import today; this file is the deliberate,
|
|
22
|
+
* clearly-labeled fork until the devkit ships a plain-JS build people outside
|
|
23
|
+
* the monorepo can install.
|
|
24
|
+
*
|
|
25
|
+
* Kept honest three ways:
|
|
26
|
+
* 1. Every export below is line-for-line the same logic as its `.ts`
|
|
27
|
+
* source (types erased, nothing behaviorally changed) — diff against
|
|
28
|
+
* `packages/private-apps-devkit/src/{signing,jwt,manifest}.ts` to audit.
|
|
29
|
+
* 2. `test/app-devkit-parity.test.mjs` cross-verifies interop with the REAL
|
|
30
|
+
* devkit (signs with one, verifies with the other) whenever the sibling
|
|
31
|
+
* package is resolvable (i.e. inside this pnpm workspace); it skips
|
|
32
|
+
* cleanly otherwise, so it never breaks the no-install CI path above.
|
|
33
|
+
* 3. Web Crypto (`crypto.subtle`) + `Buffer` only, same as the source —
|
|
34
|
+
* zero new runtime dependencies.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// ── signing (RFC 9421 HTTP Message Signatures + RFC 9530 Content-Digest) ───
|
|
38
|
+
|
|
39
|
+
export const SIGNATURE_LABEL = "sig1";
|
|
40
|
+
|
|
41
|
+
export const WEBHOOK_SIGNATURE_COVERED_COMPONENTS = ["@method", "@target-uri", "content-digest"];
|
|
42
|
+
|
|
43
|
+
export const WEBHOOK_SIGNING_HTTPSIG_ALG = "rsa-v1_5-sha256";
|
|
44
|
+
|
|
45
|
+
/** RFC 9530 `Content-Digest: sha-256=:<base64(SHA-256(body))>:`, via Web Crypto. */
|
|
46
|
+
export async function computeContentDigestSha256(body) {
|
|
47
|
+
const bytes = new TextEncoder().encode(body);
|
|
48
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
49
|
+
return `sha-256=:${Buffer.from(digest).toString("base64")}:`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildSignatureParamsValue(componentIds, params) {
|
|
53
|
+
const list = componentIds.map((id) => `"${id}"`).join(" ");
|
|
54
|
+
return `(${list});created=${params.created};keyid="${params.keyid}";alg="${params.alg}"`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildSignatureBase(covered, signatureParamsValue) {
|
|
58
|
+
const lines = covered.map(([id, value]) => `"${id}": ${value}`);
|
|
59
|
+
lines.push(`"@signature-params": ${signatureParamsValue}`);
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function coveredComponents(method, url, contentDigest) {
|
|
64
|
+
return [
|
|
65
|
+
["@method", method.toUpperCase()],
|
|
66
|
+
["@target-uri", url],
|
|
67
|
+
["content-digest", contentDigest],
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sign one webhook delivery request. The private key never leaves the caller. */
|
|
72
|
+
export async function signWebhookRequest(input) {
|
|
73
|
+
const created = input.created ?? Math.floor(Date.now() / 1000);
|
|
74
|
+
const contentDigest = await computeContentDigestSha256(input.body);
|
|
75
|
+
const paramsValue = buildSignatureParamsValue(WEBHOOK_SIGNATURE_COVERED_COMPONENTS, {
|
|
76
|
+
created,
|
|
77
|
+
keyid: input.kid,
|
|
78
|
+
alg: WEBHOOK_SIGNING_HTTPSIG_ALG,
|
|
79
|
+
});
|
|
80
|
+
const base = buildSignatureBase(coveredComponents(input.method, input.url, contentDigest), paramsValue);
|
|
81
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", input.key, new TextEncoder().encode(base));
|
|
82
|
+
return {
|
|
83
|
+
headers: {
|
|
84
|
+
"content-digest": contentDigest,
|
|
85
|
+
"signature-input": `${SIGNATURE_LABEL}=${paramsValue}`,
|
|
86
|
+
signature: `${SIGNATURE_LABEL}=:${Buffer.from(signature).toString("base64")}:`,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const EXPECTED_COMPONENT_LIST = WEBHOOK_SIGNATURE_COVERED_COMPONENTS.map((id) => `"${id}"`).join(" ");
|
|
92
|
+
const SIGNATURE_INPUT_RE = new RegExp(
|
|
93
|
+
`^${SIGNATURE_LABEL}=\\(${EXPECTED_COMPONENT_LIST.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\);created=(\\d+);keyid="([^"]*)";alg="([^"]*)"$`,
|
|
94
|
+
);
|
|
95
|
+
const SIGNATURE_RE = new RegExp(`^${SIGNATURE_LABEL}=:([A-Za-z0-9+/=]+):$`);
|
|
96
|
+
|
|
97
|
+
/** Verify one delivered webhook request against the sender's public key. */
|
|
98
|
+
export async function verifyWebhookSignature(input) {
|
|
99
|
+
const contentDigestHeader = input.headers["content-digest"];
|
|
100
|
+
const signatureInputHeader = input.headers["signature-input"];
|
|
101
|
+
const signatureHeader = input.headers.signature;
|
|
102
|
+
if (!contentDigestHeader || !signatureInputHeader || !signatureHeader) {
|
|
103
|
+
return { ok: false, code: "malformed_headers", reason: "missing Content-Digest/Signature-Input/Signature" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const inputMatch = SIGNATURE_INPUT_RE.exec(signatureInputHeader);
|
|
107
|
+
if (!inputMatch) {
|
|
108
|
+
return { ok: false, code: "malformed_headers", reason: "Signature-Input does not match the expected shape" };
|
|
109
|
+
}
|
|
110
|
+
const sigMatch = SIGNATURE_RE.exec(signatureHeader);
|
|
111
|
+
if (!sigMatch) {
|
|
112
|
+
return { ok: false, code: "malformed_headers", reason: "Signature does not match the expected shape" };
|
|
113
|
+
}
|
|
114
|
+
const [, createdStr, keyid, alg] = inputMatch;
|
|
115
|
+
if (alg !== WEBHOOK_SIGNING_HTTPSIG_ALG) {
|
|
116
|
+
return { ok: false, code: "malformed_headers", reason: `unexpected alg "${alg}"` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const expectedDigest = await computeContentDigestSha256(input.body);
|
|
120
|
+
if (expectedDigest !== contentDigestHeader) {
|
|
121
|
+
return { ok: false, code: "content_digest_mismatch", reason: "body does not match Content-Digest header" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const created = Number(createdStr);
|
|
125
|
+
const paramsValue = buildSignatureParamsValue(WEBHOOK_SIGNATURE_COVERED_COMPONENTS, { created, keyid, alg });
|
|
126
|
+
const base = buildSignatureBase(coveredComponents(input.method, input.url, contentDigestHeader), paramsValue);
|
|
127
|
+
const signatureBytes = Buffer.from(sigMatch[1], "base64");
|
|
128
|
+
const valid = await crypto.subtle.verify(
|
|
129
|
+
"RSASSA-PKCS1-v1_5",
|
|
130
|
+
input.publicKey,
|
|
131
|
+
signatureBytes,
|
|
132
|
+
new TextEncoder().encode(base),
|
|
133
|
+
);
|
|
134
|
+
if (!valid) {
|
|
135
|
+
return { ok: false, code: "bad_signature", reason: "signature does not verify against the public key" };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, keyid, created };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── jwt (minimal hand-rolled RS256 JWS mint/verify, dev-only) ──────────────
|
|
141
|
+
|
|
142
|
+
export const DEV_JWT_ALG = "RS256";
|
|
143
|
+
|
|
144
|
+
function base64urlEncodeJson(value) {
|
|
145
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function base64urlDecodeJson(value) {
|
|
149
|
+
const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
150
|
+
if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
|
|
151
|
+
throw new Error("decoded segment is not a JSON object");
|
|
152
|
+
}
|
|
153
|
+
return decoded;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Mint a compact RS256 JWS. The private key never leaves the caller. */
|
|
157
|
+
export async function mintJwt(input) {
|
|
158
|
+
const iat = input.issuedAt ?? Math.floor(Date.now() / 1000);
|
|
159
|
+
const header = { alg: DEV_JWT_ALG, typ: "JWT" };
|
|
160
|
+
if (input.kid !== undefined) header.kid = input.kid;
|
|
161
|
+
const payload = {
|
|
162
|
+
iat,
|
|
163
|
+
...(input.expiresAt !== undefined ? { exp: input.expiresAt } : {}),
|
|
164
|
+
...input.claims,
|
|
165
|
+
};
|
|
166
|
+
const signingInput = `${base64urlEncodeJson(header)}.${base64urlEncodeJson(payload)}`;
|
|
167
|
+
const signature = await crypto.subtle.sign(
|
|
168
|
+
"RSASSA-PKCS1-v1_5",
|
|
169
|
+
input.privateKey,
|
|
170
|
+
new TextEncoder().encode(signingInput),
|
|
171
|
+
);
|
|
172
|
+
return `${signingInput}.${Buffer.from(signature).toString("base64url")}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Verify a compact RS256 JWS minted by {@link mintJwt} (or any RS256 JWS with this exact 3-part shape). */
|
|
176
|
+
export async function verifyJwt(input) {
|
|
177
|
+
const parts = input.token.split(".");
|
|
178
|
+
if (parts.length !== 3) {
|
|
179
|
+
return { ok: false, code: "malformed", reason: "token is not a 3-part compact JWS" };
|
|
180
|
+
}
|
|
181
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
182
|
+
|
|
183
|
+
let header;
|
|
184
|
+
let payload;
|
|
185
|
+
try {
|
|
186
|
+
header = base64urlDecodeJson(headerB64);
|
|
187
|
+
payload = base64urlDecodeJson(payloadB64);
|
|
188
|
+
} catch (cause) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
code: "malformed",
|
|
192
|
+
reason: `header/payload is not valid base64url JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (header.alg !== DEV_JWT_ALG) {
|
|
197
|
+
return { ok: false, code: "bad_alg", reason: `unexpected alg "${String(header.alg)}"` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
201
|
+
const valid = await crypto.subtle.verify(
|
|
202
|
+
"RSASSA-PKCS1-v1_5",
|
|
203
|
+
input.publicKey,
|
|
204
|
+
Buffer.from(signatureB64, "base64url"),
|
|
205
|
+
new TextEncoder().encode(signingInput),
|
|
206
|
+
);
|
|
207
|
+
if (!valid) {
|
|
208
|
+
return { ok: false, code: "bad_signature", reason: "signature does not verify against the public key" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const now = input.now ?? Math.floor(Date.now() / 1000);
|
|
212
|
+
if (typeof payload.exp === "number" && payload.exp < now) {
|
|
213
|
+
return { ok: false, code: "expired", reason: `token expired at ${payload.exp}` };
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, header, payload };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Generate an ephemeral RS256 keypair for dev/test minting — never a real gateway key. */
|
|
219
|
+
export function generateDevRs256KeyPair() {
|
|
220
|
+
return crypto.subtle.generateKey(
|
|
221
|
+
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
|
|
222
|
+
/* extractable */ true,
|
|
223
|
+
["sign", "verify"],
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── manifest (tot-app.json validator) ──────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
export const MANIFEST_CONTRACT_VERSION = "1";
|
|
230
|
+
|
|
231
|
+
export const MANIFEST_SCOPES = [
|
|
232
|
+
"catalog:read",
|
|
233
|
+
"orders:read:minimal",
|
|
234
|
+
"orders:webhook",
|
|
235
|
+
"customers:read:minimal",
|
|
236
|
+
"inventory:read",
|
|
237
|
+
"reports:read",
|
|
238
|
+
"attribution:write",
|
|
239
|
+
"widgets:launch",
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
export const MANIFEST_WEBHOOK_TOPICS = [
|
|
243
|
+
"app.installed",
|
|
244
|
+
"app.uninstalled",
|
|
245
|
+
"catalog.product.updated",
|
|
246
|
+
"inventory.changed",
|
|
247
|
+
"order.created",
|
|
248
|
+
"order.fulfilled",
|
|
249
|
+
"customer.marketing_consent.updated",
|
|
250
|
+
"attribution.finalized",
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
export const MANIFEST_WIDGET_PLACEMENTS = ["product.aside", "home.section", "global.footer"];
|
|
254
|
+
|
|
255
|
+
export const MANIFEST_INSTALL_MODES = ["external", "hosted"];
|
|
256
|
+
|
|
257
|
+
export const MANIFEST_TELEMETRY_MODES = ["errors-only", "sampled", "full"];
|
|
258
|
+
|
|
259
|
+
export const MANIFEST_PII_REDACTIONS = ["default", "strict"];
|
|
260
|
+
|
|
261
|
+
const ID_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
262
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
263
|
+
const HTTPS_URI_PATTERN = /^https:\/\/\S+$/;
|
|
264
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
265
|
+
const HOSTNAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
266
|
+
|
|
267
|
+
const TOP_LEVEL_KEYS = [
|
|
268
|
+
"contractVersion", "id", "name", "version", "owner", "description", "installMode",
|
|
269
|
+
"scopes", "webhooks", "widgets", "adminLinks", "telemetryMode", "retention", "hosting",
|
|
270
|
+
];
|
|
271
|
+
const OWNER_KEYS = ["name", "email", "url"];
|
|
272
|
+
const WEBHOOKS_KEYS = ["endpoint", "topics", "signatureKeyId"];
|
|
273
|
+
const WIDGET_KEYS = ["placement", "endpoint", "title"];
|
|
274
|
+
const ADMIN_LINK_KEYS = ["label", "href"];
|
|
275
|
+
const RETENTION_KEYS = ["eventLogDays", "piiRedaction"];
|
|
276
|
+
const HOSTING_KEYS = ["image", "allowedHosts"];
|
|
277
|
+
|
|
278
|
+
function isPlainObject(value) {
|
|
279
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function field(at, key) {
|
|
283
|
+
return at ? `${at}.${key}` : key;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function rejectUnknownKeys(obj, allowed, at, errors) {
|
|
287
|
+
const label = at || "manifest";
|
|
288
|
+
for (const key of Object.keys(obj)) {
|
|
289
|
+
if (!allowed.includes(key)) errors.push(`${label}: unknown property "${key}"`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function requireString(obj, key, at, errors) {
|
|
294
|
+
const value = obj[key];
|
|
295
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
296
|
+
errors.push(`${field(at, key)}: required string is missing`);
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function checkEnum(value, allowed, at, errors) {
|
|
303
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
304
|
+
errors.push(`${at}: "${String(value)}" is not one of ${JSON.stringify(allowed)}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function checkPattern(value, pattern, at, errors) {
|
|
309
|
+
if (typeof value !== "string" || !pattern.test(value)) {
|
|
310
|
+
errors.push(`${at}: "${String(value)}" does not match the required pattern`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function checkUniqueStringArray(value, allowed, at, errors) {
|
|
315
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
316
|
+
errors.push(`${at}: must be a non-empty array`);
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const items = value;
|
|
320
|
+
if (new Set(items).size !== items.length) errors.push(`${at}: items must be unique`);
|
|
321
|
+
if (allowed) {
|
|
322
|
+
for (const item of items) checkEnum(item, allowed, `${at}[]`, errors);
|
|
323
|
+
}
|
|
324
|
+
return items.filter((item) => typeof item === "string");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function validateOwner(owner, errors) {
|
|
328
|
+
if (!isPlainObject(owner)) {
|
|
329
|
+
errors.push("owner: required object is missing");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
rejectUnknownKeys(owner, OWNER_KEYS, "owner", errors);
|
|
333
|
+
requireString(owner, "name", "owner", errors);
|
|
334
|
+
const email = requireString(owner, "email", "owner", errors);
|
|
335
|
+
if (email !== undefined) checkPattern(email, EMAIL_PATTERN, "owner.email", errors);
|
|
336
|
+
if (owner.url !== undefined) checkPattern(owner.url, HTTPS_URI_PATTERN, "owner.url", errors);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function validateWebhooks(webhooks, errors) {
|
|
340
|
+
if (webhooks === undefined) return;
|
|
341
|
+
if (!isPlainObject(webhooks)) {
|
|
342
|
+
errors.push("webhooks: must be an object");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
rejectUnknownKeys(webhooks, WEBHOOKS_KEYS, "webhooks", errors);
|
|
346
|
+
const endpoint = requireString(webhooks, "endpoint", "webhooks", errors);
|
|
347
|
+
if (endpoint !== undefined) checkPattern(endpoint, HTTPS_URI_PATTERN, "webhooks.endpoint", errors);
|
|
348
|
+
if (webhooks.topics === undefined) {
|
|
349
|
+
errors.push("webhooks.topics: required array is missing");
|
|
350
|
+
} else {
|
|
351
|
+
checkUniqueStringArray(webhooks.topics, MANIFEST_WEBHOOK_TOPICS, "webhooks.topics", errors);
|
|
352
|
+
}
|
|
353
|
+
if (webhooks.signatureKeyId !== undefined && typeof webhooks.signatureKeyId !== "string") {
|
|
354
|
+
errors.push("webhooks.signatureKeyId: must be a string");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function validateWidgets(widgets, errors) {
|
|
359
|
+
if (widgets === undefined) return;
|
|
360
|
+
if (!Array.isArray(widgets)) {
|
|
361
|
+
errors.push("widgets: must be an array");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
widgets.forEach((widget, index) => {
|
|
365
|
+
const at = `widgets[${index}]`;
|
|
366
|
+
if (!isPlainObject(widget)) {
|
|
367
|
+
errors.push(`${at}: must be an object`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
rejectUnknownKeys(widget, WIDGET_KEYS, at, errors);
|
|
371
|
+
if (widget.placement === undefined) {
|
|
372
|
+
errors.push(`${at}.placement: required property is missing`);
|
|
373
|
+
} else {
|
|
374
|
+
checkEnum(widget.placement, MANIFEST_WIDGET_PLACEMENTS, `${at}.placement`, errors);
|
|
375
|
+
}
|
|
376
|
+
// endpoint is required + https (schema: widgets[].endpoint, ^https://), the
|
|
377
|
+
// origin Storefront frames + allowlists in CSP for this widget.
|
|
378
|
+
const widgetEndpoint = requireString(widget, "endpoint", at, errors);
|
|
379
|
+
if (widgetEndpoint !== undefined) {
|
|
380
|
+
checkPattern(widgetEndpoint, HTTPS_URI_PATTERN, `${at}.endpoint`, errors);
|
|
381
|
+
}
|
|
382
|
+
if (widget.title !== undefined && typeof widget.title !== "string") {
|
|
383
|
+
errors.push(`${at}.title: must be a string`);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function validateAdminLinks(adminLinks, errors) {
|
|
389
|
+
if (adminLinks === undefined) return;
|
|
390
|
+
if (!Array.isArray(adminLinks)) {
|
|
391
|
+
errors.push("adminLinks: must be an array");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
adminLinks.forEach((link, index) => {
|
|
395
|
+
const at = `adminLinks[${index}]`;
|
|
396
|
+
if (!isPlainObject(link)) {
|
|
397
|
+
errors.push(`${at}: must be an object`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
rejectUnknownKeys(link, ADMIN_LINK_KEYS, at, errors);
|
|
401
|
+
requireString(link, "label", at, errors);
|
|
402
|
+
const href = requireString(link, "href", at, errors);
|
|
403
|
+
if (href !== undefined) checkPattern(href, HTTPS_URI_PATTERN, `${at}.href`, errors);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function validateRetention(retention, errors) {
|
|
408
|
+
if (retention === undefined) return;
|
|
409
|
+
if (!isPlainObject(retention)) {
|
|
410
|
+
errors.push("retention: must be an object");
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
rejectUnknownKeys(retention, RETENTION_KEYS, "retention", errors);
|
|
414
|
+
const eventLogDays = retention.eventLogDays;
|
|
415
|
+
if (eventLogDays !== undefined) {
|
|
416
|
+
if (typeof eventLogDays !== "number" || !Number.isInteger(eventLogDays) || eventLogDays < 1 || eventLogDays > 365) {
|
|
417
|
+
errors.push("retention.eventLogDays: must be an integer between 1 and 365");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (retention.piiRedaction !== undefined) {
|
|
421
|
+
checkEnum(retention.piiRedaction, MANIFEST_PII_REDACTIONS, "retention.piiRedaction", errors);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function validateHosting(hosting, errors) {
|
|
426
|
+
if (hosting === undefined) return;
|
|
427
|
+
if (!isPlainObject(hosting)) {
|
|
428
|
+
errors.push("hosting: must be an object");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
rejectUnknownKeys(hosting, HOSTING_KEYS, "hosting", errors);
|
|
432
|
+
if (hosting.image !== undefined && typeof hosting.image !== "string") {
|
|
433
|
+
errors.push("hosting.image: must be a string");
|
|
434
|
+
}
|
|
435
|
+
if (hosting.allowedHosts !== undefined) {
|
|
436
|
+
checkUniqueStringArray(hosting.allowedHosts, undefined, "hosting.allowedHosts", errors);
|
|
437
|
+
if (Array.isArray(hosting.allowedHosts)) {
|
|
438
|
+
hosting.allowedHosts.forEach((host, index) => {
|
|
439
|
+
checkPattern(host, HOSTNAME_PATTERN, `hosting.allowedHosts[${index}]`, errors);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Validate a parsed `tot-app.json` manifest against the V1 contract. Returns
|
|
447
|
+
* every violation found (not just the first).
|
|
448
|
+
*/
|
|
449
|
+
export function validateManifest(input) {
|
|
450
|
+
const errors = [];
|
|
451
|
+
if (!isPlainObject(input)) {
|
|
452
|
+
return { ok: false, errors: ["manifest must be a JSON object"] };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
rejectUnknownKeys(input, TOP_LEVEL_KEYS, "", errors);
|
|
456
|
+
|
|
457
|
+
if (input.contractVersion !== MANIFEST_CONTRACT_VERSION) {
|
|
458
|
+
errors.push(`contractVersion: must be "${MANIFEST_CONTRACT_VERSION}"`);
|
|
459
|
+
}
|
|
460
|
+
const id = requireString(input, "id", "", errors);
|
|
461
|
+
if (id !== undefined) checkPattern(id, ID_PATTERN, "id", errors);
|
|
462
|
+
requireString(input, "name", "", errors);
|
|
463
|
+
const version = requireString(input, "version", "", errors);
|
|
464
|
+
if (version !== undefined) checkPattern(version, VERSION_PATTERN, "version", errors);
|
|
465
|
+
validateOwner(input.owner, errors);
|
|
466
|
+
if (input.description !== undefined && typeof input.description !== "string") {
|
|
467
|
+
errors.push("description: must be a string");
|
|
468
|
+
}
|
|
469
|
+
if (input.installMode === undefined) {
|
|
470
|
+
errors.push("installMode: required property is missing");
|
|
471
|
+
} else {
|
|
472
|
+
checkEnum(input.installMode, MANIFEST_INSTALL_MODES, "installMode", errors);
|
|
473
|
+
}
|
|
474
|
+
if (input.scopes === undefined) {
|
|
475
|
+
errors.push("scopes: required array is missing");
|
|
476
|
+
} else {
|
|
477
|
+
checkUniqueStringArray(input.scopes, MANIFEST_SCOPES, "scopes", errors);
|
|
478
|
+
}
|
|
479
|
+
validateWebhooks(input.webhooks, errors);
|
|
480
|
+
validateWidgets(input.widgets, errors);
|
|
481
|
+
validateAdminLinks(input.adminLinks, errors);
|
|
482
|
+
if (input.telemetryMode !== undefined) {
|
|
483
|
+
checkEnum(input.telemetryMode, MANIFEST_TELEMETRY_MODES, "telemetryMode", errors);
|
|
484
|
+
}
|
|
485
|
+
validateRetention(input.retention, errors);
|
|
486
|
+
validateHosting(input.hosting, errors);
|
|
487
|
+
|
|
488
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
489
|
+
return { ok: true, manifest: input };
|
|
490
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Copy to .env and fill in for your own deployment. No real values are checked
|
|
2
|
+
# in here — this file only documents the shape.
|
|
3
|
+
|
|
4
|
+
# Port the stub webhook receiver (server.js) listens on.
|
|
5
|
+
PORT=8787
|
|
6
|
+
|
|
7
|
+
# Must match tot-app.json's webhooks.signatureKeyId. `tot app dev` writes the
|
|
8
|
+
# matching public key alongside this app at .tot/dev-keys.json for local runs
|
|
9
|
+
# — you don't need to set anything here to exercise the receiver locally.
|
|
10
|
+
SIGNATURE_KEY_ID=dev-key-1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Placeholder for the optional ToT-hosted runtime (installMode: "hosted",
|
|
2
|
+
# see tot-app.json + docs/private-apps/contract/tot-app.schema.json#hosting).
|
|
3
|
+
# The SAME manifest works for "external" (self-hosted, this Dockerfile is
|
|
4
|
+
# informational only) and "hosted" (ToT runs this image; egress is deny-all
|
|
5
|
+
# except tot-app.json's hosting.allowedHosts).
|
|
6
|
+
FROM node:20-slim
|
|
7
|
+
WORKDIR /app
|
|
8
|
+
COPY package*.json ./
|
|
9
|
+
RUN [ -f package.json ] && npm install --omit=dev || true
|
|
10
|
+
COPY . .
|
|
11
|
+
EXPOSE 8787
|
|
12
|
+
CMD ["node", "server.js"]
|