@mccaskie-labs/workbench-config 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Sign-in gets its own component, `sign-in`: a built-in skill (`workbench-sign-in`) that `workbench-start` lists, with one client question (which pages and actions need a signed-in person, who among them may use each, and which also need a fresh code), written to `workbench/sign-in.json`. Its surfaces, config (`SESSION_SECRET`, `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`), Turnstile actions and three rules move out of `database` (rule ids now `sign-in.server-side`, `sign-in.writes-same-origin`, `sign-in.checks-for-bots`); the probes keep their `database.*` ids. A new optional `proved_by` lets a manifest's rules name probes in the components it lists (sign-in names `database`); only then may its own `probes` be empty.
8
+
3
9
  ## 0.2.0
4
10
 
5
11
  ### Minor Changes
@@ -17,10 +17,7 @@
17
17
  "createServerClient",
18
18
  "createUserClient",
19
19
  "createServiceClient",
20
- "WorkbenchSupabaseError",
21
- "createSessions",
22
- "safeReturnPath",
23
- "sameOrigin"
20
+ "WorkbenchSupabaseError"
24
21
  ],
25
22
  "tools": [
26
23
  "sb_project_create",
@@ -29,9 +26,7 @@
29
26
  "sb_query",
30
27
  "sb_project_delete",
31
28
  "cf_worker_secret_put",
32
- "vendor_settings_apply",
33
- "cf_turnstile_widget_create",
34
- "cf_turnstile_widget_delete"
29
+ "vendor_settings_apply"
35
30
  ]
36
31
  },
37
32
  "config": [
@@ -66,39 +61,6 @@
66
61
  "production"
67
62
  ],
68
63
  "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"
69
- },
70
- {
71
- "name": "SESSION_SECRET",
72
- "required": false,
73
- "secret": true,
74
- "environments": [
75
- "development",
76
- "staging",
77
- "production"
78
- ],
79
- "description": "Seals the sign-in session cookie (createSessions). 32 random bytes written by cf_worker_secret_put source=generate:32; replacing it signs everybody out, so it is two-step. Only needed by an app with sign-in"
80
- },
81
- {
82
- "name": "TURNSTILE_SITE_KEY",
83
- "required": false,
84
- "secret": false,
85
- "environments": [
86
- "development",
87
- "staging",
88
- "production"
89
- ],
90
- "description": "The public site key of the hostname's Turnstile widget, for the sign-in bot check; written by cf_worker_secret_put source=turnstile_sitekey after cf_turnstile_widget_create. Outside development, sign-in won't send codes without it"
91
- },
92
- {
93
- "name": "TURNSTILE_SECRET_KEY",
94
- "required": false,
95
- "secret": true,
96
- "environments": [
97
- "development",
98
- "staging",
99
- "production"
100
- ],
101
- "description": "The secret of the hostname's Turnstile widget, sent only to Cloudflare's Siteverify; written by cf_worker_secret_put source=turnstile_secret. Outside development, sign-in won't send codes without it"
102
64
  }
103
65
  ],
104
66
  "actions": [
@@ -141,16 +103,6 @@
141
103
  "gate": "two_step_when",
142
104
  "when": "production, after the first successful apply on that database; staging and check_only are free",
143
105
  "ships": "5"
144
- },
145
- {
146
- "tool": "cf_turnstile_widget_create",
147
- "gate": "free",
148
- "ships": "5"
149
- },
150
- {
151
- "tool": "cf_turnstile_widget_delete",
152
- "gate": "two_step",
153
- "ships": "5"
154
106
  }
155
107
  ],
156
108
  "rules": [
@@ -195,27 +147,6 @@
195
147
  "text": "Keep Supabase's sign-in settings at the Workbench baseline (workbench-config VENDOR_SETTINGS), or record a project's change in vendor-settings.json with a reason. Control applies it with vendor_settings_apply and checks it every day.",
196
148
  "enforced_by": "control",
197
149
  "probe": "database.vendor-settings"
198
- },
199
- {
200
- "id": "database.sign-in-server-side",
201
- "kind": "must",
202
- "text": "Sign people in through the app's own endpoints with createSessions: the browser never talks to Supabase Auth and never holds the access token, and codes are typed, never links.",
203
- "enforced_by": "review",
204
- "probe": "database.sign-in"
205
- },
206
- {
207
- "id": "database.session-writes-same-origin",
208
- "kind": "must",
209
- "text": "Refuse a write (anything but GET or HEAD) made with the session cookie unless sameOrigin(request) is true.",
210
- "enforced_by": "review",
211
- "probe": "database.session-cookie"
212
- },
213
- {
214
- "id": "database.sign-in-checks-for-bots",
215
- "kind": "must",
216
- "text": "Outside development, verify a Turnstile token with Siteverify (success, this hostname and the sign-in action) before asking Supabase to send or re-send a sign-in code, and send no code when either Turnstile key is missing.",
217
- "enforced_by": "review",
218
- "probe": "database.sign-in-bot-check"
219
150
  }
220
151
  ],
221
152
  "examples": [
@@ -34,7 +34,8 @@
34
34
  "github",
35
35
  "strings",
36
36
  "ui",
37
- "forms"
37
+ "forms",
38
+ "sign-in"
38
39
  ]
39
40
  },
40
41
  "title": {
@@ -352,7 +353,6 @@
352
353
  }
353
354
  },
354
355
  "probes": {
355
- "minItems": 1,
356
356
  "type": "array",
357
357
  "items": {
358
358
  "type": "object",
@@ -402,6 +402,38 @@
402
402
  "additionalProperties": false
403
403
  }
404
404
  },
405
+ "proved_by": {
406
+ "minItems": 1,
407
+ "type": "array",
408
+ "items": {
409
+ "type": "string",
410
+ "enum": [
411
+ "gateway",
412
+ "ai",
413
+ "images",
414
+ "image-search",
415
+ "config",
416
+ "email",
417
+ "email-inbound",
418
+ "email-sending",
419
+ "database",
420
+ "workers",
421
+ "domains",
422
+ "dns",
423
+ "analytics",
424
+ "country",
425
+ "pdf",
426
+ "access",
427
+ "r2",
428
+ "kv",
429
+ "github",
430
+ "strings",
431
+ "ui",
432
+ "forms",
433
+ "sign-in"
434
+ ]
435
+ }
436
+ },
405
437
  "offer": {
406
438
  "type": "object",
407
439
  "properties": {
@@ -0,0 +1,137 @@
1
+ {
2
+ "$schema": "./schema.json",
3
+ "format": 1,
4
+ "component": "sign-in",
5
+ "title": "Sign-in",
6
+ "summary": "People sign in to the app with a code emailed to them and typed on the next screen: no password, no link and no reset email. The Worker holds the session in one sealed cookie and talks to Supabase Auth itself, so the browser never sees a token, and a Cloudflare Turnstile check runs before any code is sent. The template has it all; a page that needs a signed-in person asks for one with requireUser. Signing in proves who someone is, not what they may do: anyone can make an account.",
7
+ "status": "built",
8
+ "delivery": {
9
+ "choice": "built_in",
10
+ "intro": "People signing in to the app with a code emailed to them, with no passwords to forget."
11
+ },
12
+ "surfaces": {
13
+ "package": [
14
+ "createSessions",
15
+ "safeReturnPath",
16
+ "sameOrigin",
17
+ "plainAuthError"
18
+ ],
19
+ "tools": [
20
+ "cf_worker_secret_put",
21
+ "cf_turnstile_widget_create",
22
+ "cf_turnstile_widget_delete"
23
+ ]
24
+ },
25
+ "config": [
26
+ {
27
+ "name": "SESSION_SECRET",
28
+ "required": false,
29
+ "secret": true,
30
+ "environments": [
31
+ "development",
32
+ "staging",
33
+ "production"
34
+ ],
35
+ "description": "Seals the sign-in session cookie (createSessions). 32 random bytes written by cf_worker_secret_put source=generate:32; replacing it signs everybody out, so it is two-step. Only needed by an app with sign-in"
36
+ },
37
+ {
38
+ "name": "TURNSTILE_SITE_KEY",
39
+ "required": false,
40
+ "secret": false,
41
+ "environments": [
42
+ "development",
43
+ "staging",
44
+ "production"
45
+ ],
46
+ "description": "The public site key of the hostname's Turnstile widget, for the sign-in bot check; written by cf_worker_secret_put source=turnstile_sitekey after cf_turnstile_widget_create. Outside development, sign-in won't send codes without it"
47
+ },
48
+ {
49
+ "name": "TURNSTILE_SECRET_KEY",
50
+ "required": false,
51
+ "secret": true,
52
+ "environments": [
53
+ "development",
54
+ "staging",
55
+ "production"
56
+ ],
57
+ "description": "The secret of the hostname's Turnstile widget, sent only to Cloudflare's Siteverify; written by cf_worker_secret_put source=turnstile_secret. Outside development, sign-in won't send codes without it"
58
+ }
59
+ ],
60
+ "actions": [
61
+ {
62
+ "tool": "cf_turnstile_widget_create",
63
+ "gate": "free",
64
+ "ships": "5"
65
+ },
66
+ {
67
+ "tool": "cf_turnstile_widget_delete",
68
+ "gate": "two_step",
69
+ "ships": "5"
70
+ }
71
+ ],
72
+ "rules": [
73
+ {
74
+ "id": "sign-in.server-side",
75
+ "kind": "must",
76
+ "text": "Sign people in through the app's own endpoints with createSessions: the browser never talks to Supabase Auth and never holds the access token, and codes are typed, never links.",
77
+ "enforced_by": "review",
78
+ "probe": "database.sign-in"
79
+ },
80
+ {
81
+ "id": "sign-in.writes-same-origin",
82
+ "kind": "must",
83
+ "text": "Refuse a write (anything but GET or HEAD) made with the session cookie unless sameOrigin(request) is true.",
84
+ "enforced_by": "review",
85
+ "probe": "database.session-cookie"
86
+ },
87
+ {
88
+ "id": "sign-in.checks-for-bots",
89
+ "kind": "must",
90
+ "text": "Outside development, verify a Turnstile token with Siteverify (success, this hostname and the sign-in action) before asking Supabase to send or re-send a sign-in code, and send no code when either Turnstile key is missing.",
91
+ "enforced_by": "review",
92
+ "probe": "database.sign-in-bot-check"
93
+ }
94
+ ],
95
+ "probes": [],
96
+ "offer": {
97
+ "pattern": "switch_on",
98
+ "when": "The client wants people to sign in or have accounts: a members' area, saved orders or bookings, a profile, \"my account\", or a page only some people may see.",
99
+ "say": "Sign-in is already built in: people get a code by email and type it, so there are no passwords to forget or leak. I'll put the pages that need it behind it. Here's what I think needs sign-in; tell me what to change.",
100
+ "answers_file": "workbench/sign-in.json",
101
+ "answers_format": "`{ \"format\": 1, \"status\": \"draft\" | \"done\", \"pages\": [{ \"path\": \"/orders\", \"what\": \"<what it shows or does, in the client's words>\", \"who\": \"signed_in\" | \"owner\" | \"admins\", \"fresh\": true | false }] }`, one entry per page or action that needs a signed-in person. `who` is `signed_in` (anyone with an account), `owner` (only the person the thing belongs to, enforced by row-level security on their user id through `dbAs`) or `admins` (only people in `app_admins`). `fresh` is true where it also needs a code typed in the last 15 minutes. Save it as a draft until the client accepts the table. Then build each page with `requireUser`, enforce its `who`, and check `fresh` on the ones marked so.",
102
+ "questions": [
103
+ {
104
+ "id": "sign-in.pages",
105
+ "sets": [
106
+ "page.sign_in",
107
+ "page.who",
108
+ "page.fresh"
109
+ ],
110
+ "ask": "Here are the pages and actions that need someone signed in, who among them may use each one, and which also need a fresh code because they show or change something sensitive. Accept them, or change some?",
111
+ "style": "table",
112
+ "note": "Propose from the pages built or planned. Anything that shows or changes a person's own things (orders, bookings, a profile) needs sign-in, with `who` set to `owner`. Signing in only proves who someone is, and anyone can make an account, so a page for staff or admins must say `admins`: `requireUser` alone would let any stranger who signs up use it. Mark `fresh` where a stranger holding a signed-in phone could do harm: showing payment details, changing an email address, adding an admin, deleting an account. The template has no re-check screen: when `fresh` is false, the page tells the person to sign out and sign in again with a new code, as the admins section of `/admin/brand` does. Pages anyone may read, such as the home page, a menu or a contact form, stay open. Only the client knows what is private and who may do what, so always show the table."
113
+ }
114
+ ],
115
+ "not_asked": [
116
+ {
117
+ "sets": "sign_in.method",
118
+ "why": "The template signs people in with an emailed code only (Workbench's choice: no password to forget, leak or reuse), and the authentication standard rules out sign-in links. Passwords and social sign-in (\"sign in with Google\") aren't built. If the client asks for one, say so and that it is the Workbench owner's call; don't build it."
119
+ },
120
+ {
121
+ "sets": "sign_in.length",
122
+ "why": "Sessions last weeks, with a cap, as the authentication standard (§10) asks. The package's defaults (SESSION_DEFAULTS) sign a person out after 14 days without a visit and 30 days after the code was typed, whatever happens; on a shared device they untick \"keep me signed in\" and are signed out after 30 minutes idle. Say so if the client asks, and don't change them."
123
+ },
124
+ {
125
+ "sets": "sign_in.who",
126
+ "why": "Anyone with an email address can make an account; invite-only sign-up isn't built yet. If the client needs only invited people, say it isn't built yet and ask them how they want to handle it. Don't build an invite system of your own."
127
+ },
128
+ {
129
+ "sets": "sign_in.bot_check",
130
+ "why": "The Turnstile check is always on outside development. Control sets it up (cf_turnstile_widget_create, then cf_worker_secret_put sources turnstile_sitekey and turnstile_secret); there is nothing to ask."
131
+ }
132
+ ]
133
+ },
134
+ "proved_by": [
135
+ "database"
136
+ ]
137
+ }
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "5f4a5d2dad37";
1
+ export declare const MANIFEST_VERSION: "984099bc7d3f";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -437,8 +437,8 @@ export declare const capabilities: {
437
437
  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.";
438
438
  readonly status: "built";
439
439
  readonly surfaces: {
440
- readonly tools: readonly ["sb_project_create", "sb_migration_apply", "sb_migration_rollback", "sb_query", "sb_project_delete", "cf_worker_secret_put", "vendor_settings_apply", "cf_turnstile_widget_create", "cf_turnstile_widget_delete"];
441
- readonly package: readonly ["supabaseConfig", "createBrowserClient", "createServerClient", "createUserClient", "createServiceClient", "WorkbenchSupabaseError", "createSessions", "safeReturnPath", "sameOrigin"];
440
+ readonly tools: readonly ["sb_project_create", "sb_migration_apply", "sb_migration_rollback", "sb_query", "sb_project_delete", "cf_worker_secret_put", "vendor_settings_apply"];
441
+ readonly package: readonly ["supabaseConfig", "createBrowserClient", "createServerClient", "createUserClient", "createServiceClient", "WorkbenchSupabaseError"];
442
442
  };
443
443
  readonly config: readonly [{
444
444
  readonly name: "SUPABASE_URL";
@@ -458,24 +458,6 @@ export declare const capabilities: {
458
458
  readonly secret: true;
459
459
  readonly environments: readonly ["staging", "production"];
460
460
  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";
461
- }, {
462
- readonly name: "SESSION_SECRET";
463
- readonly required: false;
464
- readonly secret: true;
465
- readonly environments: readonly ["development", "staging", "production"];
466
- readonly description: "Seals the sign-in session cookie (createSessions). 32 random bytes written by cf_worker_secret_put source=generate:32; replacing it signs everybody out, so it is two-step. Only needed by an app with sign-in";
467
- }, {
468
- readonly name: "TURNSTILE_SITE_KEY";
469
- readonly required: false;
470
- readonly secret: false;
471
- readonly environments: readonly ["development", "staging", "production"];
472
- readonly description: "The public site key of the hostname's Turnstile widget, for the sign-in bot check; written by cf_worker_secret_put source=turnstile_sitekey after cf_turnstile_widget_create. Outside development, sign-in won't send codes without it";
473
- }, {
474
- readonly name: "TURNSTILE_SECRET_KEY";
475
- readonly required: false;
476
- readonly secret: true;
477
- readonly environments: readonly ["development", "staging", "production"];
478
- readonly description: "The secret of the hostname's Turnstile widget, sent only to Cloudflare's Siteverify; written by cf_worker_secret_put source=turnstile_secret. Outside development, sign-in won't send codes without it";
479
461
  }];
480
462
  readonly actions: readonly [{
481
463
  readonly tool: "sb_project_create";
@@ -510,14 +492,6 @@ export declare const capabilities: {
510
492
  readonly gate: "two_step_when";
511
493
  readonly when: "production, after the first successful apply on that database; staging and check_only are free";
512
494
  readonly ships: "5";
513
- }, {
514
- readonly tool: "cf_turnstile_widget_create";
515
- readonly gate: "free";
516
- readonly ships: "5";
517
- }, {
518
- readonly tool: "cf_turnstile_widget_delete";
519
- readonly gate: "two_step";
520
- readonly ships: "5";
521
495
  }];
522
496
  readonly rules: readonly [{
523
497
  readonly id: "database.rls-isolates-users";
@@ -555,24 +529,6 @@ export declare const capabilities: {
555
529
  readonly text: "Keep Supabase's sign-in settings at the Workbench baseline (workbench-config VENDOR_SETTINGS), or record a project's change in vendor-settings.json with a reason. Control applies it with vendor_settings_apply and checks it every day.";
556
530
  readonly enforced_by: "control";
557
531
  readonly probe: "database.vendor-settings";
558
- }, {
559
- readonly id: "database.sign-in-server-side";
560
- readonly kind: "must";
561
- readonly text: "Sign people in through the app's own endpoints with createSessions: the browser never talks to Supabase Auth and never holds the access token, and codes are typed, never links.";
562
- readonly enforced_by: "review";
563
- readonly probe: "database.sign-in";
564
- }, {
565
- readonly id: "database.session-writes-same-origin";
566
- readonly kind: "must";
567
- readonly text: "Refuse a write (anything but GET or HEAD) made with the session cookie unless sameOrigin(request) is true.";
568
- readonly enforced_by: "review";
569
- readonly probe: "database.session-cookie";
570
- }, {
571
- readonly id: "database.sign-in-checks-for-bots";
572
- readonly kind: "must";
573
- readonly text: "Outside development, verify a Turnstile token with Siteverify (success, this hostname and the sign-in action) before asking Supabase to send or re-send a sign-in code, and send no code when either Turnstile key is missing.";
574
- readonly enforced_by: "review";
575
- readonly probe: "database.sign-in-bot-check";
576
532
  }];
577
533
  readonly examples: readonly [{
578
534
  readonly id: "database.example.user-client";
@@ -1934,6 +1890,98 @@ export declare const capabilities: {
1934
1890
  readonly when: "The client wants a PDF: an invoice, receipt, quote, certificate, report or anything else to download or print.";
1935
1891
  };
1936
1892
  };
1893
+ readonly "sign-in": {
1894
+ readonly $schema: "./schema.json";
1895
+ readonly format: 1;
1896
+ readonly component: "sign-in";
1897
+ readonly title: "Sign-in";
1898
+ readonly summary: "People sign in to the app with a code emailed to them and typed on the next screen: no password, no link and no reset email. The Worker holds the session in one sealed cookie and talks to Supabase Auth itself, so the browser never sees a token, and a Cloudflare Turnstile check runs before any code is sent. The template has it all; a page that needs a signed-in person asks for one with requireUser. Signing in proves who someone is, not what they may do: anyone can make an account.";
1899
+ readonly status: "built";
1900
+ readonly surfaces: {
1901
+ readonly tools: readonly ["cf_worker_secret_put", "cf_turnstile_widget_create", "cf_turnstile_widget_delete"];
1902
+ readonly package: readonly ["createSessions", "safeReturnPath", "sameOrigin", "plainAuthError"];
1903
+ };
1904
+ readonly config: readonly [{
1905
+ readonly name: "SESSION_SECRET";
1906
+ readonly required: false;
1907
+ readonly secret: true;
1908
+ readonly environments: readonly ["development", "staging", "production"];
1909
+ readonly description: "Seals the sign-in session cookie (createSessions). 32 random bytes written by cf_worker_secret_put source=generate:32; replacing it signs everybody out, so it is two-step. Only needed by an app with sign-in";
1910
+ }, {
1911
+ readonly name: "TURNSTILE_SITE_KEY";
1912
+ readonly required: false;
1913
+ readonly secret: false;
1914
+ readonly environments: readonly ["development", "staging", "production"];
1915
+ readonly description: "The public site key of the hostname's Turnstile widget, for the sign-in bot check; written by cf_worker_secret_put source=turnstile_sitekey after cf_turnstile_widget_create. Outside development, sign-in won't send codes without it";
1916
+ }, {
1917
+ readonly name: "TURNSTILE_SECRET_KEY";
1918
+ readonly required: false;
1919
+ readonly secret: true;
1920
+ readonly environments: readonly ["development", "staging", "production"];
1921
+ readonly description: "The secret of the hostname's Turnstile widget, sent only to Cloudflare's Siteverify; written by cf_worker_secret_put source=turnstile_secret. Outside development, sign-in won't send codes without it";
1922
+ }];
1923
+ readonly actions: readonly [{
1924
+ readonly tool: "cf_turnstile_widget_create";
1925
+ readonly gate: "free";
1926
+ readonly ships: "5";
1927
+ }, {
1928
+ readonly tool: "cf_turnstile_widget_delete";
1929
+ readonly gate: "two_step";
1930
+ readonly ships: "5";
1931
+ }];
1932
+ readonly rules: readonly [{
1933
+ readonly id: "sign-in.server-side";
1934
+ readonly kind: "must";
1935
+ readonly text: "Sign people in through the app's own endpoints with createSessions: the browser never talks to Supabase Auth and never holds the access token, and codes are typed, never links.";
1936
+ readonly enforced_by: "review";
1937
+ readonly probe: "database.sign-in";
1938
+ }, {
1939
+ readonly id: "sign-in.writes-same-origin";
1940
+ readonly kind: "must";
1941
+ readonly text: "Refuse a write (anything but GET or HEAD) made with the session cookie unless sameOrigin(request) is true.";
1942
+ readonly enforced_by: "review";
1943
+ readonly probe: "database.session-cookie";
1944
+ }, {
1945
+ readonly id: "sign-in.checks-for-bots";
1946
+ readonly kind: "must";
1947
+ readonly text: "Outside development, verify a Turnstile token with Siteverify (success, this hostname and the sign-in action) before asking Supabase to send or re-send a sign-in code, and send no code when either Turnstile key is missing.";
1948
+ readonly enforced_by: "review";
1949
+ readonly probe: "database.sign-in-bot-check";
1950
+ }];
1951
+ readonly probes: readonly [];
1952
+ readonly proved_by: readonly ["database"];
1953
+ readonly offer: {
1954
+ readonly pattern: "switch_on";
1955
+ readonly when: "The client wants people to sign in or have accounts: a members' area, saved orders or bookings, a profile, \"my account\", or a page only some people may see.";
1956
+ readonly say: "Sign-in is already built in: people get a code by email and type it, so there are no passwords to forget or leak. I'll put the pages that need it behind it. Here's what I think needs sign-in; tell me what to change.";
1957
+ readonly answers_file: "workbench/sign-in.json";
1958
+ readonly answers_format: "`{ \"format\": 1, \"status\": \"draft\" | \"done\", \"pages\": [{ \"path\": \"/orders\", \"what\": \"<what it shows or does, in the client's words>\", \"who\": \"signed_in\" | \"owner\" | \"admins\", \"fresh\": true | false }] }`, one entry per page or action that needs a signed-in person. `who` is `signed_in` (anyone with an account), `owner` (only the person the thing belongs to, enforced by row-level security on their user id through `dbAs`) or `admins` (only people in `app_admins`). `fresh` is true where it also needs a code typed in the last 15 minutes. Save it as a draft until the client accepts the table. Then build each page with `requireUser`, enforce its `who`, and check `fresh` on the ones marked so.";
1959
+ readonly questions: readonly [{
1960
+ readonly id: "sign-in.pages";
1961
+ readonly sets: readonly ["page.sign_in", "page.who", "page.fresh"];
1962
+ readonly ask: "Here are the pages and actions that need someone signed in, who among them may use each one, and which also need a fresh code because they show or change something sensitive. Accept them, or change some?";
1963
+ readonly style: "table";
1964
+ readonly note: "Propose from the pages built or planned. Anything that shows or changes a person's own things (orders, bookings, a profile) needs sign-in, with `who` set to `owner`. Signing in only proves who someone is, and anyone can make an account, so a page for staff or admins must say `admins`: `requireUser` alone would let any stranger who signs up use it. Mark `fresh` where a stranger holding a signed-in phone could do harm: showing payment details, changing an email address, adding an admin, deleting an account. The template has no re-check screen: when `fresh` is false, the page tells the person to sign out and sign in again with a new code, as the admins section of `/admin/brand` does. Pages anyone may read, such as the home page, a menu or a contact form, stay open. Only the client knows what is private and who may do what, so always show the table.";
1965
+ }];
1966
+ readonly not_asked: readonly [{
1967
+ readonly sets: "sign_in.method";
1968
+ readonly why: "The template signs people in with an emailed code only (Workbench's choice: no password to forget, leak or reuse), and the authentication standard rules out sign-in links. Passwords and social sign-in (\"sign in with Google\") aren't built. If the client asks for one, say so and that it is the Workbench owner's call; don't build it.";
1969
+ }, {
1970
+ readonly sets: "sign_in.length";
1971
+ readonly why: "Sessions last weeks, with a cap, as the authentication standard (§10) asks. The package's defaults (SESSION_DEFAULTS) sign a person out after 14 days without a visit and 30 days after the code was typed, whatever happens; on a shared device they untick \"keep me signed in\" and are signed out after 30 minutes idle. Say so if the client asks, and don't change them.";
1972
+ }, {
1973
+ readonly sets: "sign_in.who";
1974
+ readonly why: "Anyone with an email address can make an account; invite-only sign-up isn't built yet. If the client needs only invited people, say it isn't built yet and ask them how they want to handle it. Don't build an invite system of your own.";
1975
+ }, {
1976
+ readonly sets: "sign_in.bot_check";
1977
+ readonly why: "The Turnstile check is always on outside development. Control sets it up (cf_turnstile_widget_create, then cf_worker_secret_put sources turnstile_sitekey and turnstile_secret); there is nothing to ask.";
1978
+ }];
1979
+ };
1980
+ readonly delivery: {
1981
+ readonly choice: "built_in";
1982
+ readonly intro: "People signing in to the app with a code emailed to them, with no passwords to forget.";
1983
+ };
1984
+ };
1937
1985
  readonly strings: {
1938
1986
  readonly $schema: "./schema.json";
1939
1987
  readonly format: 1;
@@ -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 = "5f4a5d2dad37";
3
+ export const MANIFEST_VERSION = "984099bc7d3f";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -637,9 +637,7 @@ export const capabilities = {
637
637
  "sb_query",
638
638
  "sb_project_delete",
639
639
  "cf_worker_secret_put",
640
- "vendor_settings_apply",
641
- "cf_turnstile_widget_create",
642
- "cf_turnstile_widget_delete"
640
+ "vendor_settings_apply"
643
641
  ],
644
642
  "package": [
645
643
  "supabaseConfig",
@@ -647,10 +645,7 @@ export const capabilities = {
647
645
  "createServerClient",
648
646
  "createUserClient",
649
647
  "createServiceClient",
650
- "WorkbenchSupabaseError",
651
- "createSessions",
652
- "safeReturnPath",
653
- "sameOrigin"
648
+ "WorkbenchSupabaseError"
654
649
  ]
655
650
  },
656
651
  "config": [
@@ -685,39 +680,6 @@ export const capabilities = {
685
680
  "production"
686
681
  ],
687
682
  "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"
688
- },
689
- {
690
- "name": "SESSION_SECRET",
691
- "required": false,
692
- "secret": true,
693
- "environments": [
694
- "development",
695
- "staging",
696
- "production"
697
- ],
698
- "description": "Seals the sign-in session cookie (createSessions). 32 random bytes written by cf_worker_secret_put source=generate:32; replacing it signs everybody out, so it is two-step. Only needed by an app with sign-in"
699
- },
700
- {
701
- "name": "TURNSTILE_SITE_KEY",
702
- "required": false,
703
- "secret": false,
704
- "environments": [
705
- "development",
706
- "staging",
707
- "production"
708
- ],
709
- "description": "The public site key of the hostname's Turnstile widget, for the sign-in bot check; written by cf_worker_secret_put source=turnstile_sitekey after cf_turnstile_widget_create. Outside development, sign-in won't send codes without it"
710
- },
711
- {
712
- "name": "TURNSTILE_SECRET_KEY",
713
- "required": false,
714
- "secret": true,
715
- "environments": [
716
- "development",
717
- "staging",
718
- "production"
719
- ],
720
- "description": "The secret of the hostname's Turnstile widget, sent only to Cloudflare's Siteverify; written by cf_worker_secret_put source=turnstile_secret. Outside development, sign-in won't send codes without it"
721
683
  }
722
684
  ],
723
685
  "actions": [
@@ -760,16 +722,6 @@ export const capabilities = {
760
722
  "gate": "two_step_when",
761
723
  "when": "production, after the first successful apply on that database; staging and check_only are free",
762
724
  "ships": "5"
763
- },
764
- {
765
- "tool": "cf_turnstile_widget_create",
766
- "gate": "free",
767
- "ships": "5"
768
- },
769
- {
770
- "tool": "cf_turnstile_widget_delete",
771
- "gate": "two_step",
772
- "ships": "5"
773
725
  }
774
726
  ],
775
727
  "rules": [
@@ -814,27 +766,6 @@ export const capabilities = {
814
766
  "text": "Keep Supabase's sign-in settings at the Workbench baseline (workbench-config VENDOR_SETTINGS), or record a project's change in vendor-settings.json with a reason. Control applies it with vendor_settings_apply and checks it every day.",
815
767
  "enforced_by": "control",
816
768
  "probe": "database.vendor-settings"
817
- },
818
- {
819
- "id": "database.sign-in-server-side",
820
- "kind": "must",
821
- "text": "Sign people in through the app's own endpoints with createSessions: the browser never talks to Supabase Auth and never holds the access token, and codes are typed, never links.",
822
- "enforced_by": "review",
823
- "probe": "database.sign-in"
824
- },
825
- {
826
- "id": "database.session-writes-same-origin",
827
- "kind": "must",
828
- "text": "Refuse a write (anything but GET or HEAD) made with the session cookie unless sameOrigin(request) is true.",
829
- "enforced_by": "review",
830
- "probe": "database.session-cookie"
831
- },
832
- {
833
- "id": "database.sign-in-checks-for-bots",
834
- "kind": "must",
835
- "text": "Outside development, verify a Turnstile token with Siteverify (success, this hostname and the sign-in action) before asking Supabase to send or re-send a sign-in code, and send no code when either Turnstile key is missing.",
836
- "enforced_by": "review",
837
- "probe": "database.sign-in-bot-check"
838
769
  }
839
770
  ],
840
771
  "examples": [
@@ -2607,6 +2538,143 @@ export const capabilities = {
2607
2538
  "when": "The client wants a PDF: an invoice, receipt, quote, certificate, report or anything else to download or print."
2608
2539
  }
2609
2540
  },
2541
+ "sign-in": {
2542
+ "$schema": "./schema.json",
2543
+ "format": 1,
2544
+ "component": "sign-in",
2545
+ "title": "Sign-in",
2546
+ "summary": "People sign in to the app with a code emailed to them and typed on the next screen: no password, no link and no reset email. The Worker holds the session in one sealed cookie and talks to Supabase Auth itself, so the browser never sees a token, and a Cloudflare Turnstile check runs before any code is sent. The template has it all; a page that needs a signed-in person asks for one with requireUser. Signing in proves who someone is, not what they may do: anyone can make an account.",
2547
+ "status": "built",
2548
+ "surfaces": {
2549
+ "tools": [
2550
+ "cf_worker_secret_put",
2551
+ "cf_turnstile_widget_create",
2552
+ "cf_turnstile_widget_delete"
2553
+ ],
2554
+ "package": [
2555
+ "createSessions",
2556
+ "safeReturnPath",
2557
+ "sameOrigin",
2558
+ "plainAuthError"
2559
+ ]
2560
+ },
2561
+ "config": [
2562
+ {
2563
+ "name": "SESSION_SECRET",
2564
+ "required": false,
2565
+ "secret": true,
2566
+ "environments": [
2567
+ "development",
2568
+ "staging",
2569
+ "production"
2570
+ ],
2571
+ "description": "Seals the sign-in session cookie (createSessions). 32 random bytes written by cf_worker_secret_put source=generate:32; replacing it signs everybody out, so it is two-step. Only needed by an app with sign-in"
2572
+ },
2573
+ {
2574
+ "name": "TURNSTILE_SITE_KEY",
2575
+ "required": false,
2576
+ "secret": false,
2577
+ "environments": [
2578
+ "development",
2579
+ "staging",
2580
+ "production"
2581
+ ],
2582
+ "description": "The public site key of the hostname's Turnstile widget, for the sign-in bot check; written by cf_worker_secret_put source=turnstile_sitekey after cf_turnstile_widget_create. Outside development, sign-in won't send codes without it"
2583
+ },
2584
+ {
2585
+ "name": "TURNSTILE_SECRET_KEY",
2586
+ "required": false,
2587
+ "secret": true,
2588
+ "environments": [
2589
+ "development",
2590
+ "staging",
2591
+ "production"
2592
+ ],
2593
+ "description": "The secret of the hostname's Turnstile widget, sent only to Cloudflare's Siteverify; written by cf_worker_secret_put source=turnstile_secret. Outside development, sign-in won't send codes without it"
2594
+ }
2595
+ ],
2596
+ "actions": [
2597
+ {
2598
+ "tool": "cf_turnstile_widget_create",
2599
+ "gate": "free",
2600
+ "ships": "5"
2601
+ },
2602
+ {
2603
+ "tool": "cf_turnstile_widget_delete",
2604
+ "gate": "two_step",
2605
+ "ships": "5"
2606
+ }
2607
+ ],
2608
+ "rules": [
2609
+ {
2610
+ "id": "sign-in.server-side",
2611
+ "kind": "must",
2612
+ "text": "Sign people in through the app's own endpoints with createSessions: the browser never talks to Supabase Auth and never holds the access token, and codes are typed, never links.",
2613
+ "enforced_by": "review",
2614
+ "probe": "database.sign-in"
2615
+ },
2616
+ {
2617
+ "id": "sign-in.writes-same-origin",
2618
+ "kind": "must",
2619
+ "text": "Refuse a write (anything but GET or HEAD) made with the session cookie unless sameOrigin(request) is true.",
2620
+ "enforced_by": "review",
2621
+ "probe": "database.session-cookie"
2622
+ },
2623
+ {
2624
+ "id": "sign-in.checks-for-bots",
2625
+ "kind": "must",
2626
+ "text": "Outside development, verify a Turnstile token with Siteverify (success, this hostname and the sign-in action) before asking Supabase to send or re-send a sign-in code, and send no code when either Turnstile key is missing.",
2627
+ "enforced_by": "review",
2628
+ "probe": "database.sign-in-bot-check"
2629
+ }
2630
+ ],
2631
+ "probes": [],
2632
+ "proved_by": [
2633
+ "database"
2634
+ ],
2635
+ "offer": {
2636
+ "pattern": "switch_on",
2637
+ "when": "The client wants people to sign in or have accounts: a members' area, saved orders or bookings, a profile, \"my account\", or a page only some people may see.",
2638
+ "say": "Sign-in is already built in: people get a code by email and type it, so there are no passwords to forget or leak. I'll put the pages that need it behind it. Here's what I think needs sign-in; tell me what to change.",
2639
+ "answers_file": "workbench/sign-in.json",
2640
+ "answers_format": "`{ \"format\": 1, \"status\": \"draft\" | \"done\", \"pages\": [{ \"path\": \"/orders\", \"what\": \"<what it shows or does, in the client's words>\", \"who\": \"signed_in\" | \"owner\" | \"admins\", \"fresh\": true | false }] }`, one entry per page or action that needs a signed-in person. `who` is `signed_in` (anyone with an account), `owner` (only the person the thing belongs to, enforced by row-level security on their user id through `dbAs`) or `admins` (only people in `app_admins`). `fresh` is true where it also needs a code typed in the last 15 minutes. Save it as a draft until the client accepts the table. Then build each page with `requireUser`, enforce its `who`, and check `fresh` on the ones marked so.",
2641
+ "questions": [
2642
+ {
2643
+ "id": "sign-in.pages",
2644
+ "sets": [
2645
+ "page.sign_in",
2646
+ "page.who",
2647
+ "page.fresh"
2648
+ ],
2649
+ "ask": "Here are the pages and actions that need someone signed in, who among them may use each one, and which also need a fresh code because they show or change something sensitive. Accept them, or change some?",
2650
+ "style": "table",
2651
+ "note": "Propose from the pages built or planned. Anything that shows or changes a person's own things (orders, bookings, a profile) needs sign-in, with `who` set to `owner`. Signing in only proves who someone is, and anyone can make an account, so a page for staff or admins must say `admins`: `requireUser` alone would let any stranger who signs up use it. Mark `fresh` where a stranger holding a signed-in phone could do harm: showing payment details, changing an email address, adding an admin, deleting an account. The template has no re-check screen: when `fresh` is false, the page tells the person to sign out and sign in again with a new code, as the admins section of `/admin/brand` does. Pages anyone may read, such as the home page, a menu or a contact form, stay open. Only the client knows what is private and who may do what, so always show the table."
2652
+ }
2653
+ ],
2654
+ "not_asked": [
2655
+ {
2656
+ "sets": "sign_in.method",
2657
+ "why": "The template signs people in with an emailed code only (Workbench's choice: no password to forget, leak or reuse), and the authentication standard rules out sign-in links. Passwords and social sign-in (\"sign in with Google\") aren't built. If the client asks for one, say so and that it is the Workbench owner's call; don't build it."
2658
+ },
2659
+ {
2660
+ "sets": "sign_in.length",
2661
+ "why": "Sessions last weeks, with a cap, as the authentication standard (§10) asks. The package's defaults (SESSION_DEFAULTS) sign a person out after 14 days without a visit and 30 days after the code was typed, whatever happens; on a shared device they untick \"keep me signed in\" and are signed out after 30 minutes idle. Say so if the client asks, and don't change them."
2662
+ },
2663
+ {
2664
+ "sets": "sign_in.who",
2665
+ "why": "Anyone with an email address can make an account; invite-only sign-up isn't built yet. If the client needs only invited people, say it isn't built yet and ask them how they want to handle it. Don't build an invite system of your own."
2666
+ },
2667
+ {
2668
+ "sets": "sign_in.bot_check",
2669
+ "why": "The Turnstile check is always on outside development. Control sets it up (cf_turnstile_widget_create, then cf_worker_secret_put sources turnstile_sitekey and turnstile_secret); there is nothing to ask."
2670
+ }
2671
+ ]
2672
+ },
2673
+ "delivery": {
2674
+ "choice": "built_in",
2675
+ "intro": "People signing in to the app with a code emailed to them, with no passwords to forget."
2676
+ }
2677
+ },
2610
2678
  "strings": {
2611
2679
  "$schema": "./schema.json",
2612
2680
  "format": 1,
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings", "ui", "forms"];
2
+ export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings", "ui", "forms", "sign-in"];
3
3
  export type Component = (typeof COMPONENTS)[number];
4
4
  export declare const allowedSchemas: {
5
5
  gateway: z.ZodObject<{
@@ -179,6 +179,7 @@ export declare const capabilitySchema: z.ZodObject<{
179
179
  kv: "kv";
180
180
  pdf: "pdf";
181
181
  r2: "r2";
182
+ "sign-in": "sign-in";
182
183
  strings: "strings";
183
184
  ui: "ui";
184
185
  workers: "workers";
@@ -289,6 +290,31 @@ export declare const capabilitySchema: z.ZodObject<{
289
290
  two_step: z.ZodOptional<z.ZodBoolean>;
290
291
  note: z.ZodOptional<z.ZodString>;
291
292
  }, z.core.$strict>>;
293
+ proved_by: z.ZodOptional<z.ZodArray<z.ZodEnum<{
294
+ access: "access";
295
+ ai: "ai";
296
+ analytics: "analytics";
297
+ config: "config";
298
+ country: "country";
299
+ database: "database";
300
+ dns: "dns";
301
+ domains: "domains";
302
+ email: "email";
303
+ "email-inbound": "email-inbound";
304
+ "email-sending": "email-sending";
305
+ forms: "forms";
306
+ gateway: "gateway";
307
+ github: "github";
308
+ "image-search": "image-search";
309
+ images: "images";
310
+ kv: "kv";
311
+ pdf: "pdf";
312
+ r2: "r2";
313
+ "sign-in": "sign-in";
314
+ strings: "strings";
315
+ ui: "ui";
316
+ workers: "workers";
317
+ }>>>;
292
318
  offer: z.ZodOptional<z.ZodObject<{
293
319
  pattern: z.ZodEnum<{
294
320
  drafts: "drafts";
@@ -25,6 +25,7 @@ export const COMPONENTS = [
25
25
  "strings",
26
26
  "ui",
27
27
  "forms",
28
+ "sign-in",
28
29
  ];
29
30
  const id = z.string().regex(/^[a-z0-9][a-z0-9.-]*$/, "lower case, digits, dots and hyphens");
30
31
  const code = z.string().regex(/^[a-z][a-z0-9_]*$/, "a snake_case error code");
@@ -226,7 +227,14 @@ const baseShape = {
226
227
  errors: z.array(errorEntry).min(1).optional(),
227
228
  rules: z.array(rule).min(1),
228
229
  examples: z.array(example).min(1).optional(),
229
- probes: z.array(probe).min(1),
230
+ /** Empty only with `proved_by`, when every rule's probe lives in those manifests. */
231
+ probes: z.array(probe),
232
+ /**
233
+ * Components whose probes may prove this one's rules, when one run proves
234
+ * both (sign-in's rules are proved by database's sign-in probes, whose ids
235
+ * wb-probe's run history keys on).
236
+ */
237
+ proved_by: z.array(z.enum(COMPONENTS)).min(1).optional(),
230
238
  offer: offer.optional(),
231
239
  /** Why a client has nothing to answer for this component (plumbing). A manifest has `offer` or `no_offer`. */
232
240
  no_offer: z.string().min(1).optional(),
@@ -307,6 +315,8 @@ export function checkManifests(files) {
307
315
  const problems = [];
308
316
  const manifests = [];
309
317
  const globalIds = new Map();
318
+ // A rule may name a probe in a `proved_by` manifest; checked once every file is read.
319
+ const deferred = [];
310
320
  for (const [file, json] of Object.entries(files)) {
311
321
  const parsed = capabilitySchema.safeParse(json);
312
322
  if (!parsed.success) {
@@ -344,10 +354,9 @@ export function checkManifests(files) {
344
354
  seen("limit", l.id);
345
355
  for (const e of m.examples ?? [])
346
356
  seen("example", e.id);
347
- for (const r of m.rules) {
348
- if (!probeIds.has(r.probe))
349
- problems.push({ file, check: "proof", message: `rule ${r.id} names probe ${r.probe}, which isn't in this file` });
350
- }
357
+ deferred.push({ file, m, own: probeIds });
358
+ if (m.probes.length === 0 && !m.proved_by)
359
+ problems.push({ file, check: "proof", message: "has no probes: add one, or name in proved_by the component whose probes prove these rules" });
351
360
  for (const e of m.examples ?? []) {
352
361
  if (!probeIds.has(e.probe))
353
362
  problems.push({ file, check: "proof", message: `example ${e.id} names probe ${e.probe}, which isn't in this file` });
@@ -388,5 +397,21 @@ export function checkManifests(files) {
388
397
  if (m.delivery && !m.offer && !m.delivery.when)
389
398
  problems.push({ file, check: "schema", message: "delivery needs when, because there is no offer to say when the tool applies" });
390
399
  }
400
+ const byComponent = new Map(manifests.map((m) => [m.component, m]));
401
+ for (const { file, m, own } of deferred) {
402
+ const provers = (m.proved_by ?? []).flatMap((c) => {
403
+ const prover = byComponent.get(c);
404
+ if (!prover)
405
+ problems.push({ file, check: "proof", message: `proved_by names ${c}, which isn't among the manifests` });
406
+ else if (m.status === "built" && prover.status !== "built")
407
+ problems.push({ file, check: "proof", message: `proved_by names ${c}, which isn't built` });
408
+ return prover ? [prover] : [];
409
+ });
410
+ for (const r of m.rules) {
411
+ if (own.has(r.probe) || provers.some((p) => p.probes.some((x) => x.id === r.probe)))
412
+ continue;
413
+ problems.push({ file, check: "proof", message: `rule ${r.id} names probe ${r.probe}, which isn't in this file${m.proved_by ? " or a proved_by manifest" : ""}` });
414
+ }
415
+ }
391
416
  return { manifests, problems };
392
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",