@mccaskie-labs/workbench-config 0.1.14 → 0.1.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.1.16
4
+
5
+ ### Patch Changes
6
+
7
+ - Capabilities: the `country` component (workbench-country) with its `country.packs` and `country.spelling` probes.
8
+
9
+ ## 0.1.15
10
+
11
+ ### Patch Changes
12
+
13
+ - The `email` manifest's probe ids now match wb-probe's, which already hold stored history and appear in the phase 4 checkpoint: `email.send.inline-attachment` (was `email.send.inline-image`) and `email.send.dev-key-recipient-refused` (was `email.send.dev-recipient-refused`). `email.send.transactional` is no longer in the daily smoke run, so the smoke run sends no daily mail. `email.send.new-sender` (after the Control drill's `gw_sender_add`) is listed.
14
+ - The `config` manifest lists the brand file exports (`BRAND_FILE`, `brandSchema`, `defineBrand`, `resolveBrand`, `BRAND_DEFAULTS`), a rule to keep one `brand.json` per app, and the probe `config.brand-file`.
15
+
3
16
  ## 0.1.14
4
17
 
5
18
  ### Patch Changes
@@ -17,7 +17,12 @@
17
17
  "capabilitySchema",
18
18
  "capabilities",
19
19
  "MANIFEST_VERSION",
20
- "compareDevVars"
20
+ "compareDevVars",
21
+ "BRAND_FILE",
22
+ "brandSchema",
23
+ "defineBrand",
24
+ "resolveBrand",
25
+ "BRAND_DEFAULTS"
21
26
  ]
22
27
  },
23
28
  "config": [
@@ -36,7 +41,8 @@
36
41
  { "id": "config.fail-by-name", "kind": "must", "text": "Fail at startup when a required variable is missing or invalid, naming every bad variable in one error.", "enforced_by": "types", "probe": "config.missing-variable-named" },
37
42
  { "id": "config.never-print-values", "kind": "must_not", "text": "Print, log or include in an error the value of any variable, valid or not.", "enforced_by": "types", "probe": "config.value-never-printed" },
38
43
  { "id": "config.no-node-env", "kind": "must_not", "text": "Decide the environment from NODE_ENV or a default. WORKBENCH_ENV is required and has no fallback.", "enforced_by": "types", "probe": "config.env-required" },
39
- { "id": "config.dev-vars-example", "kind": "must", "text": "Keep .dev.vars.example listing every variable .dev.vars has, with no values; check with npx workbench-config check.", "enforced_by": "review", "probe": "config.dev-vars-check" }
44
+ { "id": "config.dev-vars-example", "kind": "must", "text": "Keep .dev.vars.example listing every variable .dev.vars has, with no values; check with npx workbench-config check.", "enforced_by": "review", "probe": "config.dev-vars-check" },
45
+ { "id": "config.one-brand-file", "kind": "must", "text": "Keep the app's name, logo, colours and fonts in brand.json at the repo root, read with defineBrand, so emails, PDFs and sign-in mails are branded from one file.", "enforced_by": "types", "probe": "config.brand-file" }
40
46
  ],
41
47
  "examples": [
42
48
  {
@@ -51,6 +57,7 @@
51
57
  { "id": "config.missing-variable-named", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0 },
52
58
  { "id": "config.value-never-printed", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0 },
53
59
  { "id": "config.env-required", "half": "runtime", "expect": "pass", "cost_hint_usd": 0 },
60
+ { "id": "config.brand-file", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "The app's brand.json passes defineBrand and a bad one is refused with every bad field named" },
54
61
  { "id": "config.dev-vars-check", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "Manual: run npx workbench-config check in the project" }
55
62
  ]
56
63
  }
@@ -0,0 +1,83 @@
1
+ {
2
+ "$schema": "./schema.json",
3
+ "format": 1,
4
+ "component": "country",
5
+ "title": "Country packs and the one formatter",
6
+ "summary": "workbench-country writes dates, times, numbers and money the way readers in the Philippines, Australia, the UK and the US expect, from one pack per country, and gives each pack's spelling for the Gateway. It runs in the app itself, with no Gateway call and no cost.",
7
+ "status": "built",
8
+ "surfaces": {
9
+ "package": [
10
+ "COUNTRY_PACKS",
11
+ "countryPackFor",
12
+ "audiencePack",
13
+ "checkPack",
14
+ "CountryPackError",
15
+ "formatDate",
16
+ "formatDateTime",
17
+ "calendarDayIn",
18
+ "formatNumber",
19
+ "formatMoney",
20
+ "formattingLocale",
21
+ "currencySymbol",
22
+ "currencySymbolFrom",
23
+ "WORLDWIDE",
24
+ "isWorldwide"
25
+ ]
26
+ },
27
+ "rules": [
28
+ {
29
+ "id": "country.one-formatter",
30
+ "kind": "must",
31
+ "text": "Write every date, time, number and amount readers see through workbench-country, from the app's audiencePack, not hand-built strings or a bare toLocaleString.",
32
+ "enforced_by": "review",
33
+ "probe": "country.packs"
34
+ },
35
+ {
36
+ "id": "country.month-in-words",
37
+ "kind": "must_not",
38
+ "text": "Write a date readers see as digits only (03/04/2026): the month is always in words, so day-first and month-first readers read it the same way.",
39
+ "enforced_by": "types",
40
+ "probe": "country.packs"
41
+ },
42
+ {
43
+ "id": "country.no-bare-dollar",
44
+ "kind": "must_not",
45
+ "text": "Write an amount with a bare \"$\": Australian dollars are A$ and US dollars US$.",
46
+ "enforced_by": "types",
47
+ "probe": "country.packs"
48
+ },
49
+ {
50
+ "id": "country.spelling-from-pack",
51
+ "kind": "must",
52
+ "text": "Pass the pack's spelling as workbench-ai's spelling option, so AI text is spelled for the same readers its dates and money are written for.",
53
+ "enforced_by": "review",
54
+ "probe": "country.spelling"
55
+ }
56
+ ],
57
+ "examples": [
58
+ {
59
+ "id": "country.example.format",
60
+ "title": "Write a date and a price for the app's readers",
61
+ "lang": "ts",
62
+ "probe": "country.packs",
63
+ "code": "import { audiencePack, formatDate, formatMoney } from '@mccaskie-labs/workbench-country'\n\nconst pack = audiencePack({ country: 'PH' })\nconst line = `Due ${formatDate('2026-09-26', { pack })}: ${formatMoney(1400, pack)}`\n// \"Due September 26, 2026: ₱1,400\"\nconst reply = await ai.text({ prompt, spelling: pack.spelling })"
64
+ }
65
+ ],
66
+ "probes": [
67
+ {
68
+ "id": "country.packs",
69
+ "half": "runtime",
70
+ "expect": "pass",
71
+ "smoke": true,
72
+ "cost_hint_usd": 0,
73
+ "note": "In the Worker itself: formats one calendar date, one moment and one amount for PH, AU, GB and US and compares each with the expected text (September 26, 2026 / 26 September 2026; ₱1,400, A$1,400, £1,400, US$1,400)"
74
+ },
75
+ {
76
+ "id": "country.spelling",
77
+ "half": "runtime",
78
+ "expect": "pass",
79
+ "cost_hint_usd": 0,
80
+ "note": "Every pack's spelling is one the Gateway accepts (en-PH, en-AU, en-GB, en-US), checked against the ai manifest's spellings; no Gateway call"
81
+ }
82
+ ]
83
+ }
@@ -35,10 +35,10 @@
35
35
  "rules": [
36
36
  { "id": "email.text-always", "kind": "must", "text": "Send a text part with every mail, even when there is HTML.", "enforced_by": "gateway", "probe": "email.send.transactional" },
37
37
  { "id": "email.idempotency-key", "kind": "must", "text": "Give an idempotencyKey to any send a retry could repeat (invites, receipts, resets). A repeat within 24 hours returns the first result and sends nothing.", "enforced_by": "review", "probe": "email.send.idempotent-repeat" },
38
- { "id": "email.attachments-as-bytes", "kind": "must", "text": "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.", "enforced_by": "types", "probe": "email.send.inline-image" },
38
+ { "id": "email.attachments-as-bytes", "kind": "must", "text": "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.", "enforced_by": "types", "probe": "email.send.inline-attachment" },
39
39
  { "id": "email.allowed-sender", "kind": "must", "text": "Send only from an address added with gw_sender_add on a domain onboarded with cf_email_sending_onboard.", "enforced_by": "gateway", "probe": "email.send.sender-not-allowed" },
40
40
  { "id": "email.no-capture-outside-dev", "kind": "must_not", "text": "Use the capture transport outside development. createMailer throws when WORKBENCH_ENV is staging or production.", "enforced_by": "types", "probe": "email.capture-refused-in-staging" },
41
- { "id": "email.dev-key-allowlist", "kind": "must_not", "text": "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.", "enforced_by": "gateway", "probe": "email.send.dev-recipient-refused" },
41
+ { "id": "email.dev-key-allowlist", "kind": "must_not", "text": "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.", "enforced_by": "gateway", "probe": "email.send.dev-key-recipient-refused" },
42
42
  { "id": "email.transactional-only", "kind": "must_not", "text": "Send bulk or marketing mail. type bulk is refused with not_available until Amazon SES is built.", "enforced_by": "gateway", "probe": "email.send.transactional" }
43
43
  ],
44
44
  "examples": [
@@ -46,16 +46,17 @@
46
46
  "id": "email.example.send",
47
47
  "title": "Send a transactional mail with an inline logo",
48
48
  "lang": "ts",
49
- "probe": "email.send.inline-image",
49
+ "probe": "email.send.inline-attachment",
50
50
  "code": "const env = workbenchEnv(process.env)\nconst mailer = createMailer({ env, transport: selectTransport(env), from: { address: 'hello@mail.example.com', name: 'Example' }, gateway: { key: process.env.WORKBENCH_GATEWAY_KEY! } })\nawait mailer.send({ to: user.email, subject: 'Welcome', text, html: '<img src=\"cid:logo\"> ...', template: 'welcome', idempotencyKey: `welcome:${user.id}`, attachments: [{ filename: 'logo.png', mimeType: 'image/png', bytes: logo, contentId: 'logo' }] })"
51
51
  }
52
52
  ],
53
53
  "probes": [
54
- { "id": "email.send.transactional", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0.00035, "note": "To Robert; check that it arrives" },
55
- { "id": "email.send.inline-image", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.00035, "note": "Robert checks the image renders" },
54
+ { "id": "email.send.transactional", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.00035, "note": "To Robert; check that it arrives. Not in the daily smoke run, so it sends no daily mail" },
55
+ { "id": "email.send.inline-attachment", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.00035, "note": "Robert checks the image renders" },
56
56
  { "id": "email.send.idempotent-repeat", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.00035, "note": "The repeat answers replayed: true and no second mail arrives" },
57
- { "id": "email.send.dev-recipient-refused", "half": "runtime", "expect": "refused_as_expected", "refusal": "recipient_not_allowed", "cost_hint_usd": 0 },
57
+ { "id": "email.send.dev-key-recipient-refused", "half": "runtime", "expect": "refused_as_expected", "refusal": "recipient_not_allowed", "cost_hint_usd": 0 },
58
58
  { "id": "email.send.sender-not-allowed", "half": "runtime", "expect": "refused_as_expected", "refusal": "sender_not_allowed", "cost_hint_usd": 0 },
59
- { "id": "email.capture-refused-in-staging", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config" }
59
+ { "id": "email.capture-refused-in-staging", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config" },
60
+ { "id": "email.send.new-sender", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.00035, "note": "Runs after the Control drill's gw_sender_add step; skipped without it" }
60
61
  ]
61
62
  }
@@ -26,6 +26,7 @@
26
26
  "domains",
27
27
  "dns",
28
28
  "analytics",
29
+ "country",
29
30
  "access",
30
31
  "r2",
31
32
  "kv",
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "d0f2550e1f81";
1
+ export declare const MANIFEST_VERSION: "c72ad5ff6960";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -275,7 +275,7 @@ export declare const capabilities: {
275
275
  readonly summary: "Reads WORKBENCH_ENV and the app's own variables and secrets. A missing or invalid variable fails at startup, by name, and no value is ever printed.";
276
276
  readonly status: "built";
277
277
  readonly surfaces: {
278
- readonly package: readonly ["workbenchEnv", "defineConfig", "sharedSecret", "workerSecret", "redact", "redactAll", "WorkbenchConfigError", "capabilitySchema", "capabilities", "MANIFEST_VERSION", "compareDevVars"];
278
+ readonly package: readonly ["workbenchEnv", "defineConfig", "sharedSecret", "workerSecret", "redact", "redactAll", "WorkbenchConfigError", "capabilitySchema", "capabilities", "MANIFEST_VERSION", "compareDevVars", "BRAND_FILE", "brandSchema", "defineBrand", "resolveBrand", "BRAND_DEFAULTS"];
279
279
  };
280
280
  readonly config: readonly [{
281
281
  readonly name: "WORKBENCH_ENV";
@@ -311,6 +311,12 @@ export declare const capabilities: {
311
311
  readonly text: "Keep .dev.vars.example listing every variable .dev.vars has, with no values; check with npx workbench-config check.";
312
312
  readonly enforced_by: "review";
313
313
  readonly probe: "config.dev-vars-check";
314
+ }, {
315
+ readonly id: "config.one-brand-file";
316
+ readonly kind: "must";
317
+ readonly text: "Keep the app's name, logo, colours and fonts in brand.json at the repo root, read with defineBrand, so emails, PDFs and sign-in mails are branded from one file.";
318
+ readonly enforced_by: "types";
319
+ readonly probe: "config.brand-file";
314
320
  }];
315
321
  readonly examples: readonly [{
316
322
  readonly id: "config.example.define";
@@ -336,6 +342,12 @@ export declare const capabilities: {
336
342
  readonly half: "runtime";
337
343
  readonly expect: "pass";
338
344
  readonly cost_hint_usd: 0;
345
+ }, {
346
+ readonly id: "config.brand-file";
347
+ readonly half: "runtime";
348
+ readonly expect: "pass";
349
+ readonly cost_hint_usd: 0;
350
+ readonly note: "The app's brand.json passes defineBrand and a bad one is refused with every bad field named";
339
351
  }, {
340
352
  readonly id: "config.dev-vars-check";
341
353
  readonly half: "runtime";
@@ -344,6 +356,63 @@ export declare const capabilities: {
344
356
  readonly note: "Manual: run npx workbench-config check in the project";
345
357
  }];
346
358
  };
359
+ readonly country: {
360
+ readonly $schema: "./schema.json";
361
+ readonly format: 1;
362
+ readonly component: "country";
363
+ readonly title: "Country packs and the one formatter";
364
+ readonly summary: "workbench-country writes dates, times, numbers and money the way readers in the Philippines, Australia, the UK and the US expect, from one pack per country, and gives each pack's spelling for the Gateway. It runs in the app itself, with no Gateway call and no cost.";
365
+ readonly status: "built";
366
+ readonly surfaces: {
367
+ readonly package: readonly ["COUNTRY_PACKS", "countryPackFor", "audiencePack", "checkPack", "CountryPackError", "formatDate", "formatDateTime", "calendarDayIn", "formatNumber", "formatMoney", "formattingLocale", "currencySymbol", "currencySymbolFrom", "WORLDWIDE", "isWorldwide"];
368
+ };
369
+ readonly rules: readonly [{
370
+ readonly id: "country.one-formatter";
371
+ readonly kind: "must";
372
+ readonly text: "Write every date, time, number and amount readers see through workbench-country, from the app's audiencePack, not hand-built strings or a bare toLocaleString.";
373
+ readonly enforced_by: "review";
374
+ readonly probe: "country.packs";
375
+ }, {
376
+ readonly id: "country.month-in-words";
377
+ readonly kind: "must_not";
378
+ readonly text: "Write a date readers see as digits only (03/04/2026): the month is always in words, so day-first and month-first readers read it the same way.";
379
+ readonly enforced_by: "types";
380
+ readonly probe: "country.packs";
381
+ }, {
382
+ readonly id: "country.no-bare-dollar";
383
+ readonly kind: "must_not";
384
+ readonly text: "Write an amount with a bare \"$\": Australian dollars are A$ and US dollars US$.";
385
+ readonly enforced_by: "types";
386
+ readonly probe: "country.packs";
387
+ }, {
388
+ readonly id: "country.spelling-from-pack";
389
+ readonly kind: "must";
390
+ readonly text: "Pass the pack's spelling as workbench-ai's spelling option, so AI text is spelled for the same readers its dates and money are written for.";
391
+ readonly enforced_by: "review";
392
+ readonly probe: "country.spelling";
393
+ }];
394
+ readonly examples: readonly [{
395
+ readonly id: "country.example.format";
396
+ readonly title: "Write a date and a price for the app's readers";
397
+ readonly lang: "ts";
398
+ readonly code: "import { audiencePack, formatDate, formatMoney } from '@mccaskie-labs/workbench-country'\n\nconst pack = audiencePack({ country: 'PH' })\nconst line = `Due ${formatDate('2026-09-26', { pack })}: ${formatMoney(1400, pack)}`\n// \"Due September 26, 2026: ₱1,400\"\nconst reply = await ai.text({ prompt, spelling: pack.spelling })";
399
+ readonly probe: "country.packs";
400
+ }];
401
+ readonly probes: readonly [{
402
+ readonly id: "country.packs";
403
+ readonly half: "runtime";
404
+ readonly expect: "pass";
405
+ readonly smoke: true;
406
+ readonly cost_hint_usd: 0;
407
+ readonly note: "In the Worker itself: formats one calendar date, one moment and one amount for PH, AU, GB and US and compares each with the expected text (September 26, 2026 / 26 September 2026; ₱1,400, A$1,400, £1,400, US$1,400)";
408
+ }, {
409
+ readonly id: "country.spelling";
410
+ readonly half: "runtime";
411
+ readonly expect: "pass";
412
+ readonly cost_hint_usd: 0;
413
+ readonly note: "Every pack's spelling is one the Gateway accepts (en-PH, en-AU, en-GB, en-US), checked against the ai manifest's spellings; no Gateway call";
414
+ }];
415
+ };
347
416
  readonly database: {
348
417
  readonly $schema: "./schema.json";
349
418
  readonly format: 1;
@@ -610,7 +679,7 @@ export declare const capabilities: {
610
679
  readonly kind: "must";
611
680
  readonly text: "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.";
612
681
  readonly enforced_by: "types";
613
- readonly probe: "email.send.inline-image";
682
+ readonly probe: "email.send.inline-attachment";
614
683
  }, {
615
684
  readonly id: "email.allowed-sender";
616
685
  readonly kind: "must";
@@ -628,7 +697,7 @@ export declare const capabilities: {
628
697
  readonly kind: "must_not";
629
698
  readonly text: "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.";
630
699
  readonly enforced_by: "gateway";
631
- readonly probe: "email.send.dev-recipient-refused";
700
+ readonly probe: "email.send.dev-key-recipient-refused";
632
701
  }, {
633
702
  readonly id: "email.transactional-only";
634
703
  readonly kind: "must_not";
@@ -641,17 +710,16 @@ export declare const capabilities: {
641
710
  readonly title: "Send a transactional mail with an inline logo";
642
711
  readonly lang: "ts";
643
712
  readonly code: "const env = workbenchEnv(process.env)\nconst mailer = createMailer({ env, transport: selectTransport(env), from: { address: 'hello@mail.example.com', name: 'Example' }, gateway: { key: process.env.WORKBENCH_GATEWAY_KEY! } })\nawait mailer.send({ to: user.email, subject: 'Welcome', text, html: '<img src=\"cid:logo\"> ...', template: 'welcome', idempotencyKey: `welcome:${user.id}`, attachments: [{ filename: 'logo.png', mimeType: 'image/png', bytes: logo, contentId: 'logo' }] })";
644
- readonly probe: "email.send.inline-image";
713
+ readonly probe: "email.send.inline-attachment";
645
714
  }];
646
715
  readonly probes: readonly [{
647
716
  readonly id: "email.send.transactional";
648
717
  readonly half: "runtime";
649
718
  readonly expect: "pass";
650
- readonly smoke: true;
651
719
  readonly cost_hint_usd: 0.00035;
652
- readonly note: "To Robert; check that it arrives";
720
+ readonly note: "To Robert; check that it arrives. Not in the daily smoke run, so it sends no daily mail";
653
721
  }, {
654
- readonly id: "email.send.inline-image";
722
+ readonly id: "email.send.inline-attachment";
655
723
  readonly half: "runtime";
656
724
  readonly expect: "pass";
657
725
  readonly cost_hint_usd: 0.00035;
@@ -663,7 +731,7 @@ export declare const capabilities: {
663
731
  readonly cost_hint_usd: 0.00035;
664
732
  readonly note: "The repeat answers replayed: true and no second mail arrives";
665
733
  }, {
666
- readonly id: "email.send.dev-recipient-refused";
734
+ readonly id: "email.send.dev-key-recipient-refused";
667
735
  readonly half: "runtime";
668
736
  readonly expect: "refused_as_expected";
669
737
  readonly refusal: "recipient_not_allowed";
@@ -680,6 +748,12 @@ export declare const capabilities: {
680
748
  readonly expect: "pass";
681
749
  readonly cost_hint_usd: 0;
682
750
  readonly note: "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config";
751
+ }, {
752
+ readonly id: "email.send.new-sender";
753
+ readonly half: "runtime";
754
+ readonly expect: "pass";
755
+ readonly cost_hint_usd: 0.00035;
756
+ readonly note: "Runs after the Control drill's gw_sender_add step; skipped without it";
683
757
  }];
684
758
  };
685
759
  readonly "email-inbound": {
@@ -1,6 +1,6 @@
1
1
  // Generated by `npm run capabilities:build` from capabilities/*.json. Do not edit.
2
2
  // check:capabilities fails in Checks when this file is stale.
3
- export const MANIFEST_VERSION = "d0f2550e1f81";
3
+ export const MANIFEST_VERSION = "c72ad5ff6960";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -412,7 +412,12 @@ export const capabilities = {
412
412
  "capabilitySchema",
413
413
  "capabilities",
414
414
  "MANIFEST_VERSION",
415
- "compareDevVars"
415
+ "compareDevVars",
416
+ "BRAND_FILE",
417
+ "brandSchema",
418
+ "defineBrand",
419
+ "resolveBrand",
420
+ "BRAND_DEFAULTS"
416
421
  ]
417
422
  },
418
423
  "config": [
@@ -463,6 +468,13 @@ export const capabilities = {
463
468
  "text": "Keep .dev.vars.example listing every variable .dev.vars has, with no values; check with npx workbench-config check.",
464
469
  "enforced_by": "review",
465
470
  "probe": "config.dev-vars-check"
471
+ },
472
+ {
473
+ "id": "config.one-brand-file",
474
+ "kind": "must",
475
+ "text": "Keep the app's name, logo, colours and fonts in brand.json at the repo root, read with defineBrand, so emails, PDFs and sign-in mails are branded from one file.",
476
+ "enforced_by": "types",
477
+ "probe": "config.brand-file"
466
478
  }
467
479
  ],
468
480
  "examples": [
@@ -495,6 +507,13 @@ export const capabilities = {
495
507
  "expect": "pass",
496
508
  "cost_hint_usd": 0
497
509
  },
510
+ {
511
+ "id": "config.brand-file",
512
+ "half": "runtime",
513
+ "expect": "pass",
514
+ "cost_hint_usd": 0,
515
+ "note": "The app's brand.json passes defineBrand and a bad one is refused with every bad field named"
516
+ },
498
517
  {
499
518
  "id": "config.dev-vars-check",
500
519
  "half": "runtime",
@@ -504,6 +523,89 @@ export const capabilities = {
504
523
  }
505
524
  ]
506
525
  },
526
+ "country": {
527
+ "$schema": "./schema.json",
528
+ "format": 1,
529
+ "component": "country",
530
+ "title": "Country packs and the one formatter",
531
+ "summary": "workbench-country writes dates, times, numbers and money the way readers in the Philippines, Australia, the UK and the US expect, from one pack per country, and gives each pack's spelling for the Gateway. It runs in the app itself, with no Gateway call and no cost.",
532
+ "status": "built",
533
+ "surfaces": {
534
+ "package": [
535
+ "COUNTRY_PACKS",
536
+ "countryPackFor",
537
+ "audiencePack",
538
+ "checkPack",
539
+ "CountryPackError",
540
+ "formatDate",
541
+ "formatDateTime",
542
+ "calendarDayIn",
543
+ "formatNumber",
544
+ "formatMoney",
545
+ "formattingLocale",
546
+ "currencySymbol",
547
+ "currencySymbolFrom",
548
+ "WORLDWIDE",
549
+ "isWorldwide"
550
+ ]
551
+ },
552
+ "rules": [
553
+ {
554
+ "id": "country.one-formatter",
555
+ "kind": "must",
556
+ "text": "Write every date, time, number and amount readers see through workbench-country, from the app's audiencePack, not hand-built strings or a bare toLocaleString.",
557
+ "enforced_by": "review",
558
+ "probe": "country.packs"
559
+ },
560
+ {
561
+ "id": "country.month-in-words",
562
+ "kind": "must_not",
563
+ "text": "Write a date readers see as digits only (03/04/2026): the month is always in words, so day-first and month-first readers read it the same way.",
564
+ "enforced_by": "types",
565
+ "probe": "country.packs"
566
+ },
567
+ {
568
+ "id": "country.no-bare-dollar",
569
+ "kind": "must_not",
570
+ "text": "Write an amount with a bare \"$\": Australian dollars are A$ and US dollars US$.",
571
+ "enforced_by": "types",
572
+ "probe": "country.packs"
573
+ },
574
+ {
575
+ "id": "country.spelling-from-pack",
576
+ "kind": "must",
577
+ "text": "Pass the pack's spelling as workbench-ai's spelling option, so AI text is spelled for the same readers its dates and money are written for.",
578
+ "enforced_by": "review",
579
+ "probe": "country.spelling"
580
+ }
581
+ ],
582
+ "examples": [
583
+ {
584
+ "id": "country.example.format",
585
+ "title": "Write a date and a price for the app's readers",
586
+ "lang": "ts",
587
+ "code": "import { audiencePack, formatDate, formatMoney } from '@mccaskie-labs/workbench-country'\n\nconst pack = audiencePack({ country: 'PH' })\nconst line = `Due ${formatDate('2026-09-26', { pack })}: ${formatMoney(1400, pack)}`\n// \"Due September 26, 2026: ₱1,400\"\nconst reply = await ai.text({ prompt, spelling: pack.spelling })",
588
+ "probe": "country.packs"
589
+ }
590
+ ],
591
+ "probes": [
592
+ {
593
+ "id": "country.packs",
594
+ "half": "runtime",
595
+ "expect": "pass",
596
+ "smoke": true,
597
+ "cost_hint_usd": 0,
598
+ "note": "In the Worker itself: formats one calendar date, one moment and one amount for PH, AU, GB and US and compares each with the expected text (September 26, 2026 / 26 September 2026; ₱1,400, A$1,400, £1,400, US$1,400)"
599
+ },
600
+ {
601
+ "id": "country.spelling",
602
+ "half": "runtime",
603
+ "expect": "pass",
604
+ "cost_hint_usd": 0,
605
+ "note": "Every pack's spelling is one the Gateway accepts (en-PH, en-AU, en-GB, en-US), checked against the ai manifest's spellings; no Gateway call"
606
+ }
607
+ ]
608
+ },
507
609
  "database": {
508
610
  "$schema": "./schema.json",
509
611
  "format": 1,
@@ -861,7 +963,7 @@ export const capabilities = {
861
963
  "kind": "must",
862
964
  "text": "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.",
863
965
  "enforced_by": "types",
864
- "probe": "email.send.inline-image"
966
+ "probe": "email.send.inline-attachment"
865
967
  },
866
968
  {
867
969
  "id": "email.allowed-sender",
@@ -882,7 +984,7 @@ export const capabilities = {
882
984
  "kind": "must_not",
883
985
  "text": "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.",
884
986
  "enforced_by": "gateway",
885
- "probe": "email.send.dev-recipient-refused"
987
+ "probe": "email.send.dev-key-recipient-refused"
886
988
  },
887
989
  {
888
990
  "id": "email.transactional-only",
@@ -898,7 +1000,7 @@ export const capabilities = {
898
1000
  "title": "Send a transactional mail with an inline logo",
899
1001
  "lang": "ts",
900
1002
  "code": "const env = workbenchEnv(process.env)\nconst mailer = createMailer({ env, transport: selectTransport(env), from: { address: 'hello@mail.example.com', name: 'Example' }, gateway: { key: process.env.WORKBENCH_GATEWAY_KEY! } })\nawait mailer.send({ to: user.email, subject: 'Welcome', text, html: '<img src=\"cid:logo\"> ...', template: 'welcome', idempotencyKey: `welcome:${user.id}`, attachments: [{ filename: 'logo.png', mimeType: 'image/png', bytes: logo, contentId: 'logo' }] })",
901
- "probe": "email.send.inline-image"
1003
+ "probe": "email.send.inline-attachment"
902
1004
  }
903
1005
  ],
904
1006
  "probes": [
@@ -906,12 +1008,11 @@ export const capabilities = {
906
1008
  "id": "email.send.transactional",
907
1009
  "half": "runtime",
908
1010
  "expect": "pass",
909
- "smoke": true,
910
1011
  "cost_hint_usd": 0.00035,
911
- "note": "To Robert; check that it arrives"
1012
+ "note": "To Robert; check that it arrives. Not in the daily smoke run, so it sends no daily mail"
912
1013
  },
913
1014
  {
914
- "id": "email.send.inline-image",
1015
+ "id": "email.send.inline-attachment",
915
1016
  "half": "runtime",
916
1017
  "expect": "pass",
917
1018
  "cost_hint_usd": 0.00035,
@@ -925,7 +1026,7 @@ export const capabilities = {
925
1026
  "note": "The repeat answers replayed: true and no second mail arrives"
926
1027
  },
927
1028
  {
928
- "id": "email.send.dev-recipient-refused",
1029
+ "id": "email.send.dev-key-recipient-refused",
929
1030
  "half": "runtime",
930
1031
  "expect": "refused_as_expected",
931
1032
  "refusal": "recipient_not_allowed",
@@ -944,6 +1045,13 @@ export const capabilities = {
944
1045
  "expect": "pass",
945
1046
  "cost_hint_usd": 0,
946
1047
  "note": "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config"
1048
+ },
1049
+ {
1050
+ "id": "email.send.new-sender",
1051
+ "half": "runtime",
1052
+ "expect": "pass",
1053
+ "cost_hint_usd": 0.00035,
1054
+ "note": "Runs after the Control drill's gw_sender_add step; skipped without it"
947
1055
  }
948
1056
  ]
949
1057
  },
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "access", "r2", "kv", "github"];
2
+ export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "access", "r2", "kv", "github"];
3
3
  export type Component = (typeof COMPONENTS)[number];
4
4
  export declare const allowedSchemas: {
5
5
  gateway: z.ZodObject<{
@@ -96,6 +96,7 @@ export declare const capabilitySchema: z.ZodObject<{
96
96
  ai: "ai";
97
97
  analytics: "analytics";
98
98
  config: "config";
99
+ country: "country";
99
100
  database: "database";
100
101
  dns: "dns";
101
102
  domains: "domains";
@@ -16,6 +16,7 @@ export const COMPONENTS = [
16
16
  "domains",
17
17
  "dns",
18
18
  "analytics",
19
+ "country",
19
20
  "access",
20
21
  "r2",
21
22
  "kv",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Workbench app configuration: WORKBENCH_ENV, typed variables that fail by name, secrets that are never printed, and the capabilities manifest.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",