@mccaskie-labs/workbench-config 0.1.40 → 0.2.0

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,17 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Breaking: there is no built-in Gateway address any more (hard rule 13, `docs/install-plan.md`). `DEFAULT_GATEWAY_URL` is removed from workbench-config, `createAi` needs `url`, and `createMailer`'s gateway transport needs `gateway.url`. Pass the app's `WORKBENCH_GATEWAY_URL`, which Control writes into `wrangler.jsonc` when it creates a project. A missing address fails at start-up with `invalid_config` and the message "WORKBENCH_GATEWAY_URL is missing". The gateway manifest marks `WORKBENCH_GATEWAY_URL` required with no default.
8
+
9
+ ## 0.1.41
10
+
11
+ ### Patch Changes
12
+
13
+ - The email-sending and email-inbound guides and manifests no longer name one install's domain. They say `<projects domain>` and tell Claude to read it from the project's README (hard rule 13).
14
+
3
15
  ## 0.1.40
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -30,7 +30,6 @@ const { WORKBENCH_GATEWAY_KEY } = config.parse(env)
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
31
  | `checkLogo(bytes)`, `placeholderLogo(name, colour)`, `colourForId(id)`, `parseColour`, `whiteContrast`, `shownSize`, `LOGO_PATHS`, `MAX_LOGO_BYTES`, `BrandAssetError` | Setting a brand from an upload: the logo is checked by its own bytes (PNG or JPEG, at most 256 KiB; SVG refused because Gmail and Outlook don't show it), a project with no logo gets its initials on its colour as a 96 × 96 PNG, and a project with no colour gets a steady one from its id that white text reads on (WCAG 4.5:1). Runs in Workers, the browser and Node |
32
32
  | `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 |
33
- | `DEFAULT_GATEWAY_URL` | `https://gateway.mccaskielabs.dev` |
34
33
 
35
34
  `npx workbench-config check [dir]` compares the variable names in `.dev.vars` with `.dev.vars.example` and prints names only.
36
35
 
@@ -95,7 +95,7 @@
95
95
  "title": "One-shot text",
96
96
  "lang": "ts",
97
97
  "probe": "ai.text.claude-default",
98
- "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })"
98
+ "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })"
99
99
  },
100
100
  {
101
101
  "id": "ai.example.voice",
@@ -105,7 +105,7 @@
105
105
  "title": "Store inbound mail",
106
106
  "lang": "ts",
107
107
  "probe": "email-inbound.roundtrip",
108
- "code": "export async function handleEmail(mail, env, ctx, { reject }) {\n if (!mail.to.some((t) => t.address === 'inbox@wb-probe-staging.mccaskielabs.app')) return reject('Unknown mailbox')\n await saveMail(env, { message_id: mail.message_id, subject: mail.subject, text: mail.text, received_at: mail.received_at })\n}"
108
+ "code": "export async function handleEmail(mail, env, ctx, { reject }) {\n if (!mail.to.some((t) => t.address === 'inbox@<id>-staging.<projects domain>')) return reject('Unknown mailbox')\n await saveMail(env, { message_id: mail.message_id, subject: mail.subject, text: mail.text, received_at: mail.received_at })\n}"
109
109
  }
110
110
  ],
111
111
  "probes": [
@@ -1,6 +1,6 @@
1
1
  # Inbound email
2
2
 
3
- A project receives mail on a domain in its namespace, for example `wb-probe-staging.mccaskielabs.app`, and each address it uses is routed to one of its own Workers. Project-owned zones such as `wbprobe.com` join the namespace with `cf_zone_attach` (phase 6).
3
+ A project receives mail on a domain in its namespace, for example `<id>-staging.<projects domain>`, and each address it uses is routed to one of its own Workers. `<projects domain>` is the domain this Workbench install keeps its projects under: it is the last part of the project's staging address in its README, so read it there and never assume one. A domain the project owns joins the namespace with `cf_zone_attach` (phase 6).
4
4
 
5
5
  ## Setting it up (Claude, through Control)
6
6
 
@@ -24,13 +24,13 @@
24
24
  { "code": "missing_permission", "http": 403, "retryable": false, "meaning": "Control's Cloudflare token lacks Email Sending (edit)" }
25
25
  ],
26
26
  "rules": [
27
- { "id": "email-sending.namespace-only", "kind": "must", "text": "Onboard a domain in the project's own namespace, for example mail.<id>.mccaskielabs.app. Project-owned zones such as wbprobe.com follow cf_zone_attach (step 6.1).", "enforced_by": "control", "probe": "email-sending.onboard" },
27
+ { "id": "email-sending.namespace-only", "kind": "must", "text": "Onboard a domain in the project's own namespace, for example mail.<id>.<projects domain> (the domain this install keeps its projects under). A domain the project owns follows cf_zone_attach (step 6.1).", "enforced_by": "control", "probe": "email-sending.onboard" },
28
28
  { "id": "email-sending.onboard-before-sender", "kind": "must", "text": "Onboard the domain before adding senders on it. gw_sender_add refuses a domain the project hasn't onboarded.", "enforced_by": "control", "probe": "email-sending.sender-add" },
29
29
  { "id": "email-sending.never-adopt", "kind": "must_not", "text": "Expect Control to take over a domain onboarded by hand. It refuses with name_taken.", "enforced_by": "control", "probe": "email-sending.onboard" },
30
30
  { "id": "email-sending.verified-over-doh", "kind": "must", "text": "Treat a domain as ready only when cf_email_sending_status says ready: every expected record is in the zone and visible over DNS-over-HTTPS.", "enforced_by": "control", "probe": "email-sending.status" }
31
31
  ],
32
32
  "probes": [
33
- { "id": "email-sending.onboard", "half": "drill", "expect": "pass", "cost_hint_usd": 0, "note": "cf_email_sending_onboard on mail.<id>.mccaskielabs.app; a second call changes nothing" },
33
+ { "id": "email-sending.onboard", "half": "drill", "expect": "pass", "cost_hint_usd": 0, "note": "cf_email_sending_onboard on mail.<id>.<projects domain>; a second call changes nothing" },
34
34
  { "id": "email-sending.status", "half": "drill", "expect": "pass", "cost_hint_usd": 0 },
35
35
  { "id": "email-sending.sender-add", "half": "drill", "expect": "pass", "cost_hint_usd": 0, "note": "Then a runtime send from the new address (email.send.transactional)" },
36
36
  { "id": "email-sending.out-of-scope", "half": "drill", "expect": "refused_as_expected", "refusal": "out_of_scope", "cost_hint_usd": 0, "note": "Another project's namespace" }
@@ -1,9 +1,9 @@
1
1
  # Email sending domains
2
2
 
3
- A project sends from addresses on a domain Control onboarded for it. Today that is a domain in the project's namespace in `mccaskielabs.app`, usually `mail.<id>.mccaskielabs.app`. Project-owned zones such as `wbprobe.com` follow `cf_zone_attach` in step 6.1.
3
+ A project sends from addresses on a domain Control onboarded for it. Today that is a domain in the project's namespace under the install's projects domain, usually `mail.<id>.<projects domain>`. `<projects domain>` is the domain this Workbench install keeps its projects under: it is the last part of the project's staging address in its README, so read it there and never assume one. A domain the project owns follows `cf_zone_attach` in step 6.1.
4
4
 
5
- 1. `cf_email_sending_onboard domain=mail.<id>.mccaskielabs.app` creates the Email Sending subdomain in the projects account, adds the records Cloudflare expects (the `cf-bounce` MX and SPF, and DKIM) and the domain's own DMARC record (`p=reject; adkim=s; aspf=r`), records them, and checks each over DNS-over-HTTPS. A record in the way stops it; it changes nothing it didn't make.
5
+ 1. `cf_email_sending_onboard domain=mail.<id>.<projects domain>` creates the Email Sending subdomain in the projects account, adds the records Cloudflare expects (the `cf-bounce` MX and SPF, and DKIM) and the domain's own DMARC record (`p=reject; adkim=s; aspf=r`), records them, and checks each over DNS-over-HTTPS. A record in the way stops it; it changes nothing it didn't make.
6
6
  2. `cf_email_sending_status` says `ready` once every record is visible. Records can take a few minutes.
7
- 3. `gw_sender_add address=hello@mail.<id>.mccaskielabs.app` allows the address. The Gateway accepts it within a minute.
7
+ 3. `gw_sender_add address=hello@mail.<id>.<projects domain>` allows the address. The Gateway accepts it within a minute.
8
8
 
9
9
  The DNS records are ordinary `dns_record` rows for the project, so removing one is `cf_dns_delete` (two-step).
@@ -52,7 +52,7 @@
52
52
  "title": "Send a transactional mail with an inline logo",
53
53
  "lang": "ts",
54
54
  "probe": "email.send.inline-attachment",
55
- "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' }] })"
55
+ "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!, url: process.env.WORKBENCH_GATEWAY_URL! } })\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' }] })"
56
56
  }
57
57
  ],
58
58
  "probes": [
@@ -6,8 +6,7 @@
6
6
  "summary": "What every Gateway endpoint shares: project keys by environment and scope, the request and response headers, the monthly budget check and the error codes. Apps hold one Workbench key and never a vendor key.",
7
7
  "status": "built",
8
8
  "surfaces": {
9
- "gateway": ["GET /v1/health", "GET /v1/usage", "GET /v1/capabilities", "GET /v1/calls"],
10
- "package": ["DEFAULT_GATEWAY_URL"]
9
+ "gateway": ["GET /v1/health", "GET /v1/usage", "GET /v1/capabilities", "GET /v1/calls"]
11
10
  },
12
11
  "config": [
13
12
  {
@@ -19,11 +18,10 @@
19
18
  },
20
19
  {
21
20
  "name": "WORKBENCH_GATEWAY_URL",
22
- "required": false,
21
+ "required": true,
23
22
  "secret": false,
24
- "default": "https://gateway.mccaskielabs.dev",
25
23
  "environments": ["development", "staging", "production"],
26
- "description": "Gateway base URL. Leave unset outside tests"
24
+ "description": "The address of this Workbench install's Gateway. Control writes it into wrangler.jsonc when it creates the project; there is no built-in address"
27
25
  }
28
26
  ],
29
27
  "allowed": {
@@ -120,7 +120,7 @@
120
120
  "title": "Search for images",
121
121
  "lang": "ts",
122
122
  "probe": "image-search.serper",
123
- "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst results = await ai.images.search({ q: 'Mayon volcano', job: 'editor.image-search', num: 10 })"
123
+ "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst results = await ai.images.search({ q: 'Mayon volcano', job: 'editor.image-search', num: 10 })"
124
124
  }
125
125
  ],
126
126
  "probes": [
@@ -166,7 +166,7 @@
166
166
  "title": "Generate one image",
167
167
  "lang": "ts",
168
168
  "probe": "images.generate.image-default",
169
- "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst img = await ai.images.generate({ prompt, aspectRatio: '3:2', job: 'article.hero' })\nawait env.IMAGES.put(key, img.bytes, { httpMetadata: { contentType: img.mimeType }, customMetadata: { model: img.model, requestId: img.requestId } })"
169
+ "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst img = await ai.images.generate({ prompt, aspectRatio: '3:2', job: 'article.hero' })\nawait env.IMAGES.put(key, img.bytes, { httpMetadata: { contentType: img.mimeType }, customMetadata: { model: img.model, requestId: img.requestId } })"
170
170
  },
171
171
  {
172
172
  "id": "images.example.reference",
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "90023fb27145";
1
+ export declare const MANIFEST_VERSION: "5f4a5d2dad37";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -119,7 +119,7 @@ export declare const capabilities: {
119
119
  readonly id: "ai.example.text";
120
120
  readonly title: "One-shot text";
121
121
  readonly lang: "ts";
122
- readonly code: "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })";
122
+ readonly code: "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })";
123
123
  readonly probe: "ai.text.claude-default";
124
124
  }, {
125
125
  readonly id: "ai.example.voice";
@@ -816,7 +816,7 @@ export declare const capabilities: {
816
816
  readonly id: "email.example.send";
817
817
  readonly title: "Send a transactional mail with an inline logo";
818
818
  readonly lang: "ts";
819
- 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' }] })";
819
+ 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!, url: process.env.WORKBENCH_GATEWAY_URL! } })\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' }] })";
820
820
  readonly probe: "email.send.inline-attachment";
821
821
  }];
822
822
  readonly probes: readonly [{
@@ -985,7 +985,7 @@ export declare const capabilities: {
985
985
  readonly id: "email-inbound.example.handler";
986
986
  readonly title: "Store inbound mail";
987
987
  readonly lang: "ts";
988
- readonly code: "export async function handleEmail(mail, env, ctx, { reject }) {\n if (!mail.to.some((t) => t.address === 'inbox@wb-probe-staging.mccaskielabs.app')) return reject('Unknown mailbox')\n await saveMail(env, { message_id: mail.message_id, subject: mail.subject, text: mail.text, received_at: mail.received_at })\n}";
988
+ readonly code: "export async function handleEmail(mail, env, ctx, { reject }) {\n if (!mail.to.some((t) => t.address === 'inbox@<id>-staging.<projects domain>')) return reject('Unknown mailbox')\n await saveMail(env, { message_id: mail.message_id, subject: mail.subject, text: mail.text, received_at: mail.received_at })\n}";
989
989
  readonly probe: "email-inbound.roundtrip";
990
990
  }];
991
991
  readonly probes: readonly [{
@@ -1070,7 +1070,7 @@ export declare const capabilities: {
1070
1070
  readonly rules: readonly [{
1071
1071
  readonly id: "email-sending.namespace-only";
1072
1072
  readonly kind: "must";
1073
- readonly text: "Onboard a domain in the project's own namespace, for example mail.<id>.mccaskielabs.app. Project-owned zones such as wbprobe.com follow cf_zone_attach (step 6.1).";
1073
+ readonly text: "Onboard a domain in the project's own namespace, for example mail.<id>.<projects domain> (the domain this install keeps its projects under). A domain the project owns follows cf_zone_attach (step 6.1).";
1074
1074
  readonly enforced_by: "control";
1075
1075
  readonly probe: "email-sending.onboard";
1076
1076
  }, {
@@ -1097,7 +1097,7 @@ export declare const capabilities: {
1097
1097
  readonly half: "drill";
1098
1098
  readonly expect: "pass";
1099
1099
  readonly cost_hint_usd: 0;
1100
- readonly note: "cf_email_sending_onboard on mail.<id>.mccaskielabs.app; a second call changes nothing";
1100
+ readonly note: "cf_email_sending_onboard on mail.<id>.<projects domain>; a second call changes nothing";
1101
1101
  }, {
1102
1102
  readonly id: "email-sending.status";
1103
1103
  readonly half: "drill";
@@ -1366,7 +1366,6 @@ export declare const capabilities: {
1366
1366
  readonly status: "built";
1367
1367
  readonly surfaces: {
1368
1368
  readonly gateway: readonly ["GET /v1/health", "GET /v1/usage", "GET /v1/capabilities", "GET /v1/calls"];
1369
- readonly package: readonly ["DEFAULT_GATEWAY_URL"];
1370
1369
  };
1371
1370
  readonly config: readonly [{
1372
1371
  readonly name: "WORKBENCH_GATEWAY_KEY";
@@ -1376,11 +1375,10 @@ export declare const capabilities: {
1376
1375
  readonly description: "The project's Gateway key for this environment, delivered as a Worker secret by gw_key_issue. Never in code, .dev.vars.example or logs";
1377
1376
  }, {
1378
1377
  readonly name: "WORKBENCH_GATEWAY_URL";
1379
- readonly required: false;
1378
+ readonly required: true;
1380
1379
  readonly secret: false;
1381
- readonly default: "https://gateway.mccaskielabs.dev";
1382
1380
  readonly environments: readonly ["development", "staging", "production"];
1383
- readonly description: "Gateway base URL. Leave unset outside tests";
1381
+ readonly description: "The address of this Workbench install's Gateway. Control writes it into wrangler.jsonc when it creates the project; there is no built-in address";
1384
1382
  }];
1385
1383
  readonly allowed: {
1386
1384
  readonly scopes: readonly ["ai", "images", "search", "email"];
@@ -1679,7 +1677,7 @@ export declare const capabilities: {
1679
1677
  readonly id: "image-search.example.search";
1680
1678
  readonly title: "Search for images";
1681
1679
  readonly lang: "ts";
1682
- readonly code: "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst results = await ai.images.search({ q: 'Mayon volcano', job: 'editor.image-search', num: 10 })";
1680
+ readonly code: "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst results = await ai.images.search({ q: 'Mayon volcano', job: 'editor.image-search', num: 10 })";
1683
1681
  readonly probe: "image-search.serper";
1684
1682
  }];
1685
1683
  readonly probes: readonly [{
@@ -1809,7 +1807,7 @@ export declare const capabilities: {
1809
1807
  readonly id: "images.example.generate";
1810
1808
  readonly title: "Generate one image";
1811
1809
  readonly lang: "ts";
1812
- readonly code: "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst img = await ai.images.generate({ prompt, aspectRatio: '3:2', job: 'article.hero' })\nawait env.IMAGES.put(key, img.bytes, { httpMetadata: { contentType: img.mimeType }, customMetadata: { model: img.model, requestId: img.requestId } })";
1810
+ readonly code: "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst img = await ai.images.generate({ prompt, aspectRatio: '3:2', job: 'article.hero' })\nawait env.IMAGES.put(key, img.bytes, { httpMetadata: { contentType: img.mimeType }, customMetadata: { model: img.model, requestId: img.requestId } })";
1813
1811
  readonly probe: "images.generate.image-default";
1814
1812
  }, {
1815
1813
  readonly id: "images.example.reference";
@@ -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 = "90023fb27145";
3
+ export const MANIFEST_VERSION = "5f4a5d2dad37";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -204,7 +204,7 @@ export const capabilities = {
204
204
  "id": "ai.example.text",
205
205
  "title": "One-shot text",
206
206
  "lang": "ts",
207
- "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })",
207
+ "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })",
208
208
  "probe": "ai.text.claude-default"
209
209
  },
210
210
  {
@@ -1139,7 +1139,7 @@ export const capabilities = {
1139
1139
  "id": "email.example.send",
1140
1140
  "title": "Send a transactional mail with an inline logo",
1141
1141
  "lang": "ts",
1142
- "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' }] })",
1142
+ "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!, url: process.env.WORKBENCH_GATEWAY_URL! } })\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' }] })",
1143
1143
  "probe": "email.send.inline-attachment"
1144
1144
  }
1145
1145
  ],
@@ -1344,7 +1344,7 @@ export const capabilities = {
1344
1344
  "id": "email-inbound.example.handler",
1345
1345
  "title": "Store inbound mail",
1346
1346
  "lang": "ts",
1347
- "code": "export async function handleEmail(mail, env, ctx, { reject }) {\n if (!mail.to.some((t) => t.address === 'inbox@wb-probe-staging.mccaskielabs.app')) return reject('Unknown mailbox')\n await saveMail(env, { message_id: mail.message_id, subject: mail.subject, text: mail.text, received_at: mail.received_at })\n}",
1347
+ "code": "export async function handleEmail(mail, env, ctx, { reject }) {\n if (!mail.to.some((t) => t.address === 'inbox@<id>-staging.<projects domain>')) return reject('Unknown mailbox')\n await saveMail(env, { message_id: mail.message_id, subject: mail.subject, text: mail.text, received_at: mail.received_at })\n}",
1348
1348
  "probe": "email-inbound.roundtrip"
1349
1349
  }
1350
1350
  ],
@@ -1452,7 +1452,7 @@ export const capabilities = {
1452
1452
  {
1453
1453
  "id": "email-sending.namespace-only",
1454
1454
  "kind": "must",
1455
- "text": "Onboard a domain in the project's own namespace, for example mail.<id>.mccaskielabs.app. Project-owned zones such as wbprobe.com follow cf_zone_attach (step 6.1).",
1455
+ "text": "Onboard a domain in the project's own namespace, for example mail.<id>.<projects domain> (the domain this install keeps its projects under). A domain the project owns follows cf_zone_attach (step 6.1).",
1456
1456
  "enforced_by": "control",
1457
1457
  "probe": "email-sending.onboard"
1458
1458
  },
@@ -1484,7 +1484,7 @@ export const capabilities = {
1484
1484
  "half": "drill",
1485
1485
  "expect": "pass",
1486
1486
  "cost_hint_usd": 0,
1487
- "note": "cf_email_sending_onboard on mail.<id>.mccaskielabs.app; a second call changes nothing"
1487
+ "note": "cf_email_sending_onboard on mail.<id>.<projects domain>; a second call changes nothing"
1488
1488
  },
1489
1489
  {
1490
1490
  "id": "email-sending.status",
@@ -1861,9 +1861,6 @@ export const capabilities = {
1861
1861
  "GET /v1/usage",
1862
1862
  "GET /v1/capabilities",
1863
1863
  "GET /v1/calls"
1864
- ],
1865
- "package": [
1866
- "DEFAULT_GATEWAY_URL"
1867
1864
  ]
1868
1865
  },
1869
1866
  "config": [
@@ -1879,15 +1876,14 @@ export const capabilities = {
1879
1876
  },
1880
1877
  {
1881
1878
  "name": "WORKBENCH_GATEWAY_URL",
1882
- "required": false,
1879
+ "required": true,
1883
1880
  "secret": false,
1884
- "default": "https://gateway.mccaskielabs.dev",
1885
1881
  "environments": [
1886
1882
  "development",
1887
1883
  "staging",
1888
1884
  "production"
1889
1885
  ],
1890
- "description": "Gateway base URL. Leave unset outside tests"
1886
+ "description": "The address of this Workbench install's Gateway. Control writes it into wrangler.jsonc when it creates the project; there is no built-in address"
1891
1887
  }
1892
1888
  ],
1893
1889
  "allowed": {
@@ -2278,7 +2274,7 @@ export const capabilities = {
2278
2274
  "id": "image-search.example.search",
2279
2275
  "title": "Search for images",
2280
2276
  "lang": "ts",
2281
- "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst results = await ai.images.search({ q: 'Mayon volcano', job: 'editor.image-search', num: 10 })",
2277
+ "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst results = await ai.images.search({ q: 'Mayon volcano', job: 'editor.image-search', num: 10 })",
2282
2278
  "probe": "image-search.serper"
2283
2279
  }
2284
2280
  ],
@@ -2459,7 +2455,7 @@ export const capabilities = {
2459
2455
  "id": "images.example.generate",
2460
2456
  "title": "Generate one image",
2461
2457
  "lang": "ts",
2462
- "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst img = await ai.images.generate({ prompt, aspectRatio: '3:2', job: 'article.hero' })\nawait env.IMAGES.put(key, img.bytes, { httpMetadata: { contentType: img.mimeType }, customMetadata: { model: img.model, requestId: img.requestId } })",
2458
+ "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL })\nconst img = await ai.images.generate({ prompt, aspectRatio: '3:2', job: 'article.hero' })\nawait env.IMAGES.put(key, img.bytes, { httpMetadata: { contentType: img.mimeType }, customMetadata: { model: img.model, requestId: img.requestId } })",
2463
2459
  "probe": "images.generate.image-default"
2464
2460
  },
2465
2461
  {
package/dist/env.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export declare const WORKBENCH_ENVS: readonly ["development", "staging", "production"];
2
2
  export type WorkbenchEnv = (typeof WORKBENCH_ENVS)[number];
3
- export declare const DEFAULT_GATEWAY_URL = "https://gateway.mccaskielabs.dev";
4
3
  /**
5
4
  * Reads `WORKBENCH_ENV`. Throws if it is missing or not one of the three
6
5
  * environments. Never falls back to `NODE_ENV` or a default (audit F9, F18).
package/dist/env.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { invalid, missing } from "./errors.js";
2
2
  export const WORKBENCH_ENVS = ["development", "staging", "production"];
3
- export const DEFAULT_GATEWAY_URL = "https://gateway.mccaskielabs.dev";
4
3
  /**
5
4
  * Reads `WORKBENCH_ENV`. Throws if it is missing or not one of the three
6
5
  * environments. Never falls back to `NODE_ENV` or a default (audit F9, F18).
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { WORKBENCH_ENVS, DEFAULT_GATEWAY_URL, workbenchEnv, type WorkbenchEnv } from "./env.ts";
1
+ export { WORKBENCH_ENVS, workbenchEnv, type WorkbenchEnv } from "./env.ts";
2
2
  export { defineConfig, type Config } from "./define-config.ts";
3
3
  export { sharedSecret, workerSecret, redact, redactAll, type SecretsStoreSecretLike } from "./secrets.ts";
4
4
  export { WorkbenchConfigError, type ConfigErrorCode, type ConfigProblem } from "./errors.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { WORKBENCH_ENVS, DEFAULT_GATEWAY_URL, workbenchEnv } from "./env.js";
1
+ export { WORKBENCH_ENVS, workbenchEnv } from "./env.js";
2
2
  export { defineConfig } from "./define-config.js";
3
3
  export { sharedSecret, workerSecret, redact, redactAll } from "./secrets.js";
4
4
  export { WorkbenchConfigError } from "./errors.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.1.40",
3
+ "version": "0.2.0",
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",