@mccaskie-labs/workbench-config 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/capabilities/analytics.json +34 -0
- package/capabilities/database.json +192 -0
- package/capabilities/email-inbound.json +128 -0
- package/capabilities/email-inbound.md +19 -0
- package/dist/capabilities.gen.d.ts +304 -1
- package/dist/capabilities.gen.js +401 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-config
|
|
2
2
|
|
|
3
|
+
## 0.1.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add the email-inbound capability manifest (step 4.10): the inbound email tools, their refusals, rules and probes.
|
|
8
|
+
|
|
9
|
+
## 0.1.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Add the `database` and `analytics` capabilities manifests (step 4.11), both built.
|
|
14
|
+
|
|
3
15
|
## 0.1.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema.json",
|
|
3
|
+
"format": 1,
|
|
4
|
+
"component": "analytics",
|
|
5
|
+
"title": "Page views and product events",
|
|
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
|
+
"status": "built",
|
|
8
|
+
"surfaces": {
|
|
9
|
+
"package": ["beaconScript", "createEvents", "eventsMigration"],
|
|
10
|
+
"tools": ["cf_web_analytics_site_create"]
|
|
11
|
+
},
|
|
12
|
+
"actions": [
|
|
13
|
+
{ "tool": "cf_web_analytics_site_create", "gate": "free", "ships": "4" }
|
|
14
|
+
],
|
|
15
|
+
"rules": [
|
|
16
|
+
{ "id": "analytics.beacon-on-pages", "kind": "must", "text": "Put beaconScript(token) on every page, with the site's public beacon token from cf_web_analytics_site_create.", "enforced_by": "review", "probe": "analytics.beacon-present" },
|
|
17
|
+
{ "id": "analytics.track-never-throws", "kind": "must_not", "text": "Let a failed event write break the request it describes: track logs and drops it.", "enforced_by": "types", "probe": "analytics.event-round-trip" },
|
|
18
|
+
{ "id": "analytics.no-cf-token", "kind": "must_not", "text": "Hold a Cloudflare analytics token in the app (hard rule 1); page views are read with Control.", "enforced_by": "review", "probe": "analytics.page-views-drill" }
|
|
19
|
+
],
|
|
20
|
+
"examples": [
|
|
21
|
+
{
|
|
22
|
+
"id": "analytics.example.track",
|
|
23
|
+
"title": "Record a product event",
|
|
24
|
+
"lang": "ts",
|
|
25
|
+
"probe": "analytics.event-round-trip",
|
|
26
|
+
"code": "import { createEvents } from '@mccaskie-labs/workbench-analytics'\n\nawait createEvents(db).track('note.created', { length: body.length }, { type: 'note', id })"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"probes": [
|
|
30
|
+
{ "id": "analytics.beacon-present", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0 },
|
|
31
|
+
{ "id": "analytics.event-round-trip", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "A product event is written and read back" },
|
|
32
|
+
{ "id": "analytics.page-views-drill", "half": "drill", "expect": "pass", "cost_hint_usd": 0, "note": "Create the site, drive page views, query them back (cf_analytics_query, later)" }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema.json",
|
|
3
|
+
"format": 1,
|
|
4
|
+
"component": "database",
|
|
5
|
+
"title": "The app's own Supabase database",
|
|
6
|
+
"summary": "Each project has its own Supabase projects, <id>-staging and <id>-production. Apps use them natively through workbench-supabase; Control creates them, writes their URL and keys onto the Workers, and applies migrations read from the repo.",
|
|
7
|
+
"status": "built",
|
|
8
|
+
"surfaces": {
|
|
9
|
+
"package": [
|
|
10
|
+
"supabaseConfig",
|
|
11
|
+
"createBrowserClient",
|
|
12
|
+
"createServerClient",
|
|
13
|
+
"createUserClient",
|
|
14
|
+
"createServiceClient",
|
|
15
|
+
"WorkbenchSupabaseError"
|
|
16
|
+
],
|
|
17
|
+
"tools": [
|
|
18
|
+
"sb_project_create",
|
|
19
|
+
"sb_migration_apply",
|
|
20
|
+
"sb_migration_rollback",
|
|
21
|
+
"sb_query",
|
|
22
|
+
"sb_project_delete",
|
|
23
|
+
"cf_worker_secret_put"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"config": [
|
|
27
|
+
{
|
|
28
|
+
"name": "SUPABASE_URL",
|
|
29
|
+
"required": true,
|
|
30
|
+
"secret": false,
|
|
31
|
+
"environments": [
|
|
32
|
+
"development",
|
|
33
|
+
"staging",
|
|
34
|
+
"production"
|
|
35
|
+
],
|
|
36
|
+
"description": "https://<ref>.supabase.co of the Worker's own database; written by cf_worker_secret_put source=supabase_url (or the local stack's URL in .dev.vars)"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "SUPABASE_PUBLISHABLE_KEY",
|
|
40
|
+
"required": true,
|
|
41
|
+
"secret": false,
|
|
42
|
+
"environments": [
|
|
43
|
+
"development",
|
|
44
|
+
"staging",
|
|
45
|
+
"production"
|
|
46
|
+
],
|
|
47
|
+
"description": "The sb_publishable_ key; written by cf_worker_secret_put source=supabase_publishable. Legacy anon keys are refused"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "SUPABASE_SECRET_KEY",
|
|
51
|
+
"required": false,
|
|
52
|
+
"secret": true,
|
|
53
|
+
"environments": [
|
|
54
|
+
"staging",
|
|
55
|
+
"production"
|
|
56
|
+
],
|
|
57
|
+
"description": "The sb_secret_ key, only for code that must bypass RLS (createServiceClient); written by cf_worker_secret_put source=supabase_secret, always two-step"
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"actions": [
|
|
61
|
+
{
|
|
62
|
+
"tool": "sb_project_create",
|
|
63
|
+
"gate": "free",
|
|
64
|
+
"ships": "3"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"tool": "sb_migration_apply",
|
|
68
|
+
"gate": "two_step_when",
|
|
69
|
+
"when": "production, or a staging up file that isn't additive",
|
|
70
|
+
"ships": "3"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"tool": "sb_migration_rollback",
|
|
74
|
+
"gate": "two_step_when",
|
|
75
|
+
"when": "production, or a staging down file that reverses more than its own up file",
|
|
76
|
+
"ships": "3"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"tool": "sb_query",
|
|
80
|
+
"gate": "two_step_when",
|
|
81
|
+
"when": "a production write, or a staging write outside the additive classifier; reads are free",
|
|
82
|
+
"ships": "3"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"tool": "sb_project_delete",
|
|
86
|
+
"gate": "two_step",
|
|
87
|
+
"ships": "4"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"tool": "cf_worker_secret_put",
|
|
91
|
+
"gate": "two_step_when",
|
|
92
|
+
"when": "source supabase_secret, or replacing a secret that exists",
|
|
93
|
+
"ships": "4"
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"rules": [
|
|
97
|
+
{
|
|
98
|
+
"id": "database.rls-isolates-users",
|
|
99
|
+
"kind": "must",
|
|
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
|
+
"enforced_by": "database",
|
|
102
|
+
"probe": "database.rls-other-user"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "database.service-reason",
|
|
106
|
+
"kind": "must",
|
|
107
|
+
"text": "Give createServiceClient a reason saying why RLS is bypassed; it is logged once per isolate.",
|
|
108
|
+
"enforced_by": "types",
|
|
109
|
+
"probe": "database.service-logs-reason"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"id": "database.no-legacy-keys",
|
|
113
|
+
"kind": "must_not",
|
|
114
|
+
"text": "Use legacy JWT keys (anon, service_role). Only sb_publishable_ and sb_secret_ keys are accepted.",
|
|
115
|
+
"enforced_by": "types",
|
|
116
|
+
"probe": "database.config-named"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"id": "database.down-files",
|
|
120
|
+
"kind": "must",
|
|
121
|
+
"text": "Give every migration a down file in supabase/rollback; check with npx workbench-supabase check:migrations.",
|
|
122
|
+
"enforced_by": "review",
|
|
123
|
+
"probe": "database.migrations-up-down-up"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "database.prod-schema",
|
|
127
|
+
"kind": "must_not",
|
|
128
|
+
"text": "Ship code that names a table, column or function production's database lacks; check with npx workbench-supabase check:prod-schema.",
|
|
129
|
+
"enforced_by": "review",
|
|
130
|
+
"probe": "database.prod-schema-check"
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
"examples": [
|
|
134
|
+
{
|
|
135
|
+
"id": "database.example.user-client",
|
|
136
|
+
"title": "Read the caller's own rows",
|
|
137
|
+
"lang": "ts",
|
|
138
|
+
"probe": "database.crud",
|
|
139
|
+
"code": "import { createUserClient, supabaseConfig } from '@mccaskie-labs/workbench-supabase'\n\nconst db = createUserClient({ ...supabaseConfig(env), accessToken })\nconst { data, error } = await db.from('notes').select('id, body')"
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"probes": [
|
|
143
|
+
{
|
|
144
|
+
"id": "database.crud",
|
|
145
|
+
"half": "runtime",
|
|
146
|
+
"expect": "pass",
|
|
147
|
+
"smoke": true,
|
|
148
|
+
"cost_hint_usd": 0,
|
|
149
|
+
"note": "Insert, read, update and delete, each timed"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"id": "database.rls-other-user",
|
|
153
|
+
"half": "runtime",
|
|
154
|
+
"expect": "pass",
|
|
155
|
+
"cost_hint_usd": 0,
|
|
156
|
+
"note": "A second user's reads return none of the first user's rows"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "database.service-logs-reason",
|
|
160
|
+
"half": "runtime",
|
|
161
|
+
"expect": "pass",
|
|
162
|
+
"cost_hint_usd": 0
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"id": "database.config-named",
|
|
166
|
+
"half": "runtime",
|
|
167
|
+
"expect": "pass",
|
|
168
|
+
"cost_hint_usd": 0
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"id": "database.migrations-up-down-up",
|
|
172
|
+
"half": "drill",
|
|
173
|
+
"expect": "pass",
|
|
174
|
+
"cost_hint_usd": 0
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"id": "database.prod-schema-check",
|
|
178
|
+
"half": "runtime",
|
|
179
|
+
"expect": "pass",
|
|
180
|
+
"cost_hint_usd": 0,
|
|
181
|
+
"note": "Manual: run npx workbench-supabase check:prod-schema in the project"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"id": "database.production-write-two-step",
|
|
185
|
+
"half": "drill",
|
|
186
|
+
"expect": "pass",
|
|
187
|
+
"two_step": true,
|
|
188
|
+
"cost_hint_usd": 0,
|
|
189
|
+
"note": "sb_query on production with a write returns a confirmation, not a result"
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema.json",
|
|
3
|
+
"format": 1,
|
|
4
|
+
"component": "email-inbound",
|
|
5
|
+
"title": "Inbound email to the project's own Worker",
|
|
6
|
+
"summary": "Email Routing receives mail on a domain in the project's namespace and runs one routing rule per address, each sending the mail to one of the project's own Workers. The Worker's email() handler parses it with postal-mime and the app stores or rejects it. Mail is never forwarded outside the project.",
|
|
7
|
+
"status": "built",
|
|
8
|
+
"surfaces": {
|
|
9
|
+
"tools": [
|
|
10
|
+
"cf_email_routing_enable",
|
|
11
|
+
"cf_email_routing_list",
|
|
12
|
+
"cf_email_routing_rule_create",
|
|
13
|
+
"cf_email_routing_rule_delete"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"limits": [
|
|
17
|
+
{
|
|
18
|
+
"id": "email-inbound.address-length",
|
|
19
|
+
"value": 90,
|
|
20
|
+
"unit": "characters",
|
|
21
|
+
"per": "address",
|
|
22
|
+
"enforced_by": "control",
|
|
23
|
+
"refusal": "invalid_address"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"actions": [
|
|
27
|
+
{
|
|
28
|
+
"tool": "cf_email_routing_enable",
|
|
29
|
+
"gate": "free",
|
|
30
|
+
"ships": "4"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"tool": "cf_email_routing_list",
|
|
34
|
+
"gate": "free",
|
|
35
|
+
"ships": "4"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"tool": "cf_email_routing_rule_create",
|
|
39
|
+
"gate": "free",
|
|
40
|
+
"ships": "4"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"tool": "cf_email_routing_rule_delete",
|
|
44
|
+
"gate": "two_step",
|
|
45
|
+
"ships": "4"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"errors": [
|
|
49
|
+
{
|
|
50
|
+
"code": "out_of_scope",
|
|
51
|
+
"http": 403,
|
|
52
|
+
"retryable": false,
|
|
53
|
+
"meaning": "A domain outside the project's namespace, another project's Worker or another project's rule"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"code": "catch_all",
|
|
57
|
+
"http": 400,
|
|
58
|
+
"retryable": false,
|
|
59
|
+
"meaning": "A catch-all address; Control routes only one literal address per rule"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"code": "routing_not_enabled",
|
|
63
|
+
"http": 409,
|
|
64
|
+
"retryable": false,
|
|
65
|
+
"meaning": "Email Routing isn't on for the address's domain yet (cf_email_routing_enable)"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"code": "name_taken",
|
|
69
|
+
"http": 409,
|
|
70
|
+
"retryable": false,
|
|
71
|
+
"meaning": "A mail record or rule exists that Control didn't create for the project; Control never adopts"
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"rules": [
|
|
75
|
+
{
|
|
76
|
+
"id": "email-inbound.own-worker",
|
|
77
|
+
"kind": "must",
|
|
78
|
+
"text": "Route an address only to one of the project's own Workers. A rule naming another project's Worker is refused with out_of_scope.",
|
|
79
|
+
"enforced_by": "control",
|
|
80
|
+
"probe": "email-inbound.other-worker"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "email-inbound.no-forward",
|
|
84
|
+
"kind": "must_not",
|
|
85
|
+
"text": "Forward inbound mail outside the project. The template's receiveEmail gives the app the parsed mail and a reject function, not the message.",
|
|
86
|
+
"enforced_by": "review",
|
|
87
|
+
"probe": "email-inbound.roundtrip"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "email-inbound.reject-on-failure",
|
|
91
|
+
"kind": "must",
|
|
92
|
+
"text": "Reject a message the app can't store (throw from handleEmail, or call reject) rather than drop it silently.",
|
|
93
|
+
"enforced_by": "review",
|
|
94
|
+
"probe": "email-inbound.roundtrip"
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"examples": [
|
|
98
|
+
{
|
|
99
|
+
"id": "email-inbound.example.handler",
|
|
100
|
+
"title": "Store inbound mail",
|
|
101
|
+
"lang": "ts",
|
|
102
|
+
"probe": "email-inbound.roundtrip",
|
|
103
|
+
"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}"
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
"probes": [
|
|
107
|
+
{
|
|
108
|
+
"id": "email-inbound.roundtrip",
|
|
109
|
+
"half": "runtime",
|
|
110
|
+
"expect": "pass",
|
|
111
|
+
"note": "Sends a mail through the Gateway to inbox@ on the receiving domain; the Email Worker stores it and the console shows send-to-stored time. Needs step 4.9 (outbound) and a store (step 4.11)"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"id": "email-inbound.other-worker",
|
|
115
|
+
"half": "drill",
|
|
116
|
+
"expect": "refused_as_expected",
|
|
117
|
+
"refusal": "out_of_scope",
|
|
118
|
+
"note": "cf_email_routing_rule_create naming another project's Worker"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"id": "email-inbound.rule-delete",
|
|
122
|
+
"half": "drill",
|
|
123
|
+
"expect": "pass",
|
|
124
|
+
"two_step": true,
|
|
125
|
+
"note": "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves"
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Inbound email
|
|
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).
|
|
4
|
+
|
|
5
|
+
## Setting it up (Claude, through Control)
|
|
6
|
+
|
|
7
|
+
1. `cf_email_routing_enable domain=<domain>`. Cloudflare adds its MX and SPF records on that name only; the apex's locked-down baseline stays as it is. Control records the records for the project, so `cf_dns_delete` removes them again.
|
|
8
|
+
2. `cf_email_routing_rule_create address=inbox@<domain> worker=<id>-staging`. One literal address, one Worker. No catch-alls, no forwarding.
|
|
9
|
+
3. `cf_email_routing_list` shows the domains and rules. `cf_email_routing_rule_delete` (two-step) stops an address.
|
|
10
|
+
|
|
11
|
+
## In the Worker
|
|
12
|
+
|
|
13
|
+
The template's `src/email.js` exports `receiveEmail`, and `src/index.js` wires it to `handleEmail(mail, env, ctx, { reject })`. `mail` has `received_at`, `envelope_from`, `envelope_to`, `raw_size`, `message_id`, `from`, `to`, `subject`, `date`, `text`, `html`, `headers` and `attachments` (each with `filename`, `mime_type`, `content_id`, `size` and `content`). Store what the app needs and return, or call `reject(reason)` to bounce the message. A handler that throws rejects it too.
|
|
14
|
+
|
|
15
|
+
The starter `handleEmail` rejects everything, so routing an address before the app handles mail bounces it rather than losing it.
|
|
16
|
+
|
|
17
|
+
## Limits
|
|
18
|
+
|
|
19
|
+
Email Routing takes messages up to 25 MiB. Addresses are at most 90 characters, and the part before `@` takes letters, digits, dot, underscore, plus and hyphen.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const MANIFEST_VERSION: "
|
|
1
|
+
export declare const MANIFEST_VERSION: "928d1b370728";
|
|
2
2
|
export declare const capabilities: {
|
|
3
3
|
readonly ai: {
|
|
4
4
|
readonly $schema: "./schema.json";
|
|
@@ -137,6 +137,68 @@ export declare const capabilities: {
|
|
|
137
137
|
readonly cost_hint_usd: 0;
|
|
138
138
|
}];
|
|
139
139
|
};
|
|
140
|
+
readonly analytics: {
|
|
141
|
+
readonly $schema: "./schema.json";
|
|
142
|
+
readonly format: 1;
|
|
143
|
+
readonly component: "analytics";
|
|
144
|
+
readonly title: "Page views and product events";
|
|
145
|
+
readonly 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.";
|
|
146
|
+
readonly status: "built";
|
|
147
|
+
readonly surfaces: {
|
|
148
|
+
readonly tools: readonly ["cf_web_analytics_site_create"];
|
|
149
|
+
readonly package: readonly ["beaconScript", "createEvents", "eventsMigration"];
|
|
150
|
+
};
|
|
151
|
+
readonly actions: readonly [{
|
|
152
|
+
readonly tool: "cf_web_analytics_site_create";
|
|
153
|
+
readonly gate: "free";
|
|
154
|
+
readonly ships: "4";
|
|
155
|
+
}];
|
|
156
|
+
readonly rules: readonly [{
|
|
157
|
+
readonly id: "analytics.beacon-on-pages";
|
|
158
|
+
readonly kind: "must";
|
|
159
|
+
readonly text: "Put beaconScript(token) on every page, with the site's public beacon token from cf_web_analytics_site_create.";
|
|
160
|
+
readonly enforced_by: "review";
|
|
161
|
+
readonly probe: "analytics.beacon-present";
|
|
162
|
+
}, {
|
|
163
|
+
readonly id: "analytics.track-never-throws";
|
|
164
|
+
readonly kind: "must_not";
|
|
165
|
+
readonly text: "Let a failed event write break the request it describes: track logs and drops it.";
|
|
166
|
+
readonly enforced_by: "types";
|
|
167
|
+
readonly probe: "analytics.event-round-trip";
|
|
168
|
+
}, {
|
|
169
|
+
readonly id: "analytics.no-cf-token";
|
|
170
|
+
readonly kind: "must_not";
|
|
171
|
+
readonly text: "Hold a Cloudflare analytics token in the app (hard rule 1); page views are read with Control.";
|
|
172
|
+
readonly enforced_by: "review";
|
|
173
|
+
readonly probe: "analytics.page-views-drill";
|
|
174
|
+
}];
|
|
175
|
+
readonly examples: readonly [{
|
|
176
|
+
readonly id: "analytics.example.track";
|
|
177
|
+
readonly title: "Record a product event";
|
|
178
|
+
readonly lang: "ts";
|
|
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.event-round-trip";
|
|
181
|
+
}];
|
|
182
|
+
readonly probes: readonly [{
|
|
183
|
+
readonly id: "analytics.beacon-present";
|
|
184
|
+
readonly half: "runtime";
|
|
185
|
+
readonly expect: "pass";
|
|
186
|
+
readonly smoke: true;
|
|
187
|
+
readonly cost_hint_usd: 0;
|
|
188
|
+
}, {
|
|
189
|
+
readonly id: "analytics.event-round-trip";
|
|
190
|
+
readonly half: "runtime";
|
|
191
|
+
readonly expect: "pass";
|
|
192
|
+
readonly cost_hint_usd: 0;
|
|
193
|
+
readonly note: "A product event is written and read back";
|
|
194
|
+
}, {
|
|
195
|
+
readonly id: "analytics.page-views-drill";
|
|
196
|
+
readonly half: "drill";
|
|
197
|
+
readonly expect: "pass";
|
|
198
|
+
readonly cost_hint_usd: 0;
|
|
199
|
+
readonly note: "Create the site, drive page views, query them back (cf_analytics_query, later)";
|
|
200
|
+
}];
|
|
201
|
+
};
|
|
140
202
|
readonly config: {
|
|
141
203
|
readonly $schema: "./schema.json";
|
|
142
204
|
readonly format: 1;
|
|
@@ -214,6 +276,247 @@ export declare const capabilities: {
|
|
|
214
276
|
readonly note: "Manual: run npx workbench-config check in the project";
|
|
215
277
|
}];
|
|
216
278
|
};
|
|
279
|
+
readonly database: {
|
|
280
|
+
readonly $schema: "./schema.json";
|
|
281
|
+
readonly format: 1;
|
|
282
|
+
readonly component: "database";
|
|
283
|
+
readonly title: "The app's own Supabase database";
|
|
284
|
+
readonly summary: "Each project has its own Supabase projects, <id>-staging and <id>-production. Apps use them natively through workbench-supabase; Control creates them, writes their URL and keys onto the Workers, and applies migrations read from the repo.";
|
|
285
|
+
readonly status: "built";
|
|
286
|
+
readonly surfaces: {
|
|
287
|
+
readonly tools: readonly ["sb_project_create", "sb_migration_apply", "sb_migration_rollback", "sb_query", "sb_project_delete", "cf_worker_secret_put"];
|
|
288
|
+
readonly package: readonly ["supabaseConfig", "createBrowserClient", "createServerClient", "createUserClient", "createServiceClient", "WorkbenchSupabaseError"];
|
|
289
|
+
};
|
|
290
|
+
readonly config: readonly [{
|
|
291
|
+
readonly name: "SUPABASE_URL";
|
|
292
|
+
readonly required: true;
|
|
293
|
+
readonly secret: false;
|
|
294
|
+
readonly environments: readonly ["development", "staging", "production"];
|
|
295
|
+
readonly description: "https://<ref>.supabase.co of the Worker's own database; written by cf_worker_secret_put source=supabase_url (or the local stack's URL in .dev.vars)";
|
|
296
|
+
}, {
|
|
297
|
+
readonly name: "SUPABASE_PUBLISHABLE_KEY";
|
|
298
|
+
readonly required: true;
|
|
299
|
+
readonly secret: false;
|
|
300
|
+
readonly environments: readonly ["development", "staging", "production"];
|
|
301
|
+
readonly description: "The sb_publishable_ key; written by cf_worker_secret_put source=supabase_publishable. Legacy anon keys are refused";
|
|
302
|
+
}, {
|
|
303
|
+
readonly name: "SUPABASE_SECRET_KEY";
|
|
304
|
+
readonly required: false;
|
|
305
|
+
readonly secret: true;
|
|
306
|
+
readonly environments: readonly ["staging", "production"];
|
|
307
|
+
readonly description: "The sb_secret_ key, only for code that must bypass RLS (createServiceClient); written by cf_worker_secret_put source=supabase_secret, always two-step";
|
|
308
|
+
}];
|
|
309
|
+
readonly actions: readonly [{
|
|
310
|
+
readonly tool: "sb_project_create";
|
|
311
|
+
readonly gate: "free";
|
|
312
|
+
readonly ships: "3";
|
|
313
|
+
}, {
|
|
314
|
+
readonly tool: "sb_migration_apply";
|
|
315
|
+
readonly gate: "two_step_when";
|
|
316
|
+
readonly when: "production, or a staging up file that isn't additive";
|
|
317
|
+
readonly ships: "3";
|
|
318
|
+
}, {
|
|
319
|
+
readonly tool: "sb_migration_rollback";
|
|
320
|
+
readonly gate: "two_step_when";
|
|
321
|
+
readonly when: "production, or a staging down file that reverses more than its own up file";
|
|
322
|
+
readonly ships: "3";
|
|
323
|
+
}, {
|
|
324
|
+
readonly tool: "sb_query";
|
|
325
|
+
readonly gate: "two_step_when";
|
|
326
|
+
readonly when: "a production write, or a staging write outside the additive classifier; reads are free";
|
|
327
|
+
readonly ships: "3";
|
|
328
|
+
}, {
|
|
329
|
+
readonly tool: "sb_project_delete";
|
|
330
|
+
readonly gate: "two_step";
|
|
331
|
+
readonly ships: "4";
|
|
332
|
+
}, {
|
|
333
|
+
readonly tool: "cf_worker_secret_put";
|
|
334
|
+
readonly gate: "two_step_when";
|
|
335
|
+
readonly when: "source supabase_secret, or replacing a secret that exists";
|
|
336
|
+
readonly ships: "4";
|
|
337
|
+
}];
|
|
338
|
+
readonly rules: readonly [{
|
|
339
|
+
readonly id: "database.rls-isolates-users";
|
|
340
|
+
readonly kind: "must";
|
|
341
|
+
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
|
+
readonly enforced_by: "database";
|
|
343
|
+
readonly probe: "database.rls-other-user";
|
|
344
|
+
}, {
|
|
345
|
+
readonly id: "database.service-reason";
|
|
346
|
+
readonly kind: "must";
|
|
347
|
+
readonly text: "Give createServiceClient a reason saying why RLS is bypassed; it is logged once per isolate.";
|
|
348
|
+
readonly enforced_by: "types";
|
|
349
|
+
readonly probe: "database.service-logs-reason";
|
|
350
|
+
}, {
|
|
351
|
+
readonly id: "database.no-legacy-keys";
|
|
352
|
+
readonly kind: "must_not";
|
|
353
|
+
readonly text: "Use legacy JWT keys (anon, service_role). Only sb_publishable_ and sb_secret_ keys are accepted.";
|
|
354
|
+
readonly enforced_by: "types";
|
|
355
|
+
readonly probe: "database.config-named";
|
|
356
|
+
}, {
|
|
357
|
+
readonly id: "database.down-files";
|
|
358
|
+
readonly kind: "must";
|
|
359
|
+
readonly text: "Give every migration a down file in supabase/rollback; check with npx workbench-supabase check:migrations.";
|
|
360
|
+
readonly enforced_by: "review";
|
|
361
|
+
readonly probe: "database.migrations-up-down-up";
|
|
362
|
+
}, {
|
|
363
|
+
readonly id: "database.prod-schema";
|
|
364
|
+
readonly kind: "must_not";
|
|
365
|
+
readonly text: "Ship code that names a table, column or function production's database lacks; check with npx workbench-supabase check:prod-schema.";
|
|
366
|
+
readonly enforced_by: "review";
|
|
367
|
+
readonly probe: "database.prod-schema-check";
|
|
368
|
+
}];
|
|
369
|
+
readonly examples: readonly [{
|
|
370
|
+
readonly id: "database.example.user-client";
|
|
371
|
+
readonly title: "Read the caller's own rows";
|
|
372
|
+
readonly lang: "ts";
|
|
373
|
+
readonly code: "import { createUserClient, supabaseConfig } from '@mccaskie-labs/workbench-supabase'\n\nconst db = createUserClient({ ...supabaseConfig(env), accessToken })\nconst { data, error } = await db.from('notes').select('id, body')";
|
|
374
|
+
readonly probe: "database.crud";
|
|
375
|
+
}];
|
|
376
|
+
readonly probes: readonly [{
|
|
377
|
+
readonly id: "database.crud";
|
|
378
|
+
readonly half: "runtime";
|
|
379
|
+
readonly expect: "pass";
|
|
380
|
+
readonly smoke: true;
|
|
381
|
+
readonly cost_hint_usd: 0;
|
|
382
|
+
readonly note: "Insert, read, update and delete, each timed";
|
|
383
|
+
}, {
|
|
384
|
+
readonly id: "database.rls-other-user";
|
|
385
|
+
readonly half: "runtime";
|
|
386
|
+
readonly expect: "pass";
|
|
387
|
+
readonly cost_hint_usd: 0;
|
|
388
|
+
readonly note: "A second user's reads return none of the first user's rows";
|
|
389
|
+
}, {
|
|
390
|
+
readonly id: "database.service-logs-reason";
|
|
391
|
+
readonly half: "runtime";
|
|
392
|
+
readonly expect: "pass";
|
|
393
|
+
readonly cost_hint_usd: 0;
|
|
394
|
+
}, {
|
|
395
|
+
readonly id: "database.config-named";
|
|
396
|
+
readonly half: "runtime";
|
|
397
|
+
readonly expect: "pass";
|
|
398
|
+
readonly cost_hint_usd: 0;
|
|
399
|
+
}, {
|
|
400
|
+
readonly id: "database.migrations-up-down-up";
|
|
401
|
+
readonly half: "drill";
|
|
402
|
+
readonly expect: "pass";
|
|
403
|
+
readonly cost_hint_usd: 0;
|
|
404
|
+
}, {
|
|
405
|
+
readonly id: "database.prod-schema-check";
|
|
406
|
+
readonly half: "runtime";
|
|
407
|
+
readonly expect: "pass";
|
|
408
|
+
readonly cost_hint_usd: 0;
|
|
409
|
+
readonly note: "Manual: run npx workbench-supabase check:prod-schema in the project";
|
|
410
|
+
}, {
|
|
411
|
+
readonly id: "database.production-write-two-step";
|
|
412
|
+
readonly half: "drill";
|
|
413
|
+
readonly expect: "pass";
|
|
414
|
+
readonly cost_hint_usd: 0;
|
|
415
|
+
readonly two_step: true;
|
|
416
|
+
readonly note: "sb_query on production with a write returns a confirmation, not a result";
|
|
417
|
+
}];
|
|
418
|
+
};
|
|
419
|
+
readonly "email-inbound": {
|
|
420
|
+
readonly $schema: "./schema.json";
|
|
421
|
+
readonly format: 1;
|
|
422
|
+
readonly component: "email-inbound";
|
|
423
|
+
readonly title: "Inbound email to the project's own Worker";
|
|
424
|
+
readonly summary: "Email Routing receives mail on a domain in the project's namespace and runs one routing rule per address, each sending the mail to one of the project's own Workers. The Worker's email() handler parses it with postal-mime and the app stores or rejects it. Mail is never forwarded outside the project.";
|
|
425
|
+
readonly status: "built";
|
|
426
|
+
readonly surfaces: {
|
|
427
|
+
readonly tools: readonly ["cf_email_routing_enable", "cf_email_routing_list", "cf_email_routing_rule_create", "cf_email_routing_rule_delete"];
|
|
428
|
+
};
|
|
429
|
+
readonly limits: readonly [{
|
|
430
|
+
readonly id: "email-inbound.address-length";
|
|
431
|
+
readonly value: 90;
|
|
432
|
+
readonly unit: "characters";
|
|
433
|
+
readonly per: "address";
|
|
434
|
+
readonly enforced_by: "control";
|
|
435
|
+
readonly refusal: "invalid_address";
|
|
436
|
+
}];
|
|
437
|
+
readonly actions: readonly [{
|
|
438
|
+
readonly tool: "cf_email_routing_enable";
|
|
439
|
+
readonly gate: "free";
|
|
440
|
+
readonly ships: "4";
|
|
441
|
+
}, {
|
|
442
|
+
readonly tool: "cf_email_routing_list";
|
|
443
|
+
readonly gate: "free";
|
|
444
|
+
readonly ships: "4";
|
|
445
|
+
}, {
|
|
446
|
+
readonly tool: "cf_email_routing_rule_create";
|
|
447
|
+
readonly gate: "free";
|
|
448
|
+
readonly ships: "4";
|
|
449
|
+
}, {
|
|
450
|
+
readonly tool: "cf_email_routing_rule_delete";
|
|
451
|
+
readonly gate: "two_step";
|
|
452
|
+
readonly ships: "4";
|
|
453
|
+
}];
|
|
454
|
+
readonly errors: readonly [{
|
|
455
|
+
readonly code: "out_of_scope";
|
|
456
|
+
readonly http: 403;
|
|
457
|
+
readonly retryable: false;
|
|
458
|
+
readonly meaning: "A domain outside the project's namespace, another project's Worker or another project's rule";
|
|
459
|
+
}, {
|
|
460
|
+
readonly code: "catch_all";
|
|
461
|
+
readonly http: 400;
|
|
462
|
+
readonly retryable: false;
|
|
463
|
+
readonly meaning: "A catch-all address; Control routes only one literal address per rule";
|
|
464
|
+
}, {
|
|
465
|
+
readonly code: "routing_not_enabled";
|
|
466
|
+
readonly http: 409;
|
|
467
|
+
readonly retryable: false;
|
|
468
|
+
readonly meaning: "Email Routing isn't on for the address's domain yet (cf_email_routing_enable)";
|
|
469
|
+
}, {
|
|
470
|
+
readonly code: "name_taken";
|
|
471
|
+
readonly http: 409;
|
|
472
|
+
readonly retryable: false;
|
|
473
|
+
readonly meaning: "A mail record or rule exists that Control didn't create for the project; Control never adopts";
|
|
474
|
+
}];
|
|
475
|
+
readonly rules: readonly [{
|
|
476
|
+
readonly id: "email-inbound.own-worker";
|
|
477
|
+
readonly kind: "must";
|
|
478
|
+
readonly text: "Route an address only to one of the project's own Workers. A rule naming another project's Worker is refused with out_of_scope.";
|
|
479
|
+
readonly enforced_by: "control";
|
|
480
|
+
readonly probe: "email-inbound.other-worker";
|
|
481
|
+
}, {
|
|
482
|
+
readonly id: "email-inbound.no-forward";
|
|
483
|
+
readonly kind: "must_not";
|
|
484
|
+
readonly text: "Forward inbound mail outside the project. The template's receiveEmail gives the app the parsed mail and a reject function, not the message.";
|
|
485
|
+
readonly enforced_by: "review";
|
|
486
|
+
readonly probe: "email-inbound.roundtrip";
|
|
487
|
+
}, {
|
|
488
|
+
readonly id: "email-inbound.reject-on-failure";
|
|
489
|
+
readonly kind: "must";
|
|
490
|
+
readonly text: "Reject a message the app can't store (throw from handleEmail, or call reject) rather than drop it silently.";
|
|
491
|
+
readonly enforced_by: "review";
|
|
492
|
+
readonly probe: "email-inbound.roundtrip";
|
|
493
|
+
}];
|
|
494
|
+
readonly examples: readonly [{
|
|
495
|
+
readonly id: "email-inbound.example.handler";
|
|
496
|
+
readonly title: "Store inbound mail";
|
|
497
|
+
readonly lang: "ts";
|
|
498
|
+
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}";
|
|
499
|
+
readonly probe: "email-inbound.roundtrip";
|
|
500
|
+
}];
|
|
501
|
+
readonly probes: readonly [{
|
|
502
|
+
readonly id: "email-inbound.roundtrip";
|
|
503
|
+
readonly half: "runtime";
|
|
504
|
+
readonly expect: "pass";
|
|
505
|
+
readonly note: "Sends a mail through the Gateway to inbox@ on the receiving domain; the Email Worker stores it and the console shows send-to-stored time. Needs step 4.9 (outbound) and a store (step 4.11)";
|
|
506
|
+
}, {
|
|
507
|
+
readonly id: "email-inbound.other-worker";
|
|
508
|
+
readonly half: "drill";
|
|
509
|
+
readonly expect: "refused_as_expected";
|
|
510
|
+
readonly refusal: "out_of_scope";
|
|
511
|
+
readonly note: "cf_email_routing_rule_create naming another project's Worker";
|
|
512
|
+
}, {
|
|
513
|
+
readonly id: "email-inbound.rule-delete";
|
|
514
|
+
readonly half: "drill";
|
|
515
|
+
readonly expect: "pass";
|
|
516
|
+
readonly two_step: true;
|
|
517
|
+
readonly note: "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves";
|
|
518
|
+
}];
|
|
519
|
+
};
|
|
217
520
|
readonly gateway: {
|
|
218
521
|
readonly $schema: "./schema.json";
|
|
219
522
|
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 = "928d1b370728";
|
|
4
4
|
export const capabilities = {
|
|
5
5
|
"ai": {
|
|
6
6
|
"$schema": "./schema.json",
|
|
@@ -194,6 +194,86 @@ export const capabilities = {
|
|
|
194
194
|
}
|
|
195
195
|
]
|
|
196
196
|
},
|
|
197
|
+
"analytics": {
|
|
198
|
+
"$schema": "./schema.json",
|
|
199
|
+
"format": 1,
|
|
200
|
+
"component": "analytics",
|
|
201
|
+
"title": "Page views and product events",
|
|
202
|
+
"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.",
|
|
203
|
+
"status": "built",
|
|
204
|
+
"surfaces": {
|
|
205
|
+
"tools": [
|
|
206
|
+
"cf_web_analytics_site_create"
|
|
207
|
+
],
|
|
208
|
+
"package": [
|
|
209
|
+
"beaconScript",
|
|
210
|
+
"createEvents",
|
|
211
|
+
"eventsMigration"
|
|
212
|
+
]
|
|
213
|
+
},
|
|
214
|
+
"actions": [
|
|
215
|
+
{
|
|
216
|
+
"tool": "cf_web_analytics_site_create",
|
|
217
|
+
"gate": "free",
|
|
218
|
+
"ships": "4"
|
|
219
|
+
}
|
|
220
|
+
],
|
|
221
|
+
"rules": [
|
|
222
|
+
{
|
|
223
|
+
"id": "analytics.beacon-on-pages",
|
|
224
|
+
"kind": "must",
|
|
225
|
+
"text": "Put beaconScript(token) on every page, with the site's public beacon token from cf_web_analytics_site_create.",
|
|
226
|
+
"enforced_by": "review",
|
|
227
|
+
"probe": "analytics.beacon-present"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"id": "analytics.track-never-throws",
|
|
231
|
+
"kind": "must_not",
|
|
232
|
+
"text": "Let a failed event write break the request it describes: track logs and drops it.",
|
|
233
|
+
"enforced_by": "types",
|
|
234
|
+
"probe": "analytics.event-round-trip"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"id": "analytics.no-cf-token",
|
|
238
|
+
"kind": "must_not",
|
|
239
|
+
"text": "Hold a Cloudflare analytics token in the app (hard rule 1); page views are read with Control.",
|
|
240
|
+
"enforced_by": "review",
|
|
241
|
+
"probe": "analytics.page-views-drill"
|
|
242
|
+
}
|
|
243
|
+
],
|
|
244
|
+
"examples": [
|
|
245
|
+
{
|
|
246
|
+
"id": "analytics.example.track",
|
|
247
|
+
"title": "Record a product event",
|
|
248
|
+
"lang": "ts",
|
|
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.event-round-trip"
|
|
251
|
+
}
|
|
252
|
+
],
|
|
253
|
+
"probes": [
|
|
254
|
+
{
|
|
255
|
+
"id": "analytics.beacon-present",
|
|
256
|
+
"half": "runtime",
|
|
257
|
+
"expect": "pass",
|
|
258
|
+
"smoke": true,
|
|
259
|
+
"cost_hint_usd": 0
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"id": "analytics.event-round-trip",
|
|
263
|
+
"half": "runtime",
|
|
264
|
+
"expect": "pass",
|
|
265
|
+
"cost_hint_usd": 0,
|
|
266
|
+
"note": "A product event is written and read back"
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"id": "analytics.page-views-drill",
|
|
270
|
+
"half": "drill",
|
|
271
|
+
"expect": "pass",
|
|
272
|
+
"cost_hint_usd": 0,
|
|
273
|
+
"note": "Create the site, drive page views, query them back (cf_analytics_query, later)"
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
},
|
|
197
277
|
"config": {
|
|
198
278
|
"$schema": "./schema.json",
|
|
199
279
|
"format": 1,
|
|
@@ -305,6 +385,326 @@ export const capabilities = {
|
|
|
305
385
|
}
|
|
306
386
|
]
|
|
307
387
|
},
|
|
388
|
+
"database": {
|
|
389
|
+
"$schema": "./schema.json",
|
|
390
|
+
"format": 1,
|
|
391
|
+
"component": "database",
|
|
392
|
+
"title": "The app's own Supabase database",
|
|
393
|
+
"summary": "Each project has its own Supabase projects, <id>-staging and <id>-production. Apps use them natively through workbench-supabase; Control creates them, writes their URL and keys onto the Workers, and applies migrations read from the repo.",
|
|
394
|
+
"status": "built",
|
|
395
|
+
"surfaces": {
|
|
396
|
+
"tools": [
|
|
397
|
+
"sb_project_create",
|
|
398
|
+
"sb_migration_apply",
|
|
399
|
+
"sb_migration_rollback",
|
|
400
|
+
"sb_query",
|
|
401
|
+
"sb_project_delete",
|
|
402
|
+
"cf_worker_secret_put"
|
|
403
|
+
],
|
|
404
|
+
"package": [
|
|
405
|
+
"supabaseConfig",
|
|
406
|
+
"createBrowserClient",
|
|
407
|
+
"createServerClient",
|
|
408
|
+
"createUserClient",
|
|
409
|
+
"createServiceClient",
|
|
410
|
+
"WorkbenchSupabaseError"
|
|
411
|
+
]
|
|
412
|
+
},
|
|
413
|
+
"config": [
|
|
414
|
+
{
|
|
415
|
+
"name": "SUPABASE_URL",
|
|
416
|
+
"required": true,
|
|
417
|
+
"secret": false,
|
|
418
|
+
"environments": [
|
|
419
|
+
"development",
|
|
420
|
+
"staging",
|
|
421
|
+
"production"
|
|
422
|
+
],
|
|
423
|
+
"description": "https://<ref>.supabase.co of the Worker's own database; written by cf_worker_secret_put source=supabase_url (or the local stack's URL in .dev.vars)"
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"name": "SUPABASE_PUBLISHABLE_KEY",
|
|
427
|
+
"required": true,
|
|
428
|
+
"secret": false,
|
|
429
|
+
"environments": [
|
|
430
|
+
"development",
|
|
431
|
+
"staging",
|
|
432
|
+
"production"
|
|
433
|
+
],
|
|
434
|
+
"description": "The sb_publishable_ key; written by cf_worker_secret_put source=supabase_publishable. Legacy anon keys are refused"
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
"name": "SUPABASE_SECRET_KEY",
|
|
438
|
+
"required": false,
|
|
439
|
+
"secret": true,
|
|
440
|
+
"environments": [
|
|
441
|
+
"staging",
|
|
442
|
+
"production"
|
|
443
|
+
],
|
|
444
|
+
"description": "The sb_secret_ key, only for code that must bypass RLS (createServiceClient); written by cf_worker_secret_put source=supabase_secret, always two-step"
|
|
445
|
+
}
|
|
446
|
+
],
|
|
447
|
+
"actions": [
|
|
448
|
+
{
|
|
449
|
+
"tool": "sb_project_create",
|
|
450
|
+
"gate": "free",
|
|
451
|
+
"ships": "3"
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
"tool": "sb_migration_apply",
|
|
455
|
+
"gate": "two_step_when",
|
|
456
|
+
"when": "production, or a staging up file that isn't additive",
|
|
457
|
+
"ships": "3"
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
"tool": "sb_migration_rollback",
|
|
461
|
+
"gate": "two_step_when",
|
|
462
|
+
"when": "production, or a staging down file that reverses more than its own up file",
|
|
463
|
+
"ships": "3"
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
"tool": "sb_query",
|
|
467
|
+
"gate": "two_step_when",
|
|
468
|
+
"when": "a production write, or a staging write outside the additive classifier; reads are free",
|
|
469
|
+
"ships": "3"
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
"tool": "sb_project_delete",
|
|
473
|
+
"gate": "two_step",
|
|
474
|
+
"ships": "4"
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
"tool": "cf_worker_secret_put",
|
|
478
|
+
"gate": "two_step_when",
|
|
479
|
+
"when": "source supabase_secret, or replacing a secret that exists",
|
|
480
|
+
"ships": "4"
|
|
481
|
+
}
|
|
482
|
+
],
|
|
483
|
+
"rules": [
|
|
484
|
+
{
|
|
485
|
+
"id": "database.rls-isolates-users",
|
|
486
|
+
"kind": "must",
|
|
487
|
+
"text": "Turn on row level security for every table the publishable key can reach, so one user can't read another's rows.",
|
|
488
|
+
"enforced_by": "database",
|
|
489
|
+
"probe": "database.rls-other-user"
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
"id": "database.service-reason",
|
|
493
|
+
"kind": "must",
|
|
494
|
+
"text": "Give createServiceClient a reason saying why RLS is bypassed; it is logged once per isolate.",
|
|
495
|
+
"enforced_by": "types",
|
|
496
|
+
"probe": "database.service-logs-reason"
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
"id": "database.no-legacy-keys",
|
|
500
|
+
"kind": "must_not",
|
|
501
|
+
"text": "Use legacy JWT keys (anon, service_role). Only sb_publishable_ and sb_secret_ keys are accepted.",
|
|
502
|
+
"enforced_by": "types",
|
|
503
|
+
"probe": "database.config-named"
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
"id": "database.down-files",
|
|
507
|
+
"kind": "must",
|
|
508
|
+
"text": "Give every migration a down file in supabase/rollback; check with npx workbench-supabase check:migrations.",
|
|
509
|
+
"enforced_by": "review",
|
|
510
|
+
"probe": "database.migrations-up-down-up"
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
"id": "database.prod-schema",
|
|
514
|
+
"kind": "must_not",
|
|
515
|
+
"text": "Ship code that names a table, column or function production's database lacks; check with npx workbench-supabase check:prod-schema.",
|
|
516
|
+
"enforced_by": "review",
|
|
517
|
+
"probe": "database.prod-schema-check"
|
|
518
|
+
}
|
|
519
|
+
],
|
|
520
|
+
"examples": [
|
|
521
|
+
{
|
|
522
|
+
"id": "database.example.user-client",
|
|
523
|
+
"title": "Read the caller's own rows",
|
|
524
|
+
"lang": "ts",
|
|
525
|
+
"code": "import { createUserClient, supabaseConfig } from '@mccaskie-labs/workbench-supabase'\n\nconst db = createUserClient({ ...supabaseConfig(env), accessToken })\nconst { data, error } = await db.from('notes').select('id, body')",
|
|
526
|
+
"probe": "database.crud"
|
|
527
|
+
}
|
|
528
|
+
],
|
|
529
|
+
"probes": [
|
|
530
|
+
{
|
|
531
|
+
"id": "database.crud",
|
|
532
|
+
"half": "runtime",
|
|
533
|
+
"expect": "pass",
|
|
534
|
+
"smoke": true,
|
|
535
|
+
"cost_hint_usd": 0,
|
|
536
|
+
"note": "Insert, read, update and delete, each timed"
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
"id": "database.rls-other-user",
|
|
540
|
+
"half": "runtime",
|
|
541
|
+
"expect": "pass",
|
|
542
|
+
"cost_hint_usd": 0,
|
|
543
|
+
"note": "A second user's reads return none of the first user's rows"
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
"id": "database.service-logs-reason",
|
|
547
|
+
"half": "runtime",
|
|
548
|
+
"expect": "pass",
|
|
549
|
+
"cost_hint_usd": 0
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
"id": "database.config-named",
|
|
553
|
+
"half": "runtime",
|
|
554
|
+
"expect": "pass",
|
|
555
|
+
"cost_hint_usd": 0
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
"id": "database.migrations-up-down-up",
|
|
559
|
+
"half": "drill",
|
|
560
|
+
"expect": "pass",
|
|
561
|
+
"cost_hint_usd": 0
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
"id": "database.prod-schema-check",
|
|
565
|
+
"half": "runtime",
|
|
566
|
+
"expect": "pass",
|
|
567
|
+
"cost_hint_usd": 0,
|
|
568
|
+
"note": "Manual: run npx workbench-supabase check:prod-schema in the project"
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
"id": "database.production-write-two-step",
|
|
572
|
+
"half": "drill",
|
|
573
|
+
"expect": "pass",
|
|
574
|
+
"cost_hint_usd": 0,
|
|
575
|
+
"two_step": true,
|
|
576
|
+
"note": "sb_query on production with a write returns a confirmation, not a result"
|
|
577
|
+
}
|
|
578
|
+
]
|
|
579
|
+
},
|
|
580
|
+
"email-inbound": {
|
|
581
|
+
"$schema": "./schema.json",
|
|
582
|
+
"format": 1,
|
|
583
|
+
"component": "email-inbound",
|
|
584
|
+
"title": "Inbound email to the project's own Worker",
|
|
585
|
+
"summary": "Email Routing receives mail on a domain in the project's namespace and runs one routing rule per address, each sending the mail to one of the project's own Workers. The Worker's email() handler parses it with postal-mime and the app stores or rejects it. Mail is never forwarded outside the project.",
|
|
586
|
+
"status": "built",
|
|
587
|
+
"surfaces": {
|
|
588
|
+
"tools": [
|
|
589
|
+
"cf_email_routing_enable",
|
|
590
|
+
"cf_email_routing_list",
|
|
591
|
+
"cf_email_routing_rule_create",
|
|
592
|
+
"cf_email_routing_rule_delete"
|
|
593
|
+
]
|
|
594
|
+
},
|
|
595
|
+
"limits": [
|
|
596
|
+
{
|
|
597
|
+
"id": "email-inbound.address-length",
|
|
598
|
+
"value": 90,
|
|
599
|
+
"unit": "characters",
|
|
600
|
+
"per": "address",
|
|
601
|
+
"enforced_by": "control",
|
|
602
|
+
"refusal": "invalid_address"
|
|
603
|
+
}
|
|
604
|
+
],
|
|
605
|
+
"actions": [
|
|
606
|
+
{
|
|
607
|
+
"tool": "cf_email_routing_enable",
|
|
608
|
+
"gate": "free",
|
|
609
|
+
"ships": "4"
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
"tool": "cf_email_routing_list",
|
|
613
|
+
"gate": "free",
|
|
614
|
+
"ships": "4"
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
"tool": "cf_email_routing_rule_create",
|
|
618
|
+
"gate": "free",
|
|
619
|
+
"ships": "4"
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
"tool": "cf_email_routing_rule_delete",
|
|
623
|
+
"gate": "two_step",
|
|
624
|
+
"ships": "4"
|
|
625
|
+
}
|
|
626
|
+
],
|
|
627
|
+
"errors": [
|
|
628
|
+
{
|
|
629
|
+
"code": "out_of_scope",
|
|
630
|
+
"http": 403,
|
|
631
|
+
"retryable": false,
|
|
632
|
+
"meaning": "A domain outside the project's namespace, another project's Worker or another project's rule"
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
"code": "catch_all",
|
|
636
|
+
"http": 400,
|
|
637
|
+
"retryable": false,
|
|
638
|
+
"meaning": "A catch-all address; Control routes only one literal address per rule"
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
"code": "routing_not_enabled",
|
|
642
|
+
"http": 409,
|
|
643
|
+
"retryable": false,
|
|
644
|
+
"meaning": "Email Routing isn't on for the address's domain yet (cf_email_routing_enable)"
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
"code": "name_taken",
|
|
648
|
+
"http": 409,
|
|
649
|
+
"retryable": false,
|
|
650
|
+
"meaning": "A mail record or rule exists that Control didn't create for the project; Control never adopts"
|
|
651
|
+
}
|
|
652
|
+
],
|
|
653
|
+
"rules": [
|
|
654
|
+
{
|
|
655
|
+
"id": "email-inbound.own-worker",
|
|
656
|
+
"kind": "must",
|
|
657
|
+
"text": "Route an address only to one of the project's own Workers. A rule naming another project's Worker is refused with out_of_scope.",
|
|
658
|
+
"enforced_by": "control",
|
|
659
|
+
"probe": "email-inbound.other-worker"
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
"id": "email-inbound.no-forward",
|
|
663
|
+
"kind": "must_not",
|
|
664
|
+
"text": "Forward inbound mail outside the project. The template's receiveEmail gives the app the parsed mail and a reject function, not the message.",
|
|
665
|
+
"enforced_by": "review",
|
|
666
|
+
"probe": "email-inbound.roundtrip"
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
"id": "email-inbound.reject-on-failure",
|
|
670
|
+
"kind": "must",
|
|
671
|
+
"text": "Reject a message the app can't store (throw from handleEmail, or call reject) rather than drop it silently.",
|
|
672
|
+
"enforced_by": "review",
|
|
673
|
+
"probe": "email-inbound.roundtrip"
|
|
674
|
+
}
|
|
675
|
+
],
|
|
676
|
+
"examples": [
|
|
677
|
+
{
|
|
678
|
+
"id": "email-inbound.example.handler",
|
|
679
|
+
"title": "Store inbound mail",
|
|
680
|
+
"lang": "ts",
|
|
681
|
+
"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}",
|
|
682
|
+
"probe": "email-inbound.roundtrip"
|
|
683
|
+
}
|
|
684
|
+
],
|
|
685
|
+
"probes": [
|
|
686
|
+
{
|
|
687
|
+
"id": "email-inbound.roundtrip",
|
|
688
|
+
"half": "runtime",
|
|
689
|
+
"expect": "pass",
|
|
690
|
+
"note": "Sends a mail through the Gateway to inbox@ on the receiving domain; the Email Worker stores it and the console shows send-to-stored time. Needs step 4.9 (outbound) and a store (step 4.11)"
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
"id": "email-inbound.other-worker",
|
|
694
|
+
"half": "drill",
|
|
695
|
+
"expect": "refused_as_expected",
|
|
696
|
+
"refusal": "out_of_scope",
|
|
697
|
+
"note": "cf_email_routing_rule_create naming another project's Worker"
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
"id": "email-inbound.rule-delete",
|
|
701
|
+
"half": "drill",
|
|
702
|
+
"expect": "pass",
|
|
703
|
+
"two_step": true,
|
|
704
|
+
"note": "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves"
|
|
705
|
+
}
|
|
706
|
+
]
|
|
707
|
+
},
|
|
308
708
|
"gateway": {
|
|
309
709
|
"$schema": "./schema.json",
|
|
310
710
|
"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.6",
|
|
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",
|