@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.
Files changed (41) hide show
  1. package/bin/tot.cjs +44 -16
  2. package/bin/tot.mjs +8 -0
  3. package/package.json +2 -1
  4. package/src/app-scaffold.mjs +84 -0
  5. package/src/banner.mjs +46 -0
  6. package/src/commands/app/dev.mjs +268 -0
  7. package/src/commands/app/index.mjs +35 -0
  8. package/src/commands/app/scaffold.mjs +57 -0
  9. package/src/commands/checkout.mjs +5 -0
  10. package/src/commands/dev.mjs +61 -14
  11. package/src/commands/start.mjs +76 -17
  12. package/src/commands/validate.mjs +4 -0
  13. package/src/dev-logs.mjs +34 -7
  14. package/src/obstacle-beacon.cjs +111 -0
  15. package/src/obstacle.mjs +35 -0
  16. package/src/validate.mjs +77 -8
  17. package/src/vendor/private-apps-devkit.mjs +490 -0
  18. package/template/private-app/.env.example +10 -0
  19. package/template/private-app/Dockerfile +12 -0
  20. package/template/private-app/README.md +45 -0
  21. package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
  22. package/template/private-app/server.js +81 -0
  23. package/template/private-app/tot-app.json +29 -0
  24. package/template/sample-store/content/chrome.html +140 -0
  25. package/template/sample-store/content/chrome.json +86 -0
  26. package/template/sample-store/content/home.html +121 -0
  27. package/template/sample-store/content/home.json +50 -0
  28. package/template/sample-store/content/pages/about.json +10 -0
  29. package/template/sample-store/content/pages/privacy.json +10 -0
  30. package/template/sample-store/content/pages/shipping-returns.json +10 -0
  31. package/template/sample-store/content/pages-html/blogs/news.html +26 -0
  32. package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
  33. package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
  34. package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
  35. package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
  36. package/template/sample-store/public/logo.svg +6 -0
  37. package/template/sample-store/public/pages/home.css +120 -0
  38. package/template/sample-store/public/pages/mkt.css +185 -0
  39. package/template/sample-store/public/pages/page.css +155 -0
  40. package/template/sample-store/public/themes/sample.css +76 -0
  41. 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). FOLLOW-UP: collapse the two onto this module as the
11
- * single source once the package is the install surface.
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 (!pageTargets.has(path) && !pageTargets.has(norm) && !pageTargets.has(norm + "/")) {
505
- out.push(mk(WARN, "link-dangling", file, `internal link ${path} does not resolve to a known page (would 404)`));
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"]