@mccaskie-labs/workbench-config 0.1.17 → 0.1.19
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 +12 -0
- package/README.md +1 -0
- package/capabilities/database.json +22 -1
- package/capabilities/schema.json +2 -1
- package/capabilities/strings.json +131 -0
- package/dist/capabilities.gen.d.ts +126 -2
- package/dist/capabilities.gen.js +154 -2
- package/dist/capability-schema.d.ts +2 -1
- package/dist/capability-schema.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/vendor-settings.d.ts +97 -0
- package/dist/vendor-settings.js +163 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-config
|
|
2
2
|
|
|
3
|
+
## 0.1.19
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- The vendor settings baseline: `VENDOR_SETTINGS`, the register of settings that live in a vendor's dashboard (id, API field, value, where it is, what breaks if it changes), starting with Supabase sign-in: six-digit email codes, a 10-minute code lifetime, the Send Email hook on, current password to change a password, secure email change. A project changes a row in `vendor-settings.json` (`VENDOR_SETTINGS_FILE`) with a reason; `resolveVendorSettings` applies it and names every problem at once. `SEND_EMAIL_HOOK_PATH` and `SEND_EMAIL_HOOK_SECRET` name the hook's address and the Worker secret. Control applies the baseline with `vendor_settings_apply` and checks it daily.
|
|
8
|
+
|
|
9
|
+
## 0.1.18
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- The `strings` component: `capabilities/strings.json` (rules and probes for `@mccaskie-labs/workbench-strings`) joins the manifests, and `strings` joins the component list.
|
|
14
|
+
|
|
3
15
|
## 0.1.17
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ const { WORKBENCH_GATEWAY_KEY } = config.parse(env)
|
|
|
28
28
|
| `redact(value)`, `redactAll(text, secrets)` | `[redacted]` in place of a secret, for logs |
|
|
29
29
|
| `capabilities`, `MANIFEST_VERSION`, `capabilitySchema` | The Workbench capabilities manifests and their schema. The JSON files are also at `@mccaskie-labs/workbench-config/capabilities/<component>.json` |
|
|
30
30
|
| `defineBrand(json)`, `brandSchema`, `resolveBrand`, `BRAND_DEFAULTS`, `BRAND_FILE` | The app's brand file, `brand.json` at the repo root: `name`, `siteUrl` (https), `logo.url` (https PNG, JPEG or SVG; optional `alt`, `width`, `height`), `colours.primary` (`#rrggbb`; `text`, `background`, `muted` default), optional `fonts.heading`/`fonts.body` (`{ family, url? }`, `url` an https stylesheet), `supportEmail` and `footer`. Unknown keys fail. Account emails, PDFs and Supabase's sign-in emails all read it |
|
|
31
|
+
| `VENDOR_SETTINGS`, `resolveVendorSettings(file, env)`, `vendorSettingsFileSchema`, `VENDOR_SETTINGS_FILE`, `SEND_EMAIL_HOOK`, `SEND_EMAIL_HOOK_PATH`, `SEND_EMAIL_HOOK_SECRET` | The vendor settings baseline: settings that live in a vendor dashboard (field, value, where it is, what breaks if it changes). A project changes a row in `vendor-settings.json` at its repo root, with a reason. Control applies it with `vendor_settings_apply` and compares it every day |
|
|
31
32
|
| `DEFAULT_GATEWAY_URL` | `https://gateway.mccaskielabs.dev` |
|
|
32
33
|
|
|
33
34
|
`npx workbench-config check [dir]` compares the variable names in `.dev.vars` with `.dev.vars.example` and prints names only.
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"sb_migration_rollback",
|
|
21
21
|
"sb_query",
|
|
22
22
|
"sb_project_delete",
|
|
23
|
-
"cf_worker_secret_put"
|
|
23
|
+
"cf_worker_secret_put",
|
|
24
|
+
"vendor_settings_apply"
|
|
24
25
|
]
|
|
25
26
|
},
|
|
26
27
|
"config": [
|
|
@@ -91,6 +92,12 @@
|
|
|
91
92
|
"gate": "two_step_when",
|
|
92
93
|
"when": "source supabase_secret, or replacing a secret that exists",
|
|
93
94
|
"ships": "4"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"tool": "vendor_settings_apply",
|
|
98
|
+
"gate": "two_step_when",
|
|
99
|
+
"when": "production, after the first successful apply on that database; staging and check_only are free",
|
|
100
|
+
"ships": "5"
|
|
94
101
|
}
|
|
95
102
|
],
|
|
96
103
|
"rules": [
|
|
@@ -128,6 +135,13 @@
|
|
|
128
135
|
"text": "Ship code that names a table, column or function production's database lacks; check with npx workbench-supabase check:prod-schema.",
|
|
129
136
|
"enforced_by": "review",
|
|
130
137
|
"probe": "database.prod-schema-check"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"id": "database.vendor-settings-baseline",
|
|
141
|
+
"kind": "must",
|
|
142
|
+
"text": "Keep Supabase's sign-in settings at the Workbench baseline (workbench-config VENDOR_SETTINGS), or record a project's change in vendor-settings.json with a reason. Control applies it with vendor_settings_apply and checks it every day.",
|
|
143
|
+
"enforced_by": "control",
|
|
144
|
+
"probe": "database.vendor-settings"
|
|
131
145
|
}
|
|
132
146
|
],
|
|
133
147
|
"examples": [
|
|
@@ -181,6 +195,13 @@
|
|
|
181
195
|
"two_step": true,
|
|
182
196
|
"cost_hint_usd": 0,
|
|
183
197
|
"note": "sb_query on production with a write returns a confirmation, not a result"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"id": "database.vendor-settings",
|
|
201
|
+
"half": "drill",
|
|
202
|
+
"expect": "pass",
|
|
203
|
+
"cost_hint_usd": 0,
|
|
204
|
+
"note": "vendor_settings_apply check_only reports that both databases match the baseline, the Send Email hook included"
|
|
184
205
|
}
|
|
185
206
|
]
|
|
186
207
|
}
|
package/capabilities/schema.json
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema.json",
|
|
3
|
+
"format": 1,
|
|
4
|
+
"component": "strings",
|
|
5
|
+
"title": "Words on screens and in emails",
|
|
6
|
+
"summary": "workbench-strings holds the words an app's own screens and emails say: one file per language in the app's repository, looked up on the server with named placeholders and plurals, one fallback chain to the base language, language detection with country before the browser's language, back-translation notes stripped at build, and a review gate so no unreviewed translation is inferred for anyone. The standard's tests ship as a kit.",
|
|
7
|
+
"status": "built",
|
|
8
|
+
"surfaces": {
|
|
9
|
+
"package": [
|
|
10
|
+
"defineStrings",
|
|
11
|
+
"resolveLocale",
|
|
12
|
+
"stripGlosses",
|
|
13
|
+
"WORKBENCH_LANGUAGES",
|
|
14
|
+
"WorkbenchStringsError"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"rules": [
|
|
18
|
+
{
|
|
19
|
+
"id": "strings.server-only",
|
|
20
|
+
"kind": "must_not",
|
|
21
|
+
"text": "Import workbench-strings into browser code: the catalogue holds every language. Look words up on the server and pass the finished words to the page; the package's browser export throws.",
|
|
22
|
+
"enforced_by": "types",
|
|
23
|
+
"probe": "strings.lookup"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "strings.no-literals",
|
|
27
|
+
"kind": "must_not",
|
|
28
|
+
"text": "Write user-visible words as literals in code, or keep a key nothing renders. Every word is a key in strings/strings.<base>.json, and the test kit's dead-key sweep fails on an unused one.",
|
|
29
|
+
"enforced_by": "review",
|
|
30
|
+
"probe": "strings.kit"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "strings.one-chain",
|
|
34
|
+
"kind": "must",
|
|
35
|
+
"text": "Use strings.t and strings.email for every lookup, so screens and emails share one fallback chain: the locale, its parent, then the base. A word found further down the chain is logged.",
|
|
36
|
+
"enforced_by": "types",
|
|
37
|
+
"probe": "strings.lookup"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "strings.country-before-header",
|
|
41
|
+
"kind": "must",
|
|
42
|
+
"text": "Detect the language in the order URL prefix, cookie, account, country, Accept-Language, default, with strings.detect. Country comes before the browser's language because phones report English whatever their owner reads.",
|
|
43
|
+
"enforced_by": "types",
|
|
44
|
+
"probe": "strings.detect"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "strings.review-gate",
|
|
48
|
+
"kind": "must_not",
|
|
49
|
+
"text": "Serve a translated language to someone who did not choose it until its catalogue has _review.signed_off naming the reviewer and the date; country, header and default resolve to the base until then.",
|
|
50
|
+
"enforced_by": "types",
|
|
51
|
+
"probe": "strings.gate"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "strings.named-reviewer",
|
|
55
|
+
"kind": "must_not",
|
|
56
|
+
"text": "Put a translated language live in production without _review.reviewer naming a native-speaker reviewer and their cadence; defineStrings refuses to start.",
|
|
57
|
+
"enforced_by": "types",
|
|
58
|
+
"probe": "strings.no-reviewer"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "strings.email-missing-throws",
|
|
62
|
+
"kind": "must",
|
|
63
|
+
"text": "Treat an email word missing in the recipient's language and in the base as an error: strings.email throws missing_string rather than sending a key.",
|
|
64
|
+
"enforced_by": "types",
|
|
65
|
+
"probe": "strings.email-missing"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "strings.glosses-stripped",
|
|
69
|
+
"kind": "must",
|
|
70
|
+
"text": "Keep a _gloss back-translation beside every translated string in the authored file, and ship only the generated file from npx workbench-strings build, which strips them; defineStrings refuses a catalogue with glosses.",
|
|
71
|
+
"enforced_by": "types",
|
|
72
|
+
"probe": "strings.kit"
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"examples": [
|
|
76
|
+
{
|
|
77
|
+
"id": "strings.example.lookup",
|
|
78
|
+
"title": "Detect the language and look words up",
|
|
79
|
+
"lang": "ts",
|
|
80
|
+
"probe": "strings.lookup",
|
|
81
|
+
"code": "// SERVER ONLY: the catalogue holds every language.\nimport { defineStrings } from '@mccaskie-labs/workbench-strings'\nimport enAU from '../strings/strings.en-AU.json'\nimport enPH from '../strings/runtime/strings.en-PH.json'\n\nconst strings = defineStrings({ env: workbenchEnv(env), base: { locale: 'en-AU', strings: enAU }, translations: { 'en-PH': enPH }, locales: ['en-PH', 'en-AU'], defaultLocale: 'en-PH' })\nconst { locale } = strings.detect(request, { account: user?.locale })\nconst title = strings.t(locale, 'ui.cart.count', { count: 3 })"
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"probes": [
|
|
85
|
+
{
|
|
86
|
+
"id": "strings.lookup",
|
|
87
|
+
"half": "runtime",
|
|
88
|
+
"expect": "pass",
|
|
89
|
+
"smoke": true,
|
|
90
|
+
"cost_hint_usd": 0,
|
|
91
|
+
"note": "Placeholders and a plural fill in each live locale, and a word missing in a translation comes from the base and is logged"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "strings.detect",
|
|
95
|
+
"half": "runtime",
|
|
96
|
+
"expect": "pass",
|
|
97
|
+
"cost_hint_usd": 0,
|
|
98
|
+
"note": "Each step of the order wins over the next; a PH visitor whose browser says en-US gets the PH locale; XX falls through to the header"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"id": "strings.gate",
|
|
102
|
+
"half": "runtime",
|
|
103
|
+
"expect": "pass",
|
|
104
|
+
"cost_hint_usd": 0,
|
|
105
|
+
"note": "An unsigned translation is not inferred (gated: true, base served) but is served from the cookie or prefix"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "strings.no-reviewer",
|
|
109
|
+
"half": "runtime",
|
|
110
|
+
"expect": "refused_as_expected",
|
|
111
|
+
"refusal": "no_reviewer",
|
|
112
|
+
"cost_hint_usd": 0,
|
|
113
|
+
"note": "defineStrings with env production and a translation with no _review.reviewer"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"id": "strings.email-missing",
|
|
117
|
+
"half": "runtime",
|
|
118
|
+
"expect": "refused_as_expected",
|
|
119
|
+
"refusal": "missing_string",
|
|
120
|
+
"cost_hint_usd": 0,
|
|
121
|
+
"note": "strings.email for a part no catalogue has"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"id": "strings.kit",
|
|
125
|
+
"half": "runtime",
|
|
126
|
+
"expect": "pass",
|
|
127
|
+
"cost_hint_usd": 0,
|
|
128
|
+
"note": "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI"
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const MANIFEST_VERSION: "
|
|
1
|
+
export declare const MANIFEST_VERSION: "71462ff7fe4c";
|
|
2
2
|
export declare const capabilities: {
|
|
3
3
|
readonly ai: {
|
|
4
4
|
readonly $schema: "./schema.json";
|
|
@@ -421,7 +421,7 @@ export declare const capabilities: {
|
|
|
421
421
|
readonly summary: "Each project has its own Supabase projects, <id>-staging and <id>-production. Apps use them natively through workbench-supabase; Control creates them, writes their URL and keys onto the Workers, and applies migrations read from the repo.";
|
|
422
422
|
readonly status: "built";
|
|
423
423
|
readonly surfaces: {
|
|
424
|
-
readonly tools: readonly ["sb_project_create", "sb_migration_apply", "sb_migration_rollback", "sb_query", "sb_project_delete", "cf_worker_secret_put"];
|
|
424
|
+
readonly tools: readonly ["sb_project_create", "sb_migration_apply", "sb_migration_rollback", "sb_query", "sb_project_delete", "cf_worker_secret_put", "vendor_settings_apply"];
|
|
425
425
|
readonly package: readonly ["supabaseConfig", "createBrowserClient", "createServerClient", "createUserClient", "createServiceClient", "WorkbenchSupabaseError"];
|
|
426
426
|
};
|
|
427
427
|
readonly config: readonly [{
|
|
@@ -471,6 +471,11 @@ export declare const capabilities: {
|
|
|
471
471
|
readonly gate: "two_step_when";
|
|
472
472
|
readonly when: "source supabase_secret, or replacing a secret that exists";
|
|
473
473
|
readonly ships: "4";
|
|
474
|
+
}, {
|
|
475
|
+
readonly tool: "vendor_settings_apply";
|
|
476
|
+
readonly gate: "two_step_when";
|
|
477
|
+
readonly when: "production, after the first successful apply on that database; staging and check_only are free";
|
|
478
|
+
readonly ships: "5";
|
|
474
479
|
}];
|
|
475
480
|
readonly rules: readonly [{
|
|
476
481
|
readonly id: "database.rls-isolates-users";
|
|
@@ -502,6 +507,12 @@ export declare const capabilities: {
|
|
|
502
507
|
readonly text: "Ship code that names a table, column or function production's database lacks; check with npx workbench-supabase check:prod-schema.";
|
|
503
508
|
readonly enforced_by: "review";
|
|
504
509
|
readonly probe: "database.prod-schema-check";
|
|
510
|
+
}, {
|
|
511
|
+
readonly id: "database.vendor-settings-baseline";
|
|
512
|
+
readonly kind: "must";
|
|
513
|
+
readonly text: "Keep Supabase's sign-in settings at the Workbench baseline (workbench-config VENDOR_SETTINGS), or record a project's change in vendor-settings.json with a reason. Control applies it with vendor_settings_apply and checks it every day.";
|
|
514
|
+
readonly enforced_by: "control";
|
|
515
|
+
readonly probe: "database.vendor-settings";
|
|
505
516
|
}];
|
|
506
517
|
readonly examples: readonly [{
|
|
507
518
|
readonly id: "database.example.user-client";
|
|
@@ -546,6 +557,12 @@ export declare const capabilities: {
|
|
|
546
557
|
readonly cost_hint_usd: 0;
|
|
547
558
|
readonly two_step: true;
|
|
548
559
|
readonly note: "sb_query on production with a write returns a confirmation, not a result";
|
|
560
|
+
}, {
|
|
561
|
+
readonly id: "database.vendor-settings";
|
|
562
|
+
readonly half: "drill";
|
|
563
|
+
readonly expect: "pass";
|
|
564
|
+
readonly cost_hint_usd: 0;
|
|
565
|
+
readonly note: "vendor_settings_apply check_only reports that both databases match the baseline, the Send Email hook included";
|
|
549
566
|
}];
|
|
550
567
|
};
|
|
551
568
|
readonly email: {
|
|
@@ -1518,6 +1535,113 @@ export declare const capabilities: {
|
|
|
1518
1535
|
readonly note: "Prints the app's own HTML holding an image from a host not in allowOrigins; the PDF is made and the browser reports that image's request as blocked";
|
|
1519
1536
|
}];
|
|
1520
1537
|
};
|
|
1538
|
+
readonly strings: {
|
|
1539
|
+
readonly $schema: "./schema.json";
|
|
1540
|
+
readonly format: 1;
|
|
1541
|
+
readonly component: "strings";
|
|
1542
|
+
readonly title: "Words on screens and in emails";
|
|
1543
|
+
readonly summary: "workbench-strings holds the words an app's own screens and emails say: one file per language in the app's repository, looked up on the server with named placeholders and plurals, one fallback chain to the base language, language detection with country before the browser's language, back-translation notes stripped at build, and a review gate so no unreviewed translation is inferred for anyone. The standard's tests ship as a kit.";
|
|
1544
|
+
readonly status: "built";
|
|
1545
|
+
readonly surfaces: {
|
|
1546
|
+
readonly package: readonly ["defineStrings", "resolveLocale", "stripGlosses", "WORKBENCH_LANGUAGES", "WorkbenchStringsError"];
|
|
1547
|
+
};
|
|
1548
|
+
readonly rules: readonly [{
|
|
1549
|
+
readonly id: "strings.server-only";
|
|
1550
|
+
readonly kind: "must_not";
|
|
1551
|
+
readonly text: "Import workbench-strings into browser code: the catalogue holds every language. Look words up on the server and pass the finished words to the page; the package's browser export throws.";
|
|
1552
|
+
readonly enforced_by: "types";
|
|
1553
|
+
readonly probe: "strings.lookup";
|
|
1554
|
+
}, {
|
|
1555
|
+
readonly id: "strings.no-literals";
|
|
1556
|
+
readonly kind: "must_not";
|
|
1557
|
+
readonly text: "Write user-visible words as literals in code, or keep a key nothing renders. Every word is a key in strings/strings.<base>.json, and the test kit's dead-key sweep fails on an unused one.";
|
|
1558
|
+
readonly enforced_by: "review";
|
|
1559
|
+
readonly probe: "strings.kit";
|
|
1560
|
+
}, {
|
|
1561
|
+
readonly id: "strings.one-chain";
|
|
1562
|
+
readonly kind: "must";
|
|
1563
|
+
readonly text: "Use strings.t and strings.email for every lookup, so screens and emails share one fallback chain: the locale, its parent, then the base. A word found further down the chain is logged.";
|
|
1564
|
+
readonly enforced_by: "types";
|
|
1565
|
+
readonly probe: "strings.lookup";
|
|
1566
|
+
}, {
|
|
1567
|
+
readonly id: "strings.country-before-header";
|
|
1568
|
+
readonly kind: "must";
|
|
1569
|
+
readonly text: "Detect the language in the order URL prefix, cookie, account, country, Accept-Language, default, with strings.detect. Country comes before the browser's language because phones report English whatever their owner reads.";
|
|
1570
|
+
readonly enforced_by: "types";
|
|
1571
|
+
readonly probe: "strings.detect";
|
|
1572
|
+
}, {
|
|
1573
|
+
readonly id: "strings.review-gate";
|
|
1574
|
+
readonly kind: "must_not";
|
|
1575
|
+
readonly text: "Serve a translated language to someone who did not choose it until its catalogue has _review.signed_off naming the reviewer and the date; country, header and default resolve to the base until then.";
|
|
1576
|
+
readonly enforced_by: "types";
|
|
1577
|
+
readonly probe: "strings.gate";
|
|
1578
|
+
}, {
|
|
1579
|
+
readonly id: "strings.named-reviewer";
|
|
1580
|
+
readonly kind: "must_not";
|
|
1581
|
+
readonly text: "Put a translated language live in production without _review.reviewer naming a native-speaker reviewer and their cadence; defineStrings refuses to start.";
|
|
1582
|
+
readonly enforced_by: "types";
|
|
1583
|
+
readonly probe: "strings.no-reviewer";
|
|
1584
|
+
}, {
|
|
1585
|
+
readonly id: "strings.email-missing-throws";
|
|
1586
|
+
readonly kind: "must";
|
|
1587
|
+
readonly text: "Treat an email word missing in the recipient's language and in the base as an error: strings.email throws missing_string rather than sending a key.";
|
|
1588
|
+
readonly enforced_by: "types";
|
|
1589
|
+
readonly probe: "strings.email-missing";
|
|
1590
|
+
}, {
|
|
1591
|
+
readonly id: "strings.glosses-stripped";
|
|
1592
|
+
readonly kind: "must";
|
|
1593
|
+
readonly text: "Keep a _gloss back-translation beside every translated string in the authored file, and ship only the generated file from npx workbench-strings build, which strips them; defineStrings refuses a catalogue with glosses.";
|
|
1594
|
+
readonly enforced_by: "types";
|
|
1595
|
+
readonly probe: "strings.kit";
|
|
1596
|
+
}];
|
|
1597
|
+
readonly examples: readonly [{
|
|
1598
|
+
readonly id: "strings.example.lookup";
|
|
1599
|
+
readonly title: "Detect the language and look words up";
|
|
1600
|
+
readonly lang: "ts";
|
|
1601
|
+
readonly code: "// SERVER ONLY: the catalogue holds every language.\nimport { defineStrings } from '@mccaskie-labs/workbench-strings'\nimport enAU from '../strings/strings.en-AU.json'\nimport enPH from '../strings/runtime/strings.en-PH.json'\n\nconst strings = defineStrings({ env: workbenchEnv(env), base: { locale: 'en-AU', strings: enAU }, translations: { 'en-PH': enPH }, locales: ['en-PH', 'en-AU'], defaultLocale: 'en-PH' })\nconst { locale } = strings.detect(request, { account: user?.locale })\nconst title = strings.t(locale, 'ui.cart.count', { count: 3 })";
|
|
1602
|
+
readonly probe: "strings.lookup";
|
|
1603
|
+
}];
|
|
1604
|
+
readonly probes: readonly [{
|
|
1605
|
+
readonly id: "strings.lookup";
|
|
1606
|
+
readonly half: "runtime";
|
|
1607
|
+
readonly expect: "pass";
|
|
1608
|
+
readonly smoke: true;
|
|
1609
|
+
readonly cost_hint_usd: 0;
|
|
1610
|
+
readonly note: "Placeholders and a plural fill in each live locale, and a word missing in a translation comes from the base and is logged";
|
|
1611
|
+
}, {
|
|
1612
|
+
readonly id: "strings.detect";
|
|
1613
|
+
readonly half: "runtime";
|
|
1614
|
+
readonly expect: "pass";
|
|
1615
|
+
readonly cost_hint_usd: 0;
|
|
1616
|
+
readonly note: "Each step of the order wins over the next; a PH visitor whose browser says en-US gets the PH locale; XX falls through to the header";
|
|
1617
|
+
}, {
|
|
1618
|
+
readonly id: "strings.gate";
|
|
1619
|
+
readonly half: "runtime";
|
|
1620
|
+
readonly expect: "pass";
|
|
1621
|
+
readonly cost_hint_usd: 0;
|
|
1622
|
+
readonly note: "An unsigned translation is not inferred (gated: true, base served) but is served from the cookie or prefix";
|
|
1623
|
+
}, {
|
|
1624
|
+
readonly id: "strings.no-reviewer";
|
|
1625
|
+
readonly half: "runtime";
|
|
1626
|
+
readonly expect: "refused_as_expected";
|
|
1627
|
+
readonly refusal: "no_reviewer";
|
|
1628
|
+
readonly cost_hint_usd: 0;
|
|
1629
|
+
readonly note: "defineStrings with env production and a translation with no _review.reviewer";
|
|
1630
|
+
}, {
|
|
1631
|
+
readonly id: "strings.email-missing";
|
|
1632
|
+
readonly half: "runtime";
|
|
1633
|
+
readonly expect: "refused_as_expected";
|
|
1634
|
+
readonly refusal: "missing_string";
|
|
1635
|
+
readonly cost_hint_usd: 0;
|
|
1636
|
+
readonly note: "strings.email for a part no catalogue has";
|
|
1637
|
+
}, {
|
|
1638
|
+
readonly id: "strings.kit";
|
|
1639
|
+
readonly half: "runtime";
|
|
1640
|
+
readonly expect: "pass";
|
|
1641
|
+
readonly cost_hint_usd: 0;
|
|
1642
|
+
readonly note: "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI";
|
|
1643
|
+
}];
|
|
1644
|
+
};
|
|
1521
1645
|
};
|
|
1522
1646
|
export type Alias = (typeof capabilities)["ai"]["allowed"]["aliases"][number]["alias"];
|
|
1523
1647
|
export type GatewayScope = (typeof capabilities)["gateway"]["allowed"]["scopes"][number];
|
package/dist/capabilities.gen.js
CHANGED
|
@@ -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 = "
|
|
3
|
+
export const MANIFEST_VERSION = "71462ff7fe4c";
|
|
4
4
|
export const capabilities = {
|
|
5
5
|
"ai": {
|
|
6
6
|
"$schema": "./schema.json",
|
|
@@ -620,7 +620,8 @@ export const capabilities = {
|
|
|
620
620
|
"sb_migration_rollback",
|
|
621
621
|
"sb_query",
|
|
622
622
|
"sb_project_delete",
|
|
623
|
-
"cf_worker_secret_put"
|
|
623
|
+
"cf_worker_secret_put",
|
|
624
|
+
"vendor_settings_apply"
|
|
624
625
|
],
|
|
625
626
|
"package": [
|
|
626
627
|
"supabaseConfig",
|
|
@@ -699,6 +700,12 @@ export const capabilities = {
|
|
|
699
700
|
"gate": "two_step_when",
|
|
700
701
|
"when": "source supabase_secret, or replacing a secret that exists",
|
|
701
702
|
"ships": "4"
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
"tool": "vendor_settings_apply",
|
|
706
|
+
"gate": "two_step_when",
|
|
707
|
+
"when": "production, after the first successful apply on that database; staging and check_only are free",
|
|
708
|
+
"ships": "5"
|
|
702
709
|
}
|
|
703
710
|
],
|
|
704
711
|
"rules": [
|
|
@@ -736,6 +743,13 @@ export const capabilities = {
|
|
|
736
743
|
"text": "Ship code that names a table, column or function production's database lacks; check with npx workbench-supabase check:prod-schema.",
|
|
737
744
|
"enforced_by": "review",
|
|
738
745
|
"probe": "database.prod-schema-check"
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
"id": "database.vendor-settings-baseline",
|
|
749
|
+
"kind": "must",
|
|
750
|
+
"text": "Keep Supabase's sign-in settings at the Workbench baseline (workbench-config VENDOR_SETTINGS), or record a project's change in vendor-settings.json with a reason. Control applies it with vendor_settings_apply and checks it every day.",
|
|
751
|
+
"enforced_by": "control",
|
|
752
|
+
"probe": "database.vendor-settings"
|
|
739
753
|
}
|
|
740
754
|
],
|
|
741
755
|
"examples": [
|
|
@@ -789,6 +803,13 @@ export const capabilities = {
|
|
|
789
803
|
"cost_hint_usd": 0,
|
|
790
804
|
"two_step": true,
|
|
791
805
|
"note": "sb_query on production with a write returns a confirmation, not a result"
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
"id": "database.vendor-settings",
|
|
809
|
+
"half": "drill",
|
|
810
|
+
"expect": "pass",
|
|
811
|
+
"cost_hint_usd": 0,
|
|
812
|
+
"note": "vendor_settings_apply check_only reports that both databases match the baseline, the Send Email hook included"
|
|
792
813
|
}
|
|
793
814
|
]
|
|
794
815
|
},
|
|
@@ -2052,5 +2073,136 @@ export const capabilities = {
|
|
|
2052
2073
|
"note": "Prints the app's own HTML holding an image from a host not in allowOrigins; the PDF is made and the browser reports that image's request as blocked"
|
|
2053
2074
|
}
|
|
2054
2075
|
]
|
|
2076
|
+
},
|
|
2077
|
+
"strings": {
|
|
2078
|
+
"$schema": "./schema.json",
|
|
2079
|
+
"format": 1,
|
|
2080
|
+
"component": "strings",
|
|
2081
|
+
"title": "Words on screens and in emails",
|
|
2082
|
+
"summary": "workbench-strings holds the words an app's own screens and emails say: one file per language in the app's repository, looked up on the server with named placeholders and plurals, one fallback chain to the base language, language detection with country before the browser's language, back-translation notes stripped at build, and a review gate so no unreviewed translation is inferred for anyone. The standard's tests ship as a kit.",
|
|
2083
|
+
"status": "built",
|
|
2084
|
+
"surfaces": {
|
|
2085
|
+
"package": [
|
|
2086
|
+
"defineStrings",
|
|
2087
|
+
"resolveLocale",
|
|
2088
|
+
"stripGlosses",
|
|
2089
|
+
"WORKBENCH_LANGUAGES",
|
|
2090
|
+
"WorkbenchStringsError"
|
|
2091
|
+
]
|
|
2092
|
+
},
|
|
2093
|
+
"rules": [
|
|
2094
|
+
{
|
|
2095
|
+
"id": "strings.server-only",
|
|
2096
|
+
"kind": "must_not",
|
|
2097
|
+
"text": "Import workbench-strings into browser code: the catalogue holds every language. Look words up on the server and pass the finished words to the page; the package's browser export throws.",
|
|
2098
|
+
"enforced_by": "types",
|
|
2099
|
+
"probe": "strings.lookup"
|
|
2100
|
+
},
|
|
2101
|
+
{
|
|
2102
|
+
"id": "strings.no-literals",
|
|
2103
|
+
"kind": "must_not",
|
|
2104
|
+
"text": "Write user-visible words as literals in code, or keep a key nothing renders. Every word is a key in strings/strings.<base>.json, and the test kit's dead-key sweep fails on an unused one.",
|
|
2105
|
+
"enforced_by": "review",
|
|
2106
|
+
"probe": "strings.kit"
|
|
2107
|
+
},
|
|
2108
|
+
{
|
|
2109
|
+
"id": "strings.one-chain",
|
|
2110
|
+
"kind": "must",
|
|
2111
|
+
"text": "Use strings.t and strings.email for every lookup, so screens and emails share one fallback chain: the locale, its parent, then the base. A word found further down the chain is logged.",
|
|
2112
|
+
"enforced_by": "types",
|
|
2113
|
+
"probe": "strings.lookup"
|
|
2114
|
+
},
|
|
2115
|
+
{
|
|
2116
|
+
"id": "strings.country-before-header",
|
|
2117
|
+
"kind": "must",
|
|
2118
|
+
"text": "Detect the language in the order URL prefix, cookie, account, country, Accept-Language, default, with strings.detect. Country comes before the browser's language because phones report English whatever their owner reads.",
|
|
2119
|
+
"enforced_by": "types",
|
|
2120
|
+
"probe": "strings.detect"
|
|
2121
|
+
},
|
|
2122
|
+
{
|
|
2123
|
+
"id": "strings.review-gate",
|
|
2124
|
+
"kind": "must_not",
|
|
2125
|
+
"text": "Serve a translated language to someone who did not choose it until its catalogue has _review.signed_off naming the reviewer and the date; country, header and default resolve to the base until then.",
|
|
2126
|
+
"enforced_by": "types",
|
|
2127
|
+
"probe": "strings.gate"
|
|
2128
|
+
},
|
|
2129
|
+
{
|
|
2130
|
+
"id": "strings.named-reviewer",
|
|
2131
|
+
"kind": "must_not",
|
|
2132
|
+
"text": "Put a translated language live in production without _review.reviewer naming a native-speaker reviewer and their cadence; defineStrings refuses to start.",
|
|
2133
|
+
"enforced_by": "types",
|
|
2134
|
+
"probe": "strings.no-reviewer"
|
|
2135
|
+
},
|
|
2136
|
+
{
|
|
2137
|
+
"id": "strings.email-missing-throws",
|
|
2138
|
+
"kind": "must",
|
|
2139
|
+
"text": "Treat an email word missing in the recipient's language and in the base as an error: strings.email throws missing_string rather than sending a key.",
|
|
2140
|
+
"enforced_by": "types",
|
|
2141
|
+
"probe": "strings.email-missing"
|
|
2142
|
+
},
|
|
2143
|
+
{
|
|
2144
|
+
"id": "strings.glosses-stripped",
|
|
2145
|
+
"kind": "must",
|
|
2146
|
+
"text": "Keep a _gloss back-translation beside every translated string in the authored file, and ship only the generated file from npx workbench-strings build, which strips them; defineStrings refuses a catalogue with glosses.",
|
|
2147
|
+
"enforced_by": "types",
|
|
2148
|
+
"probe": "strings.kit"
|
|
2149
|
+
}
|
|
2150
|
+
],
|
|
2151
|
+
"examples": [
|
|
2152
|
+
{
|
|
2153
|
+
"id": "strings.example.lookup",
|
|
2154
|
+
"title": "Detect the language and look words up",
|
|
2155
|
+
"lang": "ts",
|
|
2156
|
+
"code": "// SERVER ONLY: the catalogue holds every language.\nimport { defineStrings } from '@mccaskie-labs/workbench-strings'\nimport enAU from '../strings/strings.en-AU.json'\nimport enPH from '../strings/runtime/strings.en-PH.json'\n\nconst strings = defineStrings({ env: workbenchEnv(env), base: { locale: 'en-AU', strings: enAU }, translations: { 'en-PH': enPH }, locales: ['en-PH', 'en-AU'], defaultLocale: 'en-PH' })\nconst { locale } = strings.detect(request, { account: user?.locale })\nconst title = strings.t(locale, 'ui.cart.count', { count: 3 })",
|
|
2157
|
+
"probe": "strings.lookup"
|
|
2158
|
+
}
|
|
2159
|
+
],
|
|
2160
|
+
"probes": [
|
|
2161
|
+
{
|
|
2162
|
+
"id": "strings.lookup",
|
|
2163
|
+
"half": "runtime",
|
|
2164
|
+
"expect": "pass",
|
|
2165
|
+
"smoke": true,
|
|
2166
|
+
"cost_hint_usd": 0,
|
|
2167
|
+
"note": "Placeholders and a plural fill in each live locale, and a word missing in a translation comes from the base and is logged"
|
|
2168
|
+
},
|
|
2169
|
+
{
|
|
2170
|
+
"id": "strings.detect",
|
|
2171
|
+
"half": "runtime",
|
|
2172
|
+
"expect": "pass",
|
|
2173
|
+
"cost_hint_usd": 0,
|
|
2174
|
+
"note": "Each step of the order wins over the next; a PH visitor whose browser says en-US gets the PH locale; XX falls through to the header"
|
|
2175
|
+
},
|
|
2176
|
+
{
|
|
2177
|
+
"id": "strings.gate",
|
|
2178
|
+
"half": "runtime",
|
|
2179
|
+
"expect": "pass",
|
|
2180
|
+
"cost_hint_usd": 0,
|
|
2181
|
+
"note": "An unsigned translation is not inferred (gated: true, base served) but is served from the cookie or prefix"
|
|
2182
|
+
},
|
|
2183
|
+
{
|
|
2184
|
+
"id": "strings.no-reviewer",
|
|
2185
|
+
"half": "runtime",
|
|
2186
|
+
"expect": "refused_as_expected",
|
|
2187
|
+
"refusal": "no_reviewer",
|
|
2188
|
+
"cost_hint_usd": 0,
|
|
2189
|
+
"note": "defineStrings with env production and a translation with no _review.reviewer"
|
|
2190
|
+
},
|
|
2191
|
+
{
|
|
2192
|
+
"id": "strings.email-missing",
|
|
2193
|
+
"half": "runtime",
|
|
2194
|
+
"expect": "refused_as_expected",
|
|
2195
|
+
"refusal": "missing_string",
|
|
2196
|
+
"cost_hint_usd": 0,
|
|
2197
|
+
"note": "strings.email for a part no catalogue has"
|
|
2198
|
+
},
|
|
2199
|
+
{
|
|
2200
|
+
"id": "strings.kit",
|
|
2201
|
+
"half": "runtime",
|
|
2202
|
+
"expect": "pass",
|
|
2203
|
+
"cost_hint_usd": 0,
|
|
2204
|
+
"note": "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI"
|
|
2205
|
+
}
|
|
2206
|
+
]
|
|
2055
2207
|
}
|
|
2056
2208
|
};
|
|
@@ -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", "country", "pdf", "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", "pdf", "access", "r2", "kv", "github", "strings"];
|
|
3
3
|
export type Component = (typeof COMPONENTS)[number];
|
|
4
4
|
export declare const allowedSchemas: {
|
|
5
5
|
gateway: z.ZodObject<{
|
|
@@ -110,6 +110,7 @@ export declare const capabilitySchema: z.ZodObject<{
|
|
|
110
110
|
kv: "kv";
|
|
111
111
|
pdf: "pdf";
|
|
112
112
|
r2: "r2";
|
|
113
|
+
strings: "strings";
|
|
113
114
|
workers: "workers";
|
|
114
115
|
}>;
|
|
115
116
|
title: z.ZodString;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capa
|
|
|
6
6
|
export { capabilities, MANIFEST_VERSION, type Alias, type GatewayScope } from "./capabilities.gen.ts";
|
|
7
7
|
export { compareDevVars, parseDevVarNames } from "./dev-vars.ts";
|
|
8
8
|
export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS, type Brand, type ResolvedBrand } from "./brand.ts";
|
|
9
|
+
export { VENDOR_SETTINGS, VENDOR_SETTINGS_FILE, SEND_EMAIL_HOOK, SEND_EMAIL_HOOK_PATH, SEND_EMAIL_HOOK_SECRET, vendorSettingsFileSchema, resolveVendorSettings, type VendorSetting, type VendorSettingEnv, type VendorSettingsFile, type ResolvedVendorSetting, } from "./vendor-settings.ts";
|
package/dist/index.js
CHANGED
|
@@ -6,3 +6,4 @@ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, } from ".
|
|
|
6
6
|
export { capabilities, MANIFEST_VERSION } from "./capabilities.gen.js";
|
|
7
7
|
export { compareDevVars, parseDevVarNames } from "./dev-vars.js";
|
|
8
8
|
export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS } from "./brand.js";
|
|
9
|
+
export { VENDOR_SETTINGS, VENDOR_SETTINGS_FILE, SEND_EMAIL_HOOK, SEND_EMAIL_HOOK_PATH, SEND_EMAIL_HOOK_SECRET, vendorSettingsFileSchema, resolveVendorSettings, } from "./vendor-settings.js";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The vendor settings baseline (docs/spec.md §10, 11 November 2026, "Four
|
|
4
|
+
* shared tools from Robert's project standards", item 3). Settings that live
|
|
5
|
+
* in a vendor's dashboard, where no migration, test or review can see them.
|
|
6
|
+
* The standards' answer is a register re-applied by hand on every new
|
|
7
|
+
* project; Workbench keeps the register here and Control applies it
|
|
8
|
+
* (`vendor_settings_apply`) and compares it with the live values every day.
|
|
9
|
+
*
|
|
10
|
+
* Each row says the setting, its value, where it lives and what breaks if it
|
|
11
|
+
* changes. A project changes a row in `vendor-settings.json` at the root of
|
|
12
|
+
* its repo, with a reason, and Control reads that file from `main`:
|
|
13
|
+
*
|
|
14
|
+
* { "overrides": [
|
|
15
|
+
* { "id": "supabase.auth.email_code_length", "value": 8,
|
|
16
|
+
* "reason": "The bank's audit asks for eight digits" } ] }
|
|
17
|
+
*/
|
|
18
|
+
/** Where a project's overrides sit in its repo. */
|
|
19
|
+
export declare const VENDOR_SETTINGS_FILE = "vendor-settings.json";
|
|
20
|
+
export type VendorSettingEnv = "staging" | "production";
|
|
21
|
+
interface Row {
|
|
22
|
+
/** Permanent name: `<vendor>.<area>.<setting>`. Overrides name it. */
|
|
23
|
+
id: string;
|
|
24
|
+
vendor: "supabase";
|
|
25
|
+
area: "auth";
|
|
26
|
+
/** The field in the vendor's API (Supabase: PATCH /v1/projects/{ref}/config/auth). */
|
|
27
|
+
field: string;
|
|
28
|
+
/** Where a person finds it in the dashboard. */
|
|
29
|
+
where: string;
|
|
30
|
+
/** What breaks if it changes. */
|
|
31
|
+
breaks: string;
|
|
32
|
+
}
|
|
33
|
+
interface BooleanSetting extends Row {
|
|
34
|
+
type: "boolean";
|
|
35
|
+
value: boolean;
|
|
36
|
+
}
|
|
37
|
+
interface IntegerSetting extends Row {
|
|
38
|
+
type: "integer";
|
|
39
|
+
value: number;
|
|
40
|
+
min: number;
|
|
41
|
+
max: number;
|
|
42
|
+
unit?: string;
|
|
43
|
+
}
|
|
44
|
+
export type VendorSetting = BooleanSetting | IntegerSetting;
|
|
45
|
+
/**
|
|
46
|
+
* The Send Email hook row. On means Supabase makes the code and calls the
|
|
47
|
+
* app's own endpoint, which sends the email through workbench-email and the
|
|
48
|
+
* Gateway (decided 11 November 2026: one sender, one set of words). Control
|
|
49
|
+
* also sets the hook's address and its signing secret, which it puts on the
|
|
50
|
+
* project's Worker as SEND_EMAIL_HOOK_SECRET; neither is a row of its own.
|
|
51
|
+
*/
|
|
52
|
+
export declare const SEND_EMAIL_HOOK = "supabase.auth.send_email_hook";
|
|
53
|
+
/** The path on the app's own hostname that Supabase calls. Agreed with the sign-in kit (createSendEmailHook in workbench-email, PR #173). */
|
|
54
|
+
export declare const SEND_EMAIL_HOOK_PATH = "/auth/send-email";
|
|
55
|
+
/** The Worker secret that holds the hook's signing secret (v1,whsec_…). */
|
|
56
|
+
export declare const SEND_EMAIL_HOOK_SECRET = "SEND_EMAIL_HOOK_SECRET";
|
|
57
|
+
/** The baseline. Order is the order Control reports in. */
|
|
58
|
+
export declare const VENDOR_SETTINGS: readonly VendorSetting[];
|
|
59
|
+
/** The shape of `vendor-settings.json`. */
|
|
60
|
+
export declare const vendorSettingsFileSchema: z.ZodObject<{
|
|
61
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
62
|
+
overrides: z.ZodArray<z.ZodObject<{
|
|
63
|
+
id: z.ZodString;
|
|
64
|
+
value: z.ZodUnion<readonly [z.ZodBoolean, z.ZodNumber]>;
|
|
65
|
+
reason: z.ZodString;
|
|
66
|
+
env: z.ZodOptional<z.ZodEnum<{
|
|
67
|
+
production: "production";
|
|
68
|
+
staging: "staging";
|
|
69
|
+
}>>;
|
|
70
|
+
}, z.core.$strict>>;
|
|
71
|
+
}, z.core.$strict>;
|
|
72
|
+
export type VendorSettingsFile = z.infer<typeof vendorSettingsFileSchema>;
|
|
73
|
+
/** A row with the value that applies to one project and environment. */
|
|
74
|
+
export type ResolvedVendorSetting = VendorSetting & {
|
|
75
|
+
/** "default" from the baseline, or "override" from the project's file. */
|
|
76
|
+
source: "default" | "override";
|
|
77
|
+
/** The override's reason. */
|
|
78
|
+
reason?: string;
|
|
79
|
+
/** The baseline's value, when an override changed it. */
|
|
80
|
+
default?: boolean | number;
|
|
81
|
+
};
|
|
82
|
+
export type ResolveResult = {
|
|
83
|
+
ok: true;
|
|
84
|
+
settings: ResolvedVendorSetting[];
|
|
85
|
+
} | {
|
|
86
|
+
ok: false;
|
|
87
|
+
problems: string[];
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* The settings for one environment: the baseline with the project's overrides
|
|
91
|
+
* applied. `file` is the parsed JSON of `vendor-settings.json`, or null when
|
|
92
|
+
* the repo has none. Returns every problem at once (an unknown id, a value of
|
|
93
|
+
* the wrong type or out of range, a row overridden twice) and no settings, so
|
|
94
|
+
* a bad file never half-applies.
|
|
95
|
+
*/
|
|
96
|
+
export declare function resolveVendorSettings(file: unknown, env: VendorSettingEnv): ResolveResult;
|
|
97
|
+
export {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The vendor settings baseline (docs/spec.md §10, 11 November 2026, "Four
|
|
4
|
+
* shared tools from Robert's project standards", item 3). Settings that live
|
|
5
|
+
* in a vendor's dashboard, where no migration, test or review can see them.
|
|
6
|
+
* The standards' answer is a register re-applied by hand on every new
|
|
7
|
+
* project; Workbench keeps the register here and Control applies it
|
|
8
|
+
* (`vendor_settings_apply`) and compares it with the live values every day.
|
|
9
|
+
*
|
|
10
|
+
* Each row says the setting, its value, where it lives and what breaks if it
|
|
11
|
+
* changes. A project changes a row in `vendor-settings.json` at the root of
|
|
12
|
+
* its repo, with a reason, and Control reads that file from `main`:
|
|
13
|
+
*
|
|
14
|
+
* { "overrides": [
|
|
15
|
+
* { "id": "supabase.auth.email_code_length", "value": 8,
|
|
16
|
+
* "reason": "The bank's audit asks for eight digits" } ] }
|
|
17
|
+
*/
|
|
18
|
+
/** Where a project's overrides sit in its repo. */
|
|
19
|
+
export const VENDOR_SETTINGS_FILE = "vendor-settings.json";
|
|
20
|
+
/**
|
|
21
|
+
* The Send Email hook row. On means Supabase makes the code and calls the
|
|
22
|
+
* app's own endpoint, which sends the email through workbench-email and the
|
|
23
|
+
* Gateway (decided 11 November 2026: one sender, one set of words). Control
|
|
24
|
+
* also sets the hook's address and its signing secret, which it puts on the
|
|
25
|
+
* project's Worker as SEND_EMAIL_HOOK_SECRET; neither is a row of its own.
|
|
26
|
+
*/
|
|
27
|
+
export const SEND_EMAIL_HOOK = "supabase.auth.send_email_hook";
|
|
28
|
+
/** The path on the app's own hostname that Supabase calls. Agreed with the sign-in kit (createSendEmailHook in workbench-email, PR #173). */
|
|
29
|
+
export const SEND_EMAIL_HOOK_PATH = "/auth/send-email";
|
|
30
|
+
/** The Worker secret that holds the hook's signing secret (v1,whsec_…). */
|
|
31
|
+
export const SEND_EMAIL_HOOK_SECRET = "SEND_EMAIL_HOOK_SECRET";
|
|
32
|
+
/** The baseline. Order is the order Control reports in. */
|
|
33
|
+
export const VENDOR_SETTINGS = Object.freeze([
|
|
34
|
+
{
|
|
35
|
+
id: "supabase.auth.email_code_length",
|
|
36
|
+
vendor: "supabase",
|
|
37
|
+
area: "auth",
|
|
38
|
+
field: "mailer_otp_length",
|
|
39
|
+
type: "integer",
|
|
40
|
+
value: 6,
|
|
41
|
+
min: 6,
|
|
42
|
+
max: 10,
|
|
43
|
+
unit: "digits",
|
|
44
|
+
where: "Supabase: Authentication, Sign In / Providers, Email, Email OTP Length",
|
|
45
|
+
breaks: "Emails carry a code of a different length from the one the app's screens, words and tests expect. It once shipped as eight digits while every document said six.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "supabase.auth.email_code_lifetime",
|
|
49
|
+
vendor: "supabase",
|
|
50
|
+
area: "auth",
|
|
51
|
+
field: "mailer_otp_exp",
|
|
52
|
+
type: "integer",
|
|
53
|
+
value: 600,
|
|
54
|
+
min: 60,
|
|
55
|
+
max: 86_400,
|
|
56
|
+
unit: "seconds",
|
|
57
|
+
where: "Supabase: Authentication, Sign In / Providers, Email, Email OTP Expiration",
|
|
58
|
+
breaks: "Shorter: people with slow mail are told their code is wrong. Longer: a leaked code works for longer.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: SEND_EMAIL_HOOK,
|
|
62
|
+
vendor: "supabase",
|
|
63
|
+
area: "auth",
|
|
64
|
+
field: "hook_send_email_enabled",
|
|
65
|
+
type: "boolean",
|
|
66
|
+
value: true,
|
|
67
|
+
where: "Supabase: Authentication, Auth Hooks, Send Email hook",
|
|
68
|
+
breaks: "Off: Supabase sends its own sign-in emails from its own sender, with its own words, outside the catalogue and its tests. On with no handler in the app: every sign-in email fails, loudly.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "supabase.auth.password_change_needs_current",
|
|
72
|
+
vendor: "supabase",
|
|
73
|
+
area: "auth",
|
|
74
|
+
field: "security_update_password_require_current_password",
|
|
75
|
+
type: "boolean",
|
|
76
|
+
value: true,
|
|
77
|
+
where: "Supabase: Authentication, Sign In / Providers, Email, Require current password when updating",
|
|
78
|
+
breaks: "Off: anyone holding a signed-in session (a borrowed phone, a sign-in code) can change the password and keep the account.",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "supabase.auth.email_change_confirms_both",
|
|
82
|
+
vendor: "supabase",
|
|
83
|
+
area: "auth",
|
|
84
|
+
field: "mailer_secure_email_change_enabled",
|
|
85
|
+
type: "boolean",
|
|
86
|
+
value: true,
|
|
87
|
+
where: "Supabase: Authentication, Sign In / Providers, Email, Secure email change",
|
|
88
|
+
breaks: "Off: an address change is confirmed from the new address only, so the owner of the old one never hears of it.",
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
const override = z.strictObject({
|
|
92
|
+
id: z.string().min(1).max(100),
|
|
93
|
+
value: z.union([z.boolean(), z.number()]),
|
|
94
|
+
/** Why this project differs. Shown on every report. */
|
|
95
|
+
reason: z.string().trim().min(10).max(300),
|
|
96
|
+
/** Only this environment. Default both. */
|
|
97
|
+
env: z.enum(["staging", "production"]).optional(),
|
|
98
|
+
});
|
|
99
|
+
/** The shape of `vendor-settings.json`. */
|
|
100
|
+
export const vendorSettingsFileSchema = z.strictObject({
|
|
101
|
+
$schema: z.string().optional(),
|
|
102
|
+
overrides: z.array(override).max(100),
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* The settings for one environment: the baseline with the project's overrides
|
|
106
|
+
* applied. `file` is the parsed JSON of `vendor-settings.json`, or null when
|
|
107
|
+
* the repo has none. Returns every problem at once (an unknown id, a value of
|
|
108
|
+
* the wrong type or out of range, a row overridden twice) and no settings, so
|
|
109
|
+
* a bad file never half-applies.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveVendorSettings(file, env) {
|
|
112
|
+
if (file === null || file === undefined)
|
|
113
|
+
return { ok: true, settings: VENDOR_SETTINGS.map((s) => ({ ...s, source: "default" })) };
|
|
114
|
+
const parsed = vendorSettingsFileSchema.safeParse(file);
|
|
115
|
+
if (!parsed.success) {
|
|
116
|
+
return { ok: false, problems: parsed.error.issues.map((i) => `${VENDOR_SETTINGS_FILE} ${i.path.join(".") || "(file)"}: ${i.message}`) };
|
|
117
|
+
}
|
|
118
|
+
const problems = [];
|
|
119
|
+
const byId = new Map(VENDOR_SETTINGS.map((s) => [s.id, s]));
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const chosen = new Map();
|
|
122
|
+
for (const [i, o] of parsed.data.overrides.entries()) {
|
|
123
|
+
const at = `${VENDOR_SETTINGS_FILE} overrides.${i}`;
|
|
124
|
+
const row = byId.get(o.id);
|
|
125
|
+
if (!row) {
|
|
126
|
+
problems.push(`${at}: no setting "${o.id}". Known: ${[...byId.keys()].join(", ")}.`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const e of o.env ? [o.env] : ["staging", "production"]) {
|
|
130
|
+
const key = `${o.id}@${e}`;
|
|
131
|
+
if (seen.has(key))
|
|
132
|
+
problems.push(`${at}: ${o.id} is overridden twice for ${e}.`);
|
|
133
|
+
seen.add(key);
|
|
134
|
+
}
|
|
135
|
+
const p = valueProblem(row, o.value);
|
|
136
|
+
if (p) {
|
|
137
|
+
problems.push(`${at}: ${p}`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (o.env === undefined || o.env === env)
|
|
141
|
+
chosen.set(o.id, { value: o.value, reason: o.reason });
|
|
142
|
+
}
|
|
143
|
+
if (problems.length > 0)
|
|
144
|
+
return { ok: false, problems };
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
settings: VENDOR_SETTINGS.map((s) => {
|
|
148
|
+
const c = chosen.get(s.id);
|
|
149
|
+
if (!c)
|
|
150
|
+
return { ...s, source: "default" };
|
|
151
|
+
return { ...s, value: c.value, source: "override", reason: c.reason, default: s.value };
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function valueProblem(row, value) {
|
|
156
|
+
if (row.type === "boolean")
|
|
157
|
+
return typeof value === "boolean" ? null : `${row.id} takes true or false.`;
|
|
158
|
+
if (typeof value !== "number" || !Number.isInteger(value))
|
|
159
|
+
return `${row.id} takes a whole number.`;
|
|
160
|
+
if (value < row.min || value > row.max)
|
|
161
|
+
return `${row.id} must be from ${row.min} to ${row.max}${row.unit ? ` ${row.unit}` : ""}.`;
|
|
162
|
+
return null;
|
|
163
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-config",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
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",
|