@mccaskie-labs/workbench-config 0.1.7 → 0.1.9
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 +13 -0
- package/capabilities/analytics.json +55 -10
- package/capabilities/database.json +12 -18
- package/capabilities/email-sending.json +38 -0
- package/capabilities/email-sending.md +9 -0
- package/capabilities/email.json +61 -0
- package/capabilities/email.md +23 -0
- package/dist/capabilities.gen.d.ts +326 -23
- package/dist/capabilities.gen.js +408 -25
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-config
|
|
2
2
|
|
|
3
|
+
## 0.1.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Ship the email and email-sending manifests (step 4.9): `capabilities.email` (POST /v1/email/send and workbench-email) and `capabilities["email-sending"]` (cf_email_sending_onboard and cf_email_sending_status), both built.
|
|
8
|
+
|
|
9
|
+
## 0.1.8
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Database and analytics manifests use wb-probe's probe ids (`database.rls`, `database.service-reason`, `analytics.beacon`, `analytics.events`), with no smoke flags, and the checks that only the drill can see are drill probes.
|
|
14
|
+
- The `bin` path is written as npm normalises it (`dist/cli.js`), so publishing no longer warns about it.
|
|
15
|
+
|
|
3
16
|
## 0.1.7
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -6,29 +6,74 @@
|
|
|
6
6
|
"summary": "Cloudflare Web Analytics counts page views through a beacon on every page; product events go into the app's own database through workbench-analytics. Apps hold no Cloudflare token: Claude reads page views through Control.",
|
|
7
7
|
"status": "built",
|
|
8
8
|
"surfaces": {
|
|
9
|
-
"package": [
|
|
10
|
-
|
|
9
|
+
"package": [
|
|
10
|
+
"beaconScript",
|
|
11
|
+
"createEvents",
|
|
12
|
+
"eventsMigration"
|
|
13
|
+
],
|
|
14
|
+
"tools": [
|
|
15
|
+
"cf_web_analytics_site_create"
|
|
16
|
+
]
|
|
11
17
|
},
|
|
12
18
|
"actions": [
|
|
13
|
-
{
|
|
19
|
+
{
|
|
20
|
+
"tool": "cf_web_analytics_site_create",
|
|
21
|
+
"gate": "free",
|
|
22
|
+
"ships": "4"
|
|
23
|
+
}
|
|
14
24
|
],
|
|
15
25
|
"rules": [
|
|
16
|
-
{
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
{
|
|
27
|
+
"id": "analytics.beacon-on-pages",
|
|
28
|
+
"kind": "must",
|
|
29
|
+
"text": "Put beaconScript(token) on every page, with the site's public beacon token from cf_web_analytics_site_create.",
|
|
30
|
+
"enforced_by": "review",
|
|
31
|
+
"probe": "analytics.beacon"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "analytics.track-never-throws",
|
|
35
|
+
"kind": "must_not",
|
|
36
|
+
"text": "Let a failed event write break the request it describes: track logs and drops it.",
|
|
37
|
+
"enforced_by": "types",
|
|
38
|
+
"probe": "analytics.events"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "analytics.no-cf-token",
|
|
42
|
+
"kind": "must_not",
|
|
43
|
+
"text": "Hold a Cloudflare analytics token in the app (hard rule 1); page views are read with Control.",
|
|
44
|
+
"enforced_by": "review",
|
|
45
|
+
"probe": "analytics.page-views-drill"
|
|
46
|
+
}
|
|
19
47
|
],
|
|
20
48
|
"examples": [
|
|
21
49
|
{
|
|
22
50
|
"id": "analytics.example.track",
|
|
23
51
|
"title": "Record a product event",
|
|
24
52
|
"lang": "ts",
|
|
25
|
-
"probe": "analytics.
|
|
53
|
+
"probe": "analytics.events",
|
|
26
54
|
"code": "import { createEvents } from '@mccaskie-labs/workbench-analytics'\n\nawait createEvents(db).track('note.created', { length: body.length }, { type: 'note', id })"
|
|
27
55
|
}
|
|
28
56
|
],
|
|
29
57
|
"probes": [
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
58
|
+
{
|
|
59
|
+
"id": "analytics.beacon",
|
|
60
|
+
"half": "runtime",
|
|
61
|
+
"expect": "pass",
|
|
62
|
+
"cost_hint_usd": 0
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "analytics.events",
|
|
66
|
+
"half": "runtime",
|
|
67
|
+
"expect": "pass",
|
|
68
|
+
"cost_hint_usd": 0,
|
|
69
|
+
"note": "A product event is written and read back"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "analytics.page-views-drill",
|
|
73
|
+
"half": "drill",
|
|
74
|
+
"expect": "pass",
|
|
75
|
+
"cost_hint_usd": 0,
|
|
76
|
+
"note": "Create the site, drive page views, query them back (cf_analytics_query, later)"
|
|
77
|
+
}
|
|
33
78
|
]
|
|
34
79
|
}
|
|
@@ -99,21 +99,21 @@
|
|
|
99
99
|
"kind": "must",
|
|
100
100
|
"text": "Turn on row level security for every table the publishable key can reach, so one user can't read another's rows.",
|
|
101
101
|
"enforced_by": "database",
|
|
102
|
-
"probe": "database.rls
|
|
102
|
+
"probe": "database.rls"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
|
-
"id": "database.service-reason",
|
|
105
|
+
"id": "database.service-client-reason",
|
|
106
106
|
"kind": "must",
|
|
107
107
|
"text": "Give createServiceClient a reason saying why RLS is bypassed; it is logged once per isolate.",
|
|
108
108
|
"enforced_by": "types",
|
|
109
|
-
"probe": "database.service-
|
|
109
|
+
"probe": "database.service-reason"
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
"id": "database.no-legacy-keys",
|
|
113
113
|
"kind": "must_not",
|
|
114
114
|
"text": "Use legacy JWT keys (anon, service_role). Only sb_publishable_ and sb_secret_ keys are accepted.",
|
|
115
115
|
"enforced_by": "types",
|
|
116
|
-
"probe": "database.
|
|
116
|
+
"probe": "database.crud"
|
|
117
117
|
},
|
|
118
118
|
{
|
|
119
119
|
"id": "database.down-files",
|
|
@@ -144,28 +144,22 @@
|
|
|
144
144
|
"id": "database.crud",
|
|
145
145
|
"half": "runtime",
|
|
146
146
|
"expect": "pass",
|
|
147
|
-
"smoke": true,
|
|
148
147
|
"cost_hint_usd": 0,
|
|
149
148
|
"note": "Insert, read, update and delete, each timed"
|
|
150
149
|
},
|
|
151
150
|
{
|
|
152
|
-
"id": "database.rls
|
|
151
|
+
"id": "database.rls",
|
|
153
152
|
"half": "runtime",
|
|
154
153
|
"expect": "pass",
|
|
155
154
|
"cost_hint_usd": 0,
|
|
156
|
-
"note": "A second user'
|
|
155
|
+
"note": "A second user can't read, update or delete the first user's row"
|
|
157
156
|
},
|
|
158
157
|
{
|
|
159
|
-
"id": "database.service-
|
|
160
|
-
"half": "
|
|
161
|
-
"expect": "pass",
|
|
162
|
-
"cost_hint_usd": 0
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
"id": "database.config-named",
|
|
166
|
-
"half": "runtime",
|
|
158
|
+
"id": "database.service-reason",
|
|
159
|
+
"half": "drill",
|
|
167
160
|
"expect": "pass",
|
|
168
|
-
"cost_hint_usd": 0
|
|
161
|
+
"cost_hint_usd": 0,
|
|
162
|
+
"note": "The reason line is in the Worker's logs, which only the drill can read"
|
|
169
163
|
},
|
|
170
164
|
{
|
|
171
165
|
"id": "database.migrations-up-down-up",
|
|
@@ -175,10 +169,10 @@
|
|
|
175
169
|
},
|
|
176
170
|
{
|
|
177
171
|
"id": "database.prod-schema-check",
|
|
178
|
-
"half": "
|
|
172
|
+
"half": "drill",
|
|
179
173
|
"expect": "pass",
|
|
180
174
|
"cost_hint_usd": 0,
|
|
181
|
-
"note": "
|
|
175
|
+
"note": "npx workbench-supabase check:prod-schema in the project's Checks"
|
|
182
176
|
},
|
|
183
177
|
{
|
|
184
178
|
"id": "database.production-write-two-step",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema.json",
|
|
3
|
+
"format": 1,
|
|
4
|
+
"component": "email-sending",
|
|
5
|
+
"title": "Sending domains and sender addresses",
|
|
6
|
+
"summary": "Control onboards a domain in the project's namespace to Cloudflare Email Sending in the projects account, with its bounce, SPF, DKIM and DMARC records checked over DNS-over-HTTPS, and allows sender addresses on it. The Gateway sends from those addresses only.",
|
|
7
|
+
"status": "built",
|
|
8
|
+
"surfaces": {
|
|
9
|
+
"tools": ["cf_email_sending_onboard", "cf_email_sending_status", "gw_sender_add", "gw_sender_list"]
|
|
10
|
+
},
|
|
11
|
+
"limits": [
|
|
12
|
+
{ "id": "email-sending.domains-per-zone", "value": 30, "unit": "domains", "per": "zone, Email Sending and Email Routing together", "enforced_by": "control" }
|
|
13
|
+
],
|
|
14
|
+
"actions": [
|
|
15
|
+
{ "tool": "cf_email_sending_onboard", "gate": "free", "ships": "4" },
|
|
16
|
+
{ "tool": "cf_email_sending_status", "gate": "free", "ships": "4" },
|
|
17
|
+
{ "tool": "gw_sender_add", "gate": "free", "ships": "4" },
|
|
18
|
+
{ "tool": "gw_sender_list", "gate": "free", "ships": "4" }
|
|
19
|
+
],
|
|
20
|
+
"errors": [
|
|
21
|
+
{ "code": "out_of_scope", "http": 403, "retryable": false, "meaning": "A domain outside the project's namespace, or a sender on a domain the project hasn't onboarded" },
|
|
22
|
+
{ "code": "name_taken", "http": 409, "retryable": false, "meaning": "The domain or address is already onboarded or allowed outside this project; Control never adopts" },
|
|
23
|
+
{ "code": "record_conflict", "http": 409, "retryable": false, "meaning": "A DNS record in the way that Control won't change (another SPF, DMARC, MX or CNAME)" },
|
|
24
|
+
{ "code": "missing_permission", "http": 403, "retryable": false, "meaning": "Control's Cloudflare token lacks Email Sending (edit)" }
|
|
25
|
+
],
|
|
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" },
|
|
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
|
+
{ "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
|
+
{ "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
|
+
],
|
|
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" },
|
|
34
|
+
{ "id": "email-sending.status", "half": "drill", "expect": "pass", "cost_hint_usd": 0 },
|
|
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
|
+
{ "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" }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Email sending domains
|
|
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.
|
|
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.
|
|
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.
|
|
8
|
+
|
|
9
|
+
The DNS records are ordinary `dns_record` rows for the project, so removing one is `cf_dns_delete` (two-step).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema.json",
|
|
3
|
+
"format": 1,
|
|
4
|
+
"component": "email",
|
|
5
|
+
"title": "Transactional email through the Gateway",
|
|
6
|
+
"summary": "Apps send transactional mail with workbench-email, or POST /v1/email/send directly. The Gateway checks the sender and recipients, keeps idempotency keys for 24 hours, meters every send, and hands the message to Cloudflare Email Sending. Apps hold no email credential.",
|
|
7
|
+
"status": "built",
|
|
8
|
+
"surfaces": {
|
|
9
|
+
"gateway": ["POST /v1/email/send"],
|
|
10
|
+
"package": ["createMailer", "selectTransport", "Mailer.send", "captureSink", "WorkbenchEmailError"]
|
|
11
|
+
},
|
|
12
|
+
"config": [
|
|
13
|
+
{ "name": "WORKBENCH_GATEWAY_KEY", "required": true, "secret": true, "environments": ["staging", "production"], "description": "Project key with the email scope, delivered as a Worker secret by gw_key_issue" },
|
|
14
|
+
{ "name": "WORKBENCH_ENV", "required": true, "secret": false, "environments": ["development", "staging", "production"], "description": "Chooses the transport: capture in development, the Gateway elsewhere" },
|
|
15
|
+
{ "name": "WORKBENCH_EMAIL_LIVE", "required": false, "secret": false, "default": "0", "environments": ["development"], "description": "1 sends real mail from development through the Gateway (a development key can only mail Robert)" }
|
|
16
|
+
],
|
|
17
|
+
"limits": [
|
|
18
|
+
{ "id": "email.recipients", "value": 50, "unit": "recipients", "per": "message (to, cc and bcc together)", "enforced_by": "gateway", "refusal": "invalid_request" },
|
|
19
|
+
{ "id": "email.attachments", "value": 32, "unit": "attachments", "per": "message", "enforced_by": "gateway", "refusal": "invalid_request" },
|
|
20
|
+
{ "id": "email.message-size", "value": 26214400, "unit": "bytes", "per": "message, attachments as base64", "enforced_by": "gateway", "refusal": "invalid_request" },
|
|
21
|
+
{ "id": "email.subject-length", "value": 998, "unit": "characters", "per": "message", "enforced_by": "gateway", "refusal": "invalid_request" },
|
|
22
|
+
{ "id": "email.idempotency-window", "value": 24, "unit": "hours", "per": "Idempotency-Key", "enforced_by": "database" },
|
|
23
|
+
{ "id": "email.upstream-timeout", "value": 30, "unit": "seconds", "per": "request", "enforced_by": "gateway", "refusal": "upstream_timeout" }
|
|
24
|
+
],
|
|
25
|
+
"errors": [
|
|
26
|
+
{ "code": "sender_not_allowed", "http": 403, "retryable": false, "meaning": "from isn't one of the project's senders (gw_sender_add)" },
|
|
27
|
+
{ "code": "recipient_not_allowed", "http": 403, "retryable": false, "meaning": "A development key sending to anyone but Workbench's allowlisted addresses" },
|
|
28
|
+
{ "code": "invalid_request", "http": 400, "retryable": false, "meaning": "A malformed message, a limit passed, or an Idempotency-Key reused with a different message (422)" },
|
|
29
|
+
{ "code": "not_available", "http": 501, "retryable": false, "meaning": "type bulk, until Amazon SES is built" },
|
|
30
|
+
{ "code": "budget_exceeded", "http": 402, "retryable": false, "meaning": "Enforced monthly budget reached" },
|
|
31
|
+
{ "code": "rate_limited", "http": 429, "retryable": true, "meaning": "Too many requests, Cloudflare's quota, or (409) a send with the same Idempotency-Key still in progress" },
|
|
32
|
+
{ "code": "upstream_error", "http": 502, "retryable": true, "meaning": "Cloudflare Email Sending failed; nothing was sent" },
|
|
33
|
+
{ "code": "upstream_timeout", "http": 504, "retryable": true, "meaning": "Cloudflare didn't answer within 30 seconds; the mail may or may not have gone" }
|
|
34
|
+
],
|
|
35
|
+
"rules": [
|
|
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
|
+
{ "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" },
|
|
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
|
+
{ "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" },
|
|
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
|
+
],
|
|
44
|
+
"examples": [
|
|
45
|
+
{
|
|
46
|
+
"id": "email.example.send",
|
|
47
|
+
"title": "Send a transactional mail with an inline logo",
|
|
48
|
+
"lang": "ts",
|
|
49
|
+
"probe": "email.send.inline-image",
|
|
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
|
+
}
|
|
52
|
+
],
|
|
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" },
|
|
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 },
|
|
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" }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Email
|
|
2
|
+
|
|
3
|
+
Apps send transactional mail with `createMailer` from `@mccaskie-labs/workbench-email`, or `POST /v1/email/send` directly. The Gateway holds the sending credential; the app holds only its Gateway key, which needs the `email` scope.
|
|
4
|
+
|
|
5
|
+
## Transports
|
|
6
|
+
|
|
7
|
+
`selectTransport(env)` picks `capture` in development and `gateway` in staging and production. Captured mail is written to `.mail/` as `.eml` files (Node) or logged (workerd), and never sent. Set `WORKBENCH_EMAIL_LIVE=1` to send for real from development; a development key can still only mail Robert. Constructing the capture transport in staging or production throws.
|
|
8
|
+
|
|
9
|
+
## Senders
|
|
10
|
+
|
|
11
|
+
`from` must be an address added with `gw_sender_add`, on a domain onboarded to Cloudflare Email Sending with `cf_email_sending_onboard`. Anything else is refused with `sender_not_allowed` before a send.
|
|
12
|
+
|
|
13
|
+
## Idempotency
|
|
14
|
+
|
|
15
|
+
Send `idempotencyKey` for anything a retry could repeat. Within 24 hours the same key and message return the first result with `replayed: true`, and nothing is sent again. The same key with a different message is refused (422). A send that failed frees its key, so a retry with it goes out.
|
|
16
|
+
|
|
17
|
+
## Attachments
|
|
18
|
+
|
|
19
|
+
Bytes, not base64 strings. Give an inline image a `contentId` and reference it in the HTML as `cid:<id>`. At most 32 attachments and 25 MiB a message.
|
|
20
|
+
|
|
21
|
+
## What a send returns
|
|
22
|
+
|
|
23
|
+
`messageId`, `reachedTheInternet` (true only when Cloudflare took the mail), `requestId` (matches a usage row) and, from the Gateway, the recipients Cloudflare delivered, queued or bounced. Every error has `reachedTheInternet: false`. Each send is charged at US$0.35 per 1,000 recipients.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const MANIFEST_VERSION: "
|
|
1
|
+
export declare const MANIFEST_VERSION: "ffb5a8a16ad1";
|
|
2
2
|
export declare const capabilities: {
|
|
3
3
|
readonly ai: {
|
|
4
4
|
readonly $schema: "./schema.json";
|
|
@@ -158,13 +158,13 @@ export declare const capabilities: {
|
|
|
158
158
|
readonly kind: "must";
|
|
159
159
|
readonly text: "Put beaconScript(token) on every page, with the site's public beacon token from cf_web_analytics_site_create.";
|
|
160
160
|
readonly enforced_by: "review";
|
|
161
|
-
readonly probe: "analytics.beacon
|
|
161
|
+
readonly probe: "analytics.beacon";
|
|
162
162
|
}, {
|
|
163
163
|
readonly id: "analytics.track-never-throws";
|
|
164
164
|
readonly kind: "must_not";
|
|
165
165
|
readonly text: "Let a failed event write break the request it describes: track logs and drops it.";
|
|
166
166
|
readonly enforced_by: "types";
|
|
167
|
-
readonly probe: "analytics.
|
|
167
|
+
readonly probe: "analytics.events";
|
|
168
168
|
}, {
|
|
169
169
|
readonly id: "analytics.no-cf-token";
|
|
170
170
|
readonly kind: "must_not";
|
|
@@ -177,16 +177,15 @@ export declare const capabilities: {
|
|
|
177
177
|
readonly title: "Record a product event";
|
|
178
178
|
readonly lang: "ts";
|
|
179
179
|
readonly code: "import { createEvents } from '@mccaskie-labs/workbench-analytics'\n\nawait createEvents(db).track('note.created', { length: body.length }, { type: 'note', id })";
|
|
180
|
-
readonly probe: "analytics.
|
|
180
|
+
readonly probe: "analytics.events";
|
|
181
181
|
}];
|
|
182
182
|
readonly probes: readonly [{
|
|
183
|
-
readonly id: "analytics.beacon
|
|
183
|
+
readonly id: "analytics.beacon";
|
|
184
184
|
readonly half: "runtime";
|
|
185
185
|
readonly expect: "pass";
|
|
186
|
-
readonly smoke: true;
|
|
187
186
|
readonly cost_hint_usd: 0;
|
|
188
187
|
}, {
|
|
189
|
-
readonly id: "analytics.
|
|
188
|
+
readonly id: "analytics.events";
|
|
190
189
|
readonly half: "runtime";
|
|
191
190
|
readonly expect: "pass";
|
|
192
191
|
readonly cost_hint_usd: 0;
|
|
@@ -340,19 +339,19 @@ export declare const capabilities: {
|
|
|
340
339
|
readonly kind: "must";
|
|
341
340
|
readonly text: "Turn on row level security for every table the publishable key can reach, so one user can't read another's rows.";
|
|
342
341
|
readonly enforced_by: "database";
|
|
343
|
-
readonly probe: "database.rls
|
|
342
|
+
readonly probe: "database.rls";
|
|
344
343
|
}, {
|
|
345
|
-
readonly id: "database.service-reason";
|
|
344
|
+
readonly id: "database.service-client-reason";
|
|
346
345
|
readonly kind: "must";
|
|
347
346
|
readonly text: "Give createServiceClient a reason saying why RLS is bypassed; it is logged once per isolate.";
|
|
348
347
|
readonly enforced_by: "types";
|
|
349
|
-
readonly probe: "database.service-
|
|
348
|
+
readonly probe: "database.service-reason";
|
|
350
349
|
}, {
|
|
351
350
|
readonly id: "database.no-legacy-keys";
|
|
352
351
|
readonly kind: "must_not";
|
|
353
352
|
readonly text: "Use legacy JWT keys (anon, service_role). Only sb_publishable_ and sb_secret_ keys are accepted.";
|
|
354
353
|
readonly enforced_by: "types";
|
|
355
|
-
readonly probe: "database.
|
|
354
|
+
readonly probe: "database.crud";
|
|
356
355
|
}, {
|
|
357
356
|
readonly id: "database.down-files";
|
|
358
357
|
readonly kind: "must";
|
|
@@ -377,25 +376,20 @@ export declare const capabilities: {
|
|
|
377
376
|
readonly id: "database.crud";
|
|
378
377
|
readonly half: "runtime";
|
|
379
378
|
readonly expect: "pass";
|
|
380
|
-
readonly smoke: true;
|
|
381
379
|
readonly cost_hint_usd: 0;
|
|
382
380
|
readonly note: "Insert, read, update and delete, each timed";
|
|
383
381
|
}, {
|
|
384
|
-
readonly id: "database.rls
|
|
382
|
+
readonly id: "database.rls";
|
|
385
383
|
readonly half: "runtime";
|
|
386
384
|
readonly expect: "pass";
|
|
387
385
|
readonly cost_hint_usd: 0;
|
|
388
|
-
readonly note: "A second user'
|
|
386
|
+
readonly note: "A second user can't read, update or delete the first user's row";
|
|
389
387
|
}, {
|
|
390
|
-
readonly id: "database.service-
|
|
391
|
-
readonly half: "
|
|
392
|
-
readonly expect: "pass";
|
|
393
|
-
readonly cost_hint_usd: 0;
|
|
394
|
-
}, {
|
|
395
|
-
readonly id: "database.config-named";
|
|
396
|
-
readonly half: "runtime";
|
|
388
|
+
readonly id: "database.service-reason";
|
|
389
|
+
readonly half: "drill";
|
|
397
390
|
readonly expect: "pass";
|
|
398
391
|
readonly cost_hint_usd: 0;
|
|
392
|
+
readonly note: "The reason line is in the Worker's logs, which only the drill can read";
|
|
399
393
|
}, {
|
|
400
394
|
readonly id: "database.migrations-up-down-up";
|
|
401
395
|
readonly half: "drill";
|
|
@@ -403,10 +397,10 @@ export declare const capabilities: {
|
|
|
403
397
|
readonly cost_hint_usd: 0;
|
|
404
398
|
}, {
|
|
405
399
|
readonly id: "database.prod-schema-check";
|
|
406
|
-
readonly half: "
|
|
400
|
+
readonly half: "drill";
|
|
407
401
|
readonly expect: "pass";
|
|
408
402
|
readonly cost_hint_usd: 0;
|
|
409
|
-
readonly note: "
|
|
403
|
+
readonly note: "npx workbench-supabase check:prod-schema in the project's Checks";
|
|
410
404
|
}, {
|
|
411
405
|
readonly id: "database.production-write-two-step";
|
|
412
406
|
readonly half: "drill";
|
|
@@ -416,6 +410,209 @@ export declare const capabilities: {
|
|
|
416
410
|
readonly note: "sb_query on production with a write returns a confirmation, not a result";
|
|
417
411
|
}];
|
|
418
412
|
};
|
|
413
|
+
readonly email: {
|
|
414
|
+
readonly $schema: "./schema.json";
|
|
415
|
+
readonly format: 1;
|
|
416
|
+
readonly component: "email";
|
|
417
|
+
readonly title: "Transactional email through the Gateway";
|
|
418
|
+
readonly summary: "Apps send transactional mail with workbench-email, or POST /v1/email/send directly. The Gateway checks the sender and recipients, keeps idempotency keys for 24 hours, meters every send, and hands the message to Cloudflare Email Sending. Apps hold no email credential.";
|
|
419
|
+
readonly status: "built";
|
|
420
|
+
readonly surfaces: {
|
|
421
|
+
readonly gateway: readonly ["POST /v1/email/send"];
|
|
422
|
+
readonly package: readonly ["createMailer", "selectTransport", "Mailer.send", "captureSink", "WorkbenchEmailError"];
|
|
423
|
+
};
|
|
424
|
+
readonly config: readonly [{
|
|
425
|
+
readonly name: "WORKBENCH_GATEWAY_KEY";
|
|
426
|
+
readonly required: true;
|
|
427
|
+
readonly secret: true;
|
|
428
|
+
readonly environments: readonly ["staging", "production"];
|
|
429
|
+
readonly description: "Project key with the email scope, delivered as a Worker secret by gw_key_issue";
|
|
430
|
+
}, {
|
|
431
|
+
readonly name: "WORKBENCH_ENV";
|
|
432
|
+
readonly required: true;
|
|
433
|
+
readonly secret: false;
|
|
434
|
+
readonly environments: readonly ["development", "staging", "production"];
|
|
435
|
+
readonly description: "Chooses the transport: capture in development, the Gateway elsewhere";
|
|
436
|
+
}, {
|
|
437
|
+
readonly name: "WORKBENCH_EMAIL_LIVE";
|
|
438
|
+
readonly required: false;
|
|
439
|
+
readonly secret: false;
|
|
440
|
+
readonly default: "0";
|
|
441
|
+
readonly environments: readonly ["development"];
|
|
442
|
+
readonly description: "1 sends real mail from development through the Gateway (a development key can only mail Robert)";
|
|
443
|
+
}];
|
|
444
|
+
readonly limits: readonly [{
|
|
445
|
+
readonly id: "email.recipients";
|
|
446
|
+
readonly value: 50;
|
|
447
|
+
readonly unit: "recipients";
|
|
448
|
+
readonly per: "message (to, cc and bcc together)";
|
|
449
|
+
readonly enforced_by: "gateway";
|
|
450
|
+
readonly refusal: "invalid_request";
|
|
451
|
+
}, {
|
|
452
|
+
readonly id: "email.attachments";
|
|
453
|
+
readonly value: 32;
|
|
454
|
+
readonly unit: "attachments";
|
|
455
|
+
readonly per: "message";
|
|
456
|
+
readonly enforced_by: "gateway";
|
|
457
|
+
readonly refusal: "invalid_request";
|
|
458
|
+
}, {
|
|
459
|
+
readonly id: "email.message-size";
|
|
460
|
+
readonly value: 26214400;
|
|
461
|
+
readonly unit: "bytes";
|
|
462
|
+
readonly per: "message, attachments as base64";
|
|
463
|
+
readonly enforced_by: "gateway";
|
|
464
|
+
readonly refusal: "invalid_request";
|
|
465
|
+
}, {
|
|
466
|
+
readonly id: "email.subject-length";
|
|
467
|
+
readonly value: 998;
|
|
468
|
+
readonly unit: "characters";
|
|
469
|
+
readonly per: "message";
|
|
470
|
+
readonly enforced_by: "gateway";
|
|
471
|
+
readonly refusal: "invalid_request";
|
|
472
|
+
}, {
|
|
473
|
+
readonly id: "email.idempotency-window";
|
|
474
|
+
readonly value: 24;
|
|
475
|
+
readonly unit: "hours";
|
|
476
|
+
readonly per: "Idempotency-Key";
|
|
477
|
+
readonly enforced_by: "database";
|
|
478
|
+
}, {
|
|
479
|
+
readonly id: "email.upstream-timeout";
|
|
480
|
+
readonly value: 30;
|
|
481
|
+
readonly unit: "seconds";
|
|
482
|
+
readonly per: "request";
|
|
483
|
+
readonly enforced_by: "gateway";
|
|
484
|
+
readonly refusal: "upstream_timeout";
|
|
485
|
+
}];
|
|
486
|
+
readonly errors: readonly [{
|
|
487
|
+
readonly code: "sender_not_allowed";
|
|
488
|
+
readonly http: 403;
|
|
489
|
+
readonly retryable: false;
|
|
490
|
+
readonly meaning: "from isn't one of the project's senders (gw_sender_add)";
|
|
491
|
+
}, {
|
|
492
|
+
readonly code: "recipient_not_allowed";
|
|
493
|
+
readonly http: 403;
|
|
494
|
+
readonly retryable: false;
|
|
495
|
+
readonly meaning: "A development key sending to anyone but Workbench's allowlisted addresses";
|
|
496
|
+
}, {
|
|
497
|
+
readonly code: "invalid_request";
|
|
498
|
+
readonly http: 400;
|
|
499
|
+
readonly retryable: false;
|
|
500
|
+
readonly meaning: "A malformed message, a limit passed, or an Idempotency-Key reused with a different message (422)";
|
|
501
|
+
}, {
|
|
502
|
+
readonly code: "not_available";
|
|
503
|
+
readonly http: 501;
|
|
504
|
+
readonly retryable: false;
|
|
505
|
+
readonly meaning: "type bulk, until Amazon SES is built";
|
|
506
|
+
}, {
|
|
507
|
+
readonly code: "budget_exceeded";
|
|
508
|
+
readonly http: 402;
|
|
509
|
+
readonly retryable: false;
|
|
510
|
+
readonly meaning: "Enforced monthly budget reached";
|
|
511
|
+
}, {
|
|
512
|
+
readonly code: "rate_limited";
|
|
513
|
+
readonly http: 429;
|
|
514
|
+
readonly retryable: true;
|
|
515
|
+
readonly meaning: "Too many requests, Cloudflare's quota, or (409) a send with the same Idempotency-Key still in progress";
|
|
516
|
+
}, {
|
|
517
|
+
readonly code: "upstream_error";
|
|
518
|
+
readonly http: 502;
|
|
519
|
+
readonly retryable: true;
|
|
520
|
+
readonly meaning: "Cloudflare Email Sending failed; nothing was sent";
|
|
521
|
+
}, {
|
|
522
|
+
readonly code: "upstream_timeout";
|
|
523
|
+
readonly http: 504;
|
|
524
|
+
readonly retryable: true;
|
|
525
|
+
readonly meaning: "Cloudflare didn't answer within 30 seconds; the mail may or may not have gone";
|
|
526
|
+
}];
|
|
527
|
+
readonly rules: readonly [{
|
|
528
|
+
readonly id: "email.text-always";
|
|
529
|
+
readonly kind: "must";
|
|
530
|
+
readonly text: "Send a text part with every mail, even when there is HTML.";
|
|
531
|
+
readonly enforced_by: "gateway";
|
|
532
|
+
readonly probe: "email.send.transactional";
|
|
533
|
+
}, {
|
|
534
|
+
readonly id: "email.idempotency-key";
|
|
535
|
+
readonly kind: "must";
|
|
536
|
+
readonly 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.";
|
|
537
|
+
readonly enforced_by: "review";
|
|
538
|
+
readonly probe: "email.send.idempotent-repeat";
|
|
539
|
+
}, {
|
|
540
|
+
readonly id: "email.attachments-as-bytes";
|
|
541
|
+
readonly kind: "must";
|
|
542
|
+
readonly text: "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.";
|
|
543
|
+
readonly enforced_by: "types";
|
|
544
|
+
readonly probe: "email.send.inline-image";
|
|
545
|
+
}, {
|
|
546
|
+
readonly id: "email.allowed-sender";
|
|
547
|
+
readonly kind: "must";
|
|
548
|
+
readonly text: "Send only from an address added with gw_sender_add on a domain onboarded with cf_email_sending_onboard.";
|
|
549
|
+
readonly enforced_by: "gateway";
|
|
550
|
+
readonly probe: "email.send.sender-not-allowed";
|
|
551
|
+
}, {
|
|
552
|
+
readonly id: "email.no-capture-outside-dev";
|
|
553
|
+
readonly kind: "must_not";
|
|
554
|
+
readonly text: "Use the capture transport outside development. createMailer throws when WORKBENCH_ENV is staging or production.";
|
|
555
|
+
readonly enforced_by: "types";
|
|
556
|
+
readonly probe: "email.capture-refused-in-staging";
|
|
557
|
+
}, {
|
|
558
|
+
readonly id: "email.dev-key-allowlist";
|
|
559
|
+
readonly kind: "must_not";
|
|
560
|
+
readonly text: "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.";
|
|
561
|
+
readonly enforced_by: "gateway";
|
|
562
|
+
readonly probe: "email.send.dev-recipient-refused";
|
|
563
|
+
}, {
|
|
564
|
+
readonly id: "email.transactional-only";
|
|
565
|
+
readonly kind: "must_not";
|
|
566
|
+
readonly text: "Send bulk or marketing mail. type bulk is refused with not_available until Amazon SES is built.";
|
|
567
|
+
readonly enforced_by: "gateway";
|
|
568
|
+
readonly probe: "email.send.transactional";
|
|
569
|
+
}];
|
|
570
|
+
readonly examples: readonly [{
|
|
571
|
+
readonly id: "email.example.send";
|
|
572
|
+
readonly title: "Send a transactional mail with an inline logo";
|
|
573
|
+
readonly lang: "ts";
|
|
574
|
+
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' }] })";
|
|
575
|
+
readonly probe: "email.send.inline-image";
|
|
576
|
+
}];
|
|
577
|
+
readonly probes: readonly [{
|
|
578
|
+
readonly id: "email.send.transactional";
|
|
579
|
+
readonly half: "runtime";
|
|
580
|
+
readonly expect: "pass";
|
|
581
|
+
readonly smoke: true;
|
|
582
|
+
readonly cost_hint_usd: 0.00035;
|
|
583
|
+
readonly note: "To Robert; check that it arrives";
|
|
584
|
+
}, {
|
|
585
|
+
readonly id: "email.send.inline-image";
|
|
586
|
+
readonly half: "runtime";
|
|
587
|
+
readonly expect: "pass";
|
|
588
|
+
readonly cost_hint_usd: 0.00035;
|
|
589
|
+
readonly note: "Robert checks the image renders";
|
|
590
|
+
}, {
|
|
591
|
+
readonly id: "email.send.idempotent-repeat";
|
|
592
|
+
readonly half: "runtime";
|
|
593
|
+
readonly expect: "pass";
|
|
594
|
+
readonly cost_hint_usd: 0.00035;
|
|
595
|
+
readonly note: "The repeat answers replayed: true and no second mail arrives";
|
|
596
|
+
}, {
|
|
597
|
+
readonly id: "email.send.dev-recipient-refused";
|
|
598
|
+
readonly half: "runtime";
|
|
599
|
+
readonly expect: "refused_as_expected";
|
|
600
|
+
readonly refusal: "recipient_not_allowed";
|
|
601
|
+
readonly cost_hint_usd: 0;
|
|
602
|
+
}, {
|
|
603
|
+
readonly id: "email.send.sender-not-allowed";
|
|
604
|
+
readonly half: "runtime";
|
|
605
|
+
readonly expect: "refused_as_expected";
|
|
606
|
+
readonly refusal: "sender_not_allowed";
|
|
607
|
+
readonly cost_hint_usd: 0;
|
|
608
|
+
}, {
|
|
609
|
+
readonly id: "email.capture-refused-in-staging";
|
|
610
|
+
readonly half: "runtime";
|
|
611
|
+
readonly expect: "pass";
|
|
612
|
+
readonly cost_hint_usd: 0;
|
|
613
|
+
readonly note: "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config";
|
|
614
|
+
}];
|
|
615
|
+
};
|
|
419
616
|
readonly "email-inbound": {
|
|
420
617
|
readonly $schema: "./schema.json";
|
|
421
618
|
readonly format: 1;
|
|
@@ -517,6 +714,112 @@ export declare const capabilities: {
|
|
|
517
714
|
readonly note: "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves";
|
|
518
715
|
}];
|
|
519
716
|
};
|
|
717
|
+
readonly "email-sending": {
|
|
718
|
+
readonly $schema: "./schema.json";
|
|
719
|
+
readonly format: 1;
|
|
720
|
+
readonly component: "email-sending";
|
|
721
|
+
readonly title: "Sending domains and sender addresses";
|
|
722
|
+
readonly summary: "Control onboards a domain in the project's namespace to Cloudflare Email Sending in the projects account, with its bounce, SPF, DKIM and DMARC records checked over DNS-over-HTTPS, and allows sender addresses on it. The Gateway sends from those addresses only.";
|
|
723
|
+
readonly status: "built";
|
|
724
|
+
readonly surfaces: {
|
|
725
|
+
readonly tools: readonly ["cf_email_sending_onboard", "cf_email_sending_status", "gw_sender_add", "gw_sender_list"];
|
|
726
|
+
};
|
|
727
|
+
readonly limits: readonly [{
|
|
728
|
+
readonly id: "email-sending.domains-per-zone";
|
|
729
|
+
readonly value: 30;
|
|
730
|
+
readonly unit: "domains";
|
|
731
|
+
readonly per: "zone, Email Sending and Email Routing together";
|
|
732
|
+
readonly enforced_by: "control";
|
|
733
|
+
}];
|
|
734
|
+
readonly actions: readonly [{
|
|
735
|
+
readonly tool: "cf_email_sending_onboard";
|
|
736
|
+
readonly gate: "free";
|
|
737
|
+
readonly ships: "4";
|
|
738
|
+
}, {
|
|
739
|
+
readonly tool: "cf_email_sending_status";
|
|
740
|
+
readonly gate: "free";
|
|
741
|
+
readonly ships: "4";
|
|
742
|
+
}, {
|
|
743
|
+
readonly tool: "gw_sender_add";
|
|
744
|
+
readonly gate: "free";
|
|
745
|
+
readonly ships: "4";
|
|
746
|
+
}, {
|
|
747
|
+
readonly tool: "gw_sender_list";
|
|
748
|
+
readonly gate: "free";
|
|
749
|
+
readonly ships: "4";
|
|
750
|
+
}];
|
|
751
|
+
readonly errors: readonly [{
|
|
752
|
+
readonly code: "out_of_scope";
|
|
753
|
+
readonly http: 403;
|
|
754
|
+
readonly retryable: false;
|
|
755
|
+
readonly meaning: "A domain outside the project's namespace, or a sender on a domain the project hasn't onboarded";
|
|
756
|
+
}, {
|
|
757
|
+
readonly code: "name_taken";
|
|
758
|
+
readonly http: 409;
|
|
759
|
+
readonly retryable: false;
|
|
760
|
+
readonly meaning: "The domain or address is already onboarded or allowed outside this project; Control never adopts";
|
|
761
|
+
}, {
|
|
762
|
+
readonly code: "record_conflict";
|
|
763
|
+
readonly http: 409;
|
|
764
|
+
readonly retryable: false;
|
|
765
|
+
readonly meaning: "A DNS record in the way that Control won't change (another SPF, DMARC, MX or CNAME)";
|
|
766
|
+
}, {
|
|
767
|
+
readonly code: "missing_permission";
|
|
768
|
+
readonly http: 403;
|
|
769
|
+
readonly retryable: false;
|
|
770
|
+
readonly meaning: "Control's Cloudflare token lacks Email Sending (edit)";
|
|
771
|
+
}];
|
|
772
|
+
readonly rules: readonly [{
|
|
773
|
+
readonly id: "email-sending.namespace-only";
|
|
774
|
+
readonly kind: "must";
|
|
775
|
+
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).";
|
|
776
|
+
readonly enforced_by: "control";
|
|
777
|
+
readonly probe: "email-sending.onboard";
|
|
778
|
+
}, {
|
|
779
|
+
readonly id: "email-sending.onboard-before-sender";
|
|
780
|
+
readonly kind: "must";
|
|
781
|
+
readonly text: "Onboard the domain before adding senders on it. gw_sender_add refuses a domain the project hasn't onboarded.";
|
|
782
|
+
readonly enforced_by: "control";
|
|
783
|
+
readonly probe: "email-sending.sender-add";
|
|
784
|
+
}, {
|
|
785
|
+
readonly id: "email-sending.never-adopt";
|
|
786
|
+
readonly kind: "must_not";
|
|
787
|
+
readonly text: "Expect Control to take over a domain onboarded by hand. It refuses with name_taken.";
|
|
788
|
+
readonly enforced_by: "control";
|
|
789
|
+
readonly probe: "email-sending.onboard";
|
|
790
|
+
}, {
|
|
791
|
+
readonly id: "email-sending.verified-over-doh";
|
|
792
|
+
readonly kind: "must";
|
|
793
|
+
readonly 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.";
|
|
794
|
+
readonly enforced_by: "control";
|
|
795
|
+
readonly probe: "email-sending.status";
|
|
796
|
+
}];
|
|
797
|
+
readonly probes: readonly [{
|
|
798
|
+
readonly id: "email-sending.onboard";
|
|
799
|
+
readonly half: "drill";
|
|
800
|
+
readonly expect: "pass";
|
|
801
|
+
readonly cost_hint_usd: 0;
|
|
802
|
+
readonly note: "cf_email_sending_onboard on mail.<id>.mccaskielabs.app; a second call changes nothing";
|
|
803
|
+
}, {
|
|
804
|
+
readonly id: "email-sending.status";
|
|
805
|
+
readonly half: "drill";
|
|
806
|
+
readonly expect: "pass";
|
|
807
|
+
readonly cost_hint_usd: 0;
|
|
808
|
+
}, {
|
|
809
|
+
readonly id: "email-sending.sender-add";
|
|
810
|
+
readonly half: "drill";
|
|
811
|
+
readonly expect: "pass";
|
|
812
|
+
readonly cost_hint_usd: 0;
|
|
813
|
+
readonly note: "Then a runtime send from the new address (email.send.transactional)";
|
|
814
|
+
}, {
|
|
815
|
+
readonly id: "email-sending.out-of-scope";
|
|
816
|
+
readonly half: "drill";
|
|
817
|
+
readonly expect: "refused_as_expected";
|
|
818
|
+
readonly refusal: "out_of_scope";
|
|
819
|
+
readonly cost_hint_usd: 0;
|
|
820
|
+
readonly note: "Another project's namespace";
|
|
821
|
+
}];
|
|
822
|
+
};
|
|
520
823
|
readonly gateway: {
|
|
521
824
|
readonly $schema: "./schema.json";
|
|
522
825
|
readonly format: 1;
|
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 = "ffb5a8a16ad1";
|
|
4
4
|
export const capabilities = {
|
|
5
5
|
"ai": {
|
|
6
6
|
"$schema": "./schema.json",
|
|
@@ -224,14 +224,14 @@ export const capabilities = {
|
|
|
224
224
|
"kind": "must",
|
|
225
225
|
"text": "Put beaconScript(token) on every page, with the site's public beacon token from cf_web_analytics_site_create.",
|
|
226
226
|
"enforced_by": "review",
|
|
227
|
-
"probe": "analytics.beacon
|
|
227
|
+
"probe": "analytics.beacon"
|
|
228
228
|
},
|
|
229
229
|
{
|
|
230
230
|
"id": "analytics.track-never-throws",
|
|
231
231
|
"kind": "must_not",
|
|
232
232
|
"text": "Let a failed event write break the request it describes: track logs and drops it.",
|
|
233
233
|
"enforced_by": "types",
|
|
234
|
-
"probe": "analytics.
|
|
234
|
+
"probe": "analytics.events"
|
|
235
235
|
},
|
|
236
236
|
{
|
|
237
237
|
"id": "analytics.no-cf-token",
|
|
@@ -247,19 +247,18 @@ export const capabilities = {
|
|
|
247
247
|
"title": "Record a product event",
|
|
248
248
|
"lang": "ts",
|
|
249
249
|
"code": "import { createEvents } from '@mccaskie-labs/workbench-analytics'\n\nawait createEvents(db).track('note.created', { length: body.length }, { type: 'note', id })",
|
|
250
|
-
"probe": "analytics.
|
|
250
|
+
"probe": "analytics.events"
|
|
251
251
|
}
|
|
252
252
|
],
|
|
253
253
|
"probes": [
|
|
254
254
|
{
|
|
255
|
-
"id": "analytics.beacon
|
|
255
|
+
"id": "analytics.beacon",
|
|
256
256
|
"half": "runtime",
|
|
257
257
|
"expect": "pass",
|
|
258
|
-
"smoke": true,
|
|
259
258
|
"cost_hint_usd": 0
|
|
260
259
|
},
|
|
261
260
|
{
|
|
262
|
-
"id": "analytics.
|
|
261
|
+
"id": "analytics.events",
|
|
263
262
|
"half": "runtime",
|
|
264
263
|
"expect": "pass",
|
|
265
264
|
"cost_hint_usd": 0,
|
|
@@ -486,21 +485,21 @@ export const capabilities = {
|
|
|
486
485
|
"kind": "must",
|
|
487
486
|
"text": "Turn on row level security for every table the publishable key can reach, so one user can't read another's rows.",
|
|
488
487
|
"enforced_by": "database",
|
|
489
|
-
"probe": "database.rls
|
|
488
|
+
"probe": "database.rls"
|
|
490
489
|
},
|
|
491
490
|
{
|
|
492
|
-
"id": "database.service-reason",
|
|
491
|
+
"id": "database.service-client-reason",
|
|
493
492
|
"kind": "must",
|
|
494
493
|
"text": "Give createServiceClient a reason saying why RLS is bypassed; it is logged once per isolate.",
|
|
495
494
|
"enforced_by": "types",
|
|
496
|
-
"probe": "database.service-
|
|
495
|
+
"probe": "database.service-reason"
|
|
497
496
|
},
|
|
498
497
|
{
|
|
499
498
|
"id": "database.no-legacy-keys",
|
|
500
499
|
"kind": "must_not",
|
|
501
500
|
"text": "Use legacy JWT keys (anon, service_role). Only sb_publishable_ and sb_secret_ keys are accepted.",
|
|
502
501
|
"enforced_by": "types",
|
|
503
|
-
"probe": "database.
|
|
502
|
+
"probe": "database.crud"
|
|
504
503
|
},
|
|
505
504
|
{
|
|
506
505
|
"id": "database.down-files",
|
|
@@ -531,28 +530,22 @@ export const capabilities = {
|
|
|
531
530
|
"id": "database.crud",
|
|
532
531
|
"half": "runtime",
|
|
533
532
|
"expect": "pass",
|
|
534
|
-
"smoke": true,
|
|
535
533
|
"cost_hint_usd": 0,
|
|
536
534
|
"note": "Insert, read, update and delete, each timed"
|
|
537
535
|
},
|
|
538
536
|
{
|
|
539
|
-
"id": "database.rls
|
|
537
|
+
"id": "database.rls",
|
|
540
538
|
"half": "runtime",
|
|
541
539
|
"expect": "pass",
|
|
542
540
|
"cost_hint_usd": 0,
|
|
543
|
-
"note": "A second user'
|
|
544
|
-
},
|
|
545
|
-
{
|
|
546
|
-
"id": "database.service-logs-reason",
|
|
547
|
-
"half": "runtime",
|
|
548
|
-
"expect": "pass",
|
|
549
|
-
"cost_hint_usd": 0
|
|
541
|
+
"note": "A second user can't read, update or delete the first user's row"
|
|
550
542
|
},
|
|
551
543
|
{
|
|
552
|
-
"id": "database.
|
|
553
|
-
"half": "
|
|
544
|
+
"id": "database.service-reason",
|
|
545
|
+
"half": "drill",
|
|
554
546
|
"expect": "pass",
|
|
555
|
-
"cost_hint_usd": 0
|
|
547
|
+
"cost_hint_usd": 0,
|
|
548
|
+
"note": "The reason line is in the Worker's logs, which only the drill can read"
|
|
556
549
|
},
|
|
557
550
|
{
|
|
558
551
|
"id": "database.migrations-up-down-up",
|
|
@@ -562,10 +555,10 @@ export const capabilities = {
|
|
|
562
555
|
},
|
|
563
556
|
{
|
|
564
557
|
"id": "database.prod-schema-check",
|
|
565
|
-
"half": "
|
|
558
|
+
"half": "drill",
|
|
566
559
|
"expect": "pass",
|
|
567
560
|
"cost_hint_usd": 0,
|
|
568
|
-
"note": "
|
|
561
|
+
"note": "npx workbench-supabase check:prod-schema in the project's Checks"
|
|
569
562
|
},
|
|
570
563
|
{
|
|
571
564
|
"id": "database.production-write-two-step",
|
|
@@ -577,6 +570,263 @@ export const capabilities = {
|
|
|
577
570
|
}
|
|
578
571
|
]
|
|
579
572
|
},
|
|
573
|
+
"email": {
|
|
574
|
+
"$schema": "./schema.json",
|
|
575
|
+
"format": 1,
|
|
576
|
+
"component": "email",
|
|
577
|
+
"title": "Transactional email through the Gateway",
|
|
578
|
+
"summary": "Apps send transactional mail with workbench-email, or POST /v1/email/send directly. The Gateway checks the sender and recipients, keeps idempotency keys for 24 hours, meters every send, and hands the message to Cloudflare Email Sending. Apps hold no email credential.",
|
|
579
|
+
"status": "built",
|
|
580
|
+
"surfaces": {
|
|
581
|
+
"gateway": [
|
|
582
|
+
"POST /v1/email/send"
|
|
583
|
+
],
|
|
584
|
+
"package": [
|
|
585
|
+
"createMailer",
|
|
586
|
+
"selectTransport",
|
|
587
|
+
"Mailer.send",
|
|
588
|
+
"captureSink",
|
|
589
|
+
"WorkbenchEmailError"
|
|
590
|
+
]
|
|
591
|
+
},
|
|
592
|
+
"config": [
|
|
593
|
+
{
|
|
594
|
+
"name": "WORKBENCH_GATEWAY_KEY",
|
|
595
|
+
"required": true,
|
|
596
|
+
"secret": true,
|
|
597
|
+
"environments": [
|
|
598
|
+
"staging",
|
|
599
|
+
"production"
|
|
600
|
+
],
|
|
601
|
+
"description": "Project key with the email scope, delivered as a Worker secret by gw_key_issue"
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
"name": "WORKBENCH_ENV",
|
|
605
|
+
"required": true,
|
|
606
|
+
"secret": false,
|
|
607
|
+
"environments": [
|
|
608
|
+
"development",
|
|
609
|
+
"staging",
|
|
610
|
+
"production"
|
|
611
|
+
],
|
|
612
|
+
"description": "Chooses the transport: capture in development, the Gateway elsewhere"
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
"name": "WORKBENCH_EMAIL_LIVE",
|
|
616
|
+
"required": false,
|
|
617
|
+
"secret": false,
|
|
618
|
+
"default": "0",
|
|
619
|
+
"environments": [
|
|
620
|
+
"development"
|
|
621
|
+
],
|
|
622
|
+
"description": "1 sends real mail from development through the Gateway (a development key can only mail Robert)"
|
|
623
|
+
}
|
|
624
|
+
],
|
|
625
|
+
"limits": [
|
|
626
|
+
{
|
|
627
|
+
"id": "email.recipients",
|
|
628
|
+
"value": 50,
|
|
629
|
+
"unit": "recipients",
|
|
630
|
+
"per": "message (to, cc and bcc together)",
|
|
631
|
+
"enforced_by": "gateway",
|
|
632
|
+
"refusal": "invalid_request"
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
"id": "email.attachments",
|
|
636
|
+
"value": 32,
|
|
637
|
+
"unit": "attachments",
|
|
638
|
+
"per": "message",
|
|
639
|
+
"enforced_by": "gateway",
|
|
640
|
+
"refusal": "invalid_request"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
"id": "email.message-size",
|
|
644
|
+
"value": 26214400,
|
|
645
|
+
"unit": "bytes",
|
|
646
|
+
"per": "message, attachments as base64",
|
|
647
|
+
"enforced_by": "gateway",
|
|
648
|
+
"refusal": "invalid_request"
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
"id": "email.subject-length",
|
|
652
|
+
"value": 998,
|
|
653
|
+
"unit": "characters",
|
|
654
|
+
"per": "message",
|
|
655
|
+
"enforced_by": "gateway",
|
|
656
|
+
"refusal": "invalid_request"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"id": "email.idempotency-window",
|
|
660
|
+
"value": 24,
|
|
661
|
+
"unit": "hours",
|
|
662
|
+
"per": "Idempotency-Key",
|
|
663
|
+
"enforced_by": "database"
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
"id": "email.upstream-timeout",
|
|
667
|
+
"value": 30,
|
|
668
|
+
"unit": "seconds",
|
|
669
|
+
"per": "request",
|
|
670
|
+
"enforced_by": "gateway",
|
|
671
|
+
"refusal": "upstream_timeout"
|
|
672
|
+
}
|
|
673
|
+
],
|
|
674
|
+
"errors": [
|
|
675
|
+
{
|
|
676
|
+
"code": "sender_not_allowed",
|
|
677
|
+
"http": 403,
|
|
678
|
+
"retryable": false,
|
|
679
|
+
"meaning": "from isn't one of the project's senders (gw_sender_add)"
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
"code": "recipient_not_allowed",
|
|
683
|
+
"http": 403,
|
|
684
|
+
"retryable": false,
|
|
685
|
+
"meaning": "A development key sending to anyone but Workbench's allowlisted addresses"
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
"code": "invalid_request",
|
|
689
|
+
"http": 400,
|
|
690
|
+
"retryable": false,
|
|
691
|
+
"meaning": "A malformed message, a limit passed, or an Idempotency-Key reused with a different message (422)"
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
"code": "not_available",
|
|
695
|
+
"http": 501,
|
|
696
|
+
"retryable": false,
|
|
697
|
+
"meaning": "type bulk, until Amazon SES is built"
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
"code": "budget_exceeded",
|
|
701
|
+
"http": 402,
|
|
702
|
+
"retryable": false,
|
|
703
|
+
"meaning": "Enforced monthly budget reached"
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
"code": "rate_limited",
|
|
707
|
+
"http": 429,
|
|
708
|
+
"retryable": true,
|
|
709
|
+
"meaning": "Too many requests, Cloudflare's quota, or (409) a send with the same Idempotency-Key still in progress"
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
"code": "upstream_error",
|
|
713
|
+
"http": 502,
|
|
714
|
+
"retryable": true,
|
|
715
|
+
"meaning": "Cloudflare Email Sending failed; nothing was sent"
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
"code": "upstream_timeout",
|
|
719
|
+
"http": 504,
|
|
720
|
+
"retryable": true,
|
|
721
|
+
"meaning": "Cloudflare didn't answer within 30 seconds; the mail may or may not have gone"
|
|
722
|
+
}
|
|
723
|
+
],
|
|
724
|
+
"rules": [
|
|
725
|
+
{
|
|
726
|
+
"id": "email.text-always",
|
|
727
|
+
"kind": "must",
|
|
728
|
+
"text": "Send a text part with every mail, even when there is HTML.",
|
|
729
|
+
"enforced_by": "gateway",
|
|
730
|
+
"probe": "email.send.transactional"
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
"id": "email.idempotency-key",
|
|
734
|
+
"kind": "must",
|
|
735
|
+
"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.",
|
|
736
|
+
"enforced_by": "review",
|
|
737
|
+
"probe": "email.send.idempotent-repeat"
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
"id": "email.attachments-as-bytes",
|
|
741
|
+
"kind": "must",
|
|
742
|
+
"text": "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.",
|
|
743
|
+
"enforced_by": "types",
|
|
744
|
+
"probe": "email.send.inline-image"
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
"id": "email.allowed-sender",
|
|
748
|
+
"kind": "must",
|
|
749
|
+
"text": "Send only from an address added with gw_sender_add on a domain onboarded with cf_email_sending_onboard.",
|
|
750
|
+
"enforced_by": "gateway",
|
|
751
|
+
"probe": "email.send.sender-not-allowed"
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
"id": "email.no-capture-outside-dev",
|
|
755
|
+
"kind": "must_not",
|
|
756
|
+
"text": "Use the capture transport outside development. createMailer throws when WORKBENCH_ENV is staging or production.",
|
|
757
|
+
"enforced_by": "types",
|
|
758
|
+
"probe": "email.capture-refused-in-staging"
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
"id": "email.dev-key-allowlist",
|
|
762
|
+
"kind": "must_not",
|
|
763
|
+
"text": "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.",
|
|
764
|
+
"enforced_by": "gateway",
|
|
765
|
+
"probe": "email.send.dev-recipient-refused"
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
"id": "email.transactional-only",
|
|
769
|
+
"kind": "must_not",
|
|
770
|
+
"text": "Send bulk or marketing mail. type bulk is refused with not_available until Amazon SES is built.",
|
|
771
|
+
"enforced_by": "gateway",
|
|
772
|
+
"probe": "email.send.transactional"
|
|
773
|
+
}
|
|
774
|
+
],
|
|
775
|
+
"examples": [
|
|
776
|
+
{
|
|
777
|
+
"id": "email.example.send",
|
|
778
|
+
"title": "Send a transactional mail with an inline logo",
|
|
779
|
+
"lang": "ts",
|
|
780
|
+
"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' }] })",
|
|
781
|
+
"probe": "email.send.inline-image"
|
|
782
|
+
}
|
|
783
|
+
],
|
|
784
|
+
"probes": [
|
|
785
|
+
{
|
|
786
|
+
"id": "email.send.transactional",
|
|
787
|
+
"half": "runtime",
|
|
788
|
+
"expect": "pass",
|
|
789
|
+
"smoke": true,
|
|
790
|
+
"cost_hint_usd": 0.00035,
|
|
791
|
+
"note": "To Robert; check that it arrives"
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
"id": "email.send.inline-image",
|
|
795
|
+
"half": "runtime",
|
|
796
|
+
"expect": "pass",
|
|
797
|
+
"cost_hint_usd": 0.00035,
|
|
798
|
+
"note": "Robert checks the image renders"
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
"id": "email.send.idempotent-repeat",
|
|
802
|
+
"half": "runtime",
|
|
803
|
+
"expect": "pass",
|
|
804
|
+
"cost_hint_usd": 0.00035,
|
|
805
|
+
"note": "The repeat answers replayed: true and no second mail arrives"
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
"id": "email.send.dev-recipient-refused",
|
|
809
|
+
"half": "runtime",
|
|
810
|
+
"expect": "refused_as_expected",
|
|
811
|
+
"refusal": "recipient_not_allowed",
|
|
812
|
+
"cost_hint_usd": 0
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
"id": "email.send.sender-not-allowed",
|
|
816
|
+
"half": "runtime",
|
|
817
|
+
"expect": "refused_as_expected",
|
|
818
|
+
"refusal": "sender_not_allowed",
|
|
819
|
+
"cost_hint_usd": 0
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
"id": "email.capture-refused-in-staging",
|
|
823
|
+
"half": "runtime",
|
|
824
|
+
"expect": "pass",
|
|
825
|
+
"cost_hint_usd": 0,
|
|
826
|
+
"note": "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config"
|
|
827
|
+
}
|
|
828
|
+
]
|
|
829
|
+
},
|
|
580
830
|
"email-inbound": {
|
|
581
831
|
"$schema": "./schema.json",
|
|
582
832
|
"format": 1,
|
|
@@ -705,6 +955,139 @@ export const capabilities = {
|
|
|
705
955
|
}
|
|
706
956
|
]
|
|
707
957
|
},
|
|
958
|
+
"email-sending": {
|
|
959
|
+
"$schema": "./schema.json",
|
|
960
|
+
"format": 1,
|
|
961
|
+
"component": "email-sending",
|
|
962
|
+
"title": "Sending domains and sender addresses",
|
|
963
|
+
"summary": "Control onboards a domain in the project's namespace to Cloudflare Email Sending in the projects account, with its bounce, SPF, DKIM and DMARC records checked over DNS-over-HTTPS, and allows sender addresses on it. The Gateway sends from those addresses only.",
|
|
964
|
+
"status": "built",
|
|
965
|
+
"surfaces": {
|
|
966
|
+
"tools": [
|
|
967
|
+
"cf_email_sending_onboard",
|
|
968
|
+
"cf_email_sending_status",
|
|
969
|
+
"gw_sender_add",
|
|
970
|
+
"gw_sender_list"
|
|
971
|
+
]
|
|
972
|
+
},
|
|
973
|
+
"limits": [
|
|
974
|
+
{
|
|
975
|
+
"id": "email-sending.domains-per-zone",
|
|
976
|
+
"value": 30,
|
|
977
|
+
"unit": "domains",
|
|
978
|
+
"per": "zone, Email Sending and Email Routing together",
|
|
979
|
+
"enforced_by": "control"
|
|
980
|
+
}
|
|
981
|
+
],
|
|
982
|
+
"actions": [
|
|
983
|
+
{
|
|
984
|
+
"tool": "cf_email_sending_onboard",
|
|
985
|
+
"gate": "free",
|
|
986
|
+
"ships": "4"
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
"tool": "cf_email_sending_status",
|
|
990
|
+
"gate": "free",
|
|
991
|
+
"ships": "4"
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
"tool": "gw_sender_add",
|
|
995
|
+
"gate": "free",
|
|
996
|
+
"ships": "4"
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
"tool": "gw_sender_list",
|
|
1000
|
+
"gate": "free",
|
|
1001
|
+
"ships": "4"
|
|
1002
|
+
}
|
|
1003
|
+
],
|
|
1004
|
+
"errors": [
|
|
1005
|
+
{
|
|
1006
|
+
"code": "out_of_scope",
|
|
1007
|
+
"http": 403,
|
|
1008
|
+
"retryable": false,
|
|
1009
|
+
"meaning": "A domain outside the project's namespace, or a sender on a domain the project hasn't onboarded"
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
"code": "name_taken",
|
|
1013
|
+
"http": 409,
|
|
1014
|
+
"retryable": false,
|
|
1015
|
+
"meaning": "The domain or address is already onboarded or allowed outside this project; Control never adopts"
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
"code": "record_conflict",
|
|
1019
|
+
"http": 409,
|
|
1020
|
+
"retryable": false,
|
|
1021
|
+
"meaning": "A DNS record in the way that Control won't change (another SPF, DMARC, MX or CNAME)"
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
"code": "missing_permission",
|
|
1025
|
+
"http": 403,
|
|
1026
|
+
"retryable": false,
|
|
1027
|
+
"meaning": "Control's Cloudflare token lacks Email Sending (edit)"
|
|
1028
|
+
}
|
|
1029
|
+
],
|
|
1030
|
+
"rules": [
|
|
1031
|
+
{
|
|
1032
|
+
"id": "email-sending.namespace-only",
|
|
1033
|
+
"kind": "must",
|
|
1034
|
+
"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).",
|
|
1035
|
+
"enforced_by": "control",
|
|
1036
|
+
"probe": "email-sending.onboard"
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
"id": "email-sending.onboard-before-sender",
|
|
1040
|
+
"kind": "must",
|
|
1041
|
+
"text": "Onboard the domain before adding senders on it. gw_sender_add refuses a domain the project hasn't onboarded.",
|
|
1042
|
+
"enforced_by": "control",
|
|
1043
|
+
"probe": "email-sending.sender-add"
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
"id": "email-sending.never-adopt",
|
|
1047
|
+
"kind": "must_not",
|
|
1048
|
+
"text": "Expect Control to take over a domain onboarded by hand. It refuses with name_taken.",
|
|
1049
|
+
"enforced_by": "control",
|
|
1050
|
+
"probe": "email-sending.onboard"
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
"id": "email-sending.verified-over-doh",
|
|
1054
|
+
"kind": "must",
|
|
1055
|
+
"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.",
|
|
1056
|
+
"enforced_by": "control",
|
|
1057
|
+
"probe": "email-sending.status"
|
|
1058
|
+
}
|
|
1059
|
+
],
|
|
1060
|
+
"probes": [
|
|
1061
|
+
{
|
|
1062
|
+
"id": "email-sending.onboard",
|
|
1063
|
+
"half": "drill",
|
|
1064
|
+
"expect": "pass",
|
|
1065
|
+
"cost_hint_usd": 0,
|
|
1066
|
+
"note": "cf_email_sending_onboard on mail.<id>.mccaskielabs.app; a second call changes nothing"
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
"id": "email-sending.status",
|
|
1070
|
+
"half": "drill",
|
|
1071
|
+
"expect": "pass",
|
|
1072
|
+
"cost_hint_usd": 0
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
"id": "email-sending.sender-add",
|
|
1076
|
+
"half": "drill",
|
|
1077
|
+
"expect": "pass",
|
|
1078
|
+
"cost_hint_usd": 0,
|
|
1079
|
+
"note": "Then a runtime send from the new address (email.send.transactional)"
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
"id": "email-sending.out-of-scope",
|
|
1083
|
+
"half": "drill",
|
|
1084
|
+
"expect": "refused_as_expected",
|
|
1085
|
+
"refusal": "out_of_scope",
|
|
1086
|
+
"cost_hint_usd": 0,
|
|
1087
|
+
"note": "Another project's namespace"
|
|
1088
|
+
}
|
|
1089
|
+
]
|
|
1090
|
+
},
|
|
708
1091
|
"gateway": {
|
|
709
1092
|
"$schema": "./schema.json",
|
|
710
1093
|
"format": 1,
|
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.9",
|
|
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",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"./package.json": "./package.json"
|
|
23
23
|
},
|
|
24
24
|
"bin": {
|
|
25
|
-
"workbench-config": "
|
|
25
|
+
"workbench-config": "dist/cli.js"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"dist",
|