@tokenoftrust/cli 1.3.4-rc.4 → 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 (37) hide show
  1. package/bin/tot.mjs +8 -0
  2. package/package.json +2 -1
  3. package/src/app-scaffold.mjs +84 -0
  4. package/src/banner.mjs +46 -0
  5. package/src/commands/app/dev.mjs +268 -0
  6. package/src/commands/app/index.mjs +35 -0
  7. package/src/commands/app/scaffold.mjs +57 -0
  8. package/src/commands/dev.mjs +57 -13
  9. package/src/commands/start.mjs +76 -17
  10. package/src/commands/validate.mjs +4 -0
  11. package/src/dev-logs.mjs +34 -7
  12. package/src/validate.mjs +77 -8
  13. package/src/vendor/private-apps-devkit.mjs +490 -0
  14. package/template/private-app/.env.example +10 -0
  15. package/template/private-app/Dockerfile +12 -0
  16. package/template/private-app/README.md +45 -0
  17. package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
  18. package/template/private-app/server.js +81 -0
  19. package/template/private-app/tot-app.json +29 -0
  20. package/template/sample-store/content/chrome.html +140 -0
  21. package/template/sample-store/content/chrome.json +86 -0
  22. package/template/sample-store/content/home.html +121 -0
  23. package/template/sample-store/content/home.json +50 -0
  24. package/template/sample-store/content/pages/about.json +10 -0
  25. package/template/sample-store/content/pages/privacy.json +10 -0
  26. package/template/sample-store/content/pages/shipping-returns.json +10 -0
  27. package/template/sample-store/content/pages-html/blogs/news.html +26 -0
  28. package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
  29. package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
  30. package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
  31. package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
  32. package/template/sample-store/public/logo.svg +6 -0
  33. package/template/sample-store/public/pages/home.css +120 -0
  34. package/template/sample-store/public/pages/mkt.css +185 -0
  35. package/template/sample-store/public/pages/page.css +155 -0
  36. package/template/sample-store/public/themes/sample.css +76 -0
  37. package/template/sample-store/theme.json +38 -0
@@ -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"]
@@ -0,0 +1,45 @@
1
+ # my-app
2
+
3
+ A Storefront Private App, scaffolded by `tot app scaffold`. See
4
+ [the Private Apps devbook](https://github.com/tokenoftrust/storefront/blob/main/docs/private-apps/devbook/private-apps-devbook.md)
5
+ for the full contract this manifest and receiver implement.
6
+
7
+ ## What's here
8
+
9
+ - **`tot-app.json`** — your app's manifest: identity, scopes, webhook
10
+ subscriptions, widgets. Validated against
11
+ [`tot-app.schema.json`](https://github.com/tokenoftrust/storefront/blob/main/docs/private-apps/contract/tot-app.schema.json).
12
+ Everything here is a PLACEHOLDER — edit `id`, `owner`, `webhooks.endpoint`,
13
+ and `scopes` for your real app before you install it anywhere.
14
+ - **`fixtures/`** — sample CloudEvents (from `docs/private-apps/contract/examples/`)
15
+ you can sign and deliver to your own receiver locally, before you have a
16
+ real install. Add one fixture per topic you subscribe to.
17
+ - **`server.js`** — a minimal Node HTTP receiver for `webhooks.endpoint`. It
18
+ verifies the RFC 9421 HTTP Message Signature + RFC 9530 Content-Digest on
19
+ every inbound request before trusting the body — replace the handler logic,
20
+ keep the verification.
21
+ - **`Dockerfile`** — only relevant if you set `installMode: "hosted"`; ignored
22
+ for `"external"` apps (the default here).
23
+ - **`.env.example`** — copy to `.env`; no real values are included.
24
+
25
+ ## Local loop (no network, no real ToT account needed)
26
+
27
+ ```
28
+ tot app dev validate # lint tot-app.json against the contract
29
+ node server.js & # start the stub receiver
30
+ tot app dev emit order.created # sign a fixture CloudEvent and POST it in
31
+ tot app dev mint # mint a dev widget-launch JWT against an ephemeral key
32
+ ```
33
+
34
+ `tot app dev` generates its own throwaway RS256 keypair per app directory
35
+ (`.tot/dev-keys.json`, gitignored) so signing and verifying works fully
36
+ offline — nothing here is a real ToT-issued credential. Run `tot app dev --help`
37
+ for the full command surface.
38
+
39
+ ## Going live
40
+
41
+ 1. Point `webhooks.endpoint` at your real, publicly reachable HTTPS receiver.
42
+ 2. Get your manifest installed for a real `(tenant, env)` — this issues your
43
+ real client credentials; nothing above touches them.
44
+ 3. Swap the dev-only signature verification for the real gateway's published
45
+ JWKS/signing-key metadata (see the devbook's Signature Verification section).
@@ -0,0 +1,36 @@
1
+ {
2
+ "specversion": "1.0",
3
+ "id": "evt_01J9Z3Q7M2K8V6C4B0N5X7Y2A1",
4
+ "source": "/tenants/acme/storefront",
5
+ "type": "com.tokenoftrust.storefront.order.created",
6
+ "time": "2026-07-21T18:42:07.512Z",
7
+ "datacontenttype": "application/json",
8
+ "dataschemaversion": "1",
9
+ "tenantid": "acme",
10
+ "appinstallid": "inst_01J9Z2R4H5T3W1P8D6F0K9M2C7",
11
+ "retrycount": 0,
12
+ "totrequestid": "req_7f3c9a1e2b4d",
13
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
14
+ "data": {
15
+ "id": "ord_10482",
16
+ "status": "created",
17
+ "currency": "USD",
18
+ "customerHash": "cus_h_9a7f2c4e8b1d6033",
19
+ "subtotal": 84.0,
20
+ "total": 91.35,
21
+ "lineItems": [
22
+ {
23
+ "sku": "VAPE-KIT-STARTER",
24
+ "handle": "starter-kit",
25
+ "quantity": 1,
26
+ "lineTotal": 49.0
27
+ },
28
+ {
29
+ "sku": "PODS-MENTHOL-4PK",
30
+ "handle": "menthol-pods-4pk",
31
+ "quantity": 1,
32
+ "lineTotal": 35.0
33
+ }
34
+ ]
35
+ }
36
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minimal webhook receiver stub for a Storefront Private App. Verifies the
4
+ * RFC 9421 HTTP Message Signature + RFC 9530 Content-Digest on every inbound
5
+ * request BEFORE trusting the body — replace the "handle the event" bit
6
+ * below, keep the verification.
7
+ *
8
+ * Trusts the public key `tot app dev` wrote to .tot/dev-keys.json (re-read on
9
+ * every request, so it always sees the harness's current key). That file is
10
+ * dev-only scaffolding: your real receiver checks against the platform's
11
+ * published JWKS/signing-key metadata instead — see the devbook's Signature
12
+ * Verification section.
13
+ */
14
+ import { createServer } from "node:http";
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { verifyWebhookSignature } from "./lib/private-apps-devkit.mjs";
18
+
19
+ const PORT = Number(process.env.PORT || 8787);
20
+ const DEV_KEYS_PATH = join(process.cwd(), ".tot", "dev-keys.json");
21
+
22
+ function loadDevPublicKey() {
23
+ if (!existsSync(DEV_KEYS_PATH)) return null;
24
+ const { publicKeyJwk } = JSON.parse(readFileSync(DEV_KEYS_PATH, "utf8"));
25
+ return crypto.subtle.importKey("jwk", publicKeyJwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
26
+ }
27
+
28
+ function readBody(req) {
29
+ return new Promise((resolvePromise, reject) => {
30
+ const chunks = [];
31
+ req.on("data", (c) => chunks.push(c));
32
+ req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
33
+ req.on("error", reject);
34
+ });
35
+ }
36
+
37
+ const server = createServer(async (req, res) => {
38
+ const requestId = req.headers["tot-request-id"] || "(none)";
39
+ if (req.method !== "POST") {
40
+ res.writeHead(405).end();
41
+ return;
42
+ }
43
+
44
+ const body = await readBody(req);
45
+ const publicKey = await loadDevPublicKey();
46
+ if (!publicKey) {
47
+ console.error(`[${requestId}] no .tot/dev-keys.json — run \`tot app dev emit\` first`);
48
+ res.writeHead(401, { "content-type": "application/json" }).end(JSON.stringify({ error: "no trusted key on file" }));
49
+ return;
50
+ }
51
+
52
+ const result = await verifyWebhookSignature({
53
+ method: req.method,
54
+ url: `http://${req.headers.host}${req.url}`,
55
+ body,
56
+ headers: {
57
+ "content-digest": req.headers["content-digest"],
58
+ "signature-input": req.headers["signature-input"],
59
+ signature: req.headers["signature"],
60
+ },
61
+ publicKey,
62
+ });
63
+
64
+ if (!result.ok) {
65
+ console.error(`[${requestId}] signature verification FAILED: ${result.code} — ${result.reason}`);
66
+ res.writeHead(401, { "content-type": "application/json" }).end(JSON.stringify(result));
67
+ return;
68
+ }
69
+
70
+ console.log(`[${requestId}] verified delivery (keyid=${result.keyid}, created=${result.created})`);
71
+ // ── Handle the event ────────────────────────────────────────────────────
72
+ const event = JSON.parse(body);
73
+ console.log(`[${requestId}] ${event.type} for tenant ${event.tenantid}`);
74
+ // ─────────────────────────────────────────────────────────────────────────
75
+
76
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
77
+ });
78
+
79
+ server.listen(PORT, () => {
80
+ console.log(`webhook receiver listening on http://localhost:${PORT} (POST here — see tot-app.json webhooks.endpoint)`);
81
+ });