History

Add admin Users console page (#1469)

* Add subject table to core schema * Populate subject table from request seam and connection create * Skip the subject sighting read for a principal already seen this window * Export the subject sighting reset for host tests * Add tenant-reach platform view with internal admin reads * Reset the subject sighting memory between platform view seeds * Add /connect/<slug> integration deep link Resolves the slug against the tenant catalog and forwards into the integration detail page's existing add-connection flow. Unknown slugs get a not-found state. Makes the link survive sign-in: reserve connect as an org slug so cloud's callback stops reading it as an org selector, and return self-host's login to the requested path (its gate swaps the page in without ever writing a returnTo). * Use a synthetic placeholder in deep-link test name * Add the admin users API over the platform view * Use synthetic placeholders in admin plane comments and tests * Include account and admin users groups in cloud OpenAPI docs * Include account and admin users groups in served cloud OpenAPI spec * Reset the subject sighting memory between admin users seeds * Add the admin users console section * Fix connect links in the admin users view Two fixes to the /users detail sheet. Exclude non-connectable integrations. The built-in Executor integration is a static, credential-free source with no connect flow, but it was listed under "not connected" with a copyable link to a route that would do nothing. Connectability follows the product integration page, which drops the whole Accounts tab for built-ins rather than disabling a button. Empty authMethods is deliberately not the signal: openapi/mcp/graphql integrations can declare none and still be connectable through createCustomMethod. A connection actually held on a non-connectable integration is still shown as connected. Scope connect links to the sending org. The bare /connect/<slug> form resolves to the recipient default org, so a recipient in several orgs could connect in a different workspace than the admin who sent the link. The link now carries the org the console is scoped to, falling back to the bare form where the route renders no org segment. * Join host identity onto admin users Resolve each user's email and name server-side in the host's own admin plane and surface them in the console. The join key is the host-auth principal id both hosts already bind as the subject external id: the member list's userId, never its membership row id. Cloud reads the org memberships once per request and fetches user detail only for the page's ids; self-host gets email and name inline from Better Auth's member listing. A directory failure leaves users unnamed rather than failing the read. The users table and detail sheet lead with the email when the host resolved one, keep the mono external id as the supporting line, and offer a copy button for each. A row the host cannot name renders exactly as before. * Regenerate route trees after stack linearization * Regenerate cloud route tree with connect route * Add single-user admin read and email identification Adds GET /admin/users/:identifier returning identity and connections in one call, with a distinct AdminUserNotFound (404). The identifier accepts an email as well as the opaque externalId, and ?email= filters both bulk lists. Email resolution is a new reverse direction on the host directory seam, implemented for WorkOS and Better Auth. Matching is case-insensitive on both sides: Better Auth stores emails lower-cased while WorkOS preserves submitted casing. A directory member with no subject row is a 404 — the admin plane reports footprint, not org membership. * Bind the self-host admin gate to the instance org Both admin planes authorized via getActiveMember, which resolves the caller's role in session.activeOrganizationId — a caller-controlled field. Any member could create an org of their own, become its owner, set it active, and pass the gate, while the reads stayed scoped to the instance org: the whole user directory on one plane, and an admin invite code on the other. Extract one requireInstanceAdmin that resolves the role against the instance's own organization id, and apply it to both planes. Also close org creation on a single-org instance as defense in depth; the seed creates its org session-lessly and is unaffected. * Project admin lastHealth down to status and checkedAt The admin plane forwarded the whole stored HealthCheckResult, which carries upstream-derived content: identity (the account's email at the provider), detail (can quote an upstream error body), and responseSample (a bounded sample of the real response body). The field allowlist is applied per column, so allowing lastHealth forwarded everything nested inside it — an admin saw another user's upstream account data. Add an explicit AdminConnectionHealth to the admin contract instead of reusing HealthCheckResult, and project in reads. Seed the tests with a verdict that actually contains those fields so the absence assertions have ground truth. * Bound admin paging and make the platform view read-only throughout Apply a default page size to the admin list reads: offset without limit was a bare OFFSET, which SQLite rejects, and an unpaged listSubjectsWithConnections fanned out over every subject in the tenant. Carry writes: "denied" on the platform executor's base owner context so connections, policies, integrations and oauth are refused at the storage boundary, not just the admin handle. Reach stays bound on those surfaces: widening them would expose every subject's credential item ids. * Fix SSR crash from the admin nav and add org API key revoke The admin nav hook read orgMembersAtom, which is wrapped in refreshOnWindowFocus — a browser-only atom whose signal touches window at read time. Evaluating it during SSR threw ReferenceError synchronously in render, above the routed outlet, so the whole authenticated document fell back to client rendering and the landing page was blank on first paint. Give the atom an explicit server value so its read never runs on the server; the role reads as unknown during SSR and resolves client-side, and the nav still fails closed. Org API keys could be minted and listed but never revoked. Add DELETE /account/org-api-keys/:apiKeyId with the same admin gate as the mint, resolving ownership against the org's own keys so a member's personal key or a sibling org's key cannot be destroyed through it. Self-host and cloudflare refuse it as they already refuse the mint. * Close admin-users test-coverage gaps and stale comments Reserve toolkits as an org slug, with a derived check that fails when a console root segment is added without a reservation. Add the multi-org connect round trip: a recipient in two orgs whose session defaults to the wrong one follows an org-scoped link and the credential lands in the sending workspace. Assert the account and admin-users groups are in the served OpenAPI spec, derive the org-purge table set from the schema, and gate generated route trees in CI. Extract the duplicated cloud session helpers, replace the superseded connect-route TODOs, and follow the rendered connect link instead of only reading it back. * Add an e2e for the admin Users pager Seeds past the page size on self-host (cloud caps a workspace at three member seats) and walks the page boundary: a full first page, a second page of different people, nobody dropped or repeated between them, and Previous returning the same first page. * Type the platform-view write-refusal helper against surface error unions * Forward the org selector header from the shared cloud session helpers

Rhys Sullivan committed 3 months ago

Commit 4a582ad1 parent 5fa146398 files · +12532 −84Browse files

Unified · Split

98 files
  1. .github/workflows/ci.yml +6 −0
  2. apps/cloud/drizzle/0012_woozy_shiva.sql +10 −0
  3. apps/cloud/drizzle/meta/0012_snapshot.json +1367 −0
  4. apps/cloud/drizzle/meta/_journal.json +7 −0
  5. apps/cloud/src/account/org-api-key-revoke.node.test.ts +192 −0
  6. apps/cloud/src/account/workos-account-service.ts +46 −0
  7. apps/cloud/src/admin/admin-users-api.ts +356 −0
  8. apps/cloud/src/admin/admin-users-email.node.test.ts +144 −0
  9. apps/cloud/src/api/protected-api-key-auth.node.test.ts +4 −0
  10. apps/cloud/src/api/protected-jwt-auth.node.test.ts +3 −0
  11. apps/cloud/src/auth/api-keys.node.test.ts +23 −8
  12. apps/cloud/src/auth/api-keys.ts +214 −12
  13. apps/cloud/src/auth/org-api-key-auth.node.test.ts +153 −0
  14. apps/cloud/src/auth/org-api-key-mint.node.test.ts +316 −0
  15. apps/cloud/src/auth/org-selector-auth.node.test.ts +3 −0
  16. apps/cloud/src/auth/workos-auth-provider.ts +90 −10
  17. apps/cloud/src/auth/workos.ts +27 −0
  18. apps/cloud/src/db/executor-schema.ts +16 −0
  19. apps/cloud/src/db/org-deletion.test.ts +100 −1
  20. apps/cloud/src/db/org-deletion.ts +2 −0
  21. apps/cloud/src/extensions/docs.ts +7 −1
  22. apps/cloud/src/extensions/routes.ts +18 −1
  23. apps/cloud/src/mcp/auth.ts +11 −0
  24. apps/cloud/src/routeTree.gen.ts +52 −0
  25. apps/cloud/src/web/shell.tsx +9 −1
  26. apps/host-cloudflare/src/account/account-provider.ts +5 −0
  27. apps/host-cloudflare/tsr.routes.ts +5 −1
  28. apps/host-cloudflare/web/routeTree.gen.ts +25 −0
  29. apps/host-selfhost/src/account/better-auth-account-provider.ts +27 −0
  30. apps/host-selfhost/src/admin/admin-escalation.node.test.ts +184 −0
  31. apps/host-selfhost/src/admin/admin-users-api.ts +225 −0
  32. apps/host-selfhost/src/admin/admin-users.node.test.ts +234 −0
  33. apps/host-selfhost/src/admin/handlers.ts +14 −14
  34. apps/host-selfhost/src/admin/require-admin.ts +126 −0
  35. apps/host-selfhost/src/app.ts +5 −0
  36. apps/host-selfhost/src/auth/better-auth.ts +20 −1
  37. apps/host-selfhost/src/auth/return-to.test.ts +45 −1
  38. apps/host-selfhost/src/auth/return-to.ts +29 −0
  39. apps/host-selfhost/src/subject-sightings.test.ts +82 −0
  40. apps/host-selfhost/web/login.tsx +5 −10
  41. apps/host-selfhost/web/routeTree.gen.ts +48 −0
  42. apps/host-selfhost/web/routes/__root.tsx +10 −1
  43. e2e/cloud/admin-users-console.test.ts +389 −0
  44. e2e/cloud/admin-users.test.ts +322 −0
  45. e2e/cloud/connect-link-multi-org.test.ts +232 −0
  46. e2e/cloud/support/session.ts +205 −0
  47. e2e/cloud/surface-reachability.test.ts +21 −0
  48. e2e/scenarios/connect-deep-link.test.ts +168 −0
  49. e2e/selfhost/admin-users-console.test.ts +330 −0
  50. e2e/selfhost/admin-users-pager.test.ts +167 −0
  51. package.json +1 −0
  52. packages/app/src/routeTree.gen.ts +23 −0
  53. packages/app/tsr.routes.ts +6 −4
  54. packages/core/api/src/account/api.ts +37 −0
  55. packages/core/api/src/account/handlers.ts +18 −0
  56. packages/core/api/src/account/org-slug.test.ts +28 −1
  57. packages/core/api/src/account/org-slug.ts +5 −2
  58. packages/core/api/src/account/service.ts +12 −0
  59. packages/core/api/src/admin/admin-users.test.ts +1070 −0
  60. packages/core/api/src/admin/api.ts +355 −0
  61. packages/core/api/src/admin/handlers.ts +69 −0
  62. packages/core/api/src/admin/reads.ts +348 −0
  63. packages/core/api/src/admin/service.ts +81 −0
  64. packages/core/api/src/client.ts +13 −0
  65. packages/core/api/src/index.ts +16 −0
  66. packages/core/api/src/server.ts +22 −0
  67. packages/core/api/src/server/host-foundation.ts +42 −0
  68. packages/core/api/src/server/scoped-executor.ts +82 −1
  69. packages/core/sdk/src/core-schema.ts +50 −3
  70. packages/core/sdk/src/executor.ts +328 −1
  71. packages/core/sdk/src/host-internal.ts +14 −0
  72. packages/core/sdk/src/index.ts +10 −0
  73. packages/core/sdk/src/owner-policy-reach.test.ts +318 −0
  74. packages/core/sdk/src/owner-policy.ts +78 −1
  75. packages/core/sdk/src/platform-view.test.ts +814 −0
  76. packages/core/sdk/src/subject-registry.test.ts +418 −0
  77. packages/core/sdk/src/subject-registry.ts +0 −0
  78. packages/core/sdk/src/subject-table.test.ts +103 −0
  79. packages/react/src/api/account-atoms.tsx +19 −5
  80. packages/react/src/api/admin-atoms.tsx +53 −0
  81. packages/react/src/api/admin-client.tsx +53 −0
  82. packages/react/src/api/reactivity-keys.tsx +4 −0
  83. packages/react/src/console-routes.test.ts +24 −0
  84. packages/react/src/console-routes.ts +10 −0
  85. packages/react/src/lib/admin-access.test.ts +52 −0
  86. packages/react/src/lib/admin-access.ts +46 −0
  87. packages/react/src/lib/admin-users-display.test.ts +326 −0
  88. packages/react/src/lib/admin-users-display.ts +283 −0
  89. packages/react/src/multiplayer/use-admin-nav.tsx +53 −0
  90. packages/react/src/pages/admin-users.tsx +692 −0
  91. packages/react/src/pages/connect-integration.tsx +109 −0
  92. packages/react/src/pages/integration-detail.tsx +10 −4
  93. packages/react/src/routes/connect-deep-link.test.ts +121 −0
  94. packages/react/src/routes/connect.$integrationSlug.tsx +10 −0
  95. packages/react/src/routes/integrations.$namespace.tsx +20 −1
  96. packages/react/src/routes/routeTree.gen.ts +43 −0
  97. packages/react/src/routes/users.tsx +7 −0
  98. scripts/check-route-trees.ts +138 −0
.github/workflows/ci.yml+6−0
@@ -136,6 +136,12 @@ jobs: · 135 unmodified lines
136136
137137 - run: bun run typecheck
138138
139+ # The committed routeTree.gen.ts files are generated but checked in, and
140+ # they carry @ts-nocheck — so the typecheck above structurally CANNOT see
141+ # a route that the contract declares and the tree lacks. That drift has
142+ # already shipped a live 404 once. Regenerate and diff instead.
143+ - run: bun run check:routes
144+
139145 test:
140146 name: Test
141147 runs-on: blacksmith-4vcpu-ubuntu-2404
apps/cloud/drizzle/0012_woozy_shiva.sql+10−0
@@ -0,0 +1,10 @@
1+CREATE TABLE "subject" (
2+ "external_id" varchar(255) NOT NULL,
3+ "created_at" timestamp NOT NULL,
4+ "last_seen_at" bigint,
5+ "status" text,
6+ "row_id" varchar(255) PRIMARY KEY NOT NULL,
7+ "tenant" varchar(255) NOT NULL
8+);
9+--> statement-breakpoint
10+CREATE UNIQUE INDEX "subject_uidx" ON "subject" USING btree ("tenant","external_id");
apps/cloud/drizzle/meta/0012_snapshot.json+1367−0
@@ -0,0 +1,1367 @@
1+{
2+ "id": "97114994-f0fc-4419-80bf-21c529a6cf0b",
3+ "prevId": "e285be7d-2427-4b1b-8b86-50a80ada25af",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.accounts": {
8+ "name": "accounts",
9+ "schema": "",
10+ "columns": {
11+ "id": {
12+ "name": "id",
13+ "type": "text",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "created_at": {
18+ "name": "created_at",
19+ "type": "timestamp with time zone",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "default": "now()"
23+ }
24+ },
25+ "indexes": {},
26+ "foreignKeys": {},
27+ "compositePrimaryKeys": {},
28+ "uniqueConstraints": {},
29+ "policies": {},
30+ "checkConstraints": {},
31+ "isRLSEnabled": false
32+ },
33+ "public.memberships": {
34+ "name": "memberships",
35+ "schema": "",
36+ "columns": {
37+ "account_id": {
38+ "name": "account_id",
39+ "type": "text",
40+ "primaryKey": false,
41+ "notNull": true
42+ },
43+ "organization_id": {
44+ "name": "organization_id",
45+ "type": "text",
46+ "primaryKey": false,
47+ "notNull": true
48+ },
49+ "created_at": {
50+ "name": "created_at",
51+ "type": "timestamp with time zone",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "default": "now()"
55+ }
56+ },
57+ "indexes": {},
58+ "foreignKeys": {
59+ "memberships_account_id_accounts_id_fk": {
60+ "name": "memberships_account_id_accounts_id_fk",
61+ "tableFrom": "memberships",
62+ "tableTo": "accounts",
63+ "columnsFrom": ["account_id"],
64+ "columnsTo": ["id"],
65+ "onDelete": "cascade",
66+ "onUpdate": "no action"
67+ },
68+ "memberships_organization_id_organizations_id_fk": {
69+ "name": "memberships_organization_id_organizations_id_fk",
70+ "tableFrom": "memberships",
71+ "tableTo": "organizations",
72+ "columnsFrom": ["organization_id"],
73+ "columnsTo": ["id"],
74+ "onDelete": "cascade",
75+ "onUpdate": "no action"
76+ }
77+ },
78+ "compositePrimaryKeys": {
79+ "memberships_account_id_organization_id_pk": {
80+ "name": "memberships_account_id_organization_id_pk",
81+ "columns": ["account_id", "organization_id"]
82+ }
83+ },
84+ "uniqueConstraints": {},
85+ "policies": {},
86+ "checkConstraints": {},
87+ "isRLSEnabled": false
88+ },
89+ "public.organizations": {
90+ "name": "organizations",
91+ "schema": "",
92+ "columns": {
93+ "id": {
94+ "name": "id",
95+ "type": "text",
96+ "primaryKey": true,
97+ "notNull": true
98+ },
99+ "name": {
100+ "name": "name",
101+ "type": "text",
102+ "primaryKey": false,
103+ "notNull": true
104+ },
105+ "slug": {
106+ "name": "slug",
107+ "type": "text",
108+ "primaryKey": false,
109+ "notNull": true
110+ },
111+ "created_at": {
112+ "name": "created_at",
113+ "type": "timestamp with time zone",
114+ "primaryKey": false,
115+ "notNull": true,
116+ "default": "now()"
117+ }
118+ },
119+ "indexes": {
120+ "organizations_slug_unique": {
121+ "name": "organizations_slug_unique",
122+ "columns": [
123+ {
124+ "expression": "slug",
125+ "isExpression": false,
126+ "asc": true,
127+ "nulls": "last"
128+ }
129+ ],
130+ "isUnique": true,
131+ "concurrently": false,
132+ "method": "btree",
133+ "with": {}
134+ }
135+ },
136+ "foreignKeys": {},
137+ "compositePrimaryKeys": {},
138+ "uniqueConstraints": {},
139+ "policies": {},
140+ "checkConstraints": {},
141+ "isRLSEnabled": false
142+ },
143+ "public.blob": {
144+ "name": "blob",
145+ "schema": "",
146+ "columns": {
147+ "namespace": {
148+ "name": "namespace",
149+ "type": "varchar(255)",
150+ "primaryKey": false,
151+ "notNull": true
152+ },
153+ "key": {
154+ "name": "key",
155+ "type": "varchar(255)",
156+ "primaryKey": false,
157+ "notNull": true
158+ },
159+ "value": {
160+ "name": "value",
161+ "type": "text",
162+ "primaryKey": false,
163+ "notNull": true
164+ },
165+ "row_id": {
166+ "name": "row_id",
167+ "type": "varchar(255)",
168+ "primaryKey": true,
169+ "notNull": true
170+ },
171+ "id": {
172+ "name": "id",
173+ "type": "varchar(255)",
174+ "primaryKey": false,
175+ "notNull": true
176+ }
177+ },
178+ "indexes": {
179+ "blob_id_uidx": {
180+ "name": "blob_id_uidx",
181+ "columns": [
182+ {
183+ "expression": "id",
184+ "isExpression": false,
185+ "asc": true,
186+ "nulls": "last"
187+ }
188+ ],
189+ "isUnique": true,
190+ "concurrently": false,
191+ "method": "btree",
192+ "with": {}
193+ }
194+ },
195+ "foreignKeys": {},
196+ "compositePrimaryKeys": {},
197+ "uniqueConstraints": {},
198+ "policies": {},
199+ "checkConstraints": {},
200+ "isRLSEnabled": false
201+ },
202+ "public.connection": {
203+ "name": "connection",
204+ "schema": "",
205+ "columns": {
206+ "integration": {
207+ "name": "integration",
208+ "type": "varchar(255)",
209+ "primaryKey": false,
210+ "notNull": true
211+ },
212+ "name": {
213+ "name": "name",
214+ "type": "varchar(255)",
215+ "primaryKey": false,
216+ "notNull": true
217+ },
218+ "template": {
219+ "name": "template",
220+ "type": "text",
221+ "primaryKey": false,
222+ "notNull": true
223+ },
224+ "provider": {
225+ "name": "provider",
226+ "type": "text",
227+ "primaryKey": false,
228+ "notNull": true
229+ },
230+ "item_ids": {
231+ "name": "item_ids",
232+ "type": "json",
233+ "primaryKey": false,
234+ "notNull": true
235+ },
236+ "identity_label": {
237+ "name": "identity_label",
238+ "type": "text",
239+ "primaryKey": false,
240+ "notNull": false
241+ },
242+ "description": {
243+ "name": "description",
244+ "type": "text",
245+ "primaryKey": false,
246+ "notNull": false
247+ },
248+ "last_health": {
249+ "name": "last_health",
250+ "type": "json",
251+ "primaryKey": false,
252+ "notNull": false
253+ },
254+ "tools_synced_at": {
255+ "name": "tools_synced_at",
256+ "type": "bigint",
257+ "primaryKey": false,
258+ "notNull": false
259+ },
260+ "oauth_client": {
261+ "name": "oauth_client",
262+ "type": "text",
263+ "primaryKey": false,
264+ "notNull": false
265+ },
266+ "oauth_client_owner": {
267+ "name": "oauth_client_owner",
268+ "type": "text",
269+ "primaryKey": false,
270+ "notNull": false
271+ },
272+ "refresh_item_id": {
273+ "name": "refresh_item_id",
274+ "type": "text",
275+ "primaryKey": false,
276+ "notNull": false
277+ },
278+ "expires_at": {
279+ "name": "expires_at",
280+ "type": "bigint",
281+ "primaryKey": false,
282+ "notNull": false
283+ },
284+ "oauth_scope": {
285+ "name": "oauth_scope",
286+ "type": "text",
287+ "primaryKey": false,
288+ "notNull": false
289+ },
290+ "oauth_token_url": {
291+ "name": "oauth_token_url",
292+ "type": "text",
293+ "primaryKey": false,
294+ "notNull": false
295+ },
296+ "provider_state": {
297+ "name": "provider_state",
298+ "type": "json",
299+ "primaryKey": false,
300+ "notNull": false
301+ },
302+ "created_at": {
303+ "name": "created_at",
304+ "type": "timestamp",
305+ "primaryKey": false,
306+ "notNull": true
307+ },
308+ "updated_at": {
309+ "name": "updated_at",
310+ "type": "timestamp",
311+ "primaryKey": false,
312+ "notNull": true
313+ },
314+ "row_id": {
315+ "name": "row_id",
316+ "type": "varchar(255)",
317+ "primaryKey": true,
318+ "notNull": true
319+ },
320+ "tenant": {
321+ "name": "tenant",
322+ "type": "varchar(255)",
323+ "primaryKey": false,
324+ "notNull": true
325+ },
326+ "owner": {
327+ "name": "owner",
328+ "type": "varchar(255)",
329+ "primaryKey": false,
330+ "notNull": true
331+ },
332+ "subject": {
333+ "name": "subject",
334+ "type": "varchar(255)",
335+ "primaryKey": false,
336+ "notNull": true
337+ }
338+ },
339+ "indexes": {
340+ "connection_uidx": {
341+ "name": "connection_uidx",
342+ "columns": [
343+ {
344+ "expression": "tenant",
345+ "isExpression": false,
346+ "asc": true,
347+ "nulls": "last"
348+ },
349+ {
350+ "expression": "owner",
351+ "isExpression": false,
352+ "asc": true,
353+ "nulls": "last"
354+ },
355+ {
356+ "expression": "subject",
357+ "isExpression": false,
358+ "asc": true,
359+ "nulls": "last"
360+ },
361+ {
362+ "expression": "integration",
363+ "isExpression": false,
364+ "asc": true,
365+ "nulls": "last"
366+ },
367+ {
368+ "expression": "name",
369+ "isExpression": false,
370+ "asc": true,
371+ "nulls": "last"
372+ }
373+ ],
374+ "isUnique": true,
375+ "concurrently": false,
376+ "method": "btree",
377+ "with": {}
378+ }
379+ },
380+ "foreignKeys": {},
381+ "compositePrimaryKeys": {},
382+ "uniqueConstraints": {},
383+ "policies": {},
384+ "checkConstraints": {},
385+ "isRLSEnabled": false
386+ },
387+ "public.definition": {
388+ "name": "definition",
389+ "schema": "",
390+ "columns": {
391+ "integration": {
392+ "name": "integration",
393+ "type": "varchar(255)",
394+ "primaryKey": false,
395+ "notNull": true
396+ },
397+ "connection": {
398+ "name": "connection",
399+ "type": "varchar(255)",
400+ "primaryKey": false,
401+ "notNull": true
402+ },
403+ "plugin_id": {
404+ "name": "plugin_id",
405+ "type": "text",
406+ "primaryKey": false,
407+ "notNull": true
408+ },
409+ "name": {
410+ "name": "name",
411+ "type": "text",
412+ "primaryKey": false,
413+ "notNull": true
414+ },
415+ "schema": {
416+ "name": "schema",
417+ "type": "json",
418+ "primaryKey": false,
419+ "notNull": true
420+ },
421+ "created_at": {
422+ "name": "created_at",
423+ "type": "timestamp",
424+ "primaryKey": false,
425+ "notNull": true
426+ },
427+ "row_id": {
428+ "name": "row_id",
429+ "type": "varchar(255)",
430+ "primaryKey": true,
431+ "notNull": true
432+ },
433+ "tenant": {
434+ "name": "tenant",
435+ "type": "varchar(255)",
436+ "primaryKey": false,
437+ "notNull": true
438+ },
439+ "owner": {
440+ "name": "owner",
441+ "type": "varchar(255)",
442+ "primaryKey": false,
443+ "notNull": true
444+ },
445+ "subject": {
446+ "name": "subject",
447+ "type": "varchar(255)",
448+ "primaryKey": false,
449+ "notNull": true
450+ }
451+ },
452+ "indexes": {
453+ "definition_uidx": {
454+ "name": "definition_uidx",
455+ "columns": [
456+ {
457+ "expression": "tenant",
458+ "isExpression": false,
459+ "asc": true,
460+ "nulls": "last"
461+ },
462+ {
463+ "expression": "owner",
464+ "isExpression": false,
465+ "asc": true,
466+ "nulls": "last"
467+ },
468+ {
469+ "expression": "subject",
470+ "isExpression": false,
471+ "asc": true,
472+ "nulls": "last"
473+ },
474+ {
475+ "expression": "integration",
476+ "isExpression": false,
477+ "asc": true,
478+ "nulls": "last"
479+ },
480+ {
481+ "expression": "connection",
482+ "isExpression": false,
483+ "asc": true,
484+ "nulls": "last"
485+ },
486+ {
487+ "expression": "name",
488+ "isExpression": false,
489+ "asc": true,
490+ "nulls": "last"
491+ }
492+ ],
493+ "isUnique": true,
494+ "concurrently": false,
495+ "method": "btree",
496+ "with": {}
497+ }
498+ },
499+ "foreignKeys": {},
500+ "compositePrimaryKeys": {},
501+ "uniqueConstraints": {},
502+ "policies": {},
503+ "checkConstraints": {},
504+ "isRLSEnabled": false
505+ },
506+ "public.integration": {
507+ "name": "integration",
508+ "schema": "",
509+ "columns": {
510+ "slug": {
511+ "name": "slug",
512+ "type": "varchar(255)",
513+ "primaryKey": false,
514+ "notNull": true
515+ },
516+ "plugin_id": {
517+ "name": "plugin_id",
518+ "type": "text",
519+ "primaryKey": false,
520+ "notNull": true
521+ },
522+ "name": {
523+ "name": "name",
524+ "type": "text",
525+ "primaryKey": false,
526+ "notNull": false
527+ },
528+ "description": {
529+ "name": "description",
530+ "type": "text",
531+ "primaryKey": false,
532+ "notNull": false
533+ },
534+ "config": {
535+ "name": "config",
536+ "type": "json",
537+ "primaryKey": false,
538+ "notNull": false
539+ },
540+ "health_check": {
541+ "name": "health_check",
542+ "type": "json",
543+ "primaryKey": false,
544+ "notNull": false
545+ },
546+ "config_revised_at": {
547+ "name": "config_revised_at",
548+ "type": "bigint",
549+ "primaryKey": false,
550+ "notNull": false
551+ },
552+ "can_remove": {
553+ "name": "can_remove",
554+ "type": "boolean",
555+ "primaryKey": false,
556+ "notNull": true,
557+ "default": true
558+ },
559+ "can_refresh": {
560+ "name": "can_refresh",
561+ "type": "boolean",
562+ "primaryKey": false,
563+ "notNull": true,
564+ "default": false
565+ },
566+ "created_at": {
567+ "name": "created_at",
568+ "type": "timestamp",
569+ "primaryKey": false,
570+ "notNull": true
571+ },
572+ "updated_at": {
573+ "name": "updated_at",
574+ "type": "timestamp",
575+ "primaryKey": false,
576+ "notNull": true
577+ },
578+ "row_id": {
579+ "name": "row_id",
580+ "type": "varchar(255)",
581+ "primaryKey": true,
582+ "notNull": true
583+ },
584+ "tenant": {
585+ "name": "tenant",
586+ "type": "varchar(255)",
587+ "primaryKey": false,
588+ "notNull": true
589+ }
590+ },
591+ "indexes": {
592+ "integration_uidx": {
593+ "name": "integration_uidx",
594+ "columns": [
595+ {
596+ "expression": "tenant",
597+ "isExpression": false,
598+ "asc": true,
599+ "nulls": "last"
600+ },
601+ {
602+ "expression": "slug",
603+ "isExpression": false,
604+ "asc": true,
605+ "nulls": "last"
606+ }
607+ ],
608+ "isUnique": true,
609+ "concurrently": false,
610+ "method": "btree",
611+ "with": {}
612+ }
613+ },
614+ "foreignKeys": {},
615+ "compositePrimaryKeys": {},
616+ "uniqueConstraints": {},
617+ "policies": {},
618+ "checkConstraints": {},
619+ "isRLSEnabled": false
620+ },
621+ "public.oauth_client": {
622+ "name": "oauth_client",
623+ "schema": "",
624+ "columns": {
625+ "slug": {
626+ "name": "slug",
627+ "type": "varchar(255)",
628+ "primaryKey": false,
629+ "notNull": true
630+ },
631+ "authorization_url": {
632+ "name": "authorization_url",
633+ "type": "text",
634+ "primaryKey": false,
635+ "notNull": true
636+ },
637+ "token_url": {
638+ "name": "token_url",
639+ "type": "text",
640+ "primaryKey": false,
641+ "notNull": true
642+ },
643+ "grant": {
644+ "name": "grant",
645+ "type": "text",
646+ "primaryKey": false,
647+ "notNull": true
648+ },
649+ "client_id": {
650+ "name": "client_id",
651+ "type": "text",
652+ "primaryKey": false,
653+ "notNull": true
654+ },
655+ "client_secret_item_id": {
656+ "name": "client_secret_item_id",
657+ "type": "text",
658+ "primaryKey": false,
659+ "notNull": false
660+ },
661+ "resource": {
662+ "name": "resource",
663+ "type": "text",
664+ "primaryKey": false,
665+ "notNull": false
666+ },
667+ "origin_kind": {
668+ "name": "origin_kind",
669+ "type": "text",
670+ "primaryKey": false,
671+ "notNull": false
672+ },
673+ "origin_integration": {
674+ "name": "origin_integration",
675+ "type": "text",
676+ "primaryKey": false,
677+ "notNull": false
678+ },
679+ "origin_issuer": {
680+ "name": "origin_issuer",
681+ "type": "text",
682+ "primaryKey": false,
683+ "notNull": false
684+ },
685+ "origin_redirect_uri": {
686+ "name": "origin_redirect_uri",
687+ "type": "text",
688+ "primaryKey": false,
689+ "notNull": false
690+ },
691+ "created_at": {
692+ "name": "created_at",
693+ "type": "timestamp",
694+ "primaryKey": false,
695+ "notNull": true
696+ },
697+ "row_id": {
698+ "name": "row_id",
699+ "type": "varchar(255)",
700+ "primaryKey": true,
701+ "notNull": true
702+ },
703+ "tenant": {
704+ "name": "tenant",
705+ "type": "varchar(255)",
706+ "primaryKey": false,
707+ "notNull": true
708+ },
709+ "owner": {
710+ "name": "owner",
711+ "type": "varchar(255)",
712+ "primaryKey": false,
713+ "notNull": true
714+ },
715+ "subject": {
716+ "name": "subject",
717+ "type": "varchar(255)",
718+ "primaryKey": false,
719+ "notNull": true
720+ }
721+ },
722+ "indexes": {
723+ "oauth_client_uidx": {
724+ "name": "oauth_client_uidx",
725+ "columns": [
726+ {
727+ "expression": "tenant",
728+ "isExpression": false,
729+ "asc": true,
730+ "nulls": "last"
731+ },
732+ {
733+ "expression": "owner",
734+ "isExpression": false,
735+ "asc": true,
736+ "nulls": "last"
737+ },
738+ {
739+ "expression": "subject",
740+ "isExpression": false,
741+ "asc": true,
742+ "nulls": "last"
743+ },
744+ {
745+ "expression": "slug",
746+ "isExpression": false,
747+ "asc": true,
748+ "nulls": "last"
749+ }
750+ ],
751+ "isUnique": true,
752+ "concurrently": false,
753+ "method": "btree",
754+ "with": {}
755+ }
756+ },
757+ "foreignKeys": {},
758+ "compositePrimaryKeys": {},
759+ "uniqueConstraints": {},
760+ "policies": {},
761+ "checkConstraints": {},
762+ "isRLSEnabled": false
763+ },
764+ "public.oauth_session": {
765+ "name": "oauth_session",
766+ "schema": "",
767+ "columns": {
768+ "state": {
769+ "name": "state",
770+ "type": "varchar(255)",
771+ "primaryKey": false,
772+ "notNull": true
773+ },
774+ "client_slug": {
775+ "name": "client_slug",
776+ "type": "text",
777+ "primaryKey": false,
778+ "notNull": true
779+ },
780+ "integration": {
781+ "name": "integration",
782+ "type": "text",
783+ "primaryKey": false,
784+ "notNull": true
785+ },
786+ "name": {
787+ "name": "name",
788+ "type": "text",
789+ "primaryKey": false,
790+ "notNull": true
791+ },
792+ "template": {
793+ "name": "template",
794+ "type": "text",
795+ "primaryKey": false,
796+ "notNull": true
797+ },
798+ "redirect_url": {
799+ "name": "redirect_url",
800+ "type": "text",
801+ "primaryKey": false,
802+ "notNull": true
803+ },
804+ "pkce_verifier": {
805+ "name": "pkce_verifier",
806+ "type": "text",
807+ "primaryKey": false,
808+ "notNull": false
809+ },
810+ "identity_label": {
811+ "name": "identity_label",
812+ "type": "text",
813+ "primaryKey": false,
814+ "notNull": false
815+ },
816+ "payload": {
817+ "name": "payload",
818+ "type": "json",
819+ "primaryKey": false,
820+ "notNull": true
821+ },
822+ "expires_at": {
823+ "name": "expires_at",
824+ "type": "bigint",
825+ "primaryKey": false,
826+ "notNull": true
827+ },
828+ "created_at": {
829+ "name": "created_at",
830+ "type": "timestamp",
831+ "primaryKey": false,
832+ "notNull": true
833+ },
834+ "row_id": {
835+ "name": "row_id",
836+ "type": "varchar(255)",
837+ "primaryKey": true,
838+ "notNull": true
839+ },
840+ "tenant": {
841+ "name": "tenant",
842+ "type": "varchar(255)",
843+ "primaryKey": false,
844+ "notNull": true
845+ },
846+ "owner": {
847+ "name": "owner",
848+ "type": "varchar(255)",
849+ "primaryKey": false,
850+ "notNull": true
851+ },
852+ "subject": {
853+ "name": "subject",
854+ "type": "varchar(255)",
855+ "primaryKey": false,
856+ "notNull": true
857+ }
858+ },
859+ "indexes": {
860+ "oauth_session_uidx": {
861+ "name": "oauth_session_uidx",
862+ "columns": [
863+ {
864+ "expression": "tenant",
865+ "isExpression": false,
866+ "asc": true,
867+ "nulls": "last"
868+ },
869+ {
870+ "expression": "state",
871+ "isExpression": false,
872+ "asc": true,
873+ "nulls": "last"
874+ }
875+ ],
876+ "isUnique": true,
877+ "concurrently": false,
878+ "method": "btree",
879+ "with": {}
880+ }
881+ },
882+ "foreignKeys": {},
883+ "compositePrimaryKeys": {},
884+ "uniqueConstraints": {},
885+ "policies": {},
886+ "checkConstraints": {},
887+ "isRLSEnabled": false
888+ },
889+ "public.plugin_storage": {
890+ "name": "plugin_storage",
891+ "schema": "",
892+ "columns": {
893+ "plugin_id": {
894+ "name": "plugin_id",
895+ "type": "varchar(255)",
896+ "primaryKey": false,
897+ "notNull": true
898+ },
899+ "collection": {
900+ "name": "collection",
901+ "type": "varchar(255)",
902+ "primaryKey": false,
903+ "notNull": true
904+ },
905+ "key": {
906+ "name": "key",
907+ "type": "varchar(255)",
908+ "primaryKey": false,
909+ "notNull": true
910+ },
911+ "data": {
912+ "name": "data",
913+ "type": "json",
914+ "primaryKey": false,
915+ "notNull": true
916+ },
917+ "created_at": {
918+ "name": "created_at",
919+ "type": "timestamp",
920+ "primaryKey": false,
921+ "notNull": true
922+ },
923+ "updated_at": {
924+ "name": "updated_at",
925+ "type": "timestamp",
926+ "primaryKey": false,
927+ "notNull": true
928+ },
929+ "row_id": {
930+ "name": "row_id",
931+ "type": "varchar(255)",
932+ "primaryKey": true,
933+ "notNull": true
934+ },
935+ "tenant": {
936+ "name": "tenant",
937+ "type": "varchar(255)",
938+ "primaryKey": false,
939+ "notNull": true
940+ },
941+ "owner": {
942+ "name": "owner",
943+ "type": "varchar(255)",
944+ "primaryKey": false,
945+ "notNull": true
946+ },
947+ "subject": {
948+ "name": "subject",
949+ "type": "varchar(255)",
950+ "primaryKey": false,
951+ "notNull": true
952+ }
953+ },
954+ "indexes": {
955+ "plugin_storage_uidx": {
956+ "name": "plugin_storage_uidx",
957+ "columns": [
958+ {
959+ "expression": "tenant",
960+ "isExpression": false,
961+ "asc": true,
962+ "nulls": "last"
963+ },
964+ {
965+ "expression": "owner",
966+ "isExpression": false,
967+ "asc": true,
968+ "nulls": "last"
969+ },
970+ {
971+ "expression": "subject",
972+ "isExpression": false,
973+ "asc": true,
974+ "nulls": "last"
975+ },
976+ {
977+ "expression": "plugin_id",
978+ "isExpression": false,
979+ "asc": true,
980+ "nulls": "last"
981+ },
982+ {
983+ "expression": "collection",
984+ "isExpression": false,
985+ "asc": true,
986+ "nulls": "last"
987+ },
988+ {
989+ "expression": "key",
990+ "isExpression": false,
991+ "asc": true,
992+ "nulls": "last"
993+ }
994+ ],
995+ "isUnique": true,
996+ "concurrently": false,
997+ "method": "btree",
998+ "with": {}
999+ }
1000+ },
1001+ "foreignKeys": {},
1002+ "compositePrimaryKeys": {},
1003+ "uniqueConstraints": {},
1004+ "policies": {},
1005+ "checkConstraints": {},
1006+ "isRLSEnabled": false
1007+ },
1008+ "public.private_executor_cloud_settings": {
1009+ "name": "private_executor_cloud_settings",
1010+ "schema": "",
1011+ "columns": {
1012+ "id": {
1013+ "name": "id",
1014+ "type": "varchar(255)",
1015+ "primaryKey": true,
1016+ "notNull": true
1017+ },
1018+ "version": {
1019+ "name": "version",
1020+ "type": "varchar(255)",
1021+ "primaryKey": false,
1022+ "notNull": true,
1023+ "default": "'1.0.0'"
1024+ }
1025+ },
1026+ "indexes": {},
1027+ "foreignKeys": {},
1028+ "compositePrimaryKeys": {},
1029+ "uniqueConstraints": {},
1030+ "policies": {},
1031+ "checkConstraints": {},
1032+ "isRLSEnabled": false
1033+ },
1034+ "public.subject": {
1035+ "name": "subject",
1036+ "schema": "",
1037+ "columns": {
1038+ "external_id": {
1039+ "name": "external_id",
1040+ "type": "varchar(255)",
1041+ "primaryKey": false,
1042+ "notNull": true
1043+ },
1044+ "created_at": {
1045+ "name": "created_at",
1046+ "type": "timestamp",
1047+ "primaryKey": false,
1048+ "notNull": true
1049+ },
1050+ "last_seen_at": {
1051+ "name": "last_seen_at",
1052+ "type": "bigint",
1053+ "primaryKey": false,
1054+ "notNull": false
1055+ },
1056+ "status": {
1057+ "name": "status",
1058+ "type": "text",
1059+ "primaryKey": false,
1060+ "notNull": false
1061+ },
1062+ "row_id": {
1063+ "name": "row_id",
1064+ "type": "varchar(255)",
1065+ "primaryKey": true,
1066+ "notNull": true
1067+ },
1068+ "tenant": {
1069+ "name": "tenant",
1070+ "type": "varchar(255)",
1071+ "primaryKey": false,
1072+ "notNull": true
1073+ }
1074+ },
1075+ "indexes": {
1076+ "subject_uidx": {
1077+ "name": "subject_uidx",
1078+ "columns": [
1079+ {
1080+ "expression": "tenant",
1081+ "isExpression": false,
1082+ "asc": true,
1083+ "nulls": "last"
1084+ },
1085+ {
1086+ "expression": "external_id",
1087+ "isExpression": false,
1088+ "asc": true,
1089+ "nulls": "last"
1090+ }
1091+ ],
1092+ "isUnique": true,
1093+ "concurrently": false,
1094+ "method": "btree",
1095+ "with": {}
1096+ }
1097+ },
1098+ "foreignKeys": {},
1099+ "compositePrimaryKeys": {},
1100+ "uniqueConstraints": {},
1101+ "policies": {},
1102+ "checkConstraints": {},
1103+ "isRLSEnabled": false
1104+ },
1105+ "public.tool": {
1106+ "name": "tool",
1107+ "schema": "",
1108+ "columns": {
1109+ "integration": {
1110+ "name": "integration",
1111+ "type": "varchar(255)",
1112+ "primaryKey": false,
1113+ "notNull": true
1114+ },
1115+ "connection": {
1116+ "name": "connection",
1117+ "type": "varchar(255)",
1118+ "primaryKey": false,
1119+ "notNull": true
1120+ },
1121+ "plugin_id": {
1122+ "name": "plugin_id",
1123+ "type": "text",
1124+ "primaryKey": false,
1125+ "notNull": true
1126+ },
1127+ "name": {
1128+ "name": "name",
1129+ "type": "varchar(255)",
1130+ "primaryKey": false,
1131+ "notNull": true
1132+ },
1133+ "description": {
1134+ "name": "description",
1135+ "type": "text",
1136+ "primaryKey": false,
1137+ "notNull": true
1138+ },
1139+ "input_schema": {
1140+ "name": "input_schema",
1141+ "type": "json",
1142+ "primaryKey": false,
1143+ "notNull": false
1144+ },
1145+ "output_schema": {
1146+ "name": "output_schema",
1147+ "type": "json",
1148+ "primaryKey": false,
1149+ "notNull": false
1150+ },
1151+ "annotations": {
1152+ "name": "annotations",
1153+ "type": "json",
1154+ "primaryKey": false,
1155+ "notNull": false
1156+ },
1157+ "created_at": {
1158+ "name": "created_at",
1159+ "type": "timestamp",
1160+ "primaryKey": false,
1161+ "notNull": true
1162+ },
1163+ "updated_at": {
1164+ "name": "updated_at",
1165+ "type": "timestamp",
1166+ "primaryKey": false,
1167+ "notNull": true
1168+ },
1169+ "row_id": {
1170+ "name": "row_id",
1171+ "type": "varchar(255)",
1172+ "primaryKey": true,
1173+ "notNull": true
1174+ },
1175+ "tenant": {
1176+ "name": "tenant",
1177+ "type": "varchar(255)",
1178+ "primaryKey": false,
1179+ "notNull": true
1180+ },
1181+ "owner": {
1182+ "name": "owner",
1183+ "type": "varchar(255)",
1184+ "primaryKey": false,
1185+ "notNull": true
1186+ },
1187+ "subject": {
1188+ "name": "subject",
1189+ "type": "varchar(255)",
1190+ "primaryKey": false,
1191+ "notNull": true
1192+ }
1193+ },
1194+ "indexes": {
1195+ "tool_uidx": {
1196+ "name": "tool_uidx",
1197+ "columns": [
1198+ {
1199+ "expression": "tenant",
1200+ "isExpression": false,
1201+ "asc": true,
1202+ "nulls": "last"
1203+ },
1204+ {
1205+ "expression": "owner",
1206+ "isExpression": false,
1207+ "asc": true,
1208+ "nulls": "last"
1209+ },
1210+ {
1211+ "expression": "subject",
1212+ "isExpression": false,
1213+ "asc": true,
1214+ "nulls": "last"
1215+ },
1216+ {
1217+ "expression": "integration",
1218+ "isExpression": false,
1219+ "asc": true,
1220+ "nulls": "last"
1221+ },
1222+ {
1223+ "expression": "connection",
1224+ "isExpression": false,
1225+ "asc": true,
1226+ "nulls": "last"
1227+ },
1228+ {
1229+ "expression": "name",
1230+ "isExpression": false,
1231+ "asc": true,
1232+ "nulls": "last"
1233+ }
1234+ ],
1235+ "isUnique": true,
1236+ "concurrently": false,
1237+ "method": "btree",
1238+ "with": {}
1239+ }
1240+ },
1241+ "foreignKeys": {},
1242+ "compositePrimaryKeys": {},
1243+ "uniqueConstraints": {},
1244+ "policies": {},
1245+ "checkConstraints": {},
1246+ "isRLSEnabled": false
1247+ },
1248+ "public.tool_policy": {
1249+ "name": "tool_policy",
1250+ "schema": "",
1251+ "columns": {
1252+ "id": {
1253+ "name": "id",
1254+ "type": "varchar(255)",
1255+ "primaryKey": false,
1256+ "notNull": true
1257+ },
1258+ "pattern": {
1259+ "name": "pattern",
1260+ "type": "text",
1261+ "primaryKey": false,
1262+ "notNull": true
1263+ },
1264+ "action": {
1265+ "name": "action",
1266+ "type": "text",
1267+ "primaryKey": false,
1268+ "notNull": true
1269+ },
1270+ "position": {
1271+ "name": "position",
1272+ "type": "text",
1273+ "primaryKey": false,
1274+ "notNull": true
1275+ },
1276+ "created_at": {
1277+ "name": "created_at",
1278+ "type": "timestamp",
1279+ "primaryKey": false,
1280+ "notNull": true
1281+ },
1282+ "updated_at": {
1283+ "name": "updated_at",
1284+ "type": "timestamp",
1285+ "primaryKey": false,
1286+ "notNull": true
1287+ },
1288+ "row_id": {
1289+ "name": "row_id",
1290+ "type": "varchar(255)",
1291+ "primaryKey": true,
1292+ "notNull": true
1293+ },
1294+ "tenant": {
1295+ "name": "tenant",
1296+ "type": "varchar(255)",
1297+ "primaryKey": false,
1298+ "notNull": true
1299+ },
1300+ "owner": {
1301+ "name": "owner",
1302+ "type": "varchar(255)",
1303+ "primaryKey": false,
1304+ "notNull": true
1305+ },
1306+ "subject": {
1307+ "name": "subject",
1308+ "type": "varchar(255)",
1309+ "primaryKey": false,
1310+ "notNull": true
1311+ }
1312+ },
1313+ "indexes": {
1314+ "tool_policy_uidx": {
1315+ "name": "tool_policy_uidx",
1316+ "columns": [
1317+ {
1318+ "expression": "tenant",
1319+ "isExpression": false,
1320+ "asc": true,
1321+ "nulls": "last"
1322+ },
1323+ {
1324+ "expression": "owner",
1325+ "isExpression": false,
1326+ "asc": true,
1327+ "nulls": "last"
1328+ },
1329+ {
1330+ "expression": "subject",
1331+ "isExpression": false,
1332+ "asc": true,
1333+ "nulls": "last"
1334+ },
1335+ {
1336+ "expression": "id",
1337+ "isExpression": false,
1338+ "asc": true,
1339+ "nulls": "last"
1340+ }
1341+ ],
1342+ "isUnique": true,
1343+ "concurrently": false,
1344+ "method": "btree",
1345+ "with": {}
1346+ }
1347+ },
1348+ "foreignKeys": {},
1349+ "compositePrimaryKeys": {},
1350+ "uniqueConstraints": {},
1351+ "policies": {},
1352+ "checkConstraints": {},
1353+ "isRLSEnabled": false
1354+ }
1355+ },
1356+ "enums": {},
1357+ "schemas": {},
1358+ "sequences": {},
1359+ "roles": {},
1360+ "policies": {},
1361+ "views": {},
1362+ "_meta": {
1363+ "columns": {},
1364+ "schemas": {},
1365+ "tables": {}
1366+ }
1367+}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -85,6 +85,13 @@ · 84 unmodified lines
8585 "when": 1784581232732,
8686 "tag": "0011_tense_caretaker",
8787 "breakpoints": true
88+ },
89+ {
90+ "idx": 12,
91+ "version": "7",
92+ "when": 1785193313601,
93+ "tag": "0012_woozy_shiva",
94+ "breakpoints": true
8895 }
8996 ]
9097 }
apps/cloud/src/account/org-api-key-revoke.node.test.ts+192−0
@@ -0,0 +1,192 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+
4+import { AccountProvider } from "@executor-js/api/server";
5+import { AccountError, AccountForbidden } from "@executor-js/api";
6+
7+import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
8+import { UserStoreService } from "../auth/context";
9+import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
10+import { AutumnService } from "../extensions/billing/service";
11+import { AccountCaller, workosAccountProvider } from "./workos-account-service";
12+
13+// ---------------------------------------------------------------------------
14+// `DELETE /account/org-api-keys/:apiKeyId` at the PROVIDER boundary.
15+//
16+// An org key reads every user and connection in the tenant through `/admin/*`,
17+// so it is the most privileged credential the product issues. Two properties
18+// have to hold together, and only this seam sees both:
19+//
20+// 1. Revoke is gated on org ADMIN membership — the same gate as the mint. A
21+// plain member calling it gets 403 and no delete goes out.
22+// 2. Revoke resolves ownership against the ORG's keys. `revokeApiKey` (the
23+// personal path) checks the caller's OWN key list, so it can never revoke
24+// an org key; conversely this path must never destroy a member's personal
25+// key. The scope-blind `deleteApiKey` would do exactly that if handed a
26+// user key id, so a wrong id has to fail cleanly instead.
27+//
28+// Ownership filtering itself is pinned per-shape in
29+// `auth/org-api-key-mint.node.test.ts`; here it is asserted end-to-end through
30+// the gate, on the provider the HTTP handler actually calls.
31+// ---------------------------------------------------------------------------
32+
33+const ORG = "org_123";
34+const ADMIN = "user_admin";
35+const MEMBER = "user_member";
36+const ORG_KEY = "key_org_1";
37+const USER_KEY = "key_user_1";
38+const createdAt = new Date("2026-01-01T00:00:00.000Z");
39+
40+const session = (accountId: string) => ({
41+ accountId,
42+ email: `${accountId}@example.test`,
43+ name: null,
44+ avatarUrl: null,
45+ organizationId: ORG,
46+ sealedSession: "sealed",
47+ refreshedSession: null,
48+});
49+
50+/** Membership roles: only ADMIN carries the `admin` role slug. */
51+const stubWorkOS = Layer.succeed(
52+ WorkOSClient,
53+ new Proxy({} as WorkOSClientService, {
54+ get: (_target, prop) => {
55+ if (prop === "listUserMemberships") {
56+ return (userId: string) =>
57+ Effect.succeed({
58+ data: [{ userId, organizationId: ORG, status: "active" }],
59+ });
60+ }
61+ if (prop === "getUserOrgMembership") {
62+ return (organizationId: string, userId: string) =>
63+ Effect.succeed(
64+ organizationId === ORG
65+ ? {
66+ id: `om_${userId}`,
67+ userId,
68+ organizationId,
69+ role: { slug: userId === ADMIN ? "admin" : "member" },
70+ }
71+ : null,
72+ );
73+ }
74+ return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
75+ },
76+ }),
77+);
78+
79+const stubUsers = Layer.succeed(UserStoreService)({
80+ use: (fn) =>
81+ Effect.promise(() =>
82+ fn({
83+ ensureAccount: async (id: string) => ({ id, createdAt }),
84+ getAccount: async (id: string) => ({ id, createdAt }),
85+ upsertOrganization: async (org: { id: string; name: string }) => ({
86+ ...org,
87+ slug: org.id,
88+ createdAt,
89+ }),
90+ getOrganization: async (id: string) => ({
91+ id,
92+ name: `Org ${id}`,
93+ slug: id,
94+ createdAt,
95+ }),
96+ getOrganizationBySlug: async (slug: string) => ({
97+ id: slug,
98+ name: `Org ${slug}`,
99+ slug,
100+ createdAt,
101+ }),
102+ deleteOrganizationCascade: async () => {},
103+ }),
104+ ),
105+});
106+
107+const stubAutumn = Layer.succeed(AutumnService)({
108+ use: () => Effect.die("revoke does not touch billing"),
109+ checkExecutionBalance: () => Effect.die("revoke does not touch billing"),
110+ trackExecution: () => Effect.void,
111+});
112+
113+/**
114+ * The provider under test, over an `ApiKeyService` whose org listing holds a
115+ * single org-owned key. Revoked ids land in `revoked` so "no delete happened"
116+ * is assertable, not merely inferred from the error.
117+ */
118+const providerWith = (accountId: string) => {
119+ const revoked: string[] = [];
120+ const stubApiKeys = Layer.succeed(ApiKeyService)({
121+ validate: () => Effect.die("revoke test does not validate keys"),
122+ listUserKeys: () => Effect.succeed([]),
123+ createUserKey: () => Effect.die("revoke test does not create user keys"),
124+ revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"),
125+ listOrgKeys: () => Effect.die("revoke test does not list org keys"),
126+ createOrgKey: () => Effect.die("revoke test does not create org keys"),
127+ revokeOrgKey: ({ keyId }) =>
128+ keyId === ORG_KEY
129+ ? Effect.sync(() => {
130+ revoked.push(keyId);
131+ })
132+ : Effect.fail(new OrgApiKeyNotFound({ keyId })),
133+ });
134+
135+ const provider = AccountProvider.asEffect().pipe(
136+ Effect.provide(
137+ workosAccountProvider.pipe(
138+ Layer.provide(
139+ Layer.mergeAll(
140+ stubWorkOS,
141+ stubUsers,
142+ stubApiKeys,
143+ stubAutumn,
144+ Layer.succeed(AccountCaller)({ session: session(accountId) }),
145+ ),
146+ ),
147+ ),
148+ ),
149+ );
150+
151+ return { provider, revoked };
152+};
153+
154+describe("revokeOrgApiKey · provider boundary", () => {
155+ it.effect("an org admin revokes an org-owned key", () =>
156+ Effect.gen(function* () {
157+ const { provider, revoked } = providerWith(ADMIN);
158+ const account = yield* provider;
159+
160+ const result = yield* account.revokeOrgApiKey({}, ORG_KEY);
161+
162+ expect(result).toEqual({ success: true });
163+ expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]);
164+ }),
165+ );
166+
167+ it.effect("a plain member is forbidden, and nothing is revoked", () =>
168+ Effect.gen(function* () {
169+ const { provider, revoked } = providerWith(MEMBER);
170+ const account = yield* provider;
171+
172+ const error = yield* Effect.flip(account.revokeOrgApiKey({}, ORG_KEY));
173+
174+ expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden);
175+ expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]);
176+ }),
177+ );
178+
179+ it.effect("a user-scoped key id fails cleanly through this path", () =>
180+ Effect.gen(function* () {
181+ // Not found — not a 500, and emphatically not a silent success that would
182+ // report a member's live personal credential as destroyed.
183+ const { provider, revoked } = providerWith(ADMIN);
184+ const account = yield* provider;
185+
186+ const error = yield* Effect.flip(account.revokeOrgApiKey({}, USER_KEY));
187+
188+ expect(error).toBeInstanceOf(AccountError);
189+ expect(revoked).toEqual([]);
190+ }),
191+ );
192+});
apps/cloud/src/account/workos-account-service.ts+46−0
@@ -246,6 +246,52 @@ export const workosAccountProvider: Layer.Layer< · 245 unmodified lines
246246 return { success: true };
247247 }),
248248
249+ // Org keys read every user in the tenant through the `/admin/*` plane, so
250+ // both paths are admin-gated — unlike personal keys, which any member may
251+ // mint for themselves.
252+ listOrgApiKeys: (headers) =>
253+ Effect.gen(function* () {
254+ const { session, org } = yield* requireOrganization(headers);
255+ yield* requireAdmin(session.accountId, org.id);
256+ const keys = yield* apiKeys
257+ .listOrgKeys({ organizationId: org.id })
258+ .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
259+ return { apiKeys: keys };
260+ }),
261+
262+ createOrgApiKey: (headers, name) =>
263+ Effect.gen(function* () {
264+ const { session, org } = yield* requireOrganization(headers);
265+ yield* requireAdmin(session.accountId, org.id);
266+ const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);
267+ if (!trimmed) {
268+ return yield* new AccountError({ message: "API key name is required" });
269+ }
270+ return yield* apiKeys
271+ .createOrgKey({ organizationId: org.id, name: trimmed })
272+ .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
273+ }),
274+
275+ // Same admin gate as the mint. Note this deliberately does NOT reuse
276+ // `revokeApiKey`: that path resolves ownership against the caller's
277+ // personal keys, so an org key id is never in its owned set. Ownership
278+ // here is resolved against the ORG's keys inside `revokeOrgKey`, which is
279+ // what keeps a user-scoped (or foreign-org) key id from being destroyed
280+ // through the org route — it surfaces as a 404-shaped AccountError, not a
281+ // silent success and not a 500.
282+ revokeOrgApiKey: (headers, apiKeyId) =>
283+ Effect.gen(function* () {
284+ const { session, org } = yield* requireOrganization(headers);
285+ yield* requireAdmin(session.accountId, org.id);
286+ yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe(
287+ Effect.catchTag("ApiKeyManagementError", toAccountError),
288+ Effect.catchTag("OrgApiKeyNotFound", () =>
289+ Effect.fail(new AccountError({ message: "Organization API key not found" })),
290+ ),
291+ );
292+ return { success: true };
293+ }),
294+
249295 listMembers: (headers) =>
250296 Effect.gen(function* () {
251297 const { session, org } = yield* requireOrganization(headers);
apps/cloud/src/admin/admin-users-api.ts+356−0
@@ -0,0 +1,356 @@
1+// ---------------------------------------------------------------------------
2+// Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers`
3+// backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`.
4+//
5+// TWO credentials reach this plane, and they are the two an operator actually
6+// has:
7+// 1. an ORG-SCOPED api key -> `PlatformAuth`. The key IS the authority: WorkOS
8+// validated it and reported which org owns it, and there is no member
9+// behind it to check membership for. This is the machine credential
10+// (a customer's backend calling us).
11+// 2. an admin SESSION member -> the console. Requires a live `getUserOrgMembership`
12+// whose role slug is `admin` AND whose status is `active`, matching the
13+// strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) —
14+// a pending admin invite is not an admin.
15+// A plain member session, or a USER-scoped api key, is refused: both name one
16+// acting member, and this plane deliberately serves the whole tenant.
17+//
18+// The executor is built by `makePlatformExecutor` — `{ tenant, subject:
19+// undefined, platformView: true }` — so the reads are tenant-wide and read-only
20+// by storage policy, and no `subject` row is minted for the caller.
21+//
22+// Cross-tenant isolation is structural, not a check in this file: the tenant is
23+// taken from the resolved credential (the key's own org, or the session's
24+// authorized org), never from client input, and `reach: "tenant"` still filters
25+// every query by that tenant.
26+// ---------------------------------------------------------------------------
27+
28+import { HttpRouter } from "effect/unstable/http";
29+import { Context, Effect, Layer, Option } from "effect";
30+
31+import {
32+ AdminUsersProvider,
33+ DbProvider,
34+ HostConfig,
35+ PluginsProvider,
36+ getAdminUser,
37+ listAdminUserConnections,
38+ listAdminUsers,
39+ listAdminUsersWithConnections,
40+ makeAdminUsersApiLayer,
41+ makePlatformExecutor,
42+ normalizeAdminUserEmail,
43+ platformViewOf,
44+ requestScopedMiddleware,
45+ type AdminEmailResolver,
46+ type AdminIdentityDirectory,
47+ type AdminUserDirectory,
48+ type AdminUserIdentity,
49+ type AdminUsersHeaders,
50+} from "@executor-js/api/server";
51+import {
52+ AdminUserNotFound,
53+ AdminUsersError,
54+ AdminUsersForbidden,
55+ AdminUsersUnauthorized,
56+} from "@executor-js/api";
57+import type { Executor } from "@executor-js/sdk";
58+
59+import { ApiKeyService } from "../auth/api-keys";
60+import { UserStoreService } from "../auth/context";
61+import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
62+import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
63+import { WorkOSClient } from "../auth/workos";
64+import { DbService } from "../db/db";
65+import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
66+
67+/**
68+ * Resolve the tenant this request may read, or fail with the neutral 401/403.
69+ *
70+ * Returns only the organization id: nothing downstream needs to know WHICH of
71+ * the two credentials got the caller here, and keeping the acting member out of
72+ * the return value means no admin read can accidentally become subject-scoped.
73+ */
74+const authorizeTenant = (
75+ request: Request,
76+): Effect.Effect<
77+ string,
78+ AdminUsersUnauthorized | AdminUsersForbidden,
79+ WorkOSClient | ApiKeyService | UserStoreService
80+> =>
81+ Effect.gen(function* () {
82+ // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`,
83+ // which rejects org keys for the product plane) is what distinguishes an
84+ // org key from a user key.
85+ const bearer = yield* resolveBearerAuth(request).pipe(
86+ // Every rejected-credential and infra failure collapses to one refusal:
87+ // this plane must not report whether a key exists, belongs to another
88+ // org, or merely lacks privilege.
89+ Effect.catchCause(() => Effect.succeed(null)),
90+ );
91+ if (bearer !== null) {
92+ if (isPlatformAuth(bearer)) return bearer.organizationId;
93+ // A user-scoped key authenticated fine but names one member; the platform
94+ // plane has no honest way to serve it.
95+ return yield* new AdminUsersForbidden();
96+ }
97+
98+ // (2) The session path: a live admin membership in the selected org.
99+ const workos = yield* WorkOSClient;
100+ const session = yield* workos
101+ .authenticateRequest(request)
102+ .pipe(Effect.catchCause(() => Effect.succeed(null)));
103+ if (!session) return yield* new AdminUsersUnauthorized();
104+
105+ const selector = orgSelectorFromRequest(request) ?? session.organizationId;
106+ if (!selector) return yield* new AdminUsersForbidden();
107+ // Re-checks live membership, so the org selector header can only ever name
108+ // an org the caller already belongs to.
109+ const org = yield* authorizeOrganizationSelector(session.userId, selector).pipe(
110+ Effect.catchCause(() => Effect.succeed(null)),
111+ );
112+ if (!org) return yield* new AdminUsersForbidden();
113+
114+ const membership = yield* workos
115+ .getUserOrgMembership(org.id, session.userId)
116+ .pipe(Effect.catchCause(() => Effect.succeed(null)));
117+ // A pending admin invite is not an active admin — require both.
118+ if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") {
119+ return yield* new AdminUsersForbidden();
120+ }
121+ return org.id;
122+ });
123+
124+/**
125+ * How many user-detail reads run at once. Matches the account plane's own
126+ * member listing (`workos-account-service.ts`), which fans out the same way for
127+ * the same reason.
128+ */
129+const IDENTITY_CONCURRENCY = 5;
130+
131+/**
132+ * Cloud's member directory: `externalId` → email/name.
133+ *
134+ * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that
135+ * `workos-auth-provider.ts` binds as `accountId` on every credential path, and
136+ * therefore what the subject table records in `external_id`. The membership's
137+ * own `id` is an `om_...` row id and joins to nothing.
138+ *
139+ * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per
140+ * request and is the authority on who belongs to the org, but WorkOS's
141+ * `listOrganizationMemberships` carries no user detail and offers no
142+ * include/expand — email and name only exist on the user resource. The SDK does
143+ * expose a batched `listUsers({ organizationId })`, but the pinned
144+ * `@executor-js/emulate` WorkOS emulator serves only `GET
145+ * /user_management/users/:id`, so taking that path would leave every cloud e2e
146+ * user unnamed. So: ONE membership read per request, then user detail fetched
147+ * only for the ids ON THIS PAGE — never for the whole org, and never once per
148+ * row of some larger list. An id that is not an active/pending member is not
149+ * fetched at all and reports absent identity, which is the honest answer for a
150+ * member who left while their connections remain.
151+ */
152+const identityDirectory =
153+ (organizationId: string, context: Context.Context<WorkOSClient>): AdminIdentityDirectory =>
154+ (externalIds) =>
155+ Effect.gen(function* () {
156+ const workos = yield* WorkOSClient;
157+ const memberships = yield* workos.listOrgMembers(organizationId);
158+ const wanted = new Set(externalIds);
159+ const memberIds = memberships.data
160+ .map((membership) => membership.userId)
161+ .filter((userId) => wanted.has(userId));
162+
163+ const resolved = yield* Effect.all(
164+ memberIds.map((userId) =>
165+ workos.getUser(userId).pipe(
166+ Effect.map(
167+ (user) =>
168+ [
169+ userId,
170+ {
171+ email: user.email,
172+ displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
173+ },
174+ ] as const,
175+ ),
176+ // One unreadable user must not cost the whole page its names.
177+ Effect.catchCause(() => Effect.succeed(null)),
178+ ),
179+ ),
180+ { concurrency: IDENTITY_CONCURRENCY },
181+ );
182+
183+ const identities = new Map<string, AdminUserIdentity>();
184+ for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]);
185+ return identities;
186+ }).pipe(Effect.provideContext(context));
187+
188+/**
189+ * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id.
190+ *
191+ * WHY THE MEMBER LIST AND NOT A SERVER-SIDE EMAIL QUERY. WorkOS's SDK does
192+ * expose `listUsers({ email })`, which would be one indexed call instead of a
193+ * scan — but the pinned `@executor-js/emulate` WorkOS emulator serves no
194+ * list-users route at all. Its route table was read directly from the shipped
195+ * bundle: under `/user_management` it has only `users/:id`,
196+ * `organization_memberships`, the auth/session routes, and invitations. Taking
197+ * the `listUsers` path would therefore 404 in every cloud e2e run and could
198+ * only be exercised in production — so the lookup is built on the SAME
199+ * membership read the forward join already makes, which the emulator does
200+ * serve.
201+ *
202+ * THE TRADEOFF is an in-memory scan of the org's members, bounded by org size
203+ * and capped by the identity fan-out below. That is acceptable at the sizes
204+ * this plane serves, and it has a real correctness advantage: the membership
205+ * list is the authority on who belongs to THIS org, so a resolver built on it
206+ * cannot return a user from another tenant, which a bare `listUsers({ email })`
207+ * could. If org sizes ever make the scan hurt, the fix is a server-side query
208+ * gated on emulator support, not a cache.
209+ *
210+ * CASING: WorkOS preserves whatever casing an email was created with (and the
211+ * emulator compares byte-exact), so the directory value is normalized here
212+ * before comparison, against an argument the seam already normalized.
213+ */
214+export const emailResolver =
215+ (organizationId: string, context: Context.Context<WorkOSClient>): AdminEmailResolver =>
216+ (email) =>
217+ Effect.gen(function* () {
218+ const workos = yield* WorkOSClient;
219+ const memberships = yield* workos.listOrgMembers(organizationId);
220+ const userIds = memberships.data.map((membership) => membership.userId);
221+
222+ // Short-circuits on the first match: `Effect.findFirst` stops fetching
223+ // once a user's email matches, so the common case costs far fewer reads
224+ // than the member count.
225+ const match = yield* Effect.findFirst(userIds, (userId) =>
226+ workos.getUser(userId).pipe(
227+ Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email),
228+ // One unreadable user must not fail the whole lookup — it simply
229+ // cannot be the match.
230+ Effect.catchCause(() => Effect.succeed(false)),
231+ ),
232+ );
233+ return Option.getOrNull(match);
234+ }).pipe(Effect.provideContext(context));
235+
236+/** Both directions of cloud's directory, built once per authorized request. */
237+const userDirectory = (
238+ organizationId: string,
239+ context: Context.Context<WorkOSClient>,
240+): AdminUserDirectory => ({
241+ identities: identityDirectory(organizationId, context),
242+ resolveEmail: emailResolver(organizationId, context),
243+});
244+
245+/**
246+ * Authorize, then run `body` against the tenant's platform view.
247+ *
248+ * The executor is built per request and closed after, like every other cloud
249+ * execution stack: it holds the per-request postgres socket, which Cloudflare's
250+ * I/O isolation forbids sharing across requests.
251+ */
252+const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>(
253+ headers: AdminUsersHeaders,
254+ body: (executor: Executor, organizationId: string) => Effect.Effect<A, E>,
255+): Effect.Effect<
256+ A,
257+ // `AdminUsersError` unconditionally: opening the platform view can fail that
258+ // way regardless of what `body` itself raises.
259+ E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden,
260+ WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
261+> =>
262+ Effect.gen(function* () {
263+ const organizationId = yield* authorizeTenant(
264+ new Request("https://admin.invalid", { headers }),
265+ );
266+ const executor = yield* makePlatformExecutor(organizationId).pipe(
267+ Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })),
268+ );
269+ // The authorized tenant is handed to the body so an identity join reads the
270+ // SAME org the reads are scoped to — never one named by client input.
271+ return yield* Effect.ensuring(
272+ body(executor, organizationId),
273+ executor.close().pipe(Effect.ignore),
274+ );
275+ });
276+
277+/**
278+ * Cloud's `AdminUsersProvider`, built per request so the platform executor
279+ * closes over the per-request postgres socket.
280+ */
281+export const workosAdminUsersProvider: Layer.Layer<
282+ AdminUsersProvider,
283+ never,
284+ WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
285+> = Layer.effect(AdminUsersProvider)(
286+ Effect.gen(function* () {
287+ const context = yield* Effect.context<
288+ WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
289+ >();
290+ return AdminUsersProvider.of({
291+ listUsers: (headers, options) =>
292+ withPlatformView(headers, (executor, organizationId) =>
293+ platformViewOf(executor).pipe(
294+ Effect.flatMap((admin) =>
295+ listAdminUsers(admin, options, userDirectory(organizationId, context)),
296+ ),
297+ ),
298+ ).pipe(Effect.provideContext(context)),
299+ listUsersWithConnections: (headers, options) =>
300+ withPlatformView(headers, (executor, organizationId) =>
301+ platformViewOf(executor).pipe(
302+ Effect.flatMap((admin) =>
303+ listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)),
304+ ),
305+ ),
306+ ).pipe(Effect.provideContext(context)),
307+ listUserConnections: (headers, externalId) =>
308+ withPlatformView(headers, (executor) =>
309+ platformViewOf(executor).pipe(
310+ Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)),
311+ ),
312+ ).pipe(Effect.provideContext(context)),
313+ getUser: (headers, identifier) =>
314+ withPlatformView(headers, (executor, organizationId) =>
315+ platformViewOf(executor).pipe(
316+ Effect.flatMap((admin) =>
317+ getAdminUser(admin, identifier, userDirectory(organizationId, context)),
318+ ),
319+ ),
320+ ).pipe(Effect.provideContext(context)),
321+ });
322+ }),
323+);
324+
325+// Builds the provider per request, providing it to the handlers. Long-lived
326+// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the
327+// per-request `DbService`/`UserStoreService` (and the execution seams built
328+// over them) are supplied by the combined `requestScopedMiddleware`.
329+const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()(
330+ Effect.gen(function* () {
331+ const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
332+ return (httpEffect) =>
333+ Effect.gen(function* () {
334+ // Built inside the request body so the execution seams close over the
335+ // per-request postgres socket.
336+ const provider = yield* Effect.provide(
337+ AdminUsersProvider.asEffect(),
338+ workosAdminUsersProvider.pipe(Layer.provide(CloudExecutionSeamsLayer)),
339+ );
340+ return yield* Effect.provideService(httpEffect, AdminUsersProvider, provider);
341+ }).pipe(Effect.provideContext(longLived));
342+ }),
343+);
344+
345+/**
346+ * The cloud admin-users route layer, mounted as an app extension under the same
347+ * `/api` prefix as the rest of the cloud router.
348+ */
349+export const makeCloudAdminUsersRoutes = (
350+ rsLive: Layer.Layer<DbService | UserStoreService>,
351+ options: Parameters<typeof makeAdminUsersApiLayer>[1] = {},
352+) =>
353+ makeAdminUsersApiLayer(
354+ AdminUsersProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer,
355+ options,
356+ );
apps/cloud/src/admin/admin-users-email.node.test.ts+144−0
@@ -0,0 +1,144 @@
1+import { expect, it } from "@effect/vitest";
2+import { Data, Effect, Layer } from "effect";
3+
4+import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
5+import { emailResolver } from "./admin-users-api";
6+
7+// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the
8+// subject table records in `external_id`.
9+//
10+// WHY THIS IS BUILT ON THE MEMBERSHIP LIST. WorkOS's SDK exposes
11+// `listUsers({ email })`, but the pinned `@executor-js/emulate` WorkOS emulator
12+// serves no list-users route at all — under `/user_management` it has only
13+// `users/:id`, `organization_memberships`, the auth/session routes, and
14+// invitations. A resolver built on `listUsers` would therefore 404 in every
15+// cloud e2e run. So the lookup reuses the SAME membership read the forward
16+// identity join already makes, which the emulator does serve, and scans it in
17+// memory (bounded by org size).
18+//
19+// The membership list is also the authority on who belongs to THIS org, so the
20+// resolver structurally cannot return a user from another tenant — something a
21+// bare `listUsers({ email })` could do.
22+
23+const ORG = "org_placeholder";
24+
25+/** The failure a real WorkOS read raises for one user. Typed rather than a bare
26+ * `Error`, so the resolver's skip-and-continue behaviour is exercised against
27+ * the shape the client actually fails with. */
28+class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{
29+ readonly userId: string;
30+}> {}
31+
32+/** Members of the org, with the casing WorkOS would have stored. WorkOS
33+ * preserves whatever casing a user was created with (and the emulator
34+ * compares byte-exact), so the mixed-case entry is the realistic case. */
35+const DIRECTORY: Record<string, { readonly email: string | null }> = {
36+ user_ada: { email: "Ada@Placeholder.test" },
37+ user_grace: { email: "grace@placeholder.test" },
38+ // A member the directory cannot name: `getUser` succeeds but carries no
39+ // email. It must never match, and must not break the scan for others.
40+ user_nameless: { email: null },
41+};
42+
43+const stubWorkOS = (calls: string[]) =>
44+ Layer.succeed(
45+ WorkOSClient,
46+ new Proxy({} as WorkOSClientService, {
47+ get: (_target, prop) => {
48+ if (prop === "listOrgMembers") {
49+ return (organizationId: string) => {
50+ calls.push(`listOrgMembers:${organizationId}`);
51+ return Effect.succeed({
52+ data: Object.keys(DIRECTORY).map((userId) => ({ userId, organizationId })),
53+ });
54+ };
55+ }
56+ if (prop === "getUser") {
57+ return (userId: string) => {
58+ calls.push(`getUser:${userId}`);
59+ const user = DIRECTORY[userId];
60+ if (!user) return Effect.die(`unexpected user ${userId}`);
61+ return Effect.succeed(user);
62+ };
63+ }
64+ // A resolver that reaches for any other WorkOS call — notably a
65+ // `listUsers` the emulator cannot serve — is the failure this catches.
66+ return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
67+ },
68+ }),
69+ );
70+
71+const resolve = (email: string, calls: string[]) =>
72+ Effect.gen(function* () {
73+ const context = yield* Effect.context<WorkOSClient>();
74+ return yield* emailResolver(ORG, context)(email);
75+ }).pipe(Effect.provide(stubWorkOS(calls)));
76+
77+it.effect("resolves an email to the member's WorkOS user id", () =>
78+ Effect.gen(function* () {
79+ const calls: string[] = [];
80+ // The seam hands the resolver an already-normalized address; the STORED
81+ // value is mixed-case, so this pins that the directory side is normalized
82+ // too. Without that, `Ada@Placeholder.test` would never match.
83+ expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada");
84+ expect(calls[0]).toBe(`listOrgMembers:${ORG}`);
85+ }),
86+);
87+
88+it.effect("returns null for an email no member holds", () =>
89+ Effect.gen(function* () {
90+ const calls: string[] = [];
91+ expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull();
92+ // It looked at everyone before saying no, and a member with no email
93+ // neither matched nor derailed the scan.
94+ expect(calls).toContain("getUser:user_nameless");
95+ }),
96+);
97+
98+it.effect("stops fetching once it finds the match", () =>
99+ Effect.gen(function* () {
100+ const calls: string[] = [];
101+ // `user_ada` is first in the member list, so a short-circuiting scan must
102+ // never reach the members behind it. This is what keeps the in-memory scan
103+ // acceptable at the sizes this plane serves.
104+ expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada");
105+ expect(calls).not.toContain("getUser:user_grace");
106+ expect(calls).not.toContain("getUser:user_nameless");
107+ }),
108+);
109+
110+it.effect("does not let one unreadable member hide the real match", () =>
111+ Effect.gen(function* () {
112+ const calls: string[] = [];
113+ const failing = Layer.succeed(
114+ WorkOSClient,
115+ new Proxy({} as WorkOSClientService, {
116+ get: (_target, prop) => {
117+ if (prop === "listOrgMembers") {
118+ return () =>
119+ Effect.succeed({
120+ data: [{ userId: "user_broken" }, { userId: "user_grace" }],
121+ });
122+ }
123+ if (prop === "getUser") {
124+ return (userId: string) => {
125+ calls.push(userId);
126+ return userId === "user_broken"
127+ ? Effect.fail(new WorkOSUnavailable({ userId }))
128+ : Effect.succeed(DIRECTORY[userId]!);
129+ };
130+ }
131+ return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
132+ },
133+ }),
134+ );
135+
136+ const resolved = yield* Effect.gen(function* () {
137+ const context = yield* Effect.context<WorkOSClient>();
138+ return yield* emailResolver(ORG, context)("grace@placeholder.test");
139+ }).pipe(Effect.provide(failing));
140+
141+ expect(resolved, "an unreadable member is skipped, not fatal").toBe("user_grace");
142+ expect(calls).toEqual(["user_broken", "user_grace"]);
143+ }),
144+);
apps/cloud/src/api/protected-api-key-auth.node.test.ts+4−0
@@ -13,6 +13,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 12 unmodified lines
1313 Effect.succeed(
1414 value === "valid_user_key"
1515 ? {
16+ scope: "user" as const,
1617 accountId: "user_123",
1718 organizationId: "org_123",
1819 keyId: "api_key_123",
@@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines
2223 listUserKeys: () => Effect.succeed([]),
2324 createUserKey: () => Effect.die("protected API auth test does not create API keys"),
2425 revokeUserKey: () => Effect.void,
26+ listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
27+ createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
28+ revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
2529 });
2630
2731 const stubWorkOS = Layer.succeed(
apps/cloud/src/api/protected-jwt-auth.node.test.ts+3−0
@@ -40,6 +40,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 39 unmodified lines
4040 listUserKeys: () => Effect.succeed([]),
4141 createUserKey: () => Effect.die("JWT auth test does not create API keys"),
4242 revokeUserKey: () => Effect.void,
43+ listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
44+ createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
45+ revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
4346 });
4447
4548 const stubWorkOS = Layer.succeed(
apps/cloud/src/auth/api-keys.node.test.ts+23−8
@@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { · 41 unmodified lines
4242 });
4343
4444 expect(principal).toEqual({
45+ scope: "user",
4546 accountId: "user_123",
4647 organizationId: "org_123",
4748 keyId: "api_key_123",
@@ -66,15 +67,9 @@ describe("ApiKeyService.WorkOS", () => { · 18 unmodified lines
6667 }),
6768 );
6869
69− it.effect("rejects missing, organization-owned, and org-less keys", () =>
70+ it.effect("rejects missing and org-less keys", () =>
7071 Effect.gen(function* () {
7172 const missing = yield* validate({ apiKey: null });
72− const orgOwned = yield* validate({
73− apiKey: {
74− id: "api_key_org",
75− owner: { type: "organization", id: "org_123" },
76− },
77− });
7873 const orgLess = yield* validate({
7974 apiKey: {
8075 id: "api_key_no_org",
@@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines
8378 });
8479
8580 expect(missing).toBeNull();
86− expect(orgOwned).toBeNull();
8781 expect(orgLess).toBeNull();
8882 }),
8983 );
9084
85+ it.effect("resolves organization-owned keys to the org scope with no account", () =>
86+ Effect.gen(function* () {
87+ // An org key USED to fail the decode and come back null — indistinguishable
88+ // from an invalid key. It now resolves explicitly: `owner.id` IS the
89+ // organization, and there is no member behind it.
90+ const principal = yield* validate({
91+ apiKey: {
92+ id: "api_key_org",
93+ owner: { type: "organization", id: "org_123" },
94+ },
95+ });
96+
97+ expect(principal).toEqual({
98+ scope: "org",
99+ accountId: null,
100+ organizationId: "org_123",
101+ keyId: "api_key_org",
102+ });
103+ }),
104+ );
105+
91106 it.effect("lists and creates user-owned keys", () =>
92107 Effect.gen(function* () {
93108 const program = Effect.gen(function* () {
apps/cloud/src/auth/api-keys.ts+214−12
@@ -3,10 +3,24 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; · 2 unmodified lines
33 import { ApiKeyManagementError } from "./errors";
44 import { WorkOSClient } from "./workos";
55
6+/**
7+ * Which view a validated key resolves to.
8+ * - `"user"` — the product view. The key belongs to a member; the request
9+ * binds to that member as the acting subject. Every key today is this.
10+ * - `"org"` — the PLATFORM view. The key belongs to the organization itself,
11+ * so there is no acting member: the request gets tenant-wide READ-ONLY
12+ * reach and no bound subject. Privileged; minted separately (PR 1.5).
13+ */
14+export type ApiKeyScope = "user" | "org";
15+
616 /** The owner an api key resolves to — NOT a full {@link Principal} (no email /
717 * name / roles), so it carries an honest, distinct name. */
818 export type ApiKeyOwner = {
9− readonly accountId: string;
19+ readonly scope: ApiKeyScope;
20+ /** The acting member for a `"user"` key; `null` for an `"org"` key, which
21+ * has no member behind it. Nullable rather than a sentinel so nothing can
22+ * accidentally bind an org key to a subject. */
23+ readonly accountId: string | null;
1024 readonly organizationId: string;
1125 readonly keyId: string;
1226 };
@@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines
2842 readonly cause: unknown;
2943 }> {}
3044
45+/**
46+ * The requested key id is not an org-owned key of this organization — it does
47+ * not exist, belongs to a member rather than the org, or belongs to a different
48+ * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an
49+ * upstream failure) so callers can answer "not found" without a 500.
50+ */
51+export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{
52+ readonly keyId: string;
53+}> {}
54+
55+// WorkOS keys are owned by either a user or an organization. The user shape
56+// carries the member id in `id` and the org in `organization_id`; the org shape
57+// carries the ORG in `id` and no member at all. Both decode here — the
58+// distinction is a branch (`ownerFromApiKey`), not a decode failure, so an
59+// org-owned key is no longer indistinguishable from an invalid one.
3160 const UserApiKeyOwner = Schema.Struct({
3261 type: Schema.Literal("user"),
3362 id: Schema.String,
@@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line
3564 organization_id: Schema.optional(Schema.String),
3665 });
3766
67+const OrgApiKeyOwner = Schema.Struct({
68+ type: Schema.Literal("organization"),
69+ id: Schema.String,
70+});
71+
72+const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]);
73+
3874 const ApiKey = Schema.Struct({
3975 id: Schema.String,
4076 owner: UserApiKeyOwner,
@@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines
4985 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),
5086 });
5187
88+// Validation accepts BOTH owner shapes. The user list/create paths below stay
89+// user-only on purpose (they are the console's personal-key CRUD); the ORG
90+// list/create paths are the separate, privileged surface beside them, gated to
91+// admins at the account-provider boundary.
92+const ValidatedApiKey = Schema.Struct({
93+ id: Schema.String,
94+ owner: WorkOSApiKeyOwner,
95+});
96+
5297 const ValidateApiKeyResponse = Schema.Struct({
53− apiKey: Schema.NullOr(ApiKey),
98+ apiKey: Schema.NullOr(ValidatedApiKey),
5499 });
55100
56101 const RawCreatedApiKey = Schema.Struct({
@@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines
72117 data: Schema.Array(ApiKey),
73118 });
74119
120+// The ORG-owned mirror of `ApiKey` / `RawCreatedApiKey`. A separate shape
121+// rather than a widened one: `summaryFromApiKey` reads the user shape's
122+// `organization_id` to prove the key belongs to the caller's org, and an
123+// org-owned row carries the org in `owner.id` instead. Merging them would mean
124+// one nullable field standing for two different things.
125+const OrgApiKey = Schema.Struct({
126+ id: Schema.String,
127+ owner: OrgApiKeyOwner,
128+ name: Schema.optional(Schema.String),
129+ obfuscatedValue: Schema.optional(Schema.String),
130+ obfuscated_value: Schema.optional(Schema.String),
131+ createdAt: Schema.optional(Schema.String),
132+ created_at: Schema.optional(Schema.String),
133+ updatedAt: Schema.optional(Schema.String),
134+ updated_at: Schema.optional(Schema.String),
135+ lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)),
136+ last_used_at: Schema.optional(Schema.NullOr(Schema.String)),
137+});
138+
139+const RawCreatedOrgApiKey = Schema.Struct({
140+ ...OrgApiKey.fields,
141+ value: Schema.String,
142+});
143+
144+// `data` is decoded element-by-element, NOT as `Schema.Array(OrgApiKey)`: WorkOS
145+// may return user-owned keys alongside org-owned ones, and a whole-array decode
146+// fails on the first non-org element — blanking the entire list rather than
147+// dropping the one row that doesn't belong. Same per-row tolerance the user-key
148+// path gets from `summaryFromApiKey` returning null.
149+const ListOrgApiKeysResponse = Schema.Struct({
150+ data: Schema.Array(Schema.Unknown),
151+});
152+
153+const CreateOrgApiKeyResponse = Schema.Union([
154+ RawCreatedOrgApiKey,
155+ Schema.Struct({ apiKey: RawCreatedOrgApiKey }),
156+ Schema.Struct({ api_key: RawCreatedOrgApiKey }),
157+]);
158+
75159 const CreateApiKeyResponse = Schema.Union([
76160 RawCreatedApiKey,
77161 Schema.Struct({ apiKey: RawCreatedApiKey }),
@@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines
81165 const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);
82166 const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);
83167 const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);
168+const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse);
169+const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey);
170+const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse);
171+
172+const ownerFromApiKey = (apiKey: typeof ValidatedApiKey.Type): ApiKeyOwner | null => {
173+ if (apiKey.owner.type === "organization") {
174+ // An org-owned key IS the org: `owner.id` is the organization, and there
175+ // is no member to act as. This resolves to the platform view.
176+ return {
177+ scope: "org",
178+ accountId: null,
179+ organizationId: apiKey.owner.id,
180+ keyId: apiKey.id,
181+ };
182+ }
183+ const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id;
184+ if (!organizationId) return null;
185+ return {
186+ scope: "user",
187+ accountId: apiKey.owner.id,
188+ organizationId,
189+ keyId: apiKey.id,
190+ };
191+};
84192
85193 const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>
86194 Option.match(decodeValidateApiKeyResponse(value), {
87195 onNone: () => null,
88− onSome: ({ apiKey }) => {
89− if (!apiKey) return null;
90− const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id;
91− if (!organizationId) return null;
92− return {
93− accountId: apiKey.owner.id,
94− organizationId,
95− keyId: apiKey.id,
96− };
97− },
196+ onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null),
98197 });
99198
100199 const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {
@@ -120,6 +219,53 @@ const listFromResponse = (value: unknown): readonly ApiKeySummary[] => · 19 unmodified lines
120219 }),
121220 });
122221
222+// The org-owned mirror of `summaryFromApiKey`. `organizationId` is checked by
223+// the CALLER against the org it asked for: WorkOS's workspace api key is
224+// workspace-wide, so a response naming a different org than the one requested
225+// must not be surfaced as that org's key.
226+const summaryFromOrgApiKey = (
227+ apiKey: typeof OrgApiKey.Type,
228+ organizationId: string,
229+): ApiKeySummary | null => {
230+ if (apiKey.owner.id !== organizationId) return null;
231+ return {
232+ id: apiKey.id,
233+ name: apiKey.name ?? "API key",
234+ obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "",
235+ createdAt: apiKey.createdAt ?? apiKey.created_at ?? "",
236+ updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "",
237+ lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null,
238+ };
239+};
240+
241+const orgListFromResponse = (value: unknown, organizationId: string): readonly ApiKeySummary[] =>
242+ Option.match(decodeListOrgApiKeysResponse(value), {
243+ onNone: () => [],
244+ onSome: ({ data }) =>
245+ data.flatMap((entry) =>
246+ Option.match(decodeOrgApiKey(entry), {
247+ // Not an org-owned key (a user key sharing the listing, or a shape we
248+ // don't recognize) — skip the row, keep the rest.
249+ onNone: () => [],
250+ onSome: (apiKey) => {
251+ const summary = summaryFromOrgApiKey(apiKey, organizationId);
252+ return summary ? [summary] : [];
253+ },
254+ }),
255+ ),
256+ });
257+
258+const orgCreatedFromResponse = (value: unknown, organizationId: string): CreatedApiKey | null =>
259+ Option.match(decodeCreateOrgApiKeyResponse(value), {
260+ onNone: () => null,
261+ onSome: (response) => {
262+ const apiKey =
263+ "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key;
264+ const summary = summaryFromOrgApiKey(apiKey, organizationId);
265+ return summary ? { ...summary, value: apiKey.value } : null;
266+ },
267+ });
268+
123269 const createdFromResponse = (value: unknown): CreatedApiKey | null =>
124270 Option.match(decodeCreateApiKeyResponse(value), {
125271 onNone: () => null,
@@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines
147293 readonly revokeUserKey: (input: {
148294 readonly keyId: string;
149295 }) => Effect.Effect<void, ApiKeyManagementError>;
296+ /**
297+ * The org-owned keys of an organization — the credentials that resolve to
298+ * the read-only platform view. Privileged: callers gate on admin membership
299+ * before reaching this.
300+ */
301+ readonly listOrgKeys: (input: {
302+ readonly organizationId: string;
303+ }) => Effect.Effect<readonly ApiKeySummary[], ApiKeyManagementError>;
304+ readonly createOrgKey: (input: {
305+ readonly organizationId: string;
306+ readonly name: string;
307+ }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>;
308+ /**
309+ * Revoke an org-owned key. Ownership is resolved against the ORG's own key
310+ * listing — never the caller's personal keys — so a member's user key id
311+ * (or another org's key id, since the WorkOS workspace key is
312+ * workspace-wide) cannot be destroyed through this path.
313+ */
314+ readonly revokeOrgKey: (input: {
315+ readonly organizationId: string;
316+ readonly keyId: string;
317+ }) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>;
150318 }
151319 >()("@executor-js/cloud/ApiKeyService") {
152320 static WorkOS = Layer.effect(this)(
@@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines
177345 workos
178346 .deleteApiKey(keyId)
179347 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))),
348+ listOrgKeys: ({ organizationId }) =>
349+ workos.listOrgApiKeys(organizationId).pipe(
350+ Effect.map((response) => orgListFromResponse(response, organizationId)),
351+ Effect.mapError((cause) => new ApiKeyManagementError({ cause })),
352+ ),
353+ createOrgKey: ({ organizationId, name }) =>
354+ workos.createOrgApiKey({ organizationId, name }).pipe(
355+ Effect.mapError((cause) => new ApiKeyManagementError({ cause })),
356+ Effect.flatMap((response) => {
357+ const created = orgCreatedFromResponse(response, organizationId);
358+ return created
359+ ? Effect.succeed(created)
360+ : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" }));
361+ }),
362+ ),
363+ // Ownership is checked against the ORG's decoded key listing before the
364+ // delete goes out. `deleteApiKey` is scope-blind — handed any key id it
365+ // would happily destroy a member's personal credential, or a key
366+ // belonging to a sibling org in the same workspace. The listing already
367+ // drops both of those (`orgListFromResponse` filters on owner type AND
368+ // organization), so filtering through it is the whole authorization.
369+ revokeOrgKey: ({ organizationId, keyId }) =>
370+ Effect.gen(function* () {
371+ const response = yield* workos
372+ .listOrgApiKeys(organizationId)
373+ .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
374+ const owned = orgListFromResponse(response, organizationId);
375+ if (!owned.some((key) => key.id === keyId)) {
376+ return yield* new OrgApiKeyNotFound({ keyId });
377+ }
378+ yield* workos
379+ .deleteApiKey(keyId)
380+ .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
381+ }),
180382 };
181383 }),
182384 );
apps/cloud/src/auth/org-api-key-auth.node.test.ts+153−0
@@ -0,0 +1,153 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+
4+import { ApiKeyService } from "./api-keys";
5+import { UserStoreService } from "./context";
6+import { WorkOSClient, type WorkOSClientService } from "./workos";
7+import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider";
8+
9+// Groundwork for the PRIVILEGED, org-level API key: it resolves to the platform
10+// view (tenant-wide, read-only, NO acting member) rather than to a member
11+// `Principal`. The product surfaces must reject it outright rather than
12+// inventing a subject for it — that is the property these tests pin.
13+
14+const createdAt = new Date("2026-01-01T00:00:00.000Z");
15+
16+const stubApiKeys = Layer.succeed(ApiKeyService)({
17+ validate: (value: string) => {
18+ if (value === "valid_org_key") {
19+ return Effect.succeed({
20+ scope: "org" as const,
21+ accountId: null,
22+ organizationId: "org_123",
23+ keyId: "api_key_org",
24+ });
25+ }
26+ if (value === "valid_user_key") {
27+ return Effect.succeed({
28+ scope: "user" as const,
29+ accountId: "user_123",
30+ organizationId: "org_123",
31+ keyId: "api_key_123",
32+ });
33+ }
34+ return Effect.succeed(null);
35+ },
36+ listUserKeys: () => Effect.succeed([]),
37+ createUserKey: () => Effect.die("org api key test does not create API keys"),
38+ revokeUserKey: () => Effect.void,
39+ listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
40+ createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
41+ revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
42+});
43+
44+const stubWorkOS = Layer.succeed(
45+ WorkOSClient,
46+ new Proxy({} as WorkOSClientService, {
47+ get: (_target, prop) => {
48+ if (prop === "listUserMemberships") {
49+ return (userId: string) =>
50+ Effect.succeed({
51+ data:
52+ userId === "user_123"
53+ ? [{ userId, organizationId: "org_123", status: "active" }]
54+ : [],
55+ });
56+ }
57+ // An org key must NOT trigger a membership check — there is no user to
58+ // check. Any such call dies here, which is the assertion.
59+ return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
60+ },
61+ }),
62+);
63+
64+const stubUsers = Layer.succeed(UserStoreService)({
65+ use: (fn) =>
66+ Effect.promise(() =>
67+ fn({
68+ ensureAccount: async (id: string) => ({ id, createdAt }),
69+ getAccount: async (id: string) => ({ id, createdAt }),
70+ upsertOrganization: async (org: { id: string; name: string }) => ({
71+ ...org,
72+ slug: `org-slug-${org.id}`,
73+ createdAt,
74+ }),
75+ getOrganization: async (id: string) => ({
76+ id,
77+ name: `Org ${id}`,
78+ slug: `org-slug-${id}`,
79+ createdAt,
80+ }),
81+ getOrganizationBySlug: async (slug: string) => ({
82+ id: "org_by_slug",
83+ name: `Org ${slug}`,
84+ slug,
85+ createdAt,
86+ }),
87+ deleteOrganizationCascade: async () => {},
88+ }),
89+ ),
90+});
91+
92+const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers);
93+
94+const bearer = (token: string) =>
95+ new Request("https://executor.test/api/tools", {
96+ headers: { authorization: `Bearer ${token}` },
97+ });
98+
99+describe("org-level API keys", () => {
100+ it.effect("resolve to the platform view with no acting member", () =>
101+ Effect.gen(function* () {
102+ const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers));
103+
104+ expect(isPlatformAuth(auth)).toBe(true);
105+ expect(auth).toEqual({
106+ kind: "platform",
107+ organizationId: "org_123",
108+ organizationName: "Org org_123",
109+ organizationSlug: "org-slug-org_123",
110+ keyId: "api_key_org",
111+ });
112+ // No `accountId` anywhere in the shape: nothing downstream can bind a
113+ // subject from it by accident.
114+ expect(auth).not.toHaveProperty("accountId");
115+ }),
116+ );
117+
118+ it.effect("user keys still resolve to a bound member principal", () =>
119+ Effect.gen(function* () {
120+ const auth = yield* resolveBearerAuth(bearer("valid_user_key")).pipe(Effect.provide(layers));
121+
122+ expect(isPlatformAuth(auth)).toBe(false);
123+ expect(auth).toMatchObject({ accountId: "user_123", organizationId: "org_123" });
124+ }),
125+ );
126+
127+ it.effect("are REJECTED on the product surface rather than bound to a subject", () =>
128+ Effect.gen(function* () {
129+ // THE security property of the api-key half: a product request carrying
130+ // an org key must fail, not silently act as some user.
131+ const error = yield* Effect.flip(
132+ resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(Effect.provide(layers)),
133+ );
134+
135+ expect(error).toMatchObject({
136+ _tag: "Unauthorized",
137+ code: "invalid_api_key",
138+ message: "Organization API keys cannot be used on this endpoint",
139+ });
140+ }),
141+ );
142+
143+ it.effect("do not trigger a user membership check", () =>
144+ Effect.gen(function* () {
145+ // `authorizeOrganization` checks a USER's live membership; there is no
146+ // user here. The WorkOS stub dies on any call other than the user path,
147+ // so a clean resolution proves the org branch never took it.
148+ const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers));
149+
150+ expect(isPlatformAuth(auth)).toBe(true);
151+ }),
152+ );
153+});
apps/cloud/src/auth/org-api-key-mint.node.test.ts+316−0
@@ -0,0 +1,316 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+
4+import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys";
5+import { ApiKeyManagementError } from "./errors";
6+import { WorkOSClient, type WorkOSClientService } from "./workos";
7+
8+// ---------------------------------------------------------------------------
9+// Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged
10+// credential that resolves to the read-only platform view (`/admin/*`).
11+//
12+// The decode boundary is what these pin. WorkOS returns an org-owned key with
13+// the ORG in `owner.id` and no member at all, which is a different shape from
14+// the user-owned keys the console's personal-key CRUD handles. The workspace
15+// api key is also workspace-WIDE, so a response naming a different org than the
16+// one asked for must not be surfaced as that org's key.
17+//
18+// NOTE (emulator gap): the WorkOS emulator's `serializeApiKey` hardcodes
19+// `owner.type: "user"` and exposes no org-key mint route, so this path cannot
20+// be exercised over the wire in e2e today. These stubs encode the REAL WorkOS
21+// API shape (`organizations.createOrganizationApiKey` /
22+// `listOrganizationApiKeys`, per the installed SDK's types).
23+// ---------------------------------------------------------------------------
24+
25+const ORG = "org_123";
26+
27+const orgKeyResponse = (organizationId: string, id: string, name: string) => ({
28+ object: "api_key",
29+ id,
30+ owner: { type: "organization", id: organizationId },
31+ name,
32+ obfuscatedValue: "sk_org…a1b2",
33+ lastUsedAt: null,
34+ permissions: [],
35+ createdAt: "2026-01-01T00:00:00.000Z",
36+ updatedAt: "2026-01-01T00:00:00.000Z",
37+});
38+
39+const stubWorkOS = (overrides: Record<string, unknown> = {}) =>
40+ Layer.succeed(
41+ WorkOSClient,
42+ new Proxy({} as WorkOSClientService, {
43+ get: (_target, prop) => {
44+ const override = overrides[String(prop)];
45+ if (override) return override;
46+ return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
47+ },
48+ }),
49+ );
50+
51+const serviceWith = (overrides: Record<string, unknown>) =>
52+ ApiKeyService.asEffect().pipe(
53+ Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))),
54+ );
55+
56+describe("organization API keys", () => {
57+ it.effect("mints an org-owned key and returns the one-time secret", () =>
58+ Effect.gen(function* () {
59+ const apiKeys = yield* serviceWith({
60+ createOrgApiKey: (params: { organizationId: string; name: string }) =>
61+ Effect.succeed({
62+ ...orgKeyResponse(params.organizationId, "key_org_1", params.name),
63+ value: "sk_org_plaintext",
64+ }),
65+ });
66+
67+ const created = yield* apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" });
68+
69+ expect(created.id).toBe("key_org_1");
70+ expect(created.name).toBe("acme-backend");
71+ expect(created.value, "create returns the one-time plaintext secret").toBe(
72+ "sk_org_plaintext",
73+ );
74+ expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe(
75+ created.value,
76+ );
77+ }),
78+ );
79+
80+ it.effect("lists the organization's own keys, masked", () =>
81+ Effect.gen(function* () {
82+ const apiKeys = yield* serviceWith({
83+ listOrgApiKeys: (organizationId: string) =>
84+ Effect.succeed({
85+ object: "list",
86+ data: [
87+ orgKeyResponse(organizationId, "key_org_1", "acme-backend"),
88+ orgKeyResponse(organizationId, "key_org_2", "ci"),
89+ ],
90+ listMetadata: { before: null, after: null },
91+ }),
92+ });
93+
94+ const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
95+
96+ expect(keys.map((key) => key.name)).toEqual(["acme-backend", "ci"]);
97+ expect(JSON.stringify(keys), "the list never carries a plaintext secret").not.toContain(
98+ "sk_org_plaintext",
99+ );
100+ }),
101+ );
102+
103+ it.effect("drops a key owned by a DIFFERENT organization", () =>
104+ Effect.gen(function* () {
105+ // The WorkOS workspace api key is workspace-wide, so a response naming
106+ // another org must never be surfaced as this org's key.
107+ const apiKeys = yield* serviceWith({
108+ listOrgApiKeys: () =>
109+ Effect.succeed({
110+ object: "list",
111+ data: [
112+ orgKeyResponse(ORG, "key_mine", "mine"),
113+ orgKeyResponse("org_other", "key_theirs", "theirs"),
114+ ],
115+ listMetadata: { before: null, after: null },
116+ }),
117+ });
118+
119+ const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
120+
121+ expect(
122+ keys.map((key) => key.id),
123+ "only this org's key survives",
124+ ).toEqual(["key_mine"]);
125+ }),
126+ );
127+
128+ it.effect("refuses a create response that names another organization", () =>
129+ Effect.gen(function* () {
130+ const apiKeys = yield* serviceWith({
131+ createOrgApiKey: () =>
132+ Effect.succeed({
133+ ...orgKeyResponse("org_other", "key_theirs", "theirs"),
134+ value: "sk_org_plaintext",
135+ }),
136+ });
137+
138+ const error = yield* Effect.flip(
139+ apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }),
140+ );
141+
142+ expect(error).toBeInstanceOf(ApiKeyManagementError);
143+ }),
144+ );
145+
146+ it.effect("ignores a user-owned key in an org-key listing", () =>
147+ Effect.gen(function* () {
148+ // The org and user shapes are decoded separately on purpose; a user key
149+ // appearing here would mean the caller could mistake a member's personal
150+ // credential for a workspace one.
151+ const apiKeys = yield* serviceWith({
152+ listOrgApiKeys: () =>
153+ Effect.succeed({
154+ object: "list",
155+ data: [
156+ orgKeyResponse(ORG, "key_mine", "mine"),
157+ {
158+ object: "api_key",
159+ id: "key_user",
160+ owner: { type: "user", id: "user_1", organization_id: ORG },
161+ name: "personal",
162+ obfuscatedValue: "sk_user…c3d4",
163+ lastUsedAt: null,
164+ createdAt: "2026-01-01T00:00:00.000Z",
165+ updatedAt: "2026-01-01T00:00:00.000Z",
166+ },
167+ ],
168+ listMetadata: { before: null, after: null },
169+ }),
170+ });
171+
172+ const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
173+
174+ expect(
175+ keys.map((key) => key.id),
176+ "a user-owned key is not an org key",
177+ ).toEqual(["key_mine"]);
178+ }),
179+ );
180+});
181+
182+// ---------------------------------------------------------------------------
183+// Revoking an org key. The mint is only half a credential lifecycle: an org key
184+// reads every user and connection in the tenant, so it has to be destroyable.
185+//
186+// Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves
187+// ownership against the CALLER's personal keys, so an org key id is never in
188+// its owned set. It also cannot just call `deleteApiKey` directly: that route is
189+// scope-blind and would happily destroy a member's personal key, or a key
190+// belonging to a sibling org in the same WorkOS workspace. So ownership is
191+// resolved against the ORG's decoded listing first, and that filter IS the
192+// authorization. These pin that filter.
193+// ---------------------------------------------------------------------------
194+
195+describe("revoking an organization API key", () => {
196+ const listing = (...keys: ReadonlyArray<unknown>) => ({
197+ object: "list",
198+ data: keys,
199+ listMetadata: { before: null, after: null },
200+ });
201+
202+ it.effect("revokes a key the organization owns", () =>
203+ Effect.gen(function* () {
204+ const deleted: string[] = [];
205+ const apiKeys = yield* serviceWith({
206+ listOrgApiKeys: (organizationId: string) =>
207+ Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
208+ deleteApiKey: (id: string) =>
209+ Effect.sync(() => {
210+ deleted.push(id);
211+ }),
212+ });
213+
214+ yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" });
215+
216+ expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([
217+ "key_org_1",
218+ ]);
219+ }),
220+ );
221+
222+ it.effect("refuses a USER-owned key id, without deleting anything", () =>
223+ Effect.gen(function* () {
224+ // The regression this guards: a member's personal credential must not be
225+ // destroyable through the org route just because an admin can call it.
226+ const deleted: string[] = [];
227+ const apiKeys = yield* serviceWith({
228+ listOrgApiKeys: () =>
229+ Effect.succeed(
230+ listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), {
231+ object: "api_key",
232+ id: "key_user",
233+ owner: { type: "user", id: "user_1", organization_id: ORG },
234+ name: "personal",
235+ obfuscatedValue: "sk_user…c3d4",
236+ lastUsedAt: null,
237+ createdAt: "2026-01-01T00:00:00.000Z",
238+ updatedAt: "2026-01-01T00:00:00.000Z",
239+ }),
240+ ),
241+ deleteApiKey: (id: string) =>
242+ Effect.sync(() => {
243+ deleted.push(id);
244+ }),
245+ });
246+
247+ const error = yield* Effect.flip(
248+ apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }),
249+ );
250+
251+ expect(error).toBeInstanceOf(OrgApiKeyNotFound);
252+ expect(deleted, "no delete is issued for a key the org does not own").toEqual([]);
253+ }),
254+ );
255+
256+ it.effect("refuses a key belonging to ANOTHER organization", () =>
257+ Effect.gen(function* () {
258+ // The WorkOS workspace key is workspace-wide, so an admin of one org must
259+ // not be able to revoke a sibling org's credential by id.
260+ const deleted: string[] = [];
261+ const apiKeys = yield* serviceWith({
262+ listOrgApiKeys: () =>
263+ Effect.succeed(
264+ listing(
265+ orgKeyResponse(ORG, "key_mine", "mine"),
266+ orgKeyResponse("org_other", "key_theirs", "theirs"),
267+ ),
268+ ),
269+ deleteApiKey: (id: string) =>
270+ Effect.sync(() => {
271+ deleted.push(id);
272+ }),
273+ });
274+
275+ const error = yield* Effect.flip(
276+ apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }),
277+ );
278+
279+ expect(error).toBeInstanceOf(OrgApiKeyNotFound);
280+ expect(deleted).toEqual([]);
281+ }),
282+ );
283+
284+ it.effect("refuses an unknown key id", () =>
285+ Effect.gen(function* () {
286+ const apiKeys = yield* serviceWith({
287+ listOrgApiKeys: () => Effect.succeed(listing()),
288+ deleteApiKey: () => Effect.die("nothing should be deleted"),
289+ });
290+
291+ const error = yield* Effect.flip(
292+ apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }),
293+ );
294+
295+ expect(error).toBeInstanceOf(OrgApiKeyNotFound);
296+ }),
297+ );
298+
299+ it.effect("surfaces an upstream delete failure as a management error", () =>
300+ Effect.gen(function* () {
301+ // A failed delete must not read as a successful revoke — the console
302+ // would tell an admin a live credential is gone.
303+ const apiKeys = yield* serviceWith({
304+ listOrgApiKeys: (organizationId: string) =>
305+ Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
306+ deleteApiKey: () => Effect.fail("workos_unavailable"),
307+ });
308+
309+ const error = yield* Effect.flip(
310+ apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }),
311+ );
312+
313+ expect(error).toBeInstanceOf(ApiKeyManagementError);
314+ }),
315+ );
316+});
apps/cloud/src/auth/org-selector-auth.node.test.ts+3−0
@@ -29,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 28 unmodified lines
2929 listUserKeys: () => Effect.succeed([]),
3030 createUserKey: () => Effect.die("not used"),
3131 revokeUserKey: () => Effect.void,
32+ listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
33+ createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
34+ revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
3235 });
3336
3437 const stubWorkOS = Layer.succeed(
apps/cloud/src/auth/workos-auth-provider.ts+90−10
@@ -47,6 +47,7 @@ import { · 46 unmodified lines
4747 authorizeOrganization,
4848 authorizeOrganizationSelector,
4949 orgSelectorFromRequest,
50+ resolveOrganization,
5051 } from "./organization";
5152 import { UserStoreService } from "./context";
5253 import { sealedSessionDisplayName } from "./middleware";
@@ -103,6 +104,13 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 50 unmodified lines
103104 code: "no_organization",
104105 message: "No organization in access token",
105106 };
107+// An org-level key resolves to the PLATFORM view, which has no acting member.
108+// The product endpoints are bound to one subject, so they reject it outright
109+// rather than inventing a subject for it to act as.
110+const ORG_KEY_ON_PRODUCT_SURFACE = {
111+ code: "invalid_api_key",
112+ message: "Organization API keys cannot be used on this endpoint",
113+};
106114
107115 // A bearer value with three dot-separated segments is a JWT (a WorkOS access
108116 // token from the CLI device-login); anything else is treated as an API key.
@@ -151,13 +159,49 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 42 unmodified lines
151159 });
152160
153161 /**
154− * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT
155− * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS
156− * API key. Returns `null` when there is no `Authorization` header, so the
157− * caller falls through to the sealed-session path. (Kept the historical name,
158− * the re-export and resolver tests reference it.)
162+ * What an ORG-level API key resolves to: the platform view. Deliberately NOT a
163+ * `Principal` — a `Principal` names an acting member (`accountId: string`), and
164+ * an org key has none. Keeping it a separate shape means no product code path
165+ * can accidentally treat it as a member, and the executor built from it binds
166+ * `subject: null` + the read-only tenant reach rather than inventing a subject.
167+ *
168+ * The `/admin/*` mount turns this into an executor with `{ tenant:
169+ * organizationId, subject: undefined, platformView: true }`.
170+ */
171+export interface PlatformAuth {
172+ readonly kind: "platform";
173+ readonly organizationId: string;
174+ readonly organizationName: string;
175+ readonly organizationSlug?: string;
176+ readonly keyId: string;
177+}
178+
179+/** A resolved bearer credential: an acting member, or the org-level platform
180+ * view. `null` means no `Authorization` header at all. */
181+export type BearerAuth = Principal | PlatformAuth | null;
182+
183+export const isPlatformAuth = (value: BearerAuth): value is PlatformAuth =>
184+ value !== null && "kind" in value && value.kind === "platform";
185+
186+/**
187+ * Resolve a `Bearer` credential into either a member `Principal` or the
188+ * org-level {@link PlatformAuth}. Returns `null` when there is no
189+ * `Authorization` header, so the caller falls through to the sealed-session
190+ * path.
191+ *
192+ * The org branch does NOT call `authorizeOrganization`: that checks a USER's
193+ * live membership, and there is no user here. The key itself is the authority —
194+ * WorkOS validated it and reported which org owns it — so the org row is merely
195+ * resolved (mirrored on first read) for its name and slug.
159196 */
160−export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) =>
197+export const resolveBearerAuth = (
198+ request: Request,
199+ jwt: JwtBearerConfig | null = null,
200+): Effect.Effect<
201+ BearerAuth,
202+ Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
203+ WorkOSClient | ApiKeyService | UserStoreService
204+> =>
161205 Effect.gen(function* () {
162206 const authHeader = request.headers.get("authorization");
163207 if (!authHeader) return null;
@@ -172,7 +216,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines
172216 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);
173217
174218 const apiKeys = yield* ApiKeyService;
175− const principal = yield* apiKeys
219+ const owner = yield* apiKeys
176220 .validate(value)
177221 .pipe(
178222 Effect.catchTag("ApiKeyValidationError", () =>
@@ -180,13 +224,28 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 1 unmodified line
180224 ),
181225 );
182226
183− if (!principal) return yield* new Unauthorized(INVALID_API_KEY);
227+ if (!owner) return yield* new Unauthorized(INVALID_API_KEY);
184228
185− const org = yield* authorizeOrganization(principal.accountId, principal.organizationId);
229+ if (owner.scope === "org") {
230+ const org = yield* resolveOrganization(owner.organizationId);
231+ return {
232+ kind: "platform",
233+ organizationId: org.id,
234+ organizationName: org.name,
235+ ...(org.slug === undefined || org.slug === null ? {} : { organizationSlug: org.slug }),
236+ keyId: owner.keyId,
237+ } satisfies PlatformAuth;
238+ }
239+
240+ // A `"user"` key always carries an accountId (see `ownerFromApiKey`); the
241+ // guard keeps the narrowing honest rather than asserting.
242+ if (owner.accountId == null) return yield* new Unauthorized(INVALID_API_KEY);
243+
244+ const org = yield* authorizeOrganization(owner.accountId, owner.organizationId);
186245 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);
187246
188247 return {
189− accountId: principal.accountId,
248+ accountId: owner.accountId,
190249 organizationId: org.id,
191250 organizationName: org.name,
192251 organizationSlug: org.slug,
@@ -197,6 +256,27 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines
197256 } satisfies Principal;
198257 });
199258
259+/**
260+ * The PRODUCT-view bearer resolver: as {@link resolveBearerAuth}, but an
261+ * org-level key is REJECTED rather than downgraded. The product endpoints are
262+ * bound to one acting subject, so there is no honest way to serve them an
263+ * org key — those belong at the `/admin/*` mount instead. (Kept the historical
264+ * name; the re-export and resolver tests reference it.)
265+ */
266+export const resolveApiKeyPrincipal = (
267+ request: Request,
268+ jwt: JwtBearerConfig | null = null,
269+): Effect.Effect<
270+ Principal | null,
271+ Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
272+ WorkOSClient | ApiKeyService | UserStoreService
273+> =>
274+ Effect.gen(function* () {
275+ const auth = yield* resolveBearerAuth(request, jwt);
276+ if (isPlatformAuth(auth)) return yield* new Unauthorized(ORG_KEY_ON_PRODUCT_SURFACE);
277+ return auth;
278+ });
279+
200280 export const resolveSessionPrincipal = (request: Request) =>
201281 Effect.gen(function* () {
202282 const workos = yield* WorkOSClient;
apps/cloud/src/auth/workos.ts+27−0
@@ -544,6 +544,33 @@ const make = Effect.gen(function* () { · 543 unmodified lines
544544 return response.data;
545545 }),
546546
547+ /**
548+ * List the ORGANIZATION-owned api keys of an org. Unlike the user-key
549+ * paths above, org keys are first-class in the installed SDK
550+ * (`organizations.listOrganizationApiKeys`), so no raw escape hatch is
551+ * needed. Returned unknown and decoded in `auth/api-keys.ts` like every
552+ * other key response.
553+ */
554+ listOrgApiKeys: (organizationId: string) =>
555+ use(async (wos) =>
556+ collectWorkOSList(await wos.organizations.listOrganizationApiKeys({ organizationId })),
557+ ),
558+
559+ /**
560+ * Mint an ORGANIZATION-owned api key: the credential that resolves to the
561+ * read-only platform view (`PlatformAuth`) instead of an acting member.
562+ * Privileged — the caller must be an admin of the org, which is enforced at
563+ * the account-provider boundary, not here.
564+ */
565+ createOrgApiKey: (params: { organizationId: string; name: string }) =>
566+ use(
567+ (wos) =>
568+ wos.organizations.createOrganizationApiKey({
569+ organizationId: params.organizationId,
570+ name: params.name,
571+ }) as Promise<unknown>,
572+ ),
573+
547574 deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)),
548575
549576 /** List organization memberships with user details. */
apps/cloud/src/db/executor-schema.ts+16−0
@@ -33,6 +33,22 @@ export const integration = pgTable( · 32 unmodified lines
3333 (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)],
3434 );
3535
36+export const subject = pgTable(
37+ "subject",
38+ {
39+ external_id: varchar("external_id", { length: 255 }).notNull(),
40+ created_at: timestamp("created_at").notNull(),
41+ last_seen_at: bigint("last_seen_at", { mode: "bigint" }),
42+ status: text("status"),
43+ row_id: varchar("row_id", { length: 255 })
44+ .primaryKey()
45+ .notNull()
46+ .$defaultFn(() => createId()),
47+ tenant: varchar("tenant", { length: 255 }).notNull(),
48+ },
49+ (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)],
50+);
51+
3652 export const connection = pgTable(
3753 "connection",
3854 {
apps/cloud/src/db/org-deletion.test.ts+100−1
@@ -11,14 +11,21 @@ · 10 unmodified lines
1111 // - a second org's data is completely untouched
1212 // - the blob prefix match escapes LIKE wildcards (a `_` in the org id must
1313 // not widen the match to a look-alike namespace)
14+//
15+// Plus a SCHEMA-DRIFT guard (see "purge coverage" below): the purge list is
16+// hand-written, so a table added to the schema without being added to it would
17+// leave that org's rows behind forever, silently. The guard derives the set of
18+// org-owned tables from the schema itself rather than restating it.
1419
1520 import { describe, it, expect } from "@effect/vitest";
1621 import { Effect } from "effect";
17−import { eq, inArray } from "drizzle-orm";
22+import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm";
1823
1924 import { DbService } from "./db";
2025 import type { DrizzleDb } from "./db";
2126 import { makeUserStore } from "../auth/user-store";
27+import * as cloudSchema from "./schema";
28+import * as executorSchema from "./executor-schema";
2229 import { memberships, accounts } from "./schema";
2330 import {
2431 blob,
@@ -28,6 +35,7 @@ import { · 3 unmodified lines
2835 oauth_client,
2936 oauth_session,
3037 plugin_storage,
38+ subject,
3139 tool,
3240 tool_policy,
3341 } from "./executor-schema";
@@ -131,6 +139,13 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { · 97 unmodified lines
131139 subject: "s",
132140 });
133141
142+ await db.insert(subject).values({
143+ external_id: `acct-${tag}`,
144+ created_at: now,
145+ last_seen_at: BigInt(now.getTime()),
146+ tenant,
147+ });
148+
134149 const orgNs = `o:${tenant}/plugin`;
135150 const userNs = `u:${tenant}:subject/plugin`;
136151 await db.insert(blob).values({
@@ -156,8 +171,25 @@ const TENANT_TABLES = [ · 19 unmodified lines
156171 definition,
157172 tool_policy,
158173 plugin_storage,
174+ subject,
159175 ] as const;
160176
177+// Tables that are NOT purged by org id, each with the reason it is exempt. Any
178+// schema table not carrying a `tenant` column must be listed here — that is
179+// what makes the drift guard below total rather than a spot check.
180+const NOT_ORG_OWNED: Record<string, string> = {
181+ // Purged, but by NAMESPACE PREFIX (`o:<org>/…` / `u:<org>:…`) rather than a
182+ // tenant column, so it cannot be checked by the column rule.
183+ blob: "org-scoped by namespace prefix, purged via the LIKE match",
184+ // Instance-wide, not owned by any org.
185+ private_executor_cloud_settings: "singleton instance settings, not org-scoped",
186+ // Identity mirror. `organizations` is deleted directly and `memberships`
187+ // cascades from its FK; `accounts` deliberately outlives the org.
188+ organizations: "the identity row itself, deleted directly",
189+ memberships: "cascades from the organizations FK",
190+ accounts: "shared across orgs — deliberately survives",
191+};
192+
161193 const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => {
162194 let total = 0;
163195 for (const table of TENANT_TABLES) {
@@ -249,3 +281,70 @@ describe("purgeOrganizationData", () => { · 85 unmodified lines
249281 );
250282 }, 20_000);
251283 });
284+
285+// ---------------------------------------------------------------------------
286+// Purge coverage vs the schema
287+// ---------------------------------------------------------------------------
288+//
289+// The purge list in `org-deletion.ts` is hand-written, one `tx.delete(...)` per
290+// table. Adding a tenant table to the schema and forgetting that line leaves
291+// every deleted org's rows in it forever — no error, no failing test, and the
292+// data is exactly what a deletion was supposed to remove.
293+//
294+// So the expected set is DERIVED: a `tenant` column is what makes a table
295+// org-owned, and every table without one must be named in `NOT_ORG_OWNED` with
296+// its reason. A new table lands in one of the two buckets automatically, and
297+// both buckets fail loudly until it is handled.
298+describe("purge coverage", () => {
299+ // Every drizzle table the cloud DB is built from (the same two modules
300+ // `combinedSchema` spreads), by table name.
301+ const allTables = Object.values({ ...cloudSchema, ...executorSchema });
302+
303+ const orgOwnedTables = allTables.filter((table) => "tenant" in getTableColumns(table));
304+ const orgOwnedNames = orgOwnedTables.map(getTableName).sort();
305+ const seededNames = TENANT_TABLES.map(getTableName).sort();
306+
307+ it("classifies every schema table as org-owned or explicitly exempt", () => {
308+ const unclassified = allTables
309+ .map(getTableName)
310+ .filter((name) => !orgOwnedNames.includes(name) && !(name in NOT_ORG_OWNED));
311+ expect(
312+ unclassified,
313+ "a new table must either carry `tenant` (and be purged) or be listed in NOT_ORG_OWNED with a reason",
314+ ).toEqual([]);
315+ });
316+
317+ it("purges every org-owned table in the schema", () => {
318+ // `TENANT_TABLES` is what this suite seeds and then asserts is empty after
319+ // the purge, so keeping it equal to the schema-derived set is what makes
320+ // the cascade test above cover the WHOLE schema rather than a snapshot of
321+ // it. A tenant table added tomorrow fails here first — the fix is to seed
322+ // it, which then fails the cascade test until `purgeOrganizationData`
323+ // deletes it too.
324+ expect(seededNames, "every org-owned table is seeded and checked by the cascade test").toEqual(
325+ orgOwnedNames,
326+ );
327+ });
328+
329+ it("seeds a row in every org-owned table, so the cascade assertions are not vacuous", async () => {
330+ // Guards the other half: a table listed in TENANT_TABLES but never seeded
331+ // would count zero rows before AND after the purge, passing while proving
332+ // nothing. Assert the seed actually populates each one.
333+ const tenant = `org_seed_${crypto.randomUUID().slice(0, 8)}`;
334+ const empty = await program(
335+ Effect.gen(function* () {
336+ const { db } = yield* DbService;
337+ return yield* Effect.promise(async () => {
338+ await seedTenant(db, tenant, `seed${crypto.randomUUID().slice(0, 6)}`);
339+ const missing: Array<string> = [];
340+ for (const table of TENANT_TABLES) {
341+ const rows = await db.select().from(table).where(eq(table.tenant, tenant));
342+ if (rows.length === 0) missing.push(getTableName(table));
343+ }
344+ return missing;
345+ });
346+ }),
347+ );
348+ expect(empty, "seedTenant must insert a row into every table the cascade checks").toEqual([]);
349+ }, 20_000);
350+});
apps/cloud/src/db/org-deletion.ts+2−0
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
2323 oauth_client,
2424 oauth_session,
2525 plugin_storage,
26+ subject,
2627 tool,
2728 tool_policy,
2829 } from "./executor-schema";
@@ -48,6 +49,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr · 19 unmodified lines
4849 await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId));
4950 await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId));
5051 await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId));
52+ await tx.delete(subject).where(eq(subject.tenant, organizationId));
5153
5254 // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by
5355 // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>`
apps/cloud/src/extensions/docs.ts+7−1
@@ -2,12 +2,18 @@ import { Effect, Layer } from "effect"; · 1 unmodified line
22 import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
33 import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
44
5+import { AccountApi, AdminUsersApi } from "@executor-js/api";
6+
57 import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";
68 import { OrgApi } from "../org/api";
79
810 import { ProtectedCloudApi } from "../api/layers";
911
10−export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi).add(CloudAuthApi).add(OrgApi);
12+export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi)
13+ .add(CloudAuthApi)
14+ .add(OrgApi)
15+ .add(AccountApi)
16+ .add(AdminUsersApi);
1117
1218 const spec = OpenApi.fromApi(CloudOpenApi);
1319
apps/cloud/src/extensions/routes.ts+18−1
@@ -24,6 +24,7 @@ import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; · 23 unmodified lines
2424 import { HttpApiBuilder } from "effect/unstable/httpapi";
2525 import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
2626
27+import { AccountApi, AdminUsersApi } from "@executor-js/api";
2728 import { requestScopedMiddleware } from "@executor-js/api/server";
2829
2930 import { UserStoreService } from "../auth/context";
@@ -34,6 +35,7 @@ import { · 4 unmodified lines
3435 } from "../auth/handlers";
3536 import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";
3637 import { SessionAuthLive } from "../auth/middleware-live";
38+import { makeCloudAdminUsersRoutes } from "../admin/admin-users-api";
3739 import { OrgApi, OrgHttpApi } from "../org/api";
3840 import { orgAuthMiddleware } from "../org/auth-middleware";
3941 import { OrgHandlers } from "../org/handlers";
@@ -55,6 +57,8 @@ const apiPrefixedRouter = Layer.effect(HttpRouter.HttpRouter)( · 15 unmodified lines
5557 const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi)
5658 .add(CloudAuthApi)
5759 .add(OrgApi)
60+ .add(AccountApi)
61+ .add(AdminUsersApi)
5862 .prefix("/api");
5963
6064 const spec = OpenApi.fromApi(CloudOpenApi);
@@ -100,5 +104,18 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 39 unmodified lines
100104
101105 const BillingRoutes = AutumnRoutesLive.pipe(Layer.provide(requestScopedMiddleware(rsLive).layer));
102106
103− return [SessionRoutes, OrgRoutes, DocsRoutes, BillingRoutes, ApiErrorLoggingLive] as const;
107+ // The tenant-wide admin plane (`/api/admin/users*`). Mounted as an extension
108+ // rather than on the protected API because the protected plane's middleware
109+ // binds a product-view executor to one acting member — this one authorizes an
110+ // org key (or an admin session) and builds a subject-less platform view.
111+ const AdminUsersRoutes = makeCloudAdminUsersRoutes(rsLive, { router: apiPrefixedRouter });
112+
113+ return [
114+ SessionRoutes,
115+ OrgRoutes,
116+ AdminUsersRoutes,
117+ DocsRoutes,
118+ BillingRoutes,
119+ ApiErrorLoggingLive,
120+ ] as const;
104121 };
apps/cloud/src/mcp/auth.ts+11−0
@@ -248,6 +248,17 @@ export const McpAuthLive = Layer.effect( · 247 unmodified lines
248248 return mcpUnauthorized("invalid_token", "The API key is invalid");
249249 }
250250
251+ // An ORG-level key resolves to the read-only platform view and has no
252+ // acting member. Every MCP session binds to one subject, so there is
253+ // nothing honest to bind here — reject rather than invent a subject.
254+ if (principal.scope === "org" || principal.accountId == null) {
255+ yield* Effect.annotateCurrentSpan({
256+ "mcp.auth.outcome": "invalid",
257+ "mcp.auth.invalid_reason": "org_api_key",
258+ });
259+ return mcpUnauthorized("invalid_token", "Organization API keys cannot open an MCP session");
260+ }
261+
251262 yield* Effect.annotateCurrentSpan({
252263 "mcp.auth.outcome": "verified",
253264 "mcp.auth.credential_type": "api_key",
apps/cloud/src/routeTree.gen.ts+52−0
@@ -13,6 +13,7 @@ import { Route as SetupMcpRouteImport } from './routes/bare/setup-mcp' · 12 unmodified lines
1313 import { Route as LoginRouteImport } from './routes/bare/login'
1414 import { Route as CreateOrgRouteImport } from './routes/bare/create-org'
1515 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
16+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
1617 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
1718 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
1819 import { Route as SecretsRouteImport } from './routes/app/secrets'
@@ -23,6 +24,7 @@ import { Route as ApiKeysRouteImport } from './routes/app/api-keys' · 4 unmodified lines
2324 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
2425 import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
2526 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
27+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2628 import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
2729 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
2830
@@ -47,6 +49,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 18 unmodified lines
4749 path: '/{-$orgSlug}/',
4850 getParentRoute: () => rootRouteImport,
4951 } as any)
52+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute =
53+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({
54+ id: '/{-$orgSlug}/users',
55+ path: '/{-$orgSlug}/users',
56+ getParentRoute: () => rootRouteImport,
57+ } as any)
5058 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =
5159 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({
5260 id: '/{-$orgSlug}/tools',
@@ -107,6 +115,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 54 unmodified lines
107115 getParentRoute: () => rootRouteImport,
108116 } as any,
109117 )
118+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
119+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
120+ {
121+ id: '/{-$orgSlug}/connect/$integrationSlug',
122+ path: '/{-$orgSlug}/connect/$integrationSlug',
123+ getParentRoute: () => rootRouteImport,
124+ } as any,
125+ )
110126 const Billing_DotplansRoute = Billing_DotplansRouteImport.update({
111127 id: '/{-$orgSlug}/billing_/plans',
112128 path: '/{-$orgSlug}/billing/plans',
@@ -132,8 +148,10 @@ export interface FileRoutesByFullPath { · 19 unmodified lines
132148 '/{-$orgSlug}/secrets': typeof SecretsRoute
133149 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
134150 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
151+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
135152 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
136153 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
154+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
137155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
138156 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
139157 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -150,8 +168,10 @@ export interface FileRoutesByTo { · 10 unmodified lines
150168 '/{-$orgSlug}/secrets': typeof SecretsRoute
151169 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
152170 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
171+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
153172 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
154173 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
174+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
155175 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
156176 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
157177 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -169,8 +189,10 @@ export interface FileRoutesById { · 11 unmodified lines
169189 '/{-$orgSlug}/secrets': typeof SecretsRoute
170190 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
171191 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
192+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
172193 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
173194 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
195+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
174196 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
175197 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176198 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -189,8 +211,10 @@ export interface FileRouteTypes { · 12 unmodified lines
189211 | '/{-$orgSlug}/secrets'
190212 | '/{-$orgSlug}/toolkits'
191213 | '/{-$orgSlug}/tools'
214+ | '/{-$orgSlug}/users'
192215 | '/{-$orgSlug}/'
193216 | '/{-$orgSlug}/billing/plans'
217+ | '/{-$orgSlug}/connect/$integrationSlug'
194218 | '/{-$orgSlug}/integrations/$namespace'
195219 | '/{-$orgSlug}/resume/$executionId'
196220 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -207,8 +231,10 @@ export interface FileRouteTypes { · 10 unmodified lines
207231 | '/{-$orgSlug}/secrets'
208232 | '/{-$orgSlug}/toolkits'
209233 | '/{-$orgSlug}/tools'
234+ | '/{-$orgSlug}/users'
210235 | '/{-$orgSlug}'
211236 | '/{-$orgSlug}/billing/plans'
237+ | '/{-$orgSlug}/connect/$integrationSlug'
212238 | '/{-$orgSlug}/integrations/$namespace'
213239 | '/{-$orgSlug}/resume/$executionId'
214240 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -225,8 +251,10 @@ export interface FileRouteTypes { · 10 unmodified lines
225251 | '/{-$orgSlug}/secrets'
226252 | '/{-$orgSlug}/toolkits'
227253 | '/{-$orgSlug}/tools'
254+ | '/{-$orgSlug}/users'
228255 | '/{-$orgSlug}/'
229256 | '/{-$orgSlug}/billing_/plans'
257+ | '/{-$orgSlug}/connect/$integrationSlug'
230258 | '/{-$orgSlug}/integrations/$namespace'
231259 | '/{-$orgSlug}/resume/$executionId'
232260 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -244,8 +272,10 @@ export interface RootRouteChildren { · 11 unmodified lines
244272 SecretsRoute: typeof SecretsRoute
245273 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
246274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
275+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
247276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
248277 Billing_DotplansRoute: typeof Billing_DotplansRoute
278+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
249279 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
250280 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute
251281 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -281,6 +311,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
281311 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport
282312 parentRoute: typeof rootRouteImport
283313 }
314+ '/{-$orgSlug}/users': {
315+ id: '/{-$orgSlug}/users'
316+ path: '/{-$orgSlug}/users'
317+ fullPath: '/{-$orgSlug}/users'
318+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport
319+ parentRoute: typeof rootRouteImport
320+ }
284321 '/{-$orgSlug}/tools': {
285322 id: '/{-$orgSlug}/tools'
286323 path: '/{-$orgSlug}/tools'
@@ -351,6 +388,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines
351388 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
352389 parentRoute: typeof rootRouteImport
353390 }
391+ '/{-$orgSlug}/connect/$integrationSlug': {
392+ id: '/{-$orgSlug}/connect/$integrationSlug'
393+ path: '/{-$orgSlug}/connect/$integrationSlug'
394+ fullPath: '/{-$orgSlug}/connect/$integrationSlug'
395+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport
396+ parentRoute: typeof rootRouteImport
397+ }
354398 '/{-$orgSlug}/billing_/plans': {
355399 id: '/{-$orgSlug}/billing_/plans'
356400 path: '/{-$orgSlug}/billing/plans'
@@ -397,9 +441,13 @@ const rootRouteChildren: RootRouteChildren = { · 40 unmodified lines
397441 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
398442 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
399443 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
444+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute:
445+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute,
400446 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
401447 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
402448 Billing_DotplansRoute: Billing_DotplansRoute,
449+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
450+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
403451 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
404452 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
405453 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,
@@ -411,11 +459,15 @@ export const routeTree = rootRouteImport · 5 unmodified lines
411459 ._addFileTypes<FileRouteTypes>()
412460
413461 import type { getRouter } from './router.tsx'
462+
414463 import type { startInstance } from './start.ts'
464+
415465 declare module '@tanstack/react-start' {
416466 interface Register {
417467 ssr: true
468+
418469 router: Awaited<ReturnType<typeof getRouter>>
470+
419471 config: Awaited<ReturnType<typeof startInstance.getOptions>>
420472 }
421473 }
apps/cloud/src/web/shell.tsx+9−1
@@ -1,6 +1,7 @@
11 import type React from "react";
22
33 import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
4+import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
45 import { trackEvent } from "@executor-js/react/api/analytics";
56 import { AUTH_PATHS } from "../auth/api";
67 import { OrgMenuSlot } from "./components/org-menu-slot";
@@ -25,6 +26,12 @@ const navItems = [ · 18 unmodified lines
2526 { to: "/billing", label: "Billing" },
2627 ];
2728
29+// Sections only an admin of the org may open. The Users page reads the
30+// tenant-wide admin plane, which cloud gates on an admin-role membership — so
31+// a plain member is not shown a link that would only refuse them. The page
32+// still renders its own denied state if one arrives by URL.
33+const adminNavItems = [{ to: "/users", label: "Users" }];
34+
2835 // A top-level form POST, not fetch: the logout response 302s through the
2936 // WorkOS logout endpoint (ending the hosted AuthKit session) before landing
3037 // back home. A fetch would follow that chain invisibly inside the XHR and
@@ -39,10 +46,11 @@ const signOut = () => { · 8 unmodified lines
3946 };
4047
4148 export function Shell(props: { readonly content?: React.ReactNode }) {
49+ const items = useAdminNavItems(navItems, adminNavItems);
4250 return (
4351 <SharedShell
4452 onSignOut={signOut}
45− navItems={navItems}
53+ navItems={items}
4654 orgMenuSlot={<OrgMenuSlot />}
4755 supportSlot={<SupportSlot />}
4856 content={props.content}
apps/host-cloudflare/src/account/account-provider.ts+5−0
@@ -61,6 +61,11 @@ export const cloudflareAccountProvider = ( · 60 unmodified lines
6161 listApiKeys: () => Effect.succeed({ apiKeys: [] }),
6262 createApiKey: () => forbiddenWrite,
6363 revokeApiKey: () => forbiddenWrite,
64+ // Org-owned keys are a WorkOS concept; Access-managed instances have no
65+ // credential store of their own to mint one from.
66+ listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }),
67+ createOrgApiKey: () => forbiddenWrite,
68+ revokeOrgApiKey: () => forbiddenWrite,
6469 listMembers: () => Effect.succeed({ members: [] }),
6570 listRoles: () => Effect.succeed({ roles: [] }),
6671 inviteMember: () => forbiddenWrite,
apps/host-cloudflare/tsr.routes.ts+5−1
@@ -10,6 +10,10 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; · 9 unmodified lines
1010 // this app only owns its root (the Cloudflare-Access shell). To add
1111 // app-specific routes, create web/routes/app and mount it inside the org
1212 // scope via `orgScoped: [physical("", "app")]` — the directory must exist.
13+//
14+// /users is excluded: this host mounts no admin-users routes (identity is
15+// Cloudflare Access, which has no in-app role to gate on), so the page would
16+// only ever render its denied state.
1317 export const routes = rootRoute("__root.tsx", [
14− ...consoleRoutes({ dir: "../../../../packages/react/src/routes" }),
18+ ...consoleRoutes({ dir: "../../../../packages/react/src/routes", exclude: ["/users"] }),
1519 ]);
apps/host-cloudflare/web/routeTree.gen.ts+25−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines
1717 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
1818 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
1919 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
20+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2021 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
2122 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
2223
@@ -75,6 +76,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 52 unmodified lines
7576 getParentRoute: () => rootRouteImport,
7677 } as any,
7778 )
79+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
80+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
81+ {
82+ id: '/{-$orgSlug}/connect/$integrationSlug',
83+ path: '/{-$orgSlug}/connect/$integrationSlug',
84+ getParentRoute: () => rootRouteImport,
85+ } as any,
86+ )
7887 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
7988 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
8089 {
@@ -98,6 +107,7 @@ export interface FileRoutesByFullPath { · 17 unmodified lines
98107 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
99108 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
100109 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
110+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
101111 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
102112 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
103113 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -110,6 +120,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
110120 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
111121 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
112122 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
123+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
113124 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
114125 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
115126 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -123,6 +134,7 @@ export interface FileRoutesById { · 7 unmodified lines
123134 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
124135 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
125136 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
137+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
126138 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
127139 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
128140 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -137,6 +149,7 @@ export interface FileRouteTypes { · 8 unmodified lines
137149 | '/{-$orgSlug}/toolkits'
138150 | '/{-$orgSlug}/tools'
139151 | '/{-$orgSlug}/'
152+ | '/{-$orgSlug}/connect/$integrationSlug'
140153 | '/{-$orgSlug}/integrations/$namespace'
141154 | '/{-$orgSlug}/resume/$executionId'
142155 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -149,6 +162,7 @@ export interface FileRouteTypes { · 6 unmodified lines
149162 | '/{-$orgSlug}/toolkits'
150163 | '/{-$orgSlug}/tools'
151164 | '/{-$orgSlug}'
165+ | '/{-$orgSlug}/connect/$integrationSlug'
152166 | '/{-$orgSlug}/integrations/$namespace'
153167 | '/{-$orgSlug}/resume/$executionId'
154168 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -161,6 +175,7 @@ export interface FileRouteTypes { · 6 unmodified lines
161175 | '/{-$orgSlug}/toolkits'
162176 | '/{-$orgSlug}/tools'
163177 | '/{-$orgSlug}/'
178+ | '/{-$orgSlug}/connect/$integrationSlug'
164179 | '/{-$orgSlug}/integrations/$namespace'
165180 | '/{-$orgSlug}/resume/$executionId'
166181 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -174,6 +189,7 @@ export interface RootRouteChildren { · 7 unmodified lines
174189 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
175190 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
176191 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
192+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
177193 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
178194 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
179195 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -238,6 +254,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
238254 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
239255 parentRoute: typeof rootRouteImport
240256 }
257+ '/{-$orgSlug}/connect/$integrationSlug': {
258+ id: '/{-$orgSlug}/connect/$integrationSlug'
259+ path: '/{-$orgSlug}/connect/$integrationSlug'
260+ fullPath: '/{-$orgSlug}/connect/$integrationSlug'
261+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport
262+ parentRoute: typeof rootRouteImport
263+ }
241264 '/{-$orgSlug}/plugins/$pluginId/$': {
242265 id: '/{-$orgSlug}/plugins/$pluginId/$'
243266 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -281,6 +304,8 @@ const rootRouteChildren: RootRouteChildren = { · 37 unmodified lines
281304 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
282305 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
283306 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
307+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
308+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
284309 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
285310 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
286311 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
apps/host-selfhost/src/account/better-auth-account-provider.ts+27−0
@@ -106,6 +106,33 @@ export const betterAuthAccountProvider: Layer.Layer<AccountProvider, never, Bett · 105 unmodified lines
106106 auth.api.deleteApiKey({ body: { keyId: apiKeyId }, headers: toHeaders(headers) }),
107107 ).pipe(Effect.as({ success: true })),
108108
109+ // Better Auth has no organization-OWNED key concept: every key it
110+ // issues belongs to the user who created it. Rather than inventing one
111+ // (a shared key filed under whichever admin happened to click the
112+ // button is not an org key — it dies with that member), self-host
113+ // reports no org keys and refuses to mint. Self-host's own `/admin/*`
114+ // plane is gated on an owner/admin SESSION instead, which is the
115+ // credential a single-instance operator already has.
116+ listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }),
117+
118+ createOrgApiKey: () =>
119+ Effect.fail(
120+ new AccountError({
121+ message: "Organization API keys are not available on self-hosted instances",
122+ }),
123+ ),
124+
125+ // Nothing to revoke: `listOrgApiKeys` is empty and `createOrgApiKey`
126+ // refuses, so any id reaching here names a key this instance never
127+ // issued. Refusing (rather than succeeding vacuously) keeps the console
128+ // from reporting a revoke that did not happen.
129+ revokeOrgApiKey: () =>
130+ Effect.fail(
131+ new AccountError({
132+ message: "Organization API keys are not available on self-hosted instances",
133+ }),
134+ ),
135+
109136 listMembers: (headers) =>
110137 Effect.gen(function* () {
111138 const resolved = yield* getSession(headers);
apps/host-selfhost/src/admin/admin-escalation.node.test.ts+184−0
@@ -0,0 +1,184 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { afterAll, expect, test } from "@effect/vitest";
6+
7+import { mintInviteCode } from "../testing/mint-invite";
8+
9+// ---------------------------------------------------------------------------
10+// THE ESCALATION, performed rather than asserted about.
11+//
12+// `admin-users.node.test.ts` already asserts a plain member gets a 403, and it
13+// PASSED while the instance was fully bypassable — because it only ever asked
14+// the question one way. A member who simply calls the admin plane is refused;
15+// a member who first arranges to be an owner OF SOMETHING is a different
16+// caller, and the old gate could not tell the difference: it read the role from
17+// `session.activeOrganizationId`, which the caller controls, while the reads
18+// underneath stayed scoped to the instance's boot-seeded org.
19+//
20+// So this file does not test the label ("members are refused"). It runs the
21+// actual attack an invited member can run with nothing but their own session
22+// and the auth routes Better Auth mounts:
23+//
24+// 1. POST /api/auth/organization/create → become `owner` of a new org
25+// 2. POST /api/auth/organization/set-active → point the session at it
26+// 3. call the admin planes → must STILL be refused
27+//
28+// and asserts the refusal survives every step, on BOTH planes. The users plane
29+// leaks the instance's whole user directory and connection inventory; the
30+// invite plane is worse, because a `role: "admin"` invite code is durable
31+// escalation that outlives the session. Step 1 is now refused outright
32+// (`allowUserToCreateOrganization: false`), so the test asserts what it can at
33+// each step and never lets a blocked earlier step silently excuse a later one —
34+// steps 2 and 3 run regardless of whether step 1 succeeded.
35+//
36+// MUTATION-CHECKED, both directions:
37+// - gate reverted to a session-scoped role lookup + org creation re-opened:
38+// FAILS with "/api/admin/users refuses the escalated member: expected 200
39+// to be 403". The escalation really is reachable, and this test really does
40+// catch it.
41+// - gate fixed, org creation re-opened: every admin-plane assertion above
42+// PASSES and only the final defense-in-depth line fails. That is the
43+// evidence that the gate — not the flag — is what closes the hole.
44+// ---------------------------------------------------------------------------
45+
46+process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-escalation-"));
47+process.env.BETTER_AUTH_SECRET = "admin-escalation-secret-0123456789-abcdefg";
48+process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@escalation.test";
49+process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";
50+
51+const { makeSelfHostApiHandler } = await import("../app");
52+const { handler, dispose } = await makeSelfHostApiHandler();
53+afterAll(() => dispose());
54+
55+const BASE = "http://localhost:4788";
56+
57+const asMember = (token: string) => ({ authorization: `Bearer ${token}` });
58+
59+const post = (path: string, token: string, body: unknown) =>
60+ handler(
61+ new Request(`${BASE}${path}`, {
62+ method: "POST",
63+ headers: { "content-type": "application/json", ...asMember(token) },
64+ body: JSON.stringify(body),
65+ }),
66+ );
67+
68+const get = (path: string, token: string) =>
69+ handler(new Request(`${BASE}${path}`, { headers: asMember(token) }));
70+
71+/** Join the instance for real: mint an invite as the bootstrap admin, redeem it.
72+ * This is a legitimate, fully authenticated member of the instance org — the
73+ * weakest principal that has any standing at all, which is precisely the
74+ * attacker this gate has to hold against. */
75+const joinAsMember = async (email: string): Promise<string> => {
76+ const inviteCode = await mintInviteCode(handler);
77+ const signUp = await handler(
78+ new Request(`${BASE}/api/auth/sign-up/email`, {
79+ method: "POST",
80+ headers: { "content-type": "application/json" },
81+ body: JSON.stringify({ email, password: "password-12345678", name: "Member", inviteCode }),
82+ }),
83+ );
84+ expect(signUp.status, "the member joined through the real invite flow").toBe(200);
85+ const token = signUp.headers.get("set-auth-token") ?? "";
86+ expect(token).not.toBe("");
87+ return token;
88+};
89+
90+test("a member cannot escalate by owning an organization of their own", async () => {
91+ const memberToken = await joinAsMember("climber@escalation.test");
92+
93+ // Baseline: the ordinary refusal the old test already covered. If this were
94+ // ever a 200 the rest of the file would be meaningless, so it is asserted
95+ // first rather than assumed.
96+ expect((await get("/api/admin/users", memberToken)).status, "a plain member is refused").toBe(
97+ 403,
98+ );
99+
100+ // -- Step 1: become an owner. ---------------------------------------------
101+ // With `allowUserToCreateOrganization: false` this is refused outright. Its
102+ // status is RECORDED, not asserted here: the security property this test
103+ // exists for is "the admin plane stays shut", and asserting the block at step
104+ // 1 would abort the run before step 3 ever asserts it. So the attack proceeds
105+ // as far as it can either way, the planes are checked, and only then is the
106+ // defense-in-depth flag asserted at the bottom.
107+ const created = await post("/api/auth/organization/create", memberToken, {
108+ name: "Climber Org",
109+ slug: "climber-org",
110+ });
111+ const createdOrgId = created.ok ? ((await created.json()) as { id?: string }).id : undefined;
112+
113+ // -- Step 2: point the session at an org the caller owns. ------------------
114+ // The cheaper repeat path. Attempted against the created org when step 1
115+ // somehow succeeded, and unconditionally against the slug either way.
116+ if (createdOrgId) {
117+ await post("/api/auth/organization/set-active", memberToken, {
118+ organizationId: createdOrgId,
119+ });
120+ }
121+ await post("/api/auth/organization/set-active", memberToken, {
122+ organizationSlug: "climber-org",
123+ });
124+
125+ // -- Step 3: the planes must still refuse. ---------------------------------
126+ // Every admin users route, not just the list: a gate applied at four call
127+ // sites can be fixed at three.
128+ for (const path of [
129+ "/api/admin/users",
130+ "/api/admin/users/with-connections",
131+ "/api/admin/users/user_anyone/connections",
132+ "/api/admin/users/user_anyone",
133+ `/api/admin/users/${encodeURIComponent("admin@escalation.test")}`,
134+ ]) {
135+ const response = await get(path, memberToken);
136+ expect(response.status, `${path} refuses the escalated member`).toBe(403);
137+ // A refusal that leaked the answer in its body would be no refusal at all.
138+ expect(await response.text(), `${path} discloses nothing`).not.toContain(
139+ "admin@escalation.test",
140+ );
141+ }
142+
143+ // The invite plane shares the gate, and a bypass there is DURABLE: a
144+ // `role: "admin"` code redeems into a real admin membership that outlives
145+ // this session. Read and write are both checked — listing invite codes is
146+ // itself a disclosure (a live code is a credential).
147+ expect((await get("/api/admin/invites", memberToken)).status, "invite list refused").toBe(403);
148+
149+ const minted = await post("/api/admin/invites", memberToken, { role: "admin" });
150+ expect(minted.status, "an admin invite cannot be minted by a member").toBe(403);
151+ expect(await minted.text(), "and no code came back").not.toContain("code");
152+
153+ const revoked = await handler(
154+ new Request(`${BASE}/api/admin/invites/some-invite-id`, {
155+ method: "DELETE",
156+ headers: asMember(memberToken),
157+ }),
158+ );
159+ expect(revoked.status, "revoking someone else's invite is refused too").toBe(403);
160+
161+ // The instance's real admin is unaffected — the gate closed the hole, it did
162+ // not close the door. Without this, every assertion above would also pass if
163+ // the admin plane were simply broken for everyone.
164+ const adminList = await get("/api/admin/users", await signInAdmin());
165+ expect(adminList.status, "the real instance admin still gets in").toBe(200);
166+
167+ // Defense in depth, asserted LAST so it can never mask the assertions above:
168+ // step 1 of the attack is itself closed on a single-org instance.
169+ expect(created.status, "org creation is closed on a single-org instance").not.toBe(200);
170+});
171+
172+const signInAdmin = async (): Promise<string> => {
173+ const response = await handler(
174+ new Request(`${BASE}/api/auth/sign-in/email`, {
175+ method: "POST",
176+ headers: { "content-type": "application/json" },
177+ body: JSON.stringify({
178+ email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
179+ password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,
180+ }),
181+ }),
182+ );
183+ return response.headers.get("set-auth-token") ?? "";
184+};
apps/host-selfhost/src/admin/admin-users-api.ts+225−0
@@ -0,0 +1,225 @@
1+// ---------------------------------------------------------------------------
2+// Self-host admin users API — the shared, provider-neutral `AdminUsersHandlers`
3+// backed by a Better-Auth-authorized platform view, mounted at
4+// `/api/admin/users*` beside the existing invite-code admin routes.
5+//
6+// AUTH: an owner/admin member of the single org, resolved by the shared
7+// `requireInstanceAdmin` — the SAME gate the invite-code admin API uses
8+// (`admin/handlers.ts`), not a new permission concept. Membership is resolved
9+// against the INSTANCE's organization id, never the caller's
10+// `session.activeOrganizationId`: see require-admin.ts for the privilege
11+// escalation that distinction refuses. Self-host has no
12+// organization-OWNED api key (Better Auth keys always belong to the user who
13+// created one), so there is no machine-credential path here; the operator's own
14+// admin session is the credential. That asymmetry with cloud is deliberate and
15+// is why `AdminUsersProvider` is a seam rather than one shared implementation.
16+//
17+// The READ half is identical to cloud's: a subject-less, tenant-reach executor
18+// from `makePlatformExecutor`, projected by the shared `admin/reads`. Self-host
19+// is single-tenant, so the tenant is always the boot-seeded org.
20+// ---------------------------------------------------------------------------
21+
22+import { HttpRouter } from "effect/unstable/http";
23+import { Effect, Layer } from "effect";
24+
25+import {
26+ AdminUsersProvider,
27+ DbProvider,
28+ HostConfig,
29+ PluginsProvider,
30+ getAdminUser,
31+ listAdminUserConnections,
32+ listAdminUsers,
33+ listAdminUsersWithConnections,
34+ makeAdminUsersApiLayer,
35+ makePlatformExecutor,
36+ normalizeAdminUserEmail,
37+ platformViewOf,
38+ requestScopedMiddleware,
39+ type AdminUserDirectory,
40+ type AdminUserIdentity,
41+ type AdminUsersHeaders,
42+} from "@executor-js/api/server";
43+import {
44+ AdminUserNotFound,
45+ AdminUsersError,
46+ AdminUsersForbidden,
47+ AdminUsersUnauthorized,
48+} from "@executor-js/api";
49+import type { Executor } from "@executor-js/sdk";
50+
51+import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
52+import { requireInstanceAdmin } from "./require-admin";
53+import { SelfHostDb, SelfHostDbProvider, type SelfHostDbHandle } from "../db/self-host-db";
54+import { SelfHostHostConfig, SelfHostPluginsProvider } from "../execution";
55+
56+/**
57+ * The SAME gate the invite-code admin API applies — literally the same
58+ * function, not a second copy of the same idea, which is how the two planes
59+ * came to share a bypass. A plain member session is refused, and so is an owner
60+ * of some OTHER organization: this plane reports every user in the instance, so
61+ * the org it authorizes against is the instance's own (see require-admin.ts).
62+ */
63+const requireAdmin = (headers: AdminUsersHeaders) =>
64+ requireInstanceAdmin(new Headers(headers)).pipe(
65+ Effect.mapError((denial) =>
66+ denial === "unauthorized" ? new AdminUsersUnauthorized() : new AdminUsersForbidden(),
67+ ),
68+ );
69+
70+/**
71+ * Self-host's member directory: `externalId` → email/name.
72+ *
73+ * THE JOIN KEY is `member.userId`, the Better Auth `user.id` — precisely what
74+ * `auth/identity.ts` binds as `accountId` and therefore what the subject table
75+ * records in `external_id`. `member.id` is the organization `member` ROW id and
76+ * joins to nothing; the two look alike, so the choice is pinned here and in the
77+ * node test rather than left to a reader.
78+ *
79+ * One `listMembers` call per request: Better Auth's organization plugin already
80+ * attaches the `user` row to each member, so email and name arrive with the
81+ * membership and no per-user lookup is needed. The requested ids are not passed
82+ * to the call — the plugin offers no id filter, and a single-instance member
83+ * list is small — but the caller only reads the ids it asked for.
84+ *
85+ * Runs as the CALLER, using their own admin headers, so this reads exactly the
86+ * directory that session is already entitled to on `/account/members`.
87+ */
88+const listMembers = (auth: BetterAuthHandle["auth"], headers: AdminUsersHeaders) =>
89+ Effect.tryPromise(() => auth.api.listMembers({ headers: new Headers(headers) }));
90+
91+/**
92+ * Both directions of self-host's directory, over the SAME single `listMembers`
93+ * read.
94+ *
95+ * The reverse (email → `user.id`) needs no extra call and no new permission:
96+ * the organization plugin already attaches the `user` row to each member, so
97+ * the email is sitting beside the id the forward join uses. Better Auth
98+ * lower-cases every email it writes, but the directory value is normalized
99+ * anyway so this host cannot answer differently from cloud if that ever
100+ * changes.
101+ *
102+ * A member with no `user.email` cannot match — `null` is not an address, and
103+ * coercing it to "" would let an empty `?email=` select an arbitrary row.
104+ */
105+const userDirectory = (
106+ auth: BetterAuthHandle["auth"],
107+ headers: AdminUsersHeaders,
108+): AdminUserDirectory => ({
109+ identities: () =>
110+ listMembers(auth, headers).pipe(
111+ Effect.map((result) => {
112+ const identities = new Map<string, AdminUserIdentity>();
113+ for (const member of result.members) {
114+ identities.set(member.userId, {
115+ email: member.user?.email ?? null,
116+ displayName: member.user?.name ?? null,
117+ });
118+ }
119+ return identities;
120+ }),
121+ ),
122+ resolveEmail: (email) =>
123+ listMembers(auth, headers).pipe(
124+ Effect.map(
125+ (result) =>
126+ result.members.find((member) => {
127+ const stored = member.user?.email;
128+ return stored != null && normalizeAdminUserEmail(stored) === email;
129+ })?.userId ?? null,
130+ ),
131+ ),
132+});
133+
134+const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>(
135+ headers: AdminUsersHeaders,
136+ organizationId: string,
137+ body: (executor: Executor) => Effect.Effect<A, E>,
138+): Effect.Effect<
139+ A,
140+ // `AdminUsersError` unconditionally: opening the platform view can fail that
141+ // way regardless of what `body` itself raises.
142+ E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden,
143+ BetterAuth | DbProvider | PluginsProvider | HostConfig
144+> =>
145+ Effect.gen(function* () {
146+ yield* requireAdmin(headers);
147+ const executor = yield* makePlatformExecutor(organizationId).pipe(
148+ Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })),
149+ );
150+ return yield* Effect.ensuring(body(executor), executor.close().pipe(Effect.ignore));
151+ });
152+
153+export const betterAuthAdminUsersProvider: Layer.Layer<
154+ AdminUsersProvider,
155+ never,
156+ BetterAuth | DbProvider | PluginsProvider | HostConfig
157+> = Layer.effect(AdminUsersProvider)(
158+ Effect.gen(function* () {
159+ const context = yield* Effect.context<BetterAuth | DbProvider | PluginsProvider | HostConfig>();
160+ const { auth, organizationId } = yield* BetterAuth;
161+ return AdminUsersProvider.of({
162+ listUsers: (headers, options) =>
163+ withPlatformView(headers, organizationId, (executor) =>
164+ platformViewOf(executor).pipe(
165+ Effect.flatMap((admin) => listAdminUsers(admin, options, userDirectory(auth, headers))),
166+ ),
167+ ).pipe(Effect.provideContext(context)),
168+ listUsersWithConnections: (headers, options) =>
169+ withPlatformView(headers, organizationId, (executor) =>
170+ platformViewOf(executor).pipe(
171+ Effect.flatMap((admin) =>
172+ listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)),
173+ ),
174+ ),
175+ ).pipe(Effect.provideContext(context)),
176+ listUserConnections: (headers, externalId) =>
177+ withPlatformView(headers, organizationId, (executor) =>
178+ platformViewOf(executor).pipe(
179+ Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)),
180+ ),
181+ ).pipe(Effect.provideContext(context)),
182+ getUser: (headers, identifier) =>
183+ withPlatformView(headers, organizationId, (executor) =>
184+ platformViewOf(executor).pipe(
185+ Effect.flatMap((admin) =>
186+ getAdminUser(admin, identifier, userDirectory(auth, headers)),
187+ ),
188+ ),
189+ ).pipe(Effect.provideContext(context)),
190+ });
191+ }),
192+);
193+
194+export interface SelfHostAdminUsersApiDeps {
195+ readonly betterAuth: BetterAuthHandle;
196+ readonly db: SelfHostDbHandle;
197+ readonly mountPrefix: `/${string}`;
198+}
199+
200+/**
201+ * The mountable extension route layer for `/api/admin/users*`. Self-host's DB
202+ * handle and Better Auth are app singletons, so the provider is self-contained
203+ * (no per-request socket to close over, unlike cloud) — but it still goes
204+ * through `requestScopedMiddleware`, because an HttpApi handler's service
205+ * requirement is not erased by a plain `Layer.provide` on the builder layer.
206+ */
207+export const makeSelfHostAdminUsersApiLayer = ({
208+ betterAuth,
209+ db,
210+ mountPrefix,
211+}: SelfHostAdminUsersApiDeps) => {
212+ const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)(
213+ Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)),
214+ );
215+ const provider = betterAuthAdminUsersProvider.pipe(
216+ Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),
217+ Layer.provide(SelfHostDbProvider),
218+ Layer.provide(SelfHostPluginsProvider),
219+ Layer.provide(SelfHostHostConfig),
220+ Layer.provide(Layer.succeed(SelfHostDb)(db)),
221+ );
222+ return makeAdminUsersApiLayer(requestScopedMiddleware(provider).layer, {
223+ router: prefixedRouter,
224+ });
225+};
apps/host-selfhost/src/admin/admin-users.node.test.ts+234−0
@@ -0,0 +1,234 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { afterAll, expect, test } from "@effect/vitest";
6+
7+import { mintInviteCode } from "../testing/mint-invite";
8+
9+// The self-host admin users plane (`/api/admin/users*`), over the REAL booted
10+// app: Better Auth, the libSQL store, and the mounted route layer.
11+//
12+// Self-host has no organization-owned api key (Better Auth keys always belong
13+// to the user who made one), so the credential here is the operator's own
14+// owner/admin session — the same gate the invite-code admin API uses. What this
15+// pins is that the mount is live, that the owner/admin gate actually refuses a
16+// plain member, and that a member who has used the instance shows up in the
17+// owner's view without their credential coming with them.
18+
19+process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-users-"));
20+process.env.BETTER_AUTH_SECRET = "admin-users-test-secret-0123456789-abcdefghij";
21+process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@admin-users.test";
22+process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";
23+
24+const { makeSelfHostApiHandler } = await import("../app");
25+const { handler, dispose } = await makeSelfHostApiHandler();
26+afterAll(() => dispose());
27+
28+const BASE = "http://localhost:4788";
29+
30+const signIn = async (email: string, password: string): Promise<string> => {
31+ const response = await handler(
32+ new Request(`${BASE}/api/auth/sign-in/email`, {
33+ method: "POST",
34+ headers: { "content-type": "application/json" },
35+ body: JSON.stringify({ email, password }),
36+ }),
37+ );
38+ return response.headers.get("set-auth-token") ?? "";
39+};
40+
41+const adminUsers = (token?: string, path = "/api/admin/users") =>
42+ handler(
43+ new Request(`${BASE}${path}`, {
44+ headers: token ? { authorization: `Bearer ${token}` } : {},
45+ }),
46+ );
47+
48+test("the owner sees who uses the instance; a plain member cannot look", async () => {
49+ const adminToken = await signIn(
50+ process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL!,
51+ process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD!,
52+ );
53+ expect(adminToken).not.toBe("");
54+
55+ // A second person joins through the real invite flow and uses the instance.
56+ const inviteCode = await mintInviteCode(handler);
57+ const signUp = await handler(
58+ new Request(`${BASE}/api/auth/sign-up/email`, {
59+ method: "POST",
60+ headers: { "content-type": "application/json" },
61+ body: JSON.stringify({
62+ email: "member@admin-users.test",
63+ password: "password-12345678",
64+ name: "Member",
65+ inviteCode,
66+ }),
67+ }),
68+ );
69+ expect(signUp.status).toBe(200);
70+ const memberToken = signUp.headers.get("set-auth-token") ?? "";
71+ expect(memberToken).not.toBe("");
72+
73+ // A user earns their row the first time they touch the EXECUTOR plane, so
74+ // have both make one product read before looking at the admin view.
75+ for (const token of [adminToken, memberToken]) {
76+ const integrations = await handler(
77+ new Request(`${BASE}/api/integrations`, { headers: { authorization: `Bearer ${token}` } }),
78+ );
79+ expect(integrations.status).toBe(200);
80+ }
81+
82+ // The owner's view reports both people.
83+ const response = await adminUsers(adminToken);
84+ expect(response.status).toBe(200);
85+ const body = (await response.json()) as {
86+ users: ReadonlyArray<{
87+ externalId: string;
88+ lastSeenAt: number | null;
89+ email: string | null;
90+ displayName: string | null;
91+ }>;
92+ };
93+ expect(body.users.length, "both the owner and the invited member are reported").toBe(2);
94+ expect(typeof body.users[0]?.lastSeenAt, "last-seen crosses the wire as a number").toBe("number");
95+
96+ // THE JOIN KEY, proven against real Better Auth rather than assumed.
97+ //
98+ // `auth/identity.ts` binds `session.user.id` as the accountId, so that is what
99+ // the subject table records in `external_id` — and it is the member list's
100+ // `userId`, NOT its `id` (the organization `member` ROW id). The two look
101+ // alike and only one joins, so this asserts the actual emails land on the
102+ // actual rows: a mismatch would leave every user unnamed while the counts
103+ // above still passed.
104+ const byEmail = new Map(body.users.map((user) => [user.email, user.externalId]));
105+ expect(
106+ [...byEmail.keys()].sort(),
107+ "each user carries the email Better Auth holds for them",
108+ ).toEqual(["admin@admin-users.test", "member@admin-users.test"]);
109+ expect(
110+ body.users.every((user) => user.externalId.length > 0),
111+ "and the id space still lines up — nobody was named by matching nothing",
112+ ).toBe(true);
113+ expect(
114+ body.users.find((user) => user.email === "member@admin-users.test")?.displayName,
115+ "the sign-up name comes through as the display name",
116+ ).toBe("Member");
117+
118+ // The joined view is mounted too, and carries the same identity.
119+ const joined = await adminUsers(adminToken, "/api/admin/users/with-connections");
120+ expect(joined.status).toBe(200);
121+ const joinedBody = (await joined.json()) as {
122+ users: ReadonlyArray<{
123+ externalId: string;
124+ email: string | null;
125+ connections: readonly unknown[];
126+ }>;
127+ };
128+ expect(joinedBody.users.length).toBe(2);
129+ expect(
130+ joinedBody.users.every((user) => Array.isArray(user.connections)),
131+ "every user carries a connections array",
132+ ).toBe(true);
133+ expect(
134+ joinedBody.users.map((user) => [user.externalId, user.email]).sort(),
135+ "the joined view reports the same identities, keyed the same way",
136+ ).toEqual(body.users.map((user) => [user.externalId, user.email]).sort());
137+
138+ // -------------------------------------------------------------------------
139+ // The single-user read, by opaque id and by email.
140+ // -------------------------------------------------------------------------
141+
142+ const memberId = byEmail.get("member@admin-users.test")!;
143+
144+ type UserBody = {
145+ user: {
146+ externalId: string;
147+ email: string | null;
148+ displayName: string | null;
149+ connections: readonly unknown[];
150+ };
151+ };
152+
153+ const single = await adminUsers(adminToken, `/api/admin/users/${memberId}`);
154+ expect(single.status).toBe(200);
155+ const singleBody = (await single.json()) as UserBody;
156+ expect(singleBody.user.externalId).toBe(memberId);
157+ expect(singleBody.user.email).toBe("member@admin-users.test");
158+ expect(singleBody.user.displayName).toBe("Member");
159+ expect(
160+ Array.isArray(singleBody.user.connections),
161+ "identity and connections arrive in ONE call",
162+ ).toBe(true);
163+
164+ // The same person by email, in three casings. Better Auth stores emails
165+ // lower-cased, so this proves the QUERY side is normalized against a real
166+ // directory rather than against a fixture that happens to agree.
167+ for (const spelling of [
168+ "member@admin-users.test",
169+ "Member@Admin-Users.Test",
170+ "MEMBER@ADMIN-USERS.TEST",
171+ ]) {
172+ const byEmailResponse = await adminUsers(
173+ adminToken,
174+ `/api/admin/users/${encodeURIComponent(spelling)}`,
175+ );
176+ expect(byEmailResponse.status, `${spelling} resolves`).toBe(200);
177+ expect(((await byEmailResponse.json()) as UserBody).user.externalId).toBe(memberId);
178+ }
179+
180+ // An email nobody in the directory holds.
181+ const unknownEmail = await adminUsers(
182+ adminToken,
183+ `/api/admin/users/${encodeURIComponent("nobody@admin-users.test")}`,
184+ );
185+ expect(unknownEmail.status, "unknown email → 404").toBe(404);
186+ // Asserted on the encoded body — the distinct error is what lets a caller
187+ // tell "no footprint" from "bad credential" without parsing a message.
188+ expect(await unknownEmail.text()).toContain("AdminUserNotFound");
189+
190+ // An id that names no subject row.
191+ const unknownId = await adminUsers(adminToken, "/api/admin/users/user_does_not_exist");
192+ expect(unknownId.status, "unknown id → 404").toBe(404);
193+
194+ // The bulk `?email=` filter, over the same real directory.
195+ const filtered = await adminUsers(
196+ adminToken,
197+ `/api/admin/users?email=${encodeURIComponent("MEMBER@admin-users.test")}`,
198+ );
199+ expect(filtered.status).toBe(200);
200+ const filteredBody = (await filtered.json()) as { users: ReadonlyArray<{ externalId: string }> };
201+ expect(
202+ filteredBody.users.map((user) => user.externalId),
203+ "the filter selects exactly the one member, case-insensitively",
204+ ).toEqual([memberId]);
205+
206+ const filteredUnknown = await adminUsers(
207+ adminToken,
208+ `/api/admin/users?email=${encodeURIComponent("nobody@admin-users.test")}`,
209+ );
210+ expect(
211+ ((await filteredUnknown.json()) as { users: readonly unknown[] }).users,
212+ "an unknown email is an EMPTY page, never the unfiltered tenant",
213+ ).toEqual([]);
214+
215+ // The router-ordering invariant against the real mount: the static segment
216+ // must still beat `/:identifier`.
217+ const stillTheList = await adminUsers(adminToken, "/api/admin/users/with-connections");
218+ expect(stillTheList.status).toBe(200);
219+ expect(((await stillTheList.json()) as { users: readonly unknown[] }).users.length).toBe(2);
220+
221+ // The gate: a plain member may not read the instance-wide view.
222+ const asMember = await adminUsers(memberToken);
223+ expect(asMember.status, "a member is refused").toBe(403);
224+
225+ // And an anonymous caller has no session at all.
226+ const anonymous = await adminUsers();
227+ expect(anonymous.status, "no session → unauthorized").toBe(401);
228+
229+ // The single-user read refuses on the same terms — and refuses BEFORE
230+ // looking, so a refused caller cannot probe which users exist.
231+ expect((await adminUsers(memberToken, `/api/admin/users/${memberId}`)).status).toBe(403);
232+ expect((await adminUsers(memberToken, "/api/admin/users/user_nobody")).status).toBe(403);
233+ expect((await adminUsers(undefined, `/api/admin/users/${memberId}`)).status).toBe(401);
234+});
apps/host-selfhost/src/admin/handlers.ts+14−14
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 type InviteCode as InviteCodeSchema,
1111 } from "./api";
1212 import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
13+import { requireInstanceAdmin } from "./require-admin";
1314 import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db";
1415 import {
1516 createInviteCode,
@@ -22,9 +23,11 @@ import { · 6 unmodified lines
2223 // ---------------------------------------------------------------------------
2324 // Handlers for the self-host admin (invite-code) API. Every Promise-returning
2425 // boundary (Better Auth, the libSQL store) is wrapped in Effect.tryPromise with
25−// a typed failure — no raw try/catch, no Promise.catch. Each route is gated:
26−// the caller must be an owner/admin member of the one org (resolved through the
27−// org primitive's getActiveMember).
26+// a typed failure — no raw try/catch, no Promise.catch. Each route is gated by
27+// the SHARED `requireInstanceAdmin`: the caller must be an owner/admin member
28+// of THIS INSTANCE'S organization, named explicitly rather than taken from the
29+// caller's session (see require-admin.ts for the escalation that rule refuses —
30+// this plane mints invite codes, so a bypass here is durable admin).
2831 // ---------------------------------------------------------------------------
2932
3033 const requestHeaders = Effect.map(
@@ -32,18 +35,15 @@ const requestHeaders = Effect.map( · 1 unmodified line
3235 (request): Headers => new Headers({ ...request.headers }),
3336 );
3437
35−// Resolve + authorize the caller, returning their member record (for userId).
38+// Resolve + authorize the caller, rendering the shared gate's denial in THIS
39+// plane's error vocabulary (the HttpApi group declares these, not the users
40+// plane's).
3641 const requireAdmin = (headers: Headers) =>
37− Effect.gen(function* () {
38− const { auth } = yield* BetterAuth;
39− const member = yield* Effect.tryPromise({
40− try: () => auth.api.getActiveMember({ headers }),
41− catch: () => new AdminError({ message: "Failed to resolve session" }),
42− }).pipe(Effect.orElseSucceed(() => null));
43− if (!member) return yield* new AdminUnauthorized();
44− if (member.role !== "owner" && member.role !== "admin") return yield* new AdminForbidden();
45− return member;
46− });
42+ requireInstanceAdmin(headers).pipe(
43+ Effect.mapError((denial) =>
44+ denial === "unauthorized" ? new AdminUnauthorized() : new AdminForbidden(),
45+ ),
46+ );
4747
4848 const narrowRole = (role: string | undefined): InviteRole =>
4949 role === "admin" ? "admin" : "member";
apps/host-selfhost/src/admin/require-admin.ts+126−0
@@ -0,0 +1,126 @@
1+// ---------------------------------------------------------------------------
2+// THE self-host admin gate. One implementation, shared by every admin plane:
3+// the invite-code API (`handlers.ts`) and the admin users API
4+// (`admin-users-api.ts`). Both planes report on, or grant power over, the whole
5+// instance, so they must agree on exactly who counts as an admin — and there is
6+// only one way to be wrong here, so there is only one place to be right.
7+//
8+// WHY THIS IS NOT `getActiveMember`.
9+//
10+// Both planes previously authorized with `auth.api.getActiveMember({ headers
11+// })`. That endpoint resolves the caller's membership in
12+// `session.activeOrganizationId` — a field the CALLER controls. Better Auth's
13+// organization plugin exposes `POST /organization/create` (mounted bare,
14+// `allowUserToCreateOrganization` defaults to true) and `POST
15+// /organization/set-active`, so any authenticated member could create an
16+// organization of their own, become its `owner` (the creator role), have that
17+// org set active, and then present a session whose active org is one they own.
18+// `getActiveMember` would answer `role: "owner"` — for THEIR org — and the gate
19+// would open. Meanwhile the reads underneath are scoped to the instance's
20+// boot-seeded org, so the escalated caller read the real instance: every user's
21+// externalId/createdAt/lastSeenAt and their whole connection inventory on one
22+// plane, and a live `role: "admin"` invite code (durable escalation) on the
23+// other.
24+//
25+// The bug is that the gate and the data disagreed about WHICH organization the
26+// request was about. This gate removes the disagreement by naming the
27+// instance's organization explicitly, so the authorization decision is made
28+// against the same org the reads are scoped to and nothing the caller sends can
29+// redirect it.
30+//
31+// HOW. `getActiveMemberRole` accepts an explicit `organizationId` query, and
32+// when given one it ignores `session.activeOrganizationId` entirely, looks up
33+// the caller's membership in THAT org, and refuses with FORBIDDEN when there
34+// isn't one (better-auth 1.6.12, `plugins/organization/routes/crud-members.mjs`
35+// — read against the installed build, not the docs). So a caller who owns ten
36+// organizations of their own still resolves to "not a member of the instance
37+// org" unless they were actually invited to it. There is no
38+// `member.organizationId !== organizationId` check to write, because the query
39+// makes the mismatch unrepresentable rather than detectable.
40+//
41+// Defense in depth lives in `auth/better-auth.ts`, which now mounts
42+// `organization({ allowUserToCreateOrganization: false })` so the first step of
43+// the escalation is refused too. Either fix alone closes the hole; the gate is
44+// the load-bearing one, because it is what makes the authorization decision
45+// correct rather than merely making one route to it harder.
46+// ---------------------------------------------------------------------------
47+
48+import { Effect } from "effect";
49+
50+import { BetterAuth } from "../auth/better-auth";
51+
52+/**
53+ * Why the caller was refused, in the vocabulary both planes share.
54+ *
55+ * The two planes render refusals through their OWN error classes
56+ * (`AdminUnauthorized` / `AdminUsersUnauthorized`, and so on), because those
57+ * are what their respective HttpApi contracts declare. So the gate decides, and
58+ * each caller translates — rather than the gate importing one plane's errors
59+ * and the other plane mapping between two error vocabularies.
60+ */
61+export type AdminGateDenial = "unauthorized" | "forbidden";
62+
63+/** What a caller who passed the gate is. `userId` is the Better Auth `user.id`
64+ * — the same id `auth/identity.ts` binds as the accountId, which is what the
65+ * invite plane records as `createdBy`. */
66+export interface InstanceAdmin {
67+ readonly userId: string;
68+ readonly role: string;
69+}
70+
71+/**
72+ * Better Auth writes a member's roles as a comma-separated list (its own
73+ * `leaveOrganization` reads them with `role.split(",")`), so a single-role
74+ * string is the common case rather than the contract. Membership in the
75+ * privileged set is therefore tested per role, not by equality on the whole
76+ * field — an `"owner,admin"` value must not read as neither.
77+ */
78+const isPrivileged = (role: string): boolean =>
79+ role
80+ .split(",")
81+ .map((part) => part.trim())
82+ .some((part) => part === "owner" || part === "admin");
83+
84+/**
85+ * Authorize the caller as an owner/admin OF THE INSTANCE'S ORGANIZATION.
86+ *
87+ * Fails with `"unauthorized"` when there is no session at all, and
88+ * `"forbidden"` when there is a session that is not an owner/admin member of
89+ * the instance org — including a session belonging to an owner of some OTHER
90+ * organization, which is the escalation this gate exists to refuse.
91+ *
92+ * Both Better Auth calls FAIL CLOSED, and closed means the less-informative
93+ * answer of the two: a `getSession` that throws is treated as "no session"
94+ * (401) and a `getActiveMemberRole` that throws is treated as "not entitled"
95+ * (403), because the endpoint's own way of saying "you are not a member of this
96+ * organization" IS a thrown FORBIDDEN. An infrastructure fault therefore
97+ * refuses the request rather than surfacing as a 500, which is the correct
98+ * trade on a plane where the wrong answer is a disclosure.
99+ *
100+ * TWO CALLS, NOT ONE. The session read is what distinguishes 401 from 403 — the
101+ * role endpoint cannot, since it refuses the anonymous and the unentitled with
102+ * the same status — and it is also where `userId` comes from, since the role
103+ * endpoint answers with a role and nothing else.
104+ */
105+export const requireInstanceAdmin = (
106+ headers: Headers,
107+): Effect.Effect<InstanceAdmin, AdminGateDenial, BetterAuth> =>
108+ Effect.gen(function* () {
109+ const { auth, organizationId } = yield* BetterAuth;
110+
111+ const session = yield* Effect.tryPromise(() => auth.api.getSession({ headers })).pipe(
112+ Effect.orElseSucceed(() => null),
113+ );
114+ if (!session) return yield* Effect.fail<AdminGateDenial>("unauthorized");
115+
116+ // The whole fix in one argument: the org is named by the INSTANCE, never
117+ // read from the caller's session.
118+ const resolved = yield* Effect.tryPromise(() =>
119+ auth.api.getActiveMemberRole({ headers, query: { organizationId } }),
120+ ).pipe(Effect.orElseSucceed(() => null));
121+ if (!resolved || !isPrivileged(resolved.role)) {
122+ return yield* Effect.fail<AdminGateDenial>("forbidden");
123+ }
124+
125+ return { userId: session.user.id, role: resolved.role };
126+ });
apps/host-selfhost/src/app.ts+5−0
@@ -9,6 +9,7 @@ import { runSqliteDataMigrations } from "@executor-js/sdk"; · 8 unmodified lines
99 import { resolveAuthProviders } from "./auth";
1010 import { selfHostDataMigrations } from "./db/data-migrations";
1111 import { makeSelfHostAdminApiLayer } from "./admin/handlers";
12+import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api";
1213 import { makeSelfHostSystemApiLayer } from "./system/handlers";
1314 import { selfHostAccountMiddleware } from "./account";
1415 import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config";
@@ -117,6 +118,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 102 unmodified lines
117118 HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),
118119 // App-local admin (invite-code) API, served under /api/admin/*.
119120 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
121+ // Tenant-wide admin users API (/api/admin/users*): the owner's view of
122+ // who uses this instance and what they've connected. Owner/admin-gated,
123+ // same as the invite routes above.
124+ makeSelfHostAdminUsersApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
120125 // Public system API: /api/health + /api/setup-status (unauthenticated).
121126 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
122127 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).
apps/host-selfhost/src/auth/better-auth.ts+20−1
@@ -110,7 +110,26 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 109 unmodified lines
110110 // are re-emitted by the shared envelope (MCP clients probe the origin root,
111111 // not the /api/auth basePath).
112112 plugins: [
113− organization(),
113+ // SINGLE-ORG INSTANCE, ENFORCED. `allowUserToCreateOrganization: false`
114+ // closes `POST /api/auth/organization/create` to every session. Left at
115+ // its default (true) it was the first step of a privilege escalation: any
116+ // invited member could create an organization of their own, become its
117+ // `owner` (the creator role), get it set as their active org, and then
118+ // pass an admin gate that resolved the caller's role from
119+ // `session.activeOrganizationId` — reading the whole instance's user
120+ // directory and minting themselves a durable `role: "admin"` invite code.
121+ // The load-bearing fix is `admin/require-admin.ts`, which authorizes
122+ // against THIS instance's org id instead of the session's; this flag is
123+ // defense in depth, and is also just true of the product — self-host
124+ // serves exactly one organization, so a second one is never legitimate.
125+ //
126+ // The bootstrap seed is unaffected: `seed.ts` calls `createOrganization`
127+ // with `userId` and NO session, which better-auth 1.6.12 treats as
128+ // `isSystemAction` and short-circuits past the `canCreateOrg` check
129+ // (`plugins/organization/routes/crud-org.mjs` — verified against the
130+ // installed build, and covered by every node test here, all of which boot
131+ // through that path).
132+ organization({ allowUserToCreateOrganization: false }),
114133 admin(),
115134 apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
116135 bearer(),
apps/host-selfhost/src/auth/return-to.test.ts+45−1
@@ -1,6 +1,12 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to";
3+import {
4+ isSafeReturnTo,
5+ loginPath,
6+ mcpAuthorizeResumeTarget,
7+ postLoginTarget,
8+ safeReturnTo,
9+} from "./return-to";
410
511 describe("isSafeReturnTo", () => {
612 const safe = [
@@ -73,6 +79,44 @@ describe("mcpAuthorizeResumeTarget", () => { · 66 unmodified lines
7379 });
7480 });
7581
82+describe("postLoginTarget", () => {
83+ // Self-host renders the login page IN PLACE of the requested route without
84+ // navigating, so the live location is the only record of where the person was
85+ // headed. A connect deep link must survive sign-in on that basis alone.
86+ it("returns to the deep link the person actually opened", () => {
87+ expect(postLoginTarget({ pathname: "/connect/linear", search: "" })).toBe("/connect/linear");
88+ expect(postLoginTarget({ pathname: "/integrations/sentry", search: "?addAccount=1" })).toBe(
89+ "/integrations/sentry?addAccount=1",
90+ );
91+ });
92+
93+ it("lands on the dashboard when signing in from the bare login page", () => {
94+ // No deep link to resume, and echoing /login back would re-render the form.
95+ expect(postLoginTarget({ pathname: "/login", search: "" })).toBe("/");
96+ });
97+
98+ it("prefers an explicit returnTo over the current location", () => {
99+ expect(postLoginTarget({ pathname: "/login", search: "?returnTo=%2Fconnect%2Flinear" })).toBe(
100+ "/connect/linear",
101+ );
102+ });
103+
104+ it("prefers an MCP authorize resume over everything else", () => {
105+ const target = postLoginTarget({
106+ pathname: "/login",
107+ search:
108+ "?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback",
109+ });
110+ expect(target.startsWith("/api/auth/mcp/authorize?")).toBe(true);
111+ });
112+
113+ it("never honors an unsafe location", () => {
114+ // `safeReturnTo` vets the echoed path too — an app-plane path is not a
115+ // place to land a freshly signed-in browser.
116+ expect(postLoginTarget({ pathname: "/api/auth/logout", search: "" })).toBe("/");
117+ });
118+});
119+
76120 describe("loginPath", () => {
77121 it("omits returnTo for the root", () => {
78122 expect(loginPath("/")).toBe("/login");
apps/host-selfhost/src/auth/return-to.ts+29−0
@@ -32,3 +32,32 @@ export const mcpAuthorizeResumeTarget = (search: string): string | null => { · 31 unmodified lines
3232 if (!params.get("client_id") || !params.get("redirect_uri")) return null;
3333 return `${MCP_AUTHORIZE_PATH}?${params.toString()}`;
3434 };
35+
36+const LOGIN_PATH = "/login";
37+
38+/**
39+ * Where the self-host login page sends someone after a successful sign-in, in
40+ * priority order:
41+ *
42+ * 1. an interrupted MCP OAuth authorize (the params ARE the request),
43+ * 2. an explicit safe `returnTo` (e.g. the integration OAuth callback),
44+ * 3. the URL they actually opened, and
45+ * 4. the dashboard.
46+ *
47+ * Step 3 is what makes a deep link like `/connect/linear` survive sign-in here.
48+ * Unlike cloud — which redirects to `/login?returnTo=…` — self-host's gate
49+ * swaps the login page in WITHOUT navigating, so the address bar still holds
50+ * the requested URL and no `returnTo` was ever written. `/login` itself is
51+ * excluded so signing in from the bare login page lands on the dashboard rather
52+ * than looping back to the form.
53+ */
54+export const postLoginTarget = (location: {
55+ readonly pathname: string;
56+ readonly search: string;
57+}): string =>
58+ mcpAuthorizeResumeTarget(location.search) ??
59+ safeReturnTo(new URLSearchParams(location.search).get("returnTo")) ??
60+ (location.pathname === LOGIN_PATH
61+ ? null
62+ : safeReturnTo(`${location.pathname}${location.search}`)) ??
63+ "/";
apps/host-selfhost/src/subject-sightings.test.ts+82−0
@@ -0,0 +1,82 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { Effect, Layer } from "effect";
6+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
7+import { withQueryContext } from "@executor-js/fumadb/query";
8+
9+import { makeScopedExecutor } from "@executor-js/api/server";
10+
11+import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
12+import { SelfHostScopedExecutorSeams } from "./execution";
13+import type { SelfHostPlugins } from "./plugins";
14+
15+// The `subject` table is populated at the request seam: `makeScopedExecutor` is
16+// what every HTTP request and MCP session on every host passes through, so
17+// building a scoped executor IS a sighting. Exercised here against a real host's
18+// seams (long-lived SQLite DbProvider + real plugins) rather than a stub, since
19+// the point is that the production wiring reaches the writer at all.
20+
21+const createScopedExecutor = (accountId: string, organizationId: string) =>
22+ makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe(
23+ Effect.provide(SelfHostScopedExecutorSeams),
24+ );
25+
26+const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-"));
27+process.env.EXECUTOR_DATA_DIR = dataDir;
28+
29+let dbLayer!: Layer.Layer<SelfHostDb>;
30+let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined;
31+
32+beforeAll(async () => {
33+ dbHandle = await createSelfHostDb({
34+ path: join(dataDir, "data.db"),
35+ namespace: "executor_selfhost",
36+ version: "1.0.0",
37+ });
38+ dbLayer = Layer.succeed(SelfHostDb)(dbHandle);
39+});
40+afterAll(async () => {
41+ await dbHandle?.close();
42+});
43+
44+const subjectsOf = async (tenant: string): Promise<readonly string[]> => {
45+ const rows = await withQueryContext(dbHandle!.db, {
46+ tenant,
47+ subject: null,
48+ }).findMany("subject", {
49+ orderBy: ["external_id", "asc"],
50+ });
51+ return rows.map((row) => String(row.external_id));
52+};
53+
54+test("building a scoped executor records the acting principal", async () => {
55+ const before = Date.now();
56+ await Effect.runPromise(
57+ createScopedExecutor("alice", "sightings-org").pipe(Effect.provide(dbLayer), Effect.asVoid),
58+ );
59+
60+ expect(await subjectsOf("sightings-org")).toEqual(["alice"]);
61+
62+ const [row] = await withQueryContext(dbHandle!.db, {
63+ tenant: "sightings-org",
64+ subject: null,
65+ }).findMany("subject", {});
66+ expect(Number(row?.last_seen_at)).toBeGreaterThanOrEqual(before);
67+});
68+
69+test("repeat requests from several members converge on one row each", async () => {
70+ const org = "sightings-org-2";
71+ await Effect.runPromise(
72+ Effect.forEach(
73+ ["alice", "alice", "bob", "alice"],
74+ (accountId) => createScopedExecutor(accountId, org).pipe(Effect.asVoid),
75+ { discard: true },
76+ ).pipe(Effect.provide(dbLayer)),
77+ );
78+
79+ // Four requests, two principals, two rows: the sighting is an upsert, and
80+ // the repeat sightings are absorbed by the throttle rather than duplicating.
81+ expect(await subjectsOf(org)).toEqual(["alice", "bob"]);
82+});
apps/host-selfhost/web/login.tsx+5−10
@@ -6,7 +6,7 @@ import { Label } from "@executor-js/react/components/label"; · 5 unmodified lines
66
77 import { authClient } from "./auth-client";
88 import { AuthLayout } from "./auth-layout";
9−import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to";
9+import { postLoginTarget } from "../src/auth/return-to";
1010
1111 // Self-host login: email + password sign-in via Better Auth. On success we
1212 // reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps
@@ -17,15 +17,10 @@ import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to"; · 4 unmodified lines
1717 // redeeming an invite — either the full /join/<code> link, or by entering the
1818 // code here ("Have an invite code?"), which forwards to the same join page.
1919 export const LoginPage = () => {
20− const search = window.location.search;
21− // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we
22− // arrived from one (Better Auth redirects it here with the OAuth params),
23− // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else
24− // land on the dashboard.
25− const postLogin =
26− mcpAuthorizeResumeTarget(search) ??
27− safeReturnTo(new URLSearchParams(search).get("returnTo")) ??
28− "/";
20+ // Where to go after sign-in. The gate renders this page IN PLACE of the
21+ // requested route without navigating, so the live location is what carries a
22+ // deep link (`/connect/linear`) across sign-in — see `postLoginTarget`.
23+ const postLogin = postLoginTarget(window.location);
2924 const [mode, setMode] = useState<"signin" | "code">("signin");
3025 const [email, setEmail] = useState("");
3126 const [password, setPassword] = useState("");
apps/host-selfhost/web/routeTree.gen.ts+48−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010
1111 import { Route as rootRouteImport } from './routes/__root'
1212 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
13+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
1314 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
1415 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
1516 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'
@@ -20,6 +21,7 @@ import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' · 4 unmodified lines
2021 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
2122 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
2223 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
24+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2325 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
2426 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
2527
@@ -29,6 +31,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 3 unmodified lines
2931 path: '/{-$orgSlug}/',
3032 getParentRoute: () => rootRouteImport,
3133 } as any)
34+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute =
35+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({
36+ id: '/{-$orgSlug}/users',
37+ path: '/{-$orgSlug}/users',
38+ getParentRoute: () => rootRouteImport,
39+ } as any)
3240 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =
3341 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({
3442 id: '/{-$orgSlug}/tools',
@@ -93,6 +101,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 58 unmodified lines
93101 getParentRoute: () => rootRouteImport,
94102 } as any,
95103 )
104+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
105+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
106+ {
107+ id: '/{-$orgSlug}/connect/$integrationSlug',
108+ path: '/{-$orgSlug}/connect/$integrationSlug',
109+ getParentRoute: () => rootRouteImport,
110+ } as any,
111+ )
96112 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
97113 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
98114 {
@@ -118,7 +134,9 @@ export interface FileRoutesByFullPath { · 19 unmodified lines
118134 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
119135 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
120136 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
137+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
121138 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
139+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
122140 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
123141 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
124142 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -133,7 +151,9 @@ export interface FileRoutesByTo { · 8 unmodified lines
133151 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
134152 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
135153 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
154+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
136155 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
156+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
137157 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
138158 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
139159 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -149,7 +169,9 @@ export interface FileRoutesById { · 9 unmodified lines
149169 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
150170 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
151171 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
172+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
152173 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
174+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
153175 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
154176 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
155177 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -166,7 +188,9 @@ export interface FileRouteTypes { · 10 unmodified lines
166188 | '/{-$orgSlug}/secrets'
167189 | '/{-$orgSlug}/toolkits'
168190 | '/{-$orgSlug}/tools'
191+ | '/{-$orgSlug}/users'
169192 | '/{-$orgSlug}/'
193+ | '/{-$orgSlug}/connect/$integrationSlug'
170194 | '/{-$orgSlug}/integrations/$namespace'
171195 | '/{-$orgSlug}/resume/$executionId'
172196 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -181,7 +205,9 @@ export interface FileRouteTypes { · 8 unmodified lines
181205 | '/{-$orgSlug}/secrets'
182206 | '/{-$orgSlug}/toolkits'
183207 | '/{-$orgSlug}/tools'
208+ | '/{-$orgSlug}/users'
184209 | '/{-$orgSlug}'
210+ | '/{-$orgSlug}/connect/$integrationSlug'
185211 | '/{-$orgSlug}/integrations/$namespace'
186212 | '/{-$orgSlug}/resume/$executionId'
187213 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -196,7 +222,9 @@ export interface FileRouteTypes { · 8 unmodified lines
196222 | '/{-$orgSlug}/secrets'
197223 | '/{-$orgSlug}/toolkits'
198224 | '/{-$orgSlug}/tools'
225+ | '/{-$orgSlug}/users'
199226 | '/{-$orgSlug}/'
227+ | '/{-$orgSlug}/connect/$integrationSlug'
200228 | '/{-$orgSlug}/integrations/$namespace'
201229 | '/{-$orgSlug}/resume/$executionId'
202230 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -212,7 +240,9 @@ export interface RootRouteChildren { · 9 unmodified lines
212240 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
213241 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
214242 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
243+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
215244 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
245+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
216246 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
217247 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
218248 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -228,6 +258,13 @@ declare module '@tanstack/react-router' { · 9 unmodified lines
228258 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport
229259 parentRoute: typeof rootRouteImport
230260 }
261+ '/{-$orgSlug}/users': {
262+ id: '/{-$orgSlug}/users'
263+ path: '/{-$orgSlug}/users'
264+ fullPath: '/{-$orgSlug}/users'
265+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport
266+ parentRoute: typeof rootRouteImport
267+ }
231268 '/{-$orgSlug}/tools': {
232269 id: '/{-$orgSlug}/tools'
233270 path: '/{-$orgSlug}/tools'
@@ -298,6 +335,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines
298335 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
299336 parentRoute: typeof rootRouteImport
300337 }
338+ '/{-$orgSlug}/connect/$integrationSlug': {
339+ id: '/{-$orgSlug}/connect/$integrationSlug'
340+ path: '/{-$orgSlug}/connect/$integrationSlug'
341+ fullPath: '/{-$orgSlug}/connect/$integrationSlug'
342+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport
343+ parentRoute: typeof rootRouteImport
344+ }
301345 '/{-$orgSlug}/plugins/$pluginId/$': {
302346 id: '/{-$orgSlug}/plugins/$pluginId/$'
303347 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -342,8 +386,12 @@ const rootRouteChildren: RootRouteChildren = { · 38 unmodified lines
342386 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
343387 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
344388 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
389+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute:
390+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute,
345391 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
346392 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
393+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
394+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
347395 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
348396 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
349397 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
apps/host-selfhost/web/routes/__root.tsx+10−1
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 } from "@executor-js/react/components/card";
1717 import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";
1818 import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
19+import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
1920 import { plugins as clientPlugins } from "virtual:executor/plugins-client";
2021
2122 import { authClient } from "../auth-client";
@@ -46,6 +47,13 @@ const selfHostNavItems = [ · 24 unmodified lines
4647 { to: "/admin", label: "Admin" },
4748 ];
4849
50+// Sections only an owner/admin of the instance may open. Users reads the
51+// tenant-wide admin plane, gated on a Better Auth owner/admin member, so a
52+// plain member is not shown a link that would only refuse them. (The existing
53+// /admin entry predates this and stays unconditional — it is this instance's
54+// member/invite page, and its own notice covers a non-admin who opens it.)
55+const selfHostAdminNavItems = [{ to: "/users", label: "Users" }];
56+
4957 const signOut = async () => {
5058 await authClient.signOut();
5159 window.location.href = "/";
@@ -150,12 +158,13 @@ function AuthGate({ children }: { children: ReactNode }) { · 98 unmodified lines
150158 function AuthenticatedApp() {
151159 const auth = useAuth();
152160 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
161+ const navItems = useAdminNavItems(selfHostNavItems, selfHostAdminNavItems);
153162
154163 // Single-org instance: a bare URL canonicalizes onto the instance org's
155164 // slug. There's only ever one org, so no other slug is reachable.
156165 const gated = (
157166 <>
158− <Shell onSignOut={signOut} navItems={selfHostNavItems} />
167+ <Shell onSignOut={signOut} navItems={navItems} />
159168 <Toaster />
160169 </>
161170 );
e2e/cloud/admin-users-console.test.ts+389−0
@@ -0,0 +1,389 @@
1+// Cloud-only: the admin Users PAGE — the console surface over `/api/admin/users*`.
2+//
3+// The sibling `admin-users.test.ts` pins the API's guarantees. This one pins
4+// what an operator actually sees, which the API scenario cannot: that the page
5+// renders every member of the workspace, that each row's connection summary
6+// distinguishes connected from merely available, that opening a user shows that
7+// user's own connections with their health, and — the failure path that matters
8+// most here — that a plain member who reaches the URL is told they don't have
9+// access rather than shown an empty workspace.
10+//
11+// Two members are built through the REAL flows (login → create-organization →
12+// invite → accept-invitation, in `./support/session`) and each connects their
13+// own credential, so the two rows differ in what they've connected and the
14+// summary has something to be right about.
15+import { randomBytes } from "node:crypto";
16+
17+import { expect } from "@effect/vitest";
18+import { Effect } from "effect";
19+import type { HttpApiClient } from "effect/unstable/httpapi";
20+import { composePluginApi } from "@executor-js/api/server";
21+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
22+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
23+
24+import { scenario } from "../src/scenario";
25+import { Api, Browser, Target } from "../src/services";
26+import { accountIdOf, forBrowser, joinOrg } from "./support/session";
27+
28+const api = composePluginApi([openApiHttpPlugin()] as const);
29+type Client = HttpApiClient.ForApi<typeof api>;
30+
31+declare global {
32+ interface Window {
33+ // The copy assertions stash what the page handed to the clipboard here, so
34+ // it can be read back out of the browser context without needing a
35+ // clipboard-read permission a headless context won't grant.
36+ __e2eCopied?: Array<string>;
37+ }
38+}
39+
40+const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
41+
42+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
43+const pingSpec = JSON.stringify({
44+ openapi: "3.0.3",
45+ info: { title: "Ping API", version: "1.0.0" },
46+ paths: {
47+ "/ping": {
48+ get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
49+ },
50+ },
51+});
52+
53+/** Registers a fresh apiKey-authenticated integration for connections to bind to. */
54+const registerIntegration = (client: Client, label: string) =>
55+ Effect.gen(function* () {
56+ const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`);
57+ yield* client.openapi.addSpec({
58+ payload: {
59+ spec: { kind: "blob", value: pingSpec },
60+ slug,
61+ baseUrl: "http://127.0.0.1:59999", // never contacted during registration
62+ authenticationTemplate: [
63+ {
64+ slug: "apiKey",
65+ type: "apiKey",
66+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
67+ },
68+ ],
69+ },
70+ });
71+ return slug;
72+ });
73+
74+const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
75+
76+scenario(
77+ "Admin · the Users page shows every member and what each has connected, and refuses a plain member",
78+ { timeout: 180_000 },
79+ Effect.gen(function* () {
80+ const target = yield* Target;
81+ const browser = yield* Browser;
82+ const { client: apiClient } = yield* Api;
83+
84+ const admin = yield* target.newIdentity();
85+ const invitee = yield* target.newIdentity({ org: false });
86+ const member = yield* joinOrg(target, admin, invitee);
87+
88+ const adminClient = yield* apiClient(api, admin);
89+ const memberClient = yield* apiClient(api, member);
90+ const adminId = yield* accountIdOf(target, admin);
91+ const memberId = yield* accountIdOf(target, member);
92+
93+ // Two integrations so the summary has a real available-vs-connected split:
94+ // each member connects one, so each row shows one connected and one not.
95+ const connectedIntegration = yield* registerIntegration(adminClient, "admin-ui-conn");
96+ const availableIntegration = yield* registerIntegration(adminClient, "admin-ui-avail");
97+ const adminConnection = freshConnectionName();
98+ const memberConnection = freshConnectionName();
99+
100+ yield* Effect.ensuring(
101+ Effect.gen(function* () {
102+ // Each member stores their OWN credential. Neither can see the other's
103+ // through the product plane — the admin page is the only surface that
104+ // reports both.
105+ yield* adminClient.connections.create({
106+ payload: {
107+ owner: "user",
108+ name: adminConnection,
109+ integration: connectedIntegration,
110+ template: TEMPLATE_API_KEY,
111+ value: "admin-personal-token",
112+ },
113+ });
114+ yield* memberClient.connections.create({
115+ payload: {
116+ owner: "user",
117+ name: memberConnection,
118+ integration: connectedIntegration,
119+ template: TEMPLATE_API_KEY,
120+ value: "member-personal-token",
121+ },
122+ });
123+
124+ // ── The admin's view ────────────────────────────────────────────────
125+ yield* browser.session(forBrowser(admin), async ({ page, step }) => {
126+ let slug = "";
127+
128+ await step("Land in the workspace and canonicalize onto the org slug", async () => {
129+ await page.goto("/", { waitUntil: "networkidle" });
130+ await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
131+ timeout: 30_000,
132+ });
133+ slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
134+ expect(slug, "the URL settled on an org slug").not.toBe("");
135+ });
136+
137+ await step("Open Users from the sidebar", async () => {
138+ // The section is admin-only, so an admin must actually see the link
139+ // — reaching the page by URL would not prove the nav gate opened.
140+ await page.getByRole("link", { name: "Users" }).click();
141+ await page.waitForURL((url) => url.pathname === `/${slug}/users`, { timeout: 30_000 });
142+ await page.locator("[data-slot='admin-users-table']").waitFor({ timeout: 30_000 });
143+ });
144+
145+ await step("Both members of the workspace are listed", async () => {
146+ const rows = page.locator("[data-slot='admin-user-row']");
147+ await rows.first().waitFor({ timeout: 30_000 });
148+ // The id is opaque and rendered shortened, so the row's full value
149+ // lives in the title the operator hovers for.
150+ const ids = await rows
151+ .locator("[data-slot='admin-user-id']")
152+ .evaluateAll((elements) =>
153+ elements.map((element) => element.getAttribute("title") ?? ""),
154+ );
155+ expect(ids, "the admin's own row is listed").toContain(adminId);
156+ expect(
157+ ids,
158+ "the invited member is listed too, though the admin's product view cannot see them",
159+ ).toContain(memberId);
160+
161+ // The host joins identity server-side, so an operator reads people
162+ // by name rather than by opaque id. The emails are the ones these
163+ // identities were actually minted with — asserting the seeded values
164+ // is what proves the join key lines up, since a wrong key would
165+ // leave every row unnamed while the id assertions above still pass.
166+ const names = await rows
167+ .locator("[data-slot='admin-user-name']")
168+ .evaluateAll((elements) => elements.map((element) => element.textContent ?? ""));
169+ expect(names, "the admin's row leads with their email").toContain(
170+ admin.credentials?.email,
171+ );
172+ expect(names, "and the invited member's with theirs").toContain(
173+ member.credentials?.email,
174+ );
175+ });
176+
177+ await step("Each row summarizes connected vs available integrations", async () => {
178+ const memberRow = page
179+ .locator("[data-slot='admin-user-row']")
180+ .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) });
181+ await memberRow.waitFor({ state: "visible", timeout: 30_000 });
182+ // A fresh cloud org's catalog is exactly the two integrations this
183+ // scenario registered: the built-in Executor integration is not
184+ // connectable, so it is neither a slot nor a denominator. The
185+ // denominator is therefore pinned, not read — a third slot here
186+ // would mean the built-in leaked back into the view.
187+ const summary = memberRow.locator("[data-slot='admin-user-connection-count']");
188+ await summary.waitFor({ state: "visible", timeout: 30_000 });
189+ expect(
190+ await summary.textContent(),
191+ "one of the two connectable integrations, with the built-in out of both numbers",
192+ ).toBe("1/2");
193+ expect(
194+ await memberRow.locator("[data-integration='executor']").count(),
195+ "the built-in integration has no connect flow, so it gets no slot",
196+ ).toBe(0);
197+ expect(
198+ await memberRow
199+ .locator(`[data-integration='${connectedIntegration}'][data-connected='true']`)
200+ .count(),
201+ "the integration this member connected is lit in their summary",
202+ ).toBe(1);
203+ expect(
204+ await memberRow
205+ .locator(`[data-integration='${availableIntegration}'][data-connected='false']`)
206+ .count(),
207+ "the one they haven't connected still has a slot — that absence is the view",
208+ ).toBe(1);
209+ });
210+
211+ await step("Open the member's detail and read their connections", async () => {
212+ await page
213+ .locator("[data-slot='admin-user-row']")
214+ .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) })
215+ .click();
216+ const detail = page.getByRole("dialog");
217+ await detail.waitFor({ state: "visible", timeout: 30_000 });
218+
219+ // Their own connection, by name, with the shared health vocabulary.
220+ await detail
221+ .getByText(memberConnection, { exact: true })
222+ .waitFor({ state: "visible", timeout: 30_000 });
223+ // Never probed, so the honest verdict is Unchecked — not Healthy.
224+ expect(
225+ await detail.getByLabel("Status: Unchecked").count(),
226+ "a never-probed connection reads as unchecked, not healthy",
227+ ).toBe(1);
228+ // The other member's credential is not this member's business.
229+ expect(
230+ await detail.getByText(adminConnection, { exact: true }).count(),
231+ "one user's detail never shows another user's connection",
232+ ).toBe(0);
233+ });
234+
235+ await step("The detail header copies the member's email and their id", async () => {
236+ const detail = page.getByRole("dialog");
237+ // Record what reaches the clipboard rather than reading it back:
238+ // a headless context grants no clipboard-read permission, and the
239+ // assertion is about what the button hands over, not about the
240+ // browser's store.
241+ await page.evaluate(() => {
242+ const copied: Array<string> = [];
243+ window.__e2eCopied = copied;
244+ Object.defineProperty(navigator, "clipboard", {
245+ configurable: true,
246+ get: () => ({
247+ writeText: (text: string) => {
248+ copied.push(text);
249+ return Promise.resolve();
250+ },
251+ }),
252+ });
253+ });
254+
255+ // Two buttons, not one: the email is what an operator pastes into a
256+ // mail client, the id is what they paste into a log search.
257+ await detail.getByRole("button", { name: "Copy email" }).click();
258+ await detail.getByRole("button", { name: "Copy ID" }).click();
259+
260+ const copied = await page.evaluate(() => window.__e2eCopied ?? []);
261+ expect(
262+ copied,
263+ "the email button copies the address the member signed up with",
264+ ).toContain(member.credentials?.email);
265+ expect(copied, "and the id button copies the opaque principal id").toContain(memberId);
266+ });
267+
268+ await step("The not-connected integration offers a copyable connect link", async () => {
269+ const detail = page.getByRole("dialog");
270+ const origin = new URL(target.baseUrl).origin;
271+ // The link carries THIS admin's org slug. A recipient who belongs
272+ // to several orgs would otherwise resolve the bare form against
273+ // their default org and connect in the wrong workspace. That
274+ // multi-org round trip is its own scenario
275+ // (`connect-link-multi-org.test.ts`), which needs a second org to
276+ // land in by mistake; here the concern is what this page RENDERS,
277+ // plus that the rendered link actually resolves (below).
278+ await detail
279+ .getByText(`${origin}/${slug}/connect/${availableIntegration}`, { exact: true })
280+ .waitFor({ state: "visible", timeout: 30_000 });
281+ expect(
282+ await detail
283+ .getByText(`${origin}/connect/${availableIntegration}`, { exact: true })
284+ .count(),
285+ "the org-free form is never rendered on a host that has orgs",
286+ ).toBe(0);
287+ // Exactly one: the member connected one of the two connectable
288+ // integrations, and the built-in offers no link at all.
289+ expect(
290+ await detail.getByRole("button", { name: "Copy link" }).count(),
291+ "one link per not-connected connectable integration, and none for the built-in",
292+ ).toBe(1);
293+ expect(
294+ await detail.getByText("/connect/executor", { exact: false }).count(),
295+ "the built-in integration is never offered as a connect link",
296+ ).toBe(0);
297+ });
298+
299+ await step("The rendered connect link actually resolves", async () => {
300+ // Following the link the page just rendered, rather than only
301+ // reading it back. A rendered string proves the page composed a URL;
302+ // it cannot tell a working link from a 404 — which is the failure an
303+ // operator would discover only after sending it to someone.
304+ const origin = new URL(target.baseUrl).origin;
305+ await page.goto(`${origin}/${slug}/connect/${availableIntegration}`, {
306+ waitUntil: "domcontentloaded",
307+ });
308+ // It forwards into that integration's own detail route with the
309+ // add-account handoff, still inside this admin's org.
310+ await page.waitForURL(
311+ (url) => url.pathname === `/${slug}/integrations/${availableIntegration}`,
312+ { timeout: 30_000 },
313+ );
314+ expect(
315+ new URL(page.url()).searchParams.get("addAccount"),
316+ "the link lands in the connect flow, not just on the page",
317+ ).toBe("1");
318+ });
319+ });
320+
321+ // ── The plain member's view ─────────────────────────────────────────
322+ yield* browser.session(forBrowser(member), async ({ page, step }) => {
323+ let slug = "";
324+
325+ await step("Sign in as a plain member of the same workspace", async () => {
326+ await page.goto("/", { waitUntil: "networkidle" });
327+ await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
328+ timeout: 30_000,
329+ });
330+ slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
331+ expect(slug, "the member landed in the workspace").not.toBe("");
332+ });
333+
334+ await step("The Users section is not offered to them", async () => {
335+ await page
336+ .getByRole("link", { name: "Integrations" })
337+ .first()
338+ .waitFor({ state: "visible", timeout: 30_000 });
339+ expect(
340+ await page.getByRole("link", { name: "Users" }).count(),
341+ "a plain member is not shown a section that would only refuse them",
342+ ).toBe(0);
343+ });
344+
345+ await step(
346+ "Reaching the URL directly says no access, not an empty workspace",
347+ async () => {
348+ await page.goto(`/${slug}/users`, { waitUntil: "networkidle" });
349+ // The denial is the assertion: an empty table here would misreport
350+ // a populated workspace as having no users.
351+ await page
352+ .getByText("You don't have access to this workspace's users")
353+ .waitFor({ state: "visible", timeout: 30_000 });
354+ expect(
355+ await page.locator("[data-slot='admin-users-table']").count(),
356+ "the refusal replaces the table rather than rendering it empty",
357+ ).toBe(0);
358+ expect(
359+ await page.getByText(adminId, { exact: true }).count(),
360+ "no other member's identity leaks to a refused viewer",
361+ ).toBe(0);
362+ },
363+ );
364+ });
365+ }),
366+ Effect.all(
367+ [
368+ adminClient.connections
369+ .remove({
370+ params: { owner: "user", integration: connectedIntegration, name: adminConnection },
371+ })
372+ .pipe(Effect.ignore),
373+ memberClient.connections
374+ .remove({
375+ params: { owner: "user", integration: connectedIntegration, name: memberConnection },
376+ })
377+ .pipe(Effect.ignore),
378+ adminClient.openapi
379+ .removeSpec({ params: { slug: connectedIntegration } })
380+ .pipe(Effect.ignore),
381+ adminClient.openapi
382+ .removeSpec({ params: { slug: availableIntegration } })
383+ .pipe(Effect.ignore),
384+ ],
385+ { discard: true },
386+ ),
387+ );
388+ }),
389+);
e2e/cloud/admin-users.test.ts+322−0
@@ -0,0 +1,322 @@
1+// Cloud-only: the OWNER's view of a workspace — `/api/admin/users*`. The
2+// product plane answers "what can I, this member, reach"; this plane answers
3+// "who uses my workspace, and what have they connected", reading across every
4+// member of the tenant instead of binding to one.
5+//
6+// Two members are built through the REAL flows (login → create-organization →
7+// invite → accept-invitation), each connects their own credential, and the
8+// admin then reads the joined view — the exact shape a customer dashboard's
9+// icon grid consumes. The guarantees pinned here:
10+//
11+// 1. the joined view reports BOTH members and each one's own connections,
12+// even though no single product-view caller can see another member's;
13+// 2. it never carries credential material;
14+// 3. a plain member is refused (403) and an anonymous caller too (401);
15+// 4. another tenant's admin sees none of it.
16+//
17+// EMULATOR GAP: the WorkOS emulator hardcodes `owner.type: "user"` in its api-key
18+// serializer and exposes no organization-key mint route, so an org-scoped API
19+// key — the machine credential for this plane — cannot exist end-to-end here.
20+// This scenario therefore drives the ADMIN SESSION path, which is the other
21+// credential the plane accepts. The org-key half is covered at unit level in
22+// `apps/cloud/src/auth/org-api-key-mint.node.test.ts` (against the real WorkOS
23+// response shape) and `org-api-key-auth.node.test.ts` (PlatformAuth resolution).
24+import { randomBytes } from "node:crypto";
25+
26+import { expect } from "@effect/vitest";
27+import { Effect } from "effect";
28+import type { HttpApiClient } from "effect/unstable/httpapi";
29+import { AdminUsersHttpApi } from "@executor-js/api";
30+import { composePluginApi } from "@executor-js/api/server";
31+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
32+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
33+
34+import { scenario } from "../src/scenario";
35+import { Api, Target } from "../src/services";
36+import { accountIdOf, cookieOf, joinOrg } from "./support/session";
37+
38+const api = composePluginApi([openApiHttpPlugin()] as const);
39+type Client = HttpApiClient.ForApi<typeof api>;
40+
41+const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
42+
43+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
44+const pingSpec = JSON.stringify({
45+ openapi: "3.0.3",
46+ info: { title: "Ping API", version: "1.0.0" },
47+ paths: {
48+ "/ping": {
49+ get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
50+ },
51+ },
52+});
53+
54+/** Registers a fresh apiKey-authenticated integration for connections to bind to. */
55+const registerIntegration = (client: Client) =>
56+ Effect.gen(function* () {
57+ const slug = IntegrationSlug.make(`admin-scn-${randomBytes(4).toString("hex")}`);
58+ yield* client.openapi.addSpec({
59+ payload: {
60+ spec: { kind: "blob", value: pingSpec },
61+ slug,
62+ baseUrl: "http://127.0.0.1:59999", // never contacted during registration
63+ authenticationTemplate: [
64+ {
65+ slug: "apiKey",
66+ type: "apiKey",
67+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
68+ },
69+ ],
70+ },
71+ });
72+ return slug;
73+ });
74+
75+const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
76+
77+scenario(
78+ "Admin · the owner sees every member of the workspace and what each has connected",
79+ {},
80+ Effect.gen(function* () {
81+ const target = yield* Target;
82+ const { client: apiClient } = yield* Api;
83+
84+ const admin = yield* target.newIdentity();
85+ const invitee = yield* target.newIdentity({ org: false });
86+ const member = yield* joinOrg(target, admin, invitee);
87+
88+ const adminClient = yield* apiClient(api, admin);
89+ const memberClient = yield* apiClient(api, member);
90+ const adminId = yield* accountIdOf(target, admin);
91+ const memberId = yield* accountIdOf(target, member);
92+
93+ const integration = yield* registerIntegration(adminClient);
94+ const adminConnection = freshConnectionName();
95+ const memberConnection = freshConnectionName();
96+
97+ yield* Effect.ensuring(
98+ Effect.gen(function* () {
99+ // Each member stores their OWN credential. Neither can see the other's
100+ // through the product plane — that is the whole point of the admin one.
101+ yield* adminClient.connections.create({
102+ payload: {
103+ owner: "user",
104+ name: adminConnection,
105+ integration,
106+ template: TEMPLATE_API_KEY,
107+ value: "admin-personal-token",
108+ },
109+ });
110+ yield* memberClient.connections.create({
111+ payload: {
112+ owner: "user",
113+ name: memberConnection,
114+ integration,
115+ template: TEMPLATE_API_KEY,
116+ value: "member-personal-token",
117+ },
118+ });
119+
120+ const client = yield* apiClient(AdminUsersHttpApi, admin);
121+
122+ // (1) The joined view: both members, each with their own connection.
123+ const joined = yield* client.adminUsers.listUsersWithConnections({ query: {} });
124+ const byId = new Map(joined.users.map((user) => [user.externalId, user]));
125+
126+ expect([...byId.keys()].sort(), "both members of the workspace are reported").toEqual(
127+ [adminId, memberId].sort(),
128+ );
129+ expect(
130+ byId.get(adminId)?.connections.map((connection) => connection.name),
131+ "the admin's own connection is attributed to them",
132+ ).toContain(adminConnection);
133+ expect(
134+ byId.get(memberId)?.connections.map((connection) => connection.name),
135+ "the member's connection is visible to the owner, though not to the admin's product view",
136+ ).toContain(memberConnection);
137+
138+ // The host identity join: the WorkOS email lands on the right row.
139+ //
140+ // The join key is the membership's `userId` — the same WorkOS `user_...`
141+ // the subject table records — so this proves the id spaces line up. A
142+ // mismatch would leave every user unnamed while the id assertions above
143+ // still passed, which is exactly the failure this asserts against.
144+ expect(
145+ byId.get(adminId)?.email,
146+ "the admin's row carries the email they signed in with",
147+ ).toBe(admin.credentials?.email);
148+ expect(byId.get(memberId)?.email, "and the invited member's row carries theirs").toBe(
149+ member.credentials?.email,
150+ );
151+
152+ // The flat list agrees with the joined one.
153+ const flat = yield* client.adminUsers.listUsers({ query: {} });
154+ expect(
155+ flat.users.map((user) => user.externalId).sort(),
156+ "the flat list reports the same members",
157+ ).toEqual([adminId, memberId].sort());
158+ expect(
159+ flat.users.map((user) => user.email).sort(),
160+ "with the same identities joined onto it",
161+ ).toEqual([admin.credentials?.email, member.credentials?.email].sort());
162+
163+ // Per-user connections resolve the same rows.
164+ const memberConnections = yield* client.adminUsers.listUserConnections({
165+ params: { externalId: memberId },
166+ });
167+ expect(
168+ memberConnections.connections.map((connection) => connection.name),
169+ "the member's connections are readable by external id",
170+ ).toContain(memberConnection);
171+
172+ // The single-user read, addressed by EMAIL, against real WorkOS-shaped
173+ // identity: the reverse directory lookup (email → user id) has to agree
174+ // with the forward join above, and the answer must carry identity AND
175+ // connections in the one call a per-user check makes.
176+ const memberEmail = member.credentials?.email ?? "";
177+ const single = yield* client.adminUsers.getUser({
178+ params: { identifier: memberEmail },
179+ });
180+ expect(single.user.externalId, "the email resolves to the same member").toBe(memberId);
181+ expect(single.user.email, "and carries the identity the list reported").toBe(memberEmail);
182+ expect(
183+ single.user.connections.map((connection) => connection.name),
184+ "with their connections joined in the same response",
185+ ).toContain(memberConnection);
186+
187+ // The same read by opaque id agrees, so neither identifier is a
188+ // different code path with a different answer.
189+ const byOpaqueId = yield* client.adminUsers.getUser({
190+ params: { identifier: memberId },
191+ });
192+ expect(byOpaqueId.user.email).toBe(memberEmail);
193+ expect(byOpaqueId.user.connections.map((connection) => connection.name)).toEqual(
194+ single.user.connections.map((connection) => connection.name),
195+ );
196+
197+ // The bulk filter selects the same member out of the workspace.
198+ const filtered = yield* client.adminUsers.listUsers({
199+ query: { email: memberEmail },
200+ });
201+ expect(
202+ filtered.users.map((user) => user.externalId),
203+ "?email= narrows the list to exactly that member",
204+ ).toEqual([memberId]);
205+
206+ // (2) No credential material anywhere in the joined view.
207+ expect(
208+ JSON.stringify(joined),
209+ "the admin view never carries a stored credential",
210+ ).not.toContain("member-personal-token");
211+ expect(JSON.stringify(joined)).not.toContain("admin-personal-token");
212+
213+ // (3) A plain member is refused: this plane reports on everyone.
214+ const asMember = yield* Effect.promise(() =>
215+ fetch(new URL("/api/admin/users", target.baseUrl), {
216+ headers: { cookie: cookieOf(member) },
217+ }),
218+ );
219+ expect(asMember.status, "a non-admin member cannot read the workspace view").toBe(403);
220+
221+ // And an anonymous caller has no credential at all.
222+ const anonymous = yield* Effect.promise(() =>
223+ fetch(new URL("/api/admin/users", target.baseUrl)),
224+ );
225+ expect(anonymous.status, "no session → unauthorized").toBe(401);
226+ }),
227+ Effect.all(
228+ [
229+ adminClient.connections
230+ .remove({ params: { owner: "user", integration, name: adminConnection } })
231+ .pipe(Effect.ignore),
232+ memberClient.connections
233+ .remove({ params: { owner: "user", integration, name: memberConnection } })
234+ .pipe(Effect.ignore),
235+ adminClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore),
236+ ],
237+ { discard: true },
238+ ),
239+ );
240+ }),
241+);
242+
243+scenario(
244+ "Admin · one workspace's owner sees nothing of another workspace",
245+ {},
246+ Effect.gen(function* () {
247+ const target = yield* Target;
248+ const { client: apiClient } = yield* Api;
249+
250+ const mine = yield* target.newIdentity();
251+ const theirs = yield* target.newIdentity();
252+ const myId = yield* accountIdOf(target, mine);
253+ const theirId = yield* accountIdOf(target, theirs);
254+
255+ const myClient = yield* apiClient(api, mine);
256+ // A user earns their row in the workspace the first time they touch the
257+ // EXECUTOR plane (the account plane doesn't bind an executor), so the other
258+ // owner makes one product read — otherwise their own workspace is empty and
259+ // the isolation assertion below would pass vacuously.
260+ yield* (yield* apiClient(api, theirs)).connections.list({ query: {} });
261+ const integration = yield* registerIntegration(myClient);
262+ const connection = freshConnectionName();
263+
264+ yield* Effect.ensuring(
265+ Effect.gen(function* () {
266+ yield* myClient.connections.create({
267+ payload: {
268+ owner: "user",
269+ name: connection,
270+ integration,
271+ template: TEMPLATE_API_KEY,
272+ value: "my-private-token",
273+ },
274+ });
275+
276+ const theirAdmin = yield* apiClient(AdminUsersHttpApi, theirs);
277+ const theirView = yield* theirAdmin.adminUsers.listUsersWithConnections({ query: {} });
278+
279+ expect(
280+ theirView.users.map((user) => user.externalId),
281+ "another workspace's owner never sees my members",
282+ ).not.toContain(myId);
283+ expect(
284+ theirView.users.map((user) => user.externalId),
285+ "they do see their own",
286+ ).toContain(theirId);
287+ expect(
288+ JSON.stringify(theirView),
289+ "and none of my connections leak across the tenant boundary",
290+ ).not.toContain(connection);
291+
292+ // Naming my account id directly returns nothing rather than my rows.
293+ const probe = yield* theirAdmin.adminUsers.listUserConnections({
294+ params: { externalId: myId },
295+ });
296+ expect(probe.connections, "a cross-tenant id resolves to nothing").toEqual([]);
297+
298+ // And the single-user read refuses to confirm I exist at all — by my
299+ // id or by my email, both of which are real, just not theirs.
300+ for (const identifier of [myId, mine.credentials?.email ?? ""]) {
301+ const response = yield* Effect.promise(() =>
302+ fetch(new URL(`/api/admin/users/${encodeURIComponent(identifier)}`, target.baseUrl), {
303+ headers: { cookie: cookieOf(theirs) },
304+ }),
305+ );
306+ expect(response.status, "a cross-tenant lookup is a plain 404, revealing nothing").toBe(
307+ 404,
308+ );
309+ }
310+ }),
311+ Effect.all(
312+ [
313+ myClient.connections
314+ .remove({ params: { owner: "user", integration, name: connection } })
315+ .pipe(Effect.ignore),
316+ myClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore),
317+ ],
318+ { discard: true },
319+ ),
320+ );
321+ }),
322+);
e2e/cloud/connect-link-multi-org.test.ts+232−0
@@ -0,0 +1,232 @@
1+// Cloud-only: the connect deep link's REASON FOR CARRYING AN ORG SLUG.
2+//
3+// An operator on the admin Users page copies a connect link for a member who
4+// hasn't connected something yet and sends it to them. `connectLinkUrl` builds
5+// that link org-PREFIXED (`/<org>/connect/<slug>`) rather than bare, and the
6+// failure that choice prevents is specific and silent: a recipient who belongs
7+// to several workspaces has ONE session-default org, and a bare `/connect/x`
8+// resolves against THAT one. They would land in the wrong workspace, connect a
9+// real credential there, and nothing anywhere would report a problem — the
10+// connection succeeds, it is just in the wrong tenant. The sender then keeps
11+// seeing an unconnected user.
12+//
13+// The existing coverage cannot catch this. `scenarios/connect-deep-link.test.ts`
14+// deliberately accepts EITHER URL form (it runs on hosts with and without an
15+// org segment) and its user belongs to one org, so both forms resolve to the
16+// same place. `packages/react/src/routes/connect-deep-link.test.ts` only proves
17+// the router parses the param. Neither has a second org to land in by mistake.
18+//
19+// So: a recipient who is a member of TWO orgs, whose session defaults to org B,
20+// follows an org-A-scoped link, and the credential must end up in org A.
21+//
22+// Both orgs register an integration under the SAME slug — that is what makes
23+// this a real test. With distinct slugs, org B's catalog would simply not
24+// contain the link's slug and the page would show its not-found state, so the
25+// scenario would pass for the wrong reason (a lookup miss rather than correct
26+// scoping). Sharing the slug means BOTH orgs can satisfy the link, and only the
27+// org prefix decides which one does.
28+import { randomBytes } from "node:crypto";
29+
30+import { expect } from "@effect/vitest";
31+import { Effect } from "effect";
32+import type { HttpApiClient } from "effect/unstable/httpapi";
33+import { composePluginApi } from "@executor-js/api/server";
34+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
35+import { IntegrationSlug } from "@executor-js/sdk/shared";
36+
37+import { scenario } from "../src/scenario";
38+import { Api, Browser, Target } from "../src/services";
39+import { activeOrg, forBrowser, joinOrg, organizationsOf } from "./support/session";
40+
41+const api = composePluginApi([openApiHttpPlugin()] as const);
42+type Client = HttpApiClient.ForApi<typeof api>;
43+
44+/** The spec's title, which the console uses to name a saved connection
45+ * ("Personal Ping API"). */
46+const INTEGRATION_TITLE = "Ping API";
47+
48+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
49+const pingSpec = JSON.stringify({
50+ openapi: "3.0.3",
51+ info: { title: INTEGRATION_TITLE, version: "1.0.0" },
52+ paths: {
53+ "/ping": {
54+ get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
55+ },
56+ },
57+});
58+
59+/** Register an apiKey-authenticated integration under an EXACT slug, so both
60+ * orgs can hold the same catalog identity. */
61+const registerIntegration = (client: Client, slug: IntegrationSlug) =>
62+ client.openapi.addSpec({
63+ payload: {
64+ spec: { kind: "blob", value: pingSpec },
65+ slug,
66+ baseUrl: "http://127.0.0.1:59999", // never contacted during registration
67+ authenticationTemplate: [
68+ {
69+ slug: "apiKey",
70+ type: "apiKey",
71+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
72+ },
73+ ],
74+ },
75+ });
76+
77+scenario(
78+ "Connect · an org-scoped connect link lands a multi-org recipient in the SENDING org",
79+ { timeout: 180_000 },
80+ Effect.gen(function* () {
81+ const target = yield* Target;
82+ const browser = yield* Browser;
83+ const { client: apiClient } = yield* Api;
84+
85+ // Two workspaces with two different owners, and one person invited into
86+ // both. Built through the real invite flow, so the memberships are the ones
87+ // WorkOS actually issues.
88+ const ownerA = yield* target.newIdentity();
89+ const ownerB = yield* target.newIdentity();
90+ const recipientSeed = yield* target.newIdentity({ org: false });
91+
92+ const joinedA = yield* joinOrg(target, ownerA, recipientSeed);
93+ // Accepting B's invitation SECOND re-seals the session onto org B, which is
94+ // exactly the hazard: the recipient's default is now the wrong workspace
95+ // for the link they are about to follow.
96+ const inOrgB = yield* joinOrg(target, ownerB, joinedA);
97+
98+ // `joinedA`'s cookie was re-sealed by the SECOND accept, so it is stale and
99+ // must not be used to read anything — the recipient's live session is
100+ // `inOrgB`, pointed at whichever org a given read is about.
101+ const orgA = yield* activeOrg(target, ownerA);
102+ const orgB = yield* activeOrg(target, inOrgB);
103+ expect(orgA.id, "the two workspaces are genuinely different").not.toBe(orgB.id);
104+
105+ const memberships = yield* organizationsOf(target, inOrgB);
106+ expect(
107+ memberships.organizations.map((org) => org.id).sort(),
108+ "the recipient really is a member of BOTH workspaces",
109+ ).toEqual([orgA.id, orgB.id].sort());
110+ expect(
111+ memberships.activeOrganizationId,
112+ "and their session defaults to org B — the org the link does NOT point at",
113+ ).toBe(orgB.id);
114+
115+ const ownerAClient = yield* apiClient(api, ownerA);
116+ const ownerBClient = yield* apiClient(api, ownerB);
117+
118+ // ONE slug, registered in BOTH catalogs (see the header note).
119+ const slug = IntegrationSlug.make(`multiorg-${randomBytes(4).toString("hex")}`);
120+
121+ yield* Effect.ensuring(
122+ Effect.gen(function* () {
123+ yield* registerIntegration(ownerAClient, slug);
124+ yield* registerIntegration(ownerBClient, slug);
125+
126+ // The link an operator copies off org A's Users page. Built here the
127+ // same way the page builds it (origin + org prefix + slug); the page's
128+ // own rendering of it is asserted in `admin-users-console.test.ts`.
129+ const origin = new URL(target.baseUrl).origin;
130+ const connectLink = `${origin}/${orgA.slug}/connect/${slug}`;
131+
132+ // The recipient follows the link with their org-B-default session.
133+ yield* browser.session(forBrowser(inOrgB), async ({ page, step }) => {
134+ await step("Follow the org-A connect link with an org-B session", async () => {
135+ // `domcontentloaded`, not `networkidle`: the console holds live
136+ // connections open, so idle is not a state this page reaches. The
137+ // real wait is the redirect assertion below.
138+ await page.goto(connectLink, { waitUntil: "domcontentloaded" });
139+ // The deep link forwards into the integration detail route with the
140+ // add-account handoff — and it must keep ORG A's prefix through the
141+ // redirect. Landing on `/${orgB.slug}/...` here IS the bug.
142+ await page.waitForURL((url) => url.pathname === `/${orgA.slug}/integrations/${slug}`, {
143+ timeout: 30_000,
144+ });
145+ expect(
146+ new URL(page.url()).pathname.split("/").filter(Boolean)[0],
147+ "the connect flow stayed in the SENDING org, not the session default",
148+ ).toBe(orgA.slug);
149+ expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1");
150+ });
151+
152+ await step("Complete the connection from that page", async () => {
153+ const dialog = page.getByRole("dialog");
154+ await dialog.getByRole("heading", { name: /Add connection/ }).waitFor({
155+ timeout: 30_000,
156+ });
157+ // The credential field is labelled by the method's PLACEMENT (the
158+ // `authorization` header this spec declares), not by the variable.
159+ // Waited for explicitly: the field renders only once the modal has
160+ // loaded the integration's auth methods, which is a second fetch
161+ // after the heading appears.
162+ const credential = dialog.getByRole("textbox", { name: "authorization" });
163+ await credential.waitFor({ state: "visible", timeout: 90_000 });
164+ await credential.fill("recipient-personal-token");
165+ // The offered health check is opt-in (it runs only on "Check"), and
166+ // this spec's base URL is never served — so the credential is saved
167+ // unprobed, which is what this scenario is about: WHERE it lands,
168+ // not whether it works.
169+ await dialog.getByRole("button", { name: "Continue" }).click();
170+ await dialog.getByRole("button", { name: "Add connection" }).click();
171+ // The saved credential appears as a row in the accounts list of the
172+ // page it was saved from — and that page is ORG A's (its URL was
173+ // pinned to `orgA.slug` in the step above). So this row IS the
174+ // "landed in the sending workspace" half of the guarantee, read
175+ // from the surface the recipient is actually looking at.
176+ //
177+ // Waited on rather than the success toast (which auto-dismisses
178+ // below the fold) or the dialog's disappearance (which races the
179+ // close animation).
180+ await page
181+ .getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true })
182+ .first()
183+ .waitFor({ state: "visible", timeout: 90_000 });
184+ expect(
185+ new URL(page.url()).pathname.split("/").filter(Boolean)[0],
186+ "the credential was saved from a page scoped to the SENDING org",
187+ ).toBe(orgA.slug);
188+ });
189+
190+ // ── The other half: it is NOT in the session's default org ─────────
191+ //
192+ // Same person, same session, same integration slug — the only thing
193+ // that differs is the org in the URL. Org B registered the SAME slug,
194+ // so this page exists and renders; it simply must hold no connection.
195+ // Had the link resolved against the session default, THIS is the page
196+ // the credential would be on.
197+ await step("Org B — the session's own default — has none", async () => {
198+ await page.goto(`${origin}/${orgB.slug}/integrations/${slug}?tab=accounts`, {
199+ waitUntil: "domcontentloaded",
200+ });
201+ // Wait for the accounts panel to finish loading before asserting an
202+ // absence, so "not rendered yet" cannot pass as "not there". The
203+ // empty state is the positive signal that the list resolved AND is
204+ // empty — checking only for the missing row would also pass while
205+ // the list was still loading.
206+ await page
207+ .getByRole("button", { name: "Add connection" })
208+ .first()
209+ .waitFor({ state: "visible", timeout: 90_000 });
210+ await page
211+ .getByText("No connections", { exact: false })
212+ .first()
213+ .waitFor({ state: "visible", timeout: 90_000 });
214+ expect(
215+ await page.getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }).count(),
216+ "nothing landed in the org the recipient's session happened to default to",
217+ ).toBe(0);
218+ });
219+ });
220+ }),
221+ // Removing each org's spec takes its connections with it, so the
222+ // UI-created credential (whose name the console chose) needs no lookup.
223+ Effect.all(
224+ [
225+ ownerAClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
226+ ownerBClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
227+ ],
228+ { discard: true },
229+ ),
230+ );
231+ }),
232+);
e2e/cloud/support/session.ts+205−0
@@ -0,0 +1,205 @@
1+// Cloud session + membership helpers shared by the scenarios that need MORE
2+// than one identity in an org.
3+//
4+// Every one of these is a thin wrapper over a REAL product endpoint — there is
5+// no back door here. They were copy-pasted verbatim into four cloud scenarios
6+// before living here, and the copies drifted in the two ways that matter, both
7+// now folded in:
8+//
9+// - only one carried `forBrowser`, the fix for the fact that `joinOrg`
10+// refreshes `headers.cookie` (the sealed session is re-issued on the new
11+// org) but leaves `cookies` on the PRE-join value — and the browser context
12+// is seeded from `cookies`. A scenario on a stale copy browses as the
13+// person's old org while its API calls use the new one.
14+// - only one forwarded the ORG SELECTOR header. Org-scoped endpoints fail
15+// closed without it (`AccountNoOrganization`), so a cookie-only copy cannot
16+// even build the fixture: `/api/account/members/invite` 403s.
17+//
18+// `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module
19+// is never collected as a suite.
20+import { Effect } from "effect";
21+
22+import type { Identity, Target as TargetShape } from "../../src/target";
23+
24+/**
25+ * The per-request org selector. `/api/auth/switch-organization` was removed
26+ * (#1000): the URL prefix or this header is the scope authority now, and the
27+ * session's own org is only a default.
28+ *
29+ * This is the RIGHT way to read one account's data in a specific org: a session
30+ * cookie captured before the account joined another org is stale the moment the
31+ * later `accept-invitation` re-seals it, so holding onto the old cookie reads as
32+ * an unauthenticated caller. Take the CURRENT identity and point it at the org
33+ * you mean.
34+ *
35+ * Mirrors `ORG_SELECTOR_HEADER` in `apps/cloud/src/auth/organization.ts`
36+ * (`EXECUTOR_ORG_SELECTOR_HEADER` in the SDK's shared exports); duplicated as a
37+ * literal so the e2e suite stays a black-box client of the deployed product.
38+ */
39+const ORG_SELECTOR_HEADER = "x-executor-organization";
40+
41+/** The session cookie header this identity authenticates API calls with. */
42+export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
43+
44+/**
45+ * The identity re-bound so the BROWSER carries the same session the API calls
46+ * do. `joinOrg` / `createOrg` refresh `headers.cookie` (the org is baked into
47+ * the sealed session) but leave `cookies` on the pre-change value, and the
48+ * browser context is seeded from `cookies`.
49+ */
50+export const forBrowser = (identity: Identity): Identity => {
51+ const cookie = cookieOf(identity);
52+ const separator = cookie.indexOf("=");
53+ if (separator < 0) throw new Error("identity carries no session cookie");
54+ return {
55+ ...identity,
56+ cookies: [{ name: cookie.slice(0, separator), value: cookie.slice(separator + 1) }],
57+ };
58+};
59+
60+/** POST as `identity`, failing loudly on a non-2xx (a silent skip here would
61+ * leave the scenario asserting against a half-built fixture).
62+ *
63+ * Sends the identity's OWN headers, not just its cookie: org-scoped endpoints
64+ * (`/api/account/members/invite` among them) fail closed with
65+ * `AccountNoOrganization` when the org selector header is absent, because the
66+ * session's own org is only a default and is never fallen back to. */
67+export const postJson = (
68+ target: TargetShape,
69+ path: string,
70+ identity: Identity,
71+ body: unknown,
72+): Effect.Effect<Response> =>
73+ Effect.promise(async () => {
74+ const response = await fetch(new URL(path, target.baseUrl), {
75+ method: "POST",
76+ headers: {
77+ ...(identity.headers ?? {}),
78+ "content-type": "application/json",
79+ origin: new URL(target.baseUrl).origin,
80+ },
81+ body: JSON.stringify(body),
82+ });
83+ if (!response.ok) {
84+ throw new Error(`${path} failed (${response.status}): ${await response.text()}`);
85+ }
86+ return response;
87+ });
88+
89+/** The identity re-bound to the refreshed session cookie a response set, and
90+ * scoped to `orgSelector` — carrying the cookie alone would leave every
91+ * org-scoped read the identity goes on to make failing closed. */
92+export const withRefreshedSession = (
93+ identity: Identity,
94+ response: Response,
95+ orgSelector: string,
96+): Identity => {
97+ const refreshed = (response.headers.getSetCookie?.() ?? [])
98+ .find((header) => header.startsWith("wos-session="))
99+ ?.split(";")[0];
100+ if (!refreshed) throw new Error("response did not refresh the session cookie");
101+ return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } };
102+};
103+
104+/** The org selector this identity's requests carry — the same header the web
105+ * client derives from the console URL's slug. */
106+export const orgSelectorOf = (identity: Identity): string => {
107+ const selector = identity.headers?.[ORG_SELECTOR_HEADER];
108+ if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`);
109+ return selector;
110+};
111+
112+/**
113+ * Invite `member` into `admin`'s org and accept — the real invite flow. The
114+ * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor
115+ * an access-denial assertion needs); pass `roleSlug` for anything else.
116+ *
117+ * The returned identity is bound to the newly-joined org — the re-sealed
118+ * session from accept-invitation, scoped by the selector to the org they were
119+ * invited INTO (`admin`'s). Calling this twice for one person is how a scenario
120+ * builds a multi-org member: the WorkOS membership in the earlier org persists,
121+ * and both the session and the selector point at the later one.
122+ */
123+export const joinOrg = (
124+ target: TargetShape,
125+ admin: Identity,
126+ member: Identity,
127+ options: { readonly roleSlug?: string } = {},
128+): Effect.Effect<Identity> =>
129+ Effect.gen(function* () {
130+ const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
131+ email: member.credentials?.email,
132+ ...(options.roleSlug === undefined ? {} : { roleSlug: options.roleSlug }),
133+ });
134+ const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string };
135+ const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
136+ invitationId: invitation.id,
137+ });
138+ return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin));
139+ });
140+
141+/** Create another org for this account; returns the identity bound to it. */
142+export const createOrg = (
143+ target: TargetShape,
144+ identity: Identity,
145+ name: string,
146+): Effect.Effect<Identity> =>
147+ Effect.gen(function* () {
148+ const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });
149+ const created = (yield* Effect.promise(() => response.clone().json())) as { id: string };
150+ return withRefreshedSession(identity, response, created.id);
151+ });
152+
153+/** This identity, scoped to `organizationId` for the requests it makes. */
154+export const inOrg = (identity: Identity, organizationId: string): Identity => ({
155+ ...identity,
156+ headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId },
157+});
158+
159+/** The caller's own account id — the `externalId` the admin plane reports and
160+ * the users page renders in mono. */
161+export const accountIdOf = (target: TargetShape, identity: Identity): Effect.Effect<string> =>
162+ Effect.promise(async () => {
163+ const response = await fetch(new URL("/api/account/me", target.baseUrl), {
164+ headers: { cookie: cookieOf(identity) },
165+ });
166+ if (!response.ok) throw new Error(`/api/account/me failed (${response.status})`);
167+ const body = (await response.json()) as { user: { id: string } };
168+ return body.user.id;
169+ });
170+
171+/** The org this identity's session is currently bound to: id and URL slug. */
172+export const activeOrg = (
173+ target: TargetShape,
174+ identity: Identity,
175+): Effect.Effect<{ readonly id: string; readonly slug: string }> =>
176+ Effect.promise(async () => {
177+ const response = await fetch(new URL("/api/auth/me", target.baseUrl), {
178+ headers: { cookie: cookieOf(identity) },
179+ });
180+ if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
181+ const body = (await response.json()) as {
182+ organization: { id: string; slug: string } | null;
183+ };
184+ if (!body.organization) throw new Error("identity has no active organization");
185+ return { id: body.organization.id, slug: body.organization.slug };
186+ });
187+
188+/** Every org this account is a member of, plus the one the session is on. */
189+export const organizationsOf = (
190+ target: TargetShape,
191+ identity: Identity,
192+): Effect.Effect<{
193+ readonly organizations: ReadonlyArray<{ readonly id: string; readonly slug: string }>;
194+ readonly activeOrganizationId: string | null;
195+}> =>
196+ Effect.promise(async () => {
197+ const response = await fetch(new URL("/api/auth/organizations", target.baseUrl), {
198+ headers: { cookie: cookieOf(identity) },
199+ });
200+ if (!response.ok) throw new Error(`/api/auth/organizations failed (${response.status})`);
201+ return (await response.json()) as {
202+ organizations: ReadonlyArray<{ id: string; slug: string }>;
203+ activeOrganizationId: string | null;
204+ };
205+ });
e2e/cloud/surface-reachability.test.ts+21−0
@@ -35,6 +35,27 @@ scenario( · 34 unmodified lines
3535 expect(paths, "path-param routes are documented").toContain(
3636 "/api/executions/{executionId}/resume",
3737 );
38+
39+ // The account and admin-users groups. These are composed into the spec by
40+ // hand (`CloudOpenApi` in `extensions/routes.ts`), and the composition is
41+ // the ONLY thing that documents them — the routes serve either way, so a
42+ // group dropped from the `.add(...)` chain leaves a mounted, undocumented
43+ // plane and nothing else fails. Asserted against the SERVED spec rather
44+ // than the module: cloud builds the same composition twice (here and in
45+ // `extensions/docs.ts`) and only this one reaches the runtime, so importing
46+ // either module could pass while the wire is wrong.
47+ expect(paths, "the account plane is documented").toContain("/api/account/me");
48+ expect(paths, "including the org-key surface the console reads").toContain(
49+ "/api/account/org-api-keys",
50+ );
51+ expect(paths, "the tenant-wide admin users plane is documented").toContain("/api/admin/users");
52+ expect(paths, "including the joined view the console list consumes").toContain(
53+ "/api/admin/users/with-connections",
54+ );
55+ expect(paths, "and the per-user connections read").toContain(
56+ "/api/admin/users/{externalId}/connections",
57+ );
58+ expect(paths, "and the single-user read").toContain("/api/admin/users/{identifier}");
3859 }),
3960 );
4061
e2e/scenarios/connect-deep-link.test.ts+168−0
@@ -0,0 +1,168 @@
1+// The connect deep link: a customer sends an end user a direct link for ONE
2+// integration ("connect your Linear") instead of pointing them at the dashboard
3+// to hunt for it. `/connect/<integration slug>` resolves the slug against the
4+// tenant's catalog and lands the person in the SAME connect flow the
5+// integration detail page's own button opens.
6+//
7+// The slug — not a plugin id — is the stable identity here: it is what a
8+// customer can write down and template into an email.
9+//
10+// Covered:
11+// 1. Signed in, known slug → the Add connection flow is open on the right
12+// integration, with the auth methods the spec declares
13+// 2. Unknown slug → a clear not-found state, not a blank page or a
14+// crash (an end user WILL mistype these, and links outlive integrations)
15+// 3. Already connected → the deep link shows the existing connection and
16+// still offers to add another account
17+import { randomBytes, randomUUID } from "node:crypto";
18+
19+import { expect } from "@effect/vitest";
20+import { Effect } from "effect";
21+import { AccountHttpApi } from "@executor-js/api";
22+import { composePluginApi } from "@executor-js/api/server";
23+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24+import {
25+ AuthTemplateSlug,
26+ ConnectionName,
27+ IntegrationSlug,
28+ ProviderItemId,
29+} from "@executor-js/sdk/shared";
30+
31+import { scenario } from "../src/scenario";
32+import { Api, Browser, Target } from "../src/services";
33+
34+const api = composePluginApi([openApiHttpPlugin()] as const);
35+
36+/** OpenAPI 3 spec with a single operation — enough to be a real catalog entry. */
37+const greetSpec = (baseUrl: string): string =>
38+ JSON.stringify({
39+ openapi: "3.0.3",
40+ info: { title: "Deep Link API", version: "1.0.0" },
41+ servers: [{ url: baseUrl }],
42+ paths: {
43+ "/greet": {
44+ get: {
45+ operationId: "getGreeting",
46+ summary: "Return a greeting message",
47+ responses: { "200": { description: "A greeting" } },
48+ },
49+ },
50+ },
51+ });
52+
53+scenario(
54+ "Connect · /connect/<slug> lands an end user in that integration's connect flow",
55+ { timeout: 180_000 },
56+ Effect.gen(function* () {
57+ const target = yield* Target;
58+ const browser = yield* Browser;
59+ const { client } = yield* Api;
60+
61+ const identity = yield* target.newIdentity();
62+ const apiClient = yield* client(api, identity);
63+
64+ // Selfhost shares one bootstrap-admin identity, so a unique slug keeps
65+ // repeated and parallel runs out of each other's catalogs.
66+ const slug = `connect-dl-${randomBytes(4).toString("hex")}`;
67+ const specBaseUrl = "http://127.0.0.1:59999"; // never contacted here
68+
69+ // Org-scoped hosts canonicalize console URLs onto the active org's slug, so
70+ // the deep link may legitimately arrive at either `/connect/x` or
71+ // `/<org>/connect/x`. Read the slug the shell will canonicalize onto.
72+ const accountClient = yield* client(AccountHttpApi, identity);
73+ const me = yield* accountClient.account.me();
74+ const orgSlug = me.organization?.slug ?? null;
75+ const detailPath = orgSlug ? `/${orgSlug}/integrations/${slug}` : `/integrations/${slug}`;
76+
77+ yield* Effect.ensuring(
78+ Effect.gen(function* () {
79+ const added = yield* apiClient.openapi.addSpec({
80+ payload: {
81+ spec: { kind: "blob", value: greetSpec(specBaseUrl) },
82+ slug,
83+ name: "Deep Link API",
84+ baseUrl: specBaseUrl,
85+ authenticationTemplate: [
86+ {
87+ slug: "apiKey",
88+ type: "apiKey",
89+ headers: { "x-api-key": [{ type: "variable", name: "token" }] },
90+ },
91+ ],
92+ },
93+ });
94+ expect(added.slug, "the integration keeps the requested slug").toBe(slug);
95+
96+ yield* browser.session(identity, async ({ page, step }) => {
97+ await step("Open the connect deep link for the seeded integration", async () => {
98+ await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" });
99+ // It forwards into the integration's OWN detail route rather than
100+ // rendering a parallel connect UI, carrying the add-account handoff.
101+ await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 });
102+ expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1");
103+ });
104+
105+ await step("The Add connection flow is open for that integration", async () => {
106+ await page
107+ .getByRole("heading", { name: /Add connection/ })
108+ .waitFor({ timeout: 20_000 });
109+ // The right integration, and the declared auth method is offered —
110+ // proof it resolved the slug rather than opening a generic dialog.
111+ await page
112+ .getByRole("dialog")
113+ .getByRole("textbox", { name: "x-api-key" })
114+ .waitFor({ timeout: 20_000 });
115+ });
116+
117+ await step("An unknown slug is a clear not-found, not a blank page", async () => {
118+ await page.goto("/connect/zz-no-such-integration", { waitUntil: "networkidle" });
119+ await page
120+ .getByText("Unknown integration: zz-no-such-integration")
121+ .waitFor({ timeout: 30_000 });
122+ await page.getByRole("link", { name: "Back to integrations" }).waitFor();
123+ });
124+ });
125+
126+ // Already connected: the deep link must show the existing connection
127+ // and still let the person add another account.
128+ const providers = yield* apiClient.providers.list();
129+ expect(providers.length, "a credential provider is available").toBeGreaterThan(0);
130+ yield* apiClient.connections.create({
131+ payload: {
132+ owner: "org",
133+ name: ConnectionName.make("existing"),
134+ integration: IntegrationSlug.make(slug),
135+ template: AuthTemplateSlug.make("apiKey"),
136+ from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
137+ },
138+ });
139+
140+ yield* browser.session(identity, async ({ page, step }) => {
141+ await step("The deep link surfaces the existing connection", async () => {
142+ await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" });
143+ await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 });
144+ // The connect flow still opens (this link means "connect me"), and
145+ // the already-saved account is visible behind it.
146+ await page
147+ .getByRole("heading", { name: /Add connection/ })
148+ .waitFor({ timeout: 20_000 });
149+ await page.keyboard.press("Escape");
150+ await page.getByText("existing").first().waitFor({ timeout: 20_000 });
151+ });
152+ });
153+ }),
154+ Effect.gen(function* () {
155+ yield* apiClient.connections
156+ .remove({
157+ params: {
158+ owner: "org",
159+ integration: IntegrationSlug.make(slug),
160+ name: ConnectionName.make("existing"),
161+ },
162+ })
163+ .pipe(Effect.ignore);
164+ yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
165+ }).pipe(Effect.ignore),
166+ );
167+ }),
168+);
e2e/selfhost/admin-users-console.test.ts+330−0
@@ -0,0 +1,330 @@
1+// Selfhost-only (browser): the admin Users page against the OTHER auth model.
2+//
3+// Cloud gates this plane on a WorkOS `admin` membership; self-host gates it on
4+// a Better Auth `owner`/`admin` member. The console is shared, so the same page
5+// must open for the instance owner and refuse an invited plain member — that
6+// second role vocabulary is exactly what the cloud scenario cannot cover.
7+//
8+// Selfhost identities are shared across scenarios (one bootstrap admin, one
9+// org), so this asserts "contains mine" rather than global counts, and every
10+// resource it creates is prefixed.
11+import { randomBytes } from "node:crypto";
12+
13+import { expect } from "@effect/vitest";
14+import { Effect } from "effect";
15+import type { HttpApiClient } from "effect/unstable/httpapi";
16+import { composePluginApi } from "@executor-js/api/server";
17+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
18+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
19+
20+import { scenario } from "../src/scenario";
21+import { createInvitedIdentity } from "../targets/selfhost";
22+import { Api, Browser, Target } from "../src/services";
23+
24+const api = composePluginApi([openApiHttpPlugin()] as const);
25+type Client = HttpApiClient.ForApi<typeof api>;
26+
27+declare global {
28+ interface Window {
29+ // What the page handed to the clipboard through the non-secure-origin
30+ // execCommand fallback, read back out of the browser context.
31+ __e2eCopied?: Array<string>;
32+ }
33+}
34+
35+const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
36+
37+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
38+const pingSpec = JSON.stringify({
39+ openapi: "3.0.3",
40+ info: { title: "Ping API", version: "1.0.0" },
41+ paths: {
42+ "/ping": {
43+ get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
44+ },
45+ },
46+});
47+
48+const registerIntegration = (client: Client, label = "admin-ui-sh") =>
49+ Effect.gen(function* () {
50+ const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`);
51+ yield* client.openapi.addSpec({
52+ payload: {
53+ spec: { kind: "blob", value: pingSpec },
54+ slug,
55+ baseUrl: "http://127.0.0.1:59999", // never contacted during registration
56+ authenticationTemplate: [
57+ {
58+ slug: "apiKey",
59+ type: "apiKey",
60+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
61+ },
62+ ],
63+ },
64+ });
65+ return slug;
66+ });
67+
68+scenario(
69+ "Admin · a self-hosted owner sees the workspace's users; an invited member is refused",
70+ { timeout: 180_000 },
71+ Effect.gen(function* () {
72+ const target = yield* Target;
73+ const browser = yield* Browser;
74+ const { client: apiClient } = yield* Api;
75+
76+ // The bootstrap identity owns the instance; the invited one is a plain
77+ // member, which is the Better Auth role the admin plane refuses.
78+ const owner = yield* target.newIdentity();
79+ const member = yield* Effect.promise(() =>
80+ createInvitedIdentity(target.baseUrl, owner, {
81+ role: "member",
82+ emailPrefix: "admin-ui-member",
83+ }),
84+ );
85+
86+ const ownerClient = yield* apiClient(api, owner);
87+ const memberClient = yield* apiClient(api, member);
88+ const integration = yield* registerIntegration(ownerClient);
89+ // A second, deliberately unconnected integration, so the member's detail is
90+ // guaranteed to render at least one connect link to assert the shape of.
91+ const availableIntegration = yield* registerIntegration(ownerClient, "admin-ui-sh-avail");
92+ const memberConnection = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
93+
94+ yield* Effect.ensuring(
95+ Effect.gen(function* () {
96+ // The member stores their own credential, so the owner's view has
97+ // something to report that the owner's product view cannot see.
98+ yield* memberClient.connections.create({
99+ payload: {
100+ owner: "user",
101+ name: memberConnection,
102+ integration,
103+ template: TEMPLATE_API_KEY,
104+ value: "member-personal-token",
105+ },
106+ });
107+
108+ yield* browser.session(owner, async ({ page, step }) => {
109+ await step("Open Users from the sidebar as the instance owner", async () => {
110+ await page.goto("/", { waitUntil: "networkidle" });
111+ await page
112+ .locator("nav")
113+ .getByRole("link", { name: "Users" })
114+ .first()
115+ .click({ timeout: 30_000 });
116+ await page
117+ .locator("[data-slot='admin-users-table']")
118+ .waitFor({ state: "visible", timeout: 30_000 });
119+ });
120+
121+ await step("The invited member's connection is attributed to them", async () => {
122+ // Selfhost shares one org across scenarios, so this asserts the
123+ // member's own row exists — never a count of the whole instance.
124+ const row = page
125+ .locator("[data-slot='admin-user-row']")
126+ .filter({
127+ has: page.locator(`[data-integration='${integration}'][data-connected='true']`),
128+ })
129+ .first();
130+ await row.waitFor({ state: "visible", timeout: 30_000 });
131+
132+ // Better Auth's identity, joined server-side. The join key is the
133+ // member list's `userId` (the Better Auth user id the subject table
134+ // records), NOT the organization `member` row id — so asserting the
135+ // email this identity was actually minted with is what proves the
136+ // id spaces line up on THIS host's auth model.
137+ expect(
138+ await row.locator("[data-slot='admin-user-name']").textContent(),
139+ "the member's row leads with the email they signed up with",
140+ ).toBe(member.credentials?.email);
141+
142+ await row.click();
143+
144+ const detail = page.getByRole("dialog");
145+ await detail.waitFor({ state: "visible", timeout: 30_000 });
146+ await detail
147+ .getByText(memberConnection, { exact: true })
148+ .waitFor({ state: "visible", timeout: 30_000 });
149+ });
150+
151+ await step("The detail header copies the member's email and their id", async () => {
152+ const detail = page.getByRole("dialog");
153+ // The member's opaque id, from the supporting line the header shows
154+ // beneath the email.
155+ const externalId = await detail
156+ .locator("[data-slot='admin-user-detail-id']")
157+ .getAttribute("title");
158+ expect(externalId, "the header carries the opaque principal id").not.toBe("");
159+
160+ // Self-host is served over plain HTTP — a NON-secure origin, where
161+ // `navigator.clipboard` does not exist and the copy must go through
162+ // the execCommand fallback (see @executor-js/react `lib/clipboard`).
163+ // Record what that path selects, exactly as the api-keys copy test
164+ // does, so this asserts the real self-host copy path rather than a
165+ // secure-context one the deployment never takes.
166+ await page.evaluate(() => {
167+ Object.defineProperty(navigator, "clipboard", {
168+ configurable: true,
169+ get: () => undefined,
170+ });
171+ const copied: Array<string> = [];
172+ window.__e2eCopied = copied;
173+ const exec = document.execCommand?.bind(document);
174+ document.execCommand = (command, ...rest) => {
175+ if (String(command).toLowerCase() === "copy") {
176+ copied.push(window.getSelection ? String(window.getSelection()) : "");
177+ }
178+ return exec ? exec(command, ...rest) : false;
179+ };
180+ });
181+
182+ await detail.getByRole("button", { name: "Copy email" }).click();
183+ await detail.getByRole("button", { name: "Copy ID" }).click();
184+
185+ const copied = await page.evaluate(() => window.__e2eCopied ?? []);
186+ expect(copied, "the email button copies the member's address").toContain(
187+ member.credentials?.email,
188+ );
189+ expect(copied, "and the id button copies their opaque id").toContain(externalId);
190+ });
191+
192+ await step(
193+ "The built-in integration is never offered as something to connect",
194+ async () => {
195+ // It is a static, credential-free source: there is no connect flow
196+ // to send anyone to, so it is neither a slot nor a link. This is the
197+ // reported bug — it used to appear under "not connected" with a
198+ // copyable link to a route that would do nothing.
199+ const detail = page.getByRole("dialog");
200+ expect(
201+ await detail.locator("[data-integration='executor']").count(),
202+ "the built-in gets no slot in the detail sheet",
203+ ).toBe(0);
204+ expect(
205+ await detail.getByText("/connect/executor", { exact: false }).count(),
206+ "and no connect link is rendered for it",
207+ ).toBe(0);
208+ },
209+ );
210+
211+ await step("A connect link is scoped to the org the admin is viewing", async () => {
212+ // The invariant is a round trip, not a fixed shape: whatever org the
213+ // console is currently scoped to is the org the link carries, so a
214+ // recipient cannot be sent into a different workspace than the one
215+ // the admin was looking at.
216+ //
217+ // Self-host DOES have an org segment — it canonicalizes onto a
218+ // `default` org — so this host exercises the prefixed form just as
219+ // cloud does. The org-free form is what a host that renders no
220+ // segment at all would produce, and it is covered by the display
221+ // unit tests rather than pinned to a host here.
222+ //
223+ // The SHAPE of every rendered link is what this step pins; that the
224+ // link resolves is asserted by following one in the next step, and
225+ // the multi-org "lands in the sending workspace" round trip is
226+ // cloud's `connect-link-multi-org.test.ts` (it needs a second org,
227+ // which self-host's single turnkey org cannot provide).
228+ const detail = page.getByRole("dialog");
229+ const origin = new URL(target.baseUrl).origin;
230+ // The console's own org prefix, read from the URL the page settled
231+ // on — "" when this host renders no segment.
232+ const consolePath = new URL(page.url()).pathname;
233+ const orgPrefix = consolePath.endsWith("/users")
234+ ? consolePath.slice(0, -"/users".length)
235+ : "";
236+
237+ // The integration nobody connected is the one whose link must be here.
238+ await detail
239+ .getByText(`${origin}${orgPrefix}/connect/${availableIntegration}`, { exact: true })
240+ .waitFor({ state: "visible", timeout: 30_000 });
241+
242+ // And EVERY rendered link agrees with the console's own scope — one
243+ // link pointing at another org would silently misroute a recipient.
244+ const links = await detail
245+ .locator("[data-slot='admin-user-connect-link']")
246+ .allTextContents();
247+ expect(links.length, "the owner sees at least one link to send").toBeGreaterThanOrEqual(
248+ 1,
249+ );
250+ for (const link of links) {
251+ const parsed = new URL(link);
252+ expect(parsed.origin, `absolute for this deployment: ${link}`).toBe(origin);
253+ expect(
254+ parsed.pathname.startsWith(`${orgPrefix}/connect/`),
255+ `scoped to the console's own org (${orgPrefix || "<none>"}): ${link}`,
256+ ).toBe(true);
257+ expect(
258+ parsed.pathname.slice(`${orgPrefix}/connect/`.length).split("/").length,
259+ `exactly one slug after /connect/: ${link}`,
260+ ).toBe(1);
261+ }
262+ });
263+
264+ await step("The rendered connect link actually resolves", async () => {
265+ // Following the link the page just rendered. A rendered string
266+ // proves the page composed a URL; it cannot distinguish a working
267+ // link from a 404 — which an operator would only discover after
268+ // sending it to someone.
269+ const origin = new URL(target.baseUrl).origin;
270+ const consolePath = new URL(page.url()).pathname;
271+ const orgPrefix = consolePath.endsWith("/users")
272+ ? consolePath.slice(0, -"/users".length)
273+ : "";
274+ await page.goto(`${origin}${orgPrefix}/connect/${availableIntegration}`, {
275+ waitUntil: "domcontentloaded",
276+ });
277+ // It forwards into that integration's own detail route with the
278+ // add-account handoff, still inside the console's own org.
279+ await page.waitForURL(
280+ (url) => url.pathname === `${orgPrefix}/integrations/${availableIntegration}`,
281+ { timeout: 30_000 },
282+ );
283+ expect(
284+ new URL(page.url()).searchParams.get("addAccount"),
285+ "the link lands in the connect flow, not just on the page",
286+ ).toBe("1");
287+ });
288+ });
289+
290+ yield* browser.session(member, async ({ page, step }) => {
291+ await step("A plain member is not offered the section", async () => {
292+ await page.goto("/", { waitUntil: "networkidle" });
293+ await page
294+ .locator("nav")
295+ .getByRole("link", { name: "Integrations" })
296+ .first()
297+ .waitFor({ state: "visible", timeout: 30_000 });
298+ expect(
299+ await page.locator("nav").getByRole("link", { name: "Users" }).count(),
300+ "a plain member is not shown a section that would only refuse them",
301+ ).toBe(0);
302+ });
303+
304+ await step("And reaching the URL directly is refused, not silently empty", async () => {
305+ await page.goto("/users", { waitUntil: "networkidle" });
306+ await page
307+ .getByText("You don't have access to this workspace's users")
308+ .waitFor({ state: "visible", timeout: 30_000 });
309+ expect(
310+ await page.locator("[data-slot='admin-users-table']").count(),
311+ "the refusal replaces the table rather than rendering it empty",
312+ ).toBe(0);
313+ });
314+ });
315+ }),
316+ Effect.all(
317+ [
318+ memberClient.connections
319+ .remove({ params: { owner: "user", integration, name: memberConnection } })
320+ .pipe(Effect.ignore),
321+ ownerClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore),
322+ ownerClient.openapi
323+ .removeSpec({ params: { slug: availableIntegration } })
324+ .pipe(Effect.ignore),
325+ ],
326+ { discard: true },
327+ ),
328+ );
329+ }),
330+);
e2e/selfhost/admin-users-pager.test.ts+167−0
@@ -0,0 +1,167 @@
1+// Selfhost-only: the admin Users PAGER, which no other scenario reaches.
2+//
3+// The page shows 25 users at a time. To know whether a "Next" exists without a
4+// count endpoint, the atom over-fetches by one (`limit: 25 + 1`) and `splitPage`
5+// drops the extra row to derive `hasNext`. That +1/-1 pair is an off-by-one
6+// waiting to happen in the most damaging place: the page BOUNDARY. Drop one row
7+// too many and user #25 is invisible on every page — present in the workspace,
8+// absent from the operator's view, with no error anywhere. Drop one too few and
9+// the same person appears twice.
10+//
11+// Both existing admin console scenarios seed two members, so the pager never
12+// renders at all and none of that logic is exercised. This one seeds past the
13+// page size and walks the boundary.
14+//
15+// Self-host rather than cloud because cloud's free plan caps a workspace at 3
16+// member seats — 26 members cannot exist there. Self-host has one turnkey org,
17+// no seat gate, and an admin invite plane that mints codes freely.
18+//
19+// A user earns their row in the workspace the first time they touch the
20+// EXECUTOR plane (`touchSubject` at the request seam) — signing up alone is not
21+// enough, so each seeded identity makes one product read.
22+//
23+// The org is SHARED with every other selfhost scenario in the run, so this
24+// asserts "contains mine, split correctly across pages" and never a global
25+// count or a fixed row index. Rows are matched by the emails this scenario
26+// minted, under a per-run prefix.
27+import { randomBytes } from "node:crypto";
28+
29+import { expect } from "@effect/vitest";
30+import { Effect } from "effect";
31+import type { Page } from "playwright";
32+
33+import { scenario } from "../src/scenario";
34+import { Browser, Target } from "../src/services";
35+import { createInvitedIdentity } from "../targets/selfhost";
36+import type { Identity } from "../src/target";
37+
38+/** The page size the atom and the page component share
39+ * (`ADMIN_USERS_PAGE_SIZE`). Restated rather than imported: this is the
40+ * contract the UI promises an operator, and a scenario that imported it would
41+ * silently follow the product if it changed. */
42+const PAGE_SIZE = 25;
43+
44+/** Enough seeded users that a second page must exist even in a workspace that
45+ * starts empty — one more than a full page. */
46+const SEEDED_USERS = PAGE_SIZE + 1;
47+
48+/** The `[data-slot='admin-user-name']` values on the page right now. Rows are
49+ * named by email on this host, which is what this scenario matches on. */
50+const rowNames = (page: Page): Promise<Array<string>> =>
51+ page
52+ .locator("[data-slot='admin-user-row'] [data-slot='admin-user-name']")
53+ .evaluateAll((elements) => elements.map((element) => element.textContent ?? ""));
54+
55+scenario(
56+ "Admin · the Users pager walks a workspace larger than one page without dropping or repeating anyone",
57+ { timeout: 180_000 },
58+ Effect.gen(function* () {
59+ const target = yield* Target;
60+ const browser = yield* Browser;
61+
62+ const owner = yield* target.newIdentity();
63+
64+ // A per-run prefix so this scenario's rows are identifiable among the users
65+ // other selfhost scenarios left in the shared org.
66+ const prefix = `pager-${randomBytes(4).toString("hex")}`;
67+
68+ // Seed past the page size. Each identity then makes ONE executor-plane read,
69+ // which is what mints its `subject` row — without it the account exists in
70+ // the auth store but never appears on this page.
71+ const seeded = yield* Effect.forEach(
72+ Array.from({ length: SEEDED_USERS }, (_, index) => index),
73+ (index) =>
74+ Effect.promise(async (): Promise<Identity> => {
75+ const identity = await createInvitedIdentity(target.baseUrl, owner, {
76+ role: "member",
77+ emailPrefix: `${prefix}-${String(index).padStart(2, "0")}`,
78+ });
79+ const response = await fetch(new URL("/api/integrations", target.baseUrl), {
80+ headers: { cookie: identity.headers?.cookie ?? "" },
81+ });
82+ if (!response.ok) {
83+ throw new Error(`seeding read failed for ${identity.label} (${response.status})`);
84+ }
85+ return identity;
86+ }),
87+ // Bounded: one dev server, one database connection, and a password hash
88+ // per signup. Unbounded here would starve the server rather than go
89+ // faster.
90+ { concurrency: 4 },
91+ );
92+
93+ const seededEmails = new Set(seeded.map((identity) => identity.credentials?.email ?? ""));
94+ expect(seededEmails.size, "every seeded member has a distinct email").toBe(SEEDED_USERS);
95+
96+ yield* browser.session(owner, async ({ page, step }) => {
97+ let firstPage: ReadonlyArray<string> = [];
98+ let secondPage: ReadonlyArray<string> = [];
99+
100+ await step("Open the Users page", async () => {
101+ await page.goto("/", { waitUntil: "domcontentloaded" });
102+ await page.getByRole("link", { name: "Users" }).click();
103+ await page.waitForURL((url) => url.pathname.endsWith("/users"), { timeout: 30_000 });
104+ await page.locator("[data-slot='admin-users-table']").waitFor({ timeout: 30_000 });
105+ });
106+
107+ await step("The first page is full and offers a next page", async () => {
108+ await page.locator("[data-slot='admin-user-row']").first().waitFor({ timeout: 30_000 });
109+ firstPage = await rowNames(page);
110+ // Exactly the page size — this is the `limit + 1` over-fetch being
111+ // trimmed back. A page of 26 would mean `splitPage` failed to drop the
112+ // probe row and the operator sees a row that belongs to page 2.
113+ expect(firstPage.length, "a full page shows exactly the page size").toBe(PAGE_SIZE);
114+ await page.getByText("Page 1", { exact: false }).waitFor({ timeout: 30_000 });
115+ // The over-fetch exists precisely to answer this without a count.
116+ await expectEnabled(page, "Next", true);
117+ await expectEnabled(page, "Previous", false);
118+ });
119+
120+ await step("Next advances to a second page of different people", async () => {
121+ await page.getByRole("button", { name: "Next" }).click();
122+ await page.getByText("Page 2", { exact: false }).waitFor({ timeout: 30_000 });
123+ await page.locator("[data-slot='admin-user-row']").first().waitFor({ timeout: 30_000 });
124+ secondPage = await rowNames(page);
125+ expect(secondPage.length, "the second page has rows").toBeGreaterThan(0);
126+
127+ // THE boundary invariant, and the whole reason this scenario exists:
128+ // the +1 over-fetch must not leak a row onto both pages, and the -1
129+ // trim must not swallow one between them.
130+ const overlap = secondPage.filter((name) => firstPage.includes(name));
131+ expect(
132+ overlap,
133+ "no one appears on both pages — the +1 probe row is not shown twice",
134+ ).toEqual([]);
135+
136+ // And nobody fell into the gap: every user this scenario seeded is on
137+ // one of the two pages. Asserted over MY rows only, since the shared
138+ // org also holds users from other scenarios.
139+ const seen = new Set([...firstPage, ...secondPage]);
140+ const missing = [...seededEmails].filter((email) => !seen.has(email));
141+ expect(missing, "every seeded member is on one of the two pages").toEqual([]);
142+
143+ await expectEnabled(page, "Previous", true);
144+ });
145+
146+ await step("Previous returns to the identical first page", async () => {
147+ await page.getByRole("button", { name: "Previous" }).click();
148+ await page.getByText("Page 1", { exact: false }).waitFor({ timeout: 30_000 });
149+ await page.locator("[data-slot='admin-user-row']").first().waitFor({ timeout: 30_000 });
150+ // Same rows, same order: paging back is navigation, not a reshuffle.
151+ // `listSubjects` orders by (created_at, external_id), a total order, so
152+ // this is a guarantee rather than a coincidence.
153+ expect(await rowNames(page), "going back shows the same first page").toEqual(firstPage);
154+ await expectEnabled(page, "Previous", false);
155+ });
156+ });
157+ }),
158+);
159+
160+/** Assert a pager button's enabled state — the affordance an operator reads to
161+ * know whether a page exists in that direction. */
162+const expectEnabled = async (page: Page, name: string, enabled: boolean): Promise<void> => {
163+ expect(
164+ await page.getByRole("button", { name }).isEnabled(),
165+ `${name} is ${enabled ? "offered" : "not offered"}`,
166+ ).toBe(enabled);
167+};
package.json+1−0
@@ -45,6 +45,7 @@ · 44 unmodified lines
4545 "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:changelog-stubs",
4646 "lint:changelog-stubs": "bun run scripts/check-changelog-stubs.ts",
4747 "check:patches": "bun run scripts/check-patched-deps.ts",
48+ "check:routes": "bun run scripts/check-route-trees.ts",
4849 "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .",
4950 "docs:snippets": "bun run scripts/generate-doc-snippets.ts",
5051 "docs:smoke:install": "bun run scripts/smoke-docs-install.ts",
packages/app/src/routeTree.gen.ts+23−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines
1717 import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
1818 import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
1919 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
20+import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'
2021 import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'
2122 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey'
2223
@@ -67,6 +68,12 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = · 44 unmodified lines
6768 path: '/{-$orgSlug}/integrations/$namespace',
6869 getParentRoute: () => rootRouteImport,
6970 } as any)
71+const DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute =
72+ DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport.update({
73+ id: '/{-$orgSlug}/connect/$integrationSlug',
74+ path: '/{-$orgSlug}/connect/$integrationSlug',
75+ getParentRoute: () => rootRouteImport,
76+ } as any)
7077 const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
7178 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({
7279 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -88,6 +95,7 @@ export interface FileRoutesByFullPath { · 15 unmodified lines
8895 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
8996 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
9097 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
98+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
9199 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
92100 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
93101 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -100,6 +108,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
100108 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
101109 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
102110 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
111+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
103112 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
104113 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
105114 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -113,6 +122,7 @@ export interface FileRoutesById { · 7 unmodified lines
113122 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
114123 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
115124 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
125+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
116126 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
117127 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
118128 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -127,6 +137,7 @@ export interface FileRouteTypes { · 8 unmodified lines
127137 | '/{-$orgSlug}/toolkits'
128138 | '/{-$orgSlug}/tools'
129139 | '/{-$orgSlug}/'
140+ | '/{-$orgSlug}/connect/$integrationSlug'
130141 | '/{-$orgSlug}/integrations/$namespace'
131142 | '/{-$orgSlug}/resume/$executionId'
132143 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -139,6 +150,7 @@ export interface FileRouteTypes { · 6 unmodified lines
139150 | '/{-$orgSlug}/toolkits'
140151 | '/{-$orgSlug}/tools'
141152 | '/{-$orgSlug}'
153+ | '/{-$orgSlug}/connect/$integrationSlug'
142154 | '/{-$orgSlug}/integrations/$namespace'
143155 | '/{-$orgSlug}/resume/$executionId'
144156 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -151,6 +163,7 @@ export interface FileRouteTypes { · 6 unmodified lines
151163 | '/{-$orgSlug}/toolkits'
152164 | '/{-$orgSlug}/tools'
153165 | '/{-$orgSlug}/'
166+ | '/{-$orgSlug}/connect/$integrationSlug'
154167 | '/{-$orgSlug}/integrations/$namespace'
155168 | '/{-$orgSlug}/resume/$executionId'
156169 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -164,6 +177,7 @@ export interface RootRouteChildren { · 7 unmodified lines
164177 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
165178 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
166179 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
180+ DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
167181 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
168182 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
169183 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -228,6 +242,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
228242 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport
229243 parentRoute: typeof rootRouteImport
230244 }
245+ '/{-$orgSlug}/connect/$integrationSlug': {
246+ id: '/{-$orgSlug}/connect/$integrationSlug'
247+ path: '/{-$orgSlug}/connect/$integrationSlug'
248+ fullPath: '/{-$orgSlug}/connect/$integrationSlug'
249+ preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport
250+ parentRoute: typeof rootRouteImport
251+ }
231252 '/{-$orgSlug}/plugins/$pluginId/$': {
232253 id: '/{-$orgSlug}/plugins/$pluginId/$'
233254 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -270,6 +291,8 @@ const rootRouteChildren: RootRouteChildren = { · 36 unmodified lines
270291 DotDotDotDotDotDotReactSrcRoutesToolsRoute,
271292 DotDotDotDotDotDotReactSrcRoutesIndexRoute:
272293 DotDotDotDotDotDotReactSrcRoutesIndexRoute,
294+ DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute:
295+ DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,
273296 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:
274297 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,
275298 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:
packages/app/tsr.routes.ts+6−4
@@ -7,13 +7,15 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; · 6 unmodified lines
77 // Shared console routes come from @executor-js/react (see its
88 // console-routes.ts); this app owns its root and the local-specific routes
99 // under src/routes/app. /secrets is excluded: the local app's variant shows
10−// credential-provider info. App routes mount INSIDE the shared `{-$orgSlug}`
11−// scope (local never sets the param, so URLs stay bare) so the override masks
12−// the shared route id.
10+// credential-provider info. /users is excluded because local serves no account
11+// or admin API at all (see apps/local/src/app.ts) — there is no tenant to
12+// operate and no endpoint behind the page. App routes mount INSIDE the shared
13+// `{-$orgSlug}` scope (local never sets the param, so URLs stay bare) so the
14+// override masks the shared route id.
1315 export const routes = rootRoute("__root.tsx", [
1416 ...consoleRoutes({
1517 dir: "../../../react/src/routes",
16− exclude: ["/secrets"],
18+ exclude: ["/secrets", "/users"],
1719 orgScoped: [physical("", "app")],
1820 }),
1921 ]);
packages/core/api/src/account/api.ts+37−0
@@ -97,6 +97,18 @@ export const CreatedApiKeyResponse = Schema.Struct({ · 96 unmodified lines
9797 value: Schema.String,
9898 });
9999
100+/**
101+ * ORGANIZATION-owned api keys — the credentials that authenticate as the
102+ * workspace itself rather than as a member, and are the credential for the
103+ * `/admin/*` plane. Listed and minted beside personal keys (same surface, same
104+ * shapes) but gated to admins, since an org key can read every user in the
105+ * tenant. A provider without an org-key concept (self-host) reports an empty
106+ * list and refuses to mint.
107+ */
108+export const OrgApiKeysResponse = Schema.Struct({
109+ apiKeys: Schema.Array(ApiKeySummary),
110+});
111+
100112 export const OrgMember = Schema.Struct({
101113 id: Schema.String,
102114 userId: Schema.String,
@@ -194,6 +206,31 @@ export const AccountApi = HttpApiGroup.make("account") · 91 unmodified lines
194206 error: [AccountError, AccountUnauthorized, AccountNoOrganization],
195207 }),
196208 )
209+ .add(
210+ HttpApiEndpoint.get("listOrgApiKeys", "/account/org-api-keys", {
211+ success: OrgApiKeysResponse,
212+ error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization],
213+ }),
214+ )
215+ .add(
216+ HttpApiEndpoint.post("createOrgApiKey", "/account/org-api-keys", {
217+ payload: CreateApiKeyBody,
218+ success: CreatedApiKeyResponse,
219+ error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization],
220+ }),
221+ )
222+ // Revoke is its own route rather than a reuse of `revokeApiKey`: that one
223+ // resolves ownership against the caller's PERSONAL keys, so an org key id is
224+ // never in its owned set and revoking one there always fails. An org key is
225+ // the most privileged credential in the product (it reads the tenant-wide
226+ // `/admin/*` plane), so it must be revocable — admin-gated, like the mint.
227+ .add(
228+ HttpApiEndpoint.delete("revokeOrgApiKey", "/account/org-api-keys/:apiKeyId", {
229+ params: ApiKeyParams,
230+ success: SuccessResponse,
231+ error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization],
232+ }),
233+ )
197234 .add(
198235 HttpApiEndpoint.get("listMembers", "/account/members", {
199236 success: OrgMembersResponse,
packages/core/api/src/account/handlers.ts+18−0
@@ -44,6 +44,24 @@ export const AccountHandlers = HttpApiBuilder.group(AccountHttpApi, "account", ( · 43 unmodified lines
4444 return yield* (yield* AccountProvider).revokeApiKey(headers, params.apiKeyId);
4545 }),
4646 )
47+ .handle("listOrgApiKeys", () =>
48+ Effect.gen(function* () {
49+ const headers = yield* requestHeaders;
50+ return yield* (yield* AccountProvider).listOrgApiKeys(headers);
51+ }),
52+ )
53+ .handle("createOrgApiKey", ({ payload }) =>
54+ Effect.gen(function* () {
55+ const headers = yield* requestHeaders;
56+ return yield* (yield* AccountProvider).createOrgApiKey(headers, payload.name);
57+ }),
58+ )
59+ .handle("revokeOrgApiKey", ({ params }) =>
60+ Effect.gen(function* () {
61+ const headers = yield* requestHeaders;
62+ return yield* (yield* AccountProvider).revokeOrgApiKey(headers, params.apiKeyId);
63+ }),
64+ )
4765 .handle("listMembers", () =>
4866 Effect.gen(function* () {
4967 const headers = yield* requestHeaders;
packages/core/api/src/account/org-slug.test.ts+28−1
@@ -57,7 +57,34 @@ describe("isValidOrgSlug", () => { · 56 unmodified lines
5757 });
5858
5959 it("reserves the segments routing depends on", () => {
60− for (const critical of ["api", "mcp", "integrations", "policies", "login", "cdn-cgi"]) {
60+ for (const critical of [
61+ "api",
62+ "mcp",
63+ "integrations",
64+ "policies",
65+ "login",
66+ "cdn-cgi",
67+ // The connect deep link's root. Cloud's post-auth callback reads the
68+ // first path segment as an org selector, so leaving `connect` claimable
69+ // made `/connect/<slug>` look like a request for an org named "connect"
70+ // — which suppressed the active-membership fallback and could drop the
71+ // deep link into org onboarding after sign-in.
72+ "connect",
73+ // The shared console's own root segments. Each one is a route the
74+ // console links to at the top level, so an org holding the slug would
75+ // shadow it for every member of that org: `/toolkits` would resolve to
76+ // that org's console index instead of the shared toolkits page.
77+ // `CONSOLE_ROUTE_PATHS` is the list these come from — the derived check
78+ // that catches a NEW console route added without a reservation lives in
79+ // `packages/react/src/console-routes.test.ts`, which is the package that
80+ // can see both lists.
81+ "users",
82+ "toolkits",
83+ "secrets",
84+ "tools",
85+ "resume",
86+ "plugins",
87+ ]) {
6188 expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true);
6289 }
6390 });
packages/core/api/src/account/org-slug.ts+5−2
@@ -17,8 +17,9 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; · 16 unmodified lines
1717 * predictably will):
1818 * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost
1919 * envelope, cloudflare `run_worker_first`)
20− * - Console routes: integrations, policies, secrets, tools, resume,
21− * plugins (the shared contract), plus host extras:
20+ * - Console routes: connect, integrations, policies, secrets, tools, users,
21+ * toolkits, resume, plugins (the shared contract, i.e.
22+ * every entry in `CONSOLE_ROUTE_PATHS`), plus host extras:
2223 * api-keys, org, billing, create-org, setup-mcp (cloud),
2324 * admin, join, docs (selfhost), login (Better Auth
2425 * `mcp({ loginPage })` + cloud/selfhost login UX)
@@ -41,10 +42,12 @@ export const RESERVED_ORG_SLUGS: ReadonlySet<string> = new Set([ · 16 unmodified lines
4142 "mcp",
4243 ".well-known",
4344 // console + host routes
45+ "connect",
4446 "integrations",
4547 "policies",
4648 "secrets",
4749 "tools",
50+ "toolkits",
4851 "resume",
4952 "plugins",
5053 "api-keys",
packages/core/api/src/account/service.ts+12−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 type AccountUnauthorized,
88 AccountMeResponse,
99 ApiKeysResponse,
10+ OrgApiKeysResponse,
1011 CreatedApiKeyResponse,
1112 OrgMembersResponse,
1213 OrgRolesResponse,
@@ -33,6 +34,7 @@ export type AccountHeaders = Record<string, string>; · 20 unmodified lines
3334
3435 type Me = typeof AccountMeResponse.Type;
3536 type ApiKeys = typeof ApiKeysResponse.Type;
37+type OrgApiKeys = typeof OrgApiKeysResponse.Type;
3638 type CreatedApiKey = typeof CreatedApiKeyResponse.Type;
3739 type Members = typeof OrgMembersResponse.Type;
3840 type Roles = typeof OrgRolesResponse.Type;
@@ -49,6 +51,16 @@ export interface AccountProviderShape { · 10 unmodified lines
4951 readonly listApiKeys: (headers: AccountHeaders) => OrgScoped<ApiKeys>;
5052 readonly createApiKey: (headers: AccountHeaders, name: string) => OrgScoped<CreatedApiKey>;
5153 readonly revokeApiKey: (headers: AccountHeaders, apiKeyId: string) => OrgScoped<Success>;
54+ /** Org-owned keys: admin-gated, hence the `AccountForbidden` on both. */
55+ readonly listOrgApiKeys: (headers: AccountHeaders) => OrgScoped<OrgApiKeys, AccountForbidden>;
56+ readonly createOrgApiKey: (
57+ headers: AccountHeaders,
58+ name: string,
59+ ) => OrgScoped<CreatedApiKey, AccountForbidden>;
60+ readonly revokeOrgApiKey: (
61+ headers: AccountHeaders,
62+ apiKeyId: string,
63+ ) => OrgScoped<Success, AccountForbidden>;
5264 readonly listMembers: (headers: AccountHeaders) => OrgScoped<Members>;
5365 readonly listRoles: (headers: AccountHeaders) => OrgScoped<Roles>;
5466 readonly inviteMember: (
packages/core/api/src/admin/admin-users.test.ts+1070−0
@@ -0,0 +1,1070 @@
1+import { HttpApiBuilder } from "effect/unstable/httpapi";
2+import { HttpRouter, HttpServer } from "effect/unstable/http";
3+import { describe, expect, it } from "@effect/vitest";
4+import { Context, Data, Effect, Layer, type Scope } from "effect";
5+
6+import { Subject, Tenant, collectTables, createExecutor, type Executor } from "@executor-js/sdk";
7+import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing";
8+import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal";
9+
10+import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api";
11+import { AdminUsersHandlers } from "./handlers";
12+import { AdminUsersProvider, type AdminUsersHeaders } from "./service";
13+import {
14+ getUser,
15+ listUserConnections,
16+ listUsers,
17+ listUsersWithConnections,
18+ platformViewOf,
19+ type AdminIdentityDirectory,
20+ type AdminUserDirectory,
21+} from "./reads";
22+
23+// ---------------------------------------------------------------------------
24+// The admin users HTTP surface, over a real SQLite-backed platform view.
25+//
26+// The reads themselves (tenant reach, the bigint `last_seen_at` round-trip, the
27+// field allowlist) are proven in the SDK's `platform-view.test.ts`. What this
28+// file proves is the HTTP EDGE on top of them:
29+// - the public vocabulary + wire shapes the contract promises,
30+// - the authorization outcomes (401 vs 403) a host's provider produces,
31+// - that a tenant's admin plane cannot see another tenant's rows,
32+// - that no credential-bearing field survives the projection.
33+//
34+// The provider seam is stubbed the way each host's real one behaves, so the
35+// authz assertions are about the CONTRACT (which status a refusal renders),
36+// not about WorkOS or Better Auth.
37+// ---------------------------------------------------------------------------
38+
39+const TENANT_A = "org_a";
40+const TENANT_B = "org_b";
41+const USER_A1 = "user_a1";
42+const USER_A2 = "user_a2";
43+const USER_B1 = "user_b1";
44+
45+/** Every field that could resolve, or help resolve, a credential. NONE may
46+ * appear anywhere in an admin HTTP response — enumerated so a future column
47+ * addition has to be argued with this list. Mirrors the SDK's own list. */
48+const FORBIDDEN_FIELDS = [
49+ "item_ids",
50+ "itemIds",
51+ "refresh_item_id",
52+ "refreshItemId",
53+ "provider",
54+ "provider_state",
55+ "providerState",
56+ "oauth_token_url",
57+ "oauthTokenUrl",
58+ "template",
59+ "client_secret",
60+ "clientSecret",
61+ "secret",
62+ "token",
63+ // The internal partition keys: the public vocabulary is users/owners, so
64+ // neither the raw column name nor the tenant may leak onto the wire.
65+ "subject",
66+ "tenant",
67+ // The upstream-derived halves of a stored health verdict. These are not
68+ // CREDENTIALS — they are the provider's own response content: the account
69+ // identity extracted from `identityField` (another user's email at the
70+ // provider), a raw diagnostic that can quote an upstream error body, and a
71+ // sample of the real response body. `lastHealth` is an allowed COLUMN, which
72+ // is exactly why these need naming: the per-column allowlist passed while the
73+ // whole nested object rode along inside it. `AdminConnectionHealth` narrows
74+ // it to status + checkedAt; this list is what keeps it narrow.
75+ "identity",
76+ "detail",
77+ "responseSample",
78+ "httpStatus",
79+] as const;
80+
81+// The bodies below acquire a scoped web handler, so `R` carries `Scope` rather
82+// than being erased; `Effect.scoped` closes it when the body finishes.
83+const withDb = <A, E>(
84+ body: (db: SqliteTestFumaDb) => Effect.Effect<A, E, Scope.Scope>,
85+): Effect.Effect<A, E> =>
86+ Effect.scoped(
87+ Effect.acquireUseRelease(
88+ Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
89+ body,
90+ (db) => Effect.promise(() => db.close()),
91+ ),
92+ );
93+
94+const insertConnection = (
95+ db: SqliteTestFumaDb,
96+ row: {
97+ readonly rowId: string;
98+ readonly tenant: string;
99+ readonly owner: string;
100+ readonly subject: string;
101+ readonly integration: string;
102+ readonly name: string;
103+ readonly oauthScope?: string | null;
104+ },
105+): Effect.Effect<void> =>
106+ Effect.promise(async () => {
107+ await db.client.execute({
108+ sql: `INSERT INTO connection (
109+ row_id, tenant, owner, subject, integration, name, template, provider,
110+ item_ids, refresh_item_id, oauth_scope, last_health, created_at, updated_at
111+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
112+ args: [
113+ row.rowId,
114+ row.tenant,
115+ row.owner,
116+ row.subject,
117+ row.integration,
118+ row.name,
119+ "oauth2",
120+ "memory",
121+ // Deliberately secret-bearing: the assertions below prove these never
122+ // reach an HTTP response.
123+ JSON.stringify({ token: `SECRET-item-${row.rowId}` }),
124+ `SECRET-refresh-${row.rowId}`,
125+ row.oauthScope ?? null,
126+ // A REAL stored health verdict, including the parts that come from the
127+ // upstream provider's own response. `identity` is the value pulled from
128+ // the configured `identityField` (typically the account's email AT the
129+ // provider), `detail` is a raw diagnostic that can quote an upstream
130+ // body, and `responseSample` is a bounded sample of the actual response
131+ // body's scalar leaves. All three are legitimate on the product plane,
132+ // where the caller owns the credential — and none may cross onto the
133+ // ADMIN plane, which reports on other people's connections. Seeded here
134+ // so the assertions below have ground truth to catch rather than
135+ // asserting the absence of something that was never present.
136+ JSON.stringify({
137+ status: "healthy",
138+ checkedAt: 1_700_000_000_000,
139+ httpStatus: 200,
140+ identity: `UPSTREAM-identity-${row.rowId}@provider.test`,
141+ detail: `UPSTREAM-detail-${row.rowId}`,
142+ responseSample: [
143+ { path: "user.email", value: `UPSTREAM-sample-${row.rowId}@provider.test` },
144+ { path: "user.login", value: `UPSTREAM-login-${row.rowId}` },
145+ ],
146+ }),
147+ Date.now(),
148+ Date.now(),
149+ ],
150+ });
151+ });
152+
153+/** Two users with connections under tenant A, plus a whole separate tenant B
154+ * that A's admin plane must never see. */
155+const seed = (db: SqliteTestFumaDb): Effect.Effect<void> =>
156+ Effect.gen(function* () {
157+ // `touchSubject` keeps a process-local memory of principals it already
158+ // filed, so a second seed against a FRESH database would be skipped as a
159+ // repeat sighting and leave the table empty. Each seed is a new world.
160+ resetSubjectTouchCache();
161+
162+ yield* touchSubject(db.db, { tenant: TENANT_A, externalId: USER_A1 });
163+ yield* touchSubject(db.db, { tenant: TENANT_A, externalId: USER_A2 });
164+ yield* touchSubject(db.db, { tenant: TENANT_B, externalId: USER_B1 });
165+
166+ yield* insertConnection(db, {
167+ rowId: "c-a1-gh",
168+ tenant: TENANT_A,
169+ owner: "user",
170+ subject: USER_A1,
171+ integration: "github",
172+ name: "personal",
173+ oauthScope: "repo read:user",
174+ });
175+ yield* insertConnection(db, {
176+ rowId: "c-a2-linear",
177+ tenant: TENANT_A,
178+ owner: "user",
179+ subject: USER_A2,
180+ integration: "linear",
181+ name: "work",
182+ });
183+ yield* insertConnection(db, {
184+ rowId: "c-b1-gh",
185+ tenant: TENANT_B,
186+ owner: "user",
187+ subject: USER_B1,
188+ integration: "github",
189+ name: "b-secret",
190+ oauthScope: "admin:org",
191+ });
192+ });
193+
194+const platformExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effect<Executor> =>
195+ createExecutor({
196+ tenant: Tenant.make(tenant),
197+ db: db.db,
198+ onElicitation: "accept-all",
199+ platformView: true,
200+ }).pipe(Effect.orDie);
201+
202+/** A product-view executor: bound to one subject, no platform view at all. */
203+const productExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effect<Executor> =>
204+ createExecutor({
205+ tenant: Tenant.make(tenant),
206+ subject: Subject.make(USER_A1),
207+ db: db.db,
208+ onElicitation: "accept-all",
209+ }).pipe(Effect.orDie);
210+
211+/**
212+ * A stub `AdminUsersProvider` shaped like a host's real one: it authorizes off
213+ * the request headers, then delegates to the shared reads. `authorize` returns
214+ * the tenant an authorized caller may read, or a refusal — exactly the decision
215+ * cloud makes from an org key / admin session and self-host from a Better Auth
216+ * member.
217+ */
218+const stubProvider = (
219+ executorFor: (tenant: string) => Effect.Effect<Executor>,
220+ authorize: (
221+ headers: AdminUsersHeaders,
222+ ) => Effect.Effect<string, AdminUsersUnauthorized | AdminUsersForbidden>,
223+ directory?: AdminIdentityDirectory | AdminUserDirectory,
224+) =>
225+ Layer.succeed(AdminUsersProvider)({
226+ listUsers: (headers, options) =>
227+ authorize(headers).pipe(
228+ Effect.flatMap(executorFor),
229+ Effect.flatMap((executor) =>
230+ platformViewOf(executor).pipe(
231+ Effect.flatMap((admin) => listUsers(admin, options, directory)),
232+ ),
233+ ),
234+ ),
235+ listUsersWithConnections: (headers, options) =>
236+ authorize(headers).pipe(
237+ Effect.flatMap(executorFor),
238+ Effect.flatMap((executor) =>
239+ platformViewOf(executor).pipe(
240+ Effect.flatMap((admin) => listUsersWithConnections(admin, options, directory)),
241+ ),
242+ ),
243+ ),
244+ listUserConnections: (headers, externalId) =>
245+ authorize(headers).pipe(
246+ Effect.flatMap(executorFor),
247+ Effect.flatMap((executor) =>
248+ platformViewOf(executor).pipe(
249+ Effect.flatMap((admin) => listUserConnections(admin, externalId)),
250+ ),
251+ ),
252+ ),
253+ getUser: (headers, identifier) =>
254+ authorize(headers).pipe(
255+ Effect.flatMap(executorFor),
256+ Effect.flatMap((executor) =>
257+ platformViewOf(executor).pipe(
258+ Effect.flatMap((admin) => getUser(admin, identifier, directory)),
259+ ),
260+ ),
261+ ),
262+ });
263+
264+/**
265+ * The authorization the hosts implement, reduced to its decision table:
266+ * an org-scoped key (or admin session) names its tenant; a plain member or a
267+ * user-scoped key is forbidden; no credential at all is unauthorized.
268+ */
269+const headerAuthorize = (headers: AdminUsersHeaders) => {
270+ const credential = headers["authorization"];
271+ if (!credential) return Effect.fail(new AdminUsersUnauthorized());
272+ if (credential === "Bearer org_a_key") return Effect.succeed(TENANT_A);
273+ if (credential === "Bearer org_b_key") return Effect.succeed(TENANT_B);
274+ // A valid user-scoped key / plain member session: authentic, but this plane
275+ // serves the whole tenant and they name one member.
276+ return Effect.fail(new AdminUsersForbidden());
277+};
278+
279+// `HttpRouter.provideRequest` (not a plain `Layer.provide`) is what clears the
280+// handlers' per-request `AdminUsersProvider` marker — the same reason the real
281+// hosts mount the provider through a router middleware.
282+const webHandlerFor = (provider: Layer.Layer<AdminUsersProvider>) =>
283+ Effect.acquireRelease(
284+ Effect.sync(() =>
285+ HttpRouter.toWebHandler(
286+ HttpApiBuilder.layer(AdminUsersHttpApi).pipe(
287+ Layer.provide(AdminUsersHandlers),
288+ HttpRouter.provideRequest(provider),
289+ Layer.provideMerge(HttpServer.layerServices),
290+ Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
291+ ),
292+ { disableLogger: true },
293+ ),
294+ ),
295+ (web) => Effect.promise(() => web.dispose()),
296+ );
297+
298+const get = (
299+ web: {
300+ readonly handler: (request: Request, context: Context.Context<never>) => Promise<Response>;
301+ },
302+ path: string,
303+ credential?: string,
304+) =>
305+ Effect.promise(() =>
306+ web.handler(
307+ new Request(`http://localhost${path}`, {
308+ headers: credential ? { authorization: credential } : {},
309+ }),
310+ Context.empty(),
311+ ),
312+ );
313+
314+const jsonOf = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>);
315+
316+type UsersBody = {
317+ readonly users: ReadonlyArray<{
318+ readonly externalId: string;
319+ readonly email: string | null;
320+ readonly displayName: string | null;
321+ }>;
322+};
323+type ConnectionsBody = {
324+ readonly connections: ReadonlyArray<{ readonly integration: string; readonly name: string }>;
325+};
326+type UserBody = {
327+ readonly user: {
328+ readonly externalId: string;
329+ readonly email: string | null;
330+ readonly displayName: string | null;
331+ readonly lastSeenAt: number | null;
332+ readonly status: string | null;
333+ readonly connections: ReadonlyArray<{
334+ readonly integration: string;
335+ readonly oauthScope: string | null;
336+ readonly lastHealth: { readonly status: string } | null;
337+ }>;
338+ };
339+};
340+type UsersWithConnectionsBody = {
341+ readonly users: ReadonlyArray<{
342+ readonly externalId: string;
343+ readonly email: string | null;
344+ readonly displayName: string | null;
345+ readonly lastSeenAt: number | null;
346+ readonly status: string | null;
347+ readonly connections: ReadonlyArray<{
348+ readonly integration: string;
349+ readonly oauthScope: string | null;
350+ readonly lastHealth: { readonly status: string } | null;
351+ }>;
352+ }>;
353+};
354+
355+const ORG_A = "Bearer org_a_key";
356+
357+/**
358+ * A host member directory that knows USER_A1 only.
359+ *
360+ * USER_A2 is deliberately absent — that is the real case of a member who left
361+ * the org while their connections remain, and the one an id-space mismatch would
362+ * also look like. `seen` records the ids the directory was asked for, so a test
363+ * can prove the join is ONE batched read of the returned page rather than a
364+ * lookup per user.
365+ */
366+const stubDirectory =
367+ (seen: string[][]): AdminIdentityDirectory =>
368+ (externalIds) => {
369+ seen.push([...externalIds]);
370+ return Effect.succeed(new Map([[USER_A1, { email: "a1@users.test", displayName: "User A1" }]]));
371+ };
372+
373+/** The email the stub directory knows USER_A1 by. Stored MIXED-CASE on
374+ * purpose: WorkOS preserves whatever casing a user was created with, so the
375+ * case-insensitivity rule has to hold against a non-normalized directory
376+ * value, not just a non-normalized query. */
377+const A1_EMAIL_STORED = "A1@Users.Test";
378+const A1_EMAIL = "a1@users.test";
379+
380+/**
381+ * The two-way directory: the same USER_A1-only knowledge as `stubDirectory`,
382+ * plus the reverse resolver.
383+ *
384+ * USER_A2 stays absent in BOTH directions, so it doubles as the "member the
385+ * directory cannot name" case. `resolved` records every email the resolver was
386+ * asked for, so a test can prove the lookup normalizes before comparing.
387+ */
388+const stubUserDirectory = (options: {
389+ readonly seen?: string[][];
390+ readonly resolved?: string[];
391+}): AdminUserDirectory => ({
392+ identities: (externalIds) => {
393+ options.seen?.push([...externalIds]);
394+ return Effect.succeed(new Map([[USER_A1, { email: A1_EMAIL_STORED, displayName: "User A1" }]]));
395+ },
396+ resolveEmail: (email) => {
397+ options.resolved?.push(email);
398+ // Compares a NORMALIZED stored value, the rule both real hosts follow.
399+ return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null);
400+ },
401+});
402+
403+/** The failure a host's directory raises — WorkOS or Better Auth being
404+ * unreachable. Typed rather than a bare `Error`, since the directory seam's
405+ * `unknown` error channel exists to carry each host's OWN error type. */
406+class DirectoryUnavailable extends Data.TaggedError("DirectoryUnavailable")<{
407+ readonly message: string;
408+}> {}
409+
410+/** A directory whose read fails, standing in for a WorkOS/Better Auth outage. */
411+const failingDirectory: AdminIdentityDirectory = () =>
412+ Effect.fail(new DirectoryUnavailable({ message: "member directory unavailable" }));
413+
414+describe("admin users API", () => {
415+ it.effect("lists every user of the tenant for an authorized org caller", () =>
416+ withDb((db) =>
417+ Effect.gen(function* () {
418+ yield* seed(db);
419+ const web = yield* webHandlerFor(
420+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
421+ );
422+
423+ const response = yield* get(web, "/admin/users", ORG_A);
424+ expect(response.status).toBe(200);
425+ const body = yield* jsonOf<UsersBody>(response);
426+
427+ expect(
428+ body.users.map((user) => user.externalId),
429+ "both of the tenant's users are reported",
430+ ).toEqual([USER_A1, USER_A2]);
431+ }),
432+ ),
433+ );
434+
435+ it.effect("reports a user's connections with access summary but no credential", () =>
436+ withDb((db) =>
437+ Effect.gen(function* () {
438+ yield* seed(db);
439+ const web = yield* webHandlerFor(
440+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
441+ );
442+
443+ const response = yield* get(web, `/admin/users/${USER_A1}/connections`, ORG_A);
444+ expect(response.status).toBe(200);
445+ const body = yield* jsonOf<ConnectionsBody>(response);
446+
447+ expect(
448+ body.connections.map((connection) => connection.integration),
449+ "the user's own connection is reported",
450+ ).toEqual(["github"]);
451+ expect(body.connections[0]?.name).toBe("personal");
452+ }),
453+ ),
454+ );
455+
456+ it.effect("joins users with their connections for the dashboard view", () =>
457+ withDb((db) =>
458+ Effect.gen(function* () {
459+ yield* seed(db);
460+ const web = yield* webHandlerFor(
461+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
462+ );
463+
464+ const response = yield* get(web, "/admin/users/with-connections", ORG_A);
465+ expect(response.status).toBe(200);
466+ const body = yield* jsonOf<UsersWithConnectionsBody>(response);
467+
468+ expect(
469+ body.users.map((user) => [user.externalId, user.connections.map((c) => c.integration)]),
470+ "each user carries their own connections",
471+ ).toEqual([
472+ [USER_A1, ["github"]],
473+ [USER_A2, ["linear"]],
474+ ]);
475+ // The access summary and health travel; `lastSeenAt` is a number the
476+ // dashboard can render (a raw SQL read would hand back a blob).
477+ expect(body.users[0]?.connections[0]?.oauthScope).toBe("repo read:user");
478+ expect(body.users[0]?.connections[0]?.lastHealth?.status).toBe("healthy");
479+ expect(typeof body.users[0]?.lastSeenAt).toBe("number");
480+ expect(body.users[0]?.status, "no lifecycle state is recorded yet").toBeNull();
481+ }),
482+ ),
483+ );
484+
485+ it.effect("never serves credential material on any admin response", () =>
486+ withDb((db) =>
487+ Effect.gen(function* () {
488+ yield* seed(db);
489+ const web = yield* webHandlerFor(
490+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
491+ );
492+
493+ for (const path of [
494+ "/admin/users",
495+ "/admin/users/with-connections",
496+ `/admin/users/${USER_A1}/connections`,
497+ `/admin/users/${USER_A1}`,
498+ ]) {
499+ const response = yield* get(web, path, ORG_A);
500+ const raw = yield* Effect.promise(() => response.text());
501+
502+ for (const field of FORBIDDEN_FIELDS) {
503+ expect(raw, `${path} must not expose "${field}"`).not.toContain(`"${field}"`);
504+ }
505+ // The seeded secrets are the ground truth: nothing derived from a
506+ // credential may appear, whatever it is called.
507+ expect(raw, `${path} must not leak a stored secret`).not.toContain("SECRET-");
508+ // And the same test for upstream ACCOUNT data, which is a different
509+ // kind of leak from a credential and was reaching the wire inside
510+ // `lastHealth`. Matching on the seeded marker rather than on field
511+ // names catches a rename, a re-nesting, or a stringified blob.
512+ expect(raw, `${path} must not leak upstream account data`).not.toContain("UPSTREAM-");
513+ }
514+ }),
515+ ),
516+ );
517+
518+ it.effect("isolates tenants: an org caller sees nothing of another tenant", () =>
519+ withDb((db) =>
520+ Effect.gen(function* () {
521+ yield* seed(db);
522+ const web = yield* webHandlerFor(
523+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
524+ );
525+
526+ const users = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users", ORG_A));
527+ expect(
528+ users.users.map((user) => user.externalId),
529+ "tenant B's user is invisible to tenant A",
530+ ).not.toContain(USER_B1);
531+
532+ // Naming another tenant's user directly returns nothing rather than
533+ // that user's connections — the tenant clause is never relaxed.
534+ const crossTenant = yield* jsonOf<ConnectionsBody>(
535+ yield* get(web, `/admin/users/${USER_B1}/connections`, ORG_A),
536+ );
537+ expect(crossTenant.connections, "tenant B's connections stay in tenant B").toEqual([]);
538+
539+ // And tenant B's own caller does see them — proving the emptiness above
540+ // is isolation, not a broken fixture.
541+ const ownView = yield* jsonOf<ConnectionsBody>(
542+ yield* get(web, `/admin/users/${USER_B1}/connections`, "Bearer org_b_key"),
543+ );
544+ expect(ownView.connections.map((c) => c.name)).toEqual(["b-secret"]);
545+ }),
546+ ),
547+ );
548+
549+ it.effect("refuses a caller with no credential and a caller who is only a member", () =>
550+ withDb((db) =>
551+ Effect.gen(function* () {
552+ yield* seed(db);
553+ const web = yield* webHandlerFor(
554+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
555+ );
556+
557+ const anonymous = yield* get(web, "/admin/users");
558+ expect(anonymous.status, "no credential → unauthorized").toBe(401);
559+
560+ // A user-scoped API key or plain member session: authentic, but this
561+ // plane serves the whole tenant.
562+ const member = yield* get(web, "/admin/users", "Bearer user_scoped_key");
563+ expect(member.status, "a non-admin caller → forbidden").toBe(403);
564+
565+ const memberJoined = yield* get(web, "/admin/users/with-connections", "Bearer user_key");
566+ expect(memberJoined.status).toBe(403);
567+ const memberConnections = yield* get(
568+ web,
569+ `/admin/users/${USER_A1}/connections`,
570+ "Bearer user_key",
571+ );
572+ expect(memberConnections.status).toBe(403);
573+
574+ // The single-user read refuses on the same terms, and crucially it
575+ // refuses BEFORE looking: an unauthorized caller must not be able to
576+ // tell a real user from an absent one by the status they get back.
577+ expect((yield* get(web, `/admin/users/${USER_A1}`)).status).toBe(401);
578+ expect((yield* get(web, `/admin/users/${USER_A1}`, "Bearer user_key")).status).toBe(403);
579+ expect((yield* get(web, "/admin/users/nobody", "Bearer user_key")).status).toBe(403);
580+ }),
581+ ),
582+ );
583+
584+ // The response schema strips unknown fields on encode, so the HTTP assertions
585+ // above would still pass if the projection leaked one. Assert the projection
586+ // directly too: it is the layer that is supposed to be an allowlist, and both
587+ // layers should hold on their own.
588+ it.effect("projects only allowlisted fields, before the schema ever encodes", () =>
589+ withDb((db) =>
590+ Effect.gen(function* () {
591+ yield* seed(db);
592+ const executor = yield* platformExecutorFor(db, TENANT_A);
593+ const admin = yield* platformViewOf(executor);
594+
595+ const { connections } = yield* listUserConnections(admin, USER_A1);
596+ expect(
597+ Object.keys(connections[0] ?? {}).sort(),
598+ "the connection projection is exactly the allowlist",
599+ ).toEqual(["integration", "lastHealth", "name", "oauthScope", "owner"]);
600+
601+ // `lastHealth` is an allowed column holding a nested object, so the
602+ // allowlist has to reach INSIDE it — a whole-object forward is what let
603+ // the provider's `identity` / `detail` / `responseSample` onto the
604+ // wire. The stored verdict here carries all of them (see the seed), so
605+ // this is a projection assertion, not a vacuous one.
606+ expect(
607+ Object.keys(connections[0]?.lastHealth ?? {}).sort(),
608+ "the health projection is exactly status + checkedAt",
609+ ).toEqual(["checkedAt", "status"]);
610+ expect(connections[0]?.lastHealth?.status, "and the verdict itself survives").toBe(
611+ "healthy",
612+ );
613+
614+ const { users } = yield* listUsers(admin, {});
615+ expect(
616+ Object.keys(users[0] ?? {}).sort(),
617+ "the user projection is exactly the allowlist",
618+ ).toEqual(["createdAt", "displayName", "email", "externalId", "lastSeenAt", "status"]);
619+ }),
620+ ),
621+ );
622+
623+ it.effect("fails loudly when wired to a product-view executor", () =>
624+ withDb((db) =>
625+ Effect.gen(function* () {
626+ yield* seed(db);
627+ // A host that forgot `platformView: true` must not silently report an
628+ // empty tenant — that would read as "this owner has no users".
629+ const web = yield* webHandlerFor(
630+ stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize),
631+ );
632+
633+ const response = yield* get(web, "/admin/users", ORG_A);
634+ expect(response.status, "a missing platform view is a server error").toBe(500);
635+ }),
636+ ),
637+ );
638+
639+ // ── Host identity join ────────────────────────────────────────────────────
640+
641+ it.effect("joins the host's email and name onto users the directory resolves", () =>
642+ withDb((db) =>
643+ Effect.gen(function* () {
644+ yield* seed(db);
645+ const seen: string[][] = [];
646+ const web = yield* webHandlerFor(
647+ stubProvider(
648+ (tenant) => platformExecutorFor(db, tenant),
649+ headerAuthorize,
650+ stubDirectory(seen),
651+ ),
652+ );
653+
654+ const body = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users", ORG_A));
655+ expect(
656+ body.users.map((user) => [user.externalId, user.email, user.displayName]),
657+ "the resolvable member is named; the unresolvable one reports absent identity",
658+ ).toEqual([
659+ [USER_A1, "a1@users.test", "User A1"],
660+ [USER_A2, null, null],
661+ ]);
662+
663+ // The join is a BATCH: one directory read for the whole page, carrying
664+ // every id at once — never one read per user.
665+ expect(seen, "one directory read, for exactly the page's ids").toEqual([
666+ [USER_A1, USER_A2],
667+ ]);
668+ }),
669+ ),
670+ );
671+
672+ it.effect("joins identity onto the connections view too, over the same one read", () =>
673+ withDb((db) =>
674+ Effect.gen(function* () {
675+ yield* seed(db);
676+ const seen: string[][] = [];
677+ const web = yield* webHandlerFor(
678+ stubProvider(
679+ (tenant) => platformExecutorFor(db, tenant),
680+ headerAuthorize,
681+ stubDirectory(seen),
682+ ),
683+ );
684+
685+ const body = yield* jsonOf<UsersWithConnectionsBody>(
686+ yield* get(web, "/admin/users/with-connections", ORG_A),
687+ );
688+ expect(body.users.map((user) => [user.externalId, user.email])).toEqual([
689+ [USER_A1, "a1@users.test"],
690+ [USER_A2, null],
691+ ]);
692+ expect(seen.length, "still one directory read for the page").toBe(1);
693+ }),
694+ ),
695+ );
696+
697+ // Identity is decoration on an operator view, not the answer: a directory
698+ // outage must cost the rows their names, never cost the operator the read.
699+ it.effect("still serves users when the host's identity lookup fails", () =>
700+ withDb((db) =>
701+ Effect.gen(function* () {
702+ yield* seed(db);
703+ const web = yield* webHandlerFor(
704+ stubProvider(
705+ (tenant) => platformExecutorFor(db, tenant),
706+ headerAuthorize,
707+ failingDirectory,
708+ ),
709+ );
710+
711+ const response = yield* get(web, "/admin/users", ORG_A);
712+ expect(response.status, "a directory outage is not a failed request").toBe(200);
713+ const body = yield* jsonOf<UsersBody>(response);
714+ expect(
715+ body.users.map((user) => [user.externalId, user.email, user.displayName]),
716+ "every user is still reported, simply unnamed",
717+ ).toEqual([
718+ [USER_A1, null, null],
719+ [USER_A2, null, null],
720+ ]);
721+ }),
722+ ),
723+ );
724+
725+ // A host that cannot map its id space to a directory at all passes none. The
726+ // rows must still be complete — absent identity, not a missing field.
727+ it.effect("reports absent identity when the host wires no directory", () =>
728+ withDb((db) =>
729+ Effect.gen(function* () {
730+ yield* seed(db);
731+ const web = yield* webHandlerFor(
732+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
733+ );
734+
735+ const body = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users", ORG_A));
736+ expect(body.users.map((user) => [user.email, user.displayName])).toEqual([
737+ [null, null],
738+ [null, null],
739+ ]);
740+ }),
741+ ),
742+ );
743+
744+ // Email and name are the ONLY fields this join is allowed to add. The
745+ // forbidden-field assertions above run without a directory, so re-run the
746+ // credential check with one wired: a directory is a new data source reaching
747+ // the response, and it must not become a hole in the allowlist.
748+ it.effect("adds only email and displayName to the allowlist", () =>
749+ withDb((db) =>
750+ Effect.gen(function* () {
751+ yield* seed(db);
752+ const seen: string[][] = [];
753+ const web = yield* webHandlerFor(
754+ stubProvider(
755+ (tenant) => platformExecutorFor(db, tenant),
756+ headerAuthorize,
757+ stubUserDirectory({ seen }),
758+ ),
759+ );
760+
761+ for (const path of [
762+ "/admin/users",
763+ "/admin/users/with-connections",
764+ // The single-user read joins identity through the same seam, so it
765+ // gets the same sweep — including the email-addressed form, whose
766+ // response is built on a directory value.
767+ `/admin/users/${USER_A1}`,
768+ `/admin/users/${encodeURIComponent(A1_EMAIL)}`,
769+ `/admin/users?email=${encodeURIComponent(A1_EMAIL)}`,
770+ ]) {
771+ const response = yield* get(web, path, ORG_A);
772+ const raw = yield* Effect.promise(() => response.text());
773+ for (const field of FORBIDDEN_FIELDS) {
774+ expect(raw, `${path} must not expose "${field}"`).not.toContain(`"${field}"`);
775+ }
776+ expect(raw, `${path} must not leak a stored secret`).not.toContain("SECRET-");
777+ }
778+ }),
779+ ),
780+ );
781+
782+ it.effect("pages the user list", () =>
783+ withDb((db) =>
784+ Effect.gen(function* () {
785+ yield* seed(db);
786+ const web = yield* webHandlerFor(
787+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
788+ );
789+
790+ const first = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users?limit=1", ORG_A));
791+ expect(first.users.map((user) => user.externalId)).toEqual([USER_A1]);
792+
793+ const second = yield* jsonOf<UsersBody>(
794+ yield* get(web, "/admin/users?limit=1&offset=1", ORG_A),
795+ );
796+ expect(second.users.map((user) => user.externalId)).toEqual([USER_A2]);
797+ }),
798+ ),
799+ );
800+
801+ // `offset` and `limit` are independently optional in the contract, so
802+ // `?offset=` alone is a request a consumer will make. Drizzle's SQLite
803+ // dialect emits OFFSET only alongside LIMIT and SQLite rejects a bare OFFSET,
804+ // so this used to 500 on every SQLite host — local, self-host and D1 alike.
805+ // The SDK's default page size is what makes it a legal query.
806+ it.effect("serves an offset with no limit rather than 500ing", () =>
807+ withDb((db) =>
808+ Effect.gen(function* () {
809+ yield* seed(db);
810+ const web = yield* webHandlerFor(
811+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
812+ );
813+
814+ for (const path of ["/admin/users?offset=1", "/admin/users/with-connections?offset=1"]) {
815+ const response = yield* get(web, path, ORG_A);
816+ expect(response.status, path).toBe(200);
817+ expect((yield* jsonOf<UsersBody>(response)).users.map((user) => user.externalId)).toEqual(
818+ [USER_A2],
819+ );
820+ }
821+
822+ // Well past the end is an empty page, still a 200.
823+ const far = yield* get(web, "/admin/users?offset=25", ORG_A);
824+ expect(far.status).toBe(200);
825+ expect((yield* jsonOf<UsersBody>(far)).users).toEqual([]);
826+ }),
827+ ),
828+ );
829+
830+ // A fraction decoded cleanly through `FiniteFromString` and reached the
831+ // driver as a `datatype mismatch` 500. A row count is an integer by nature,
832+ // so the contract rejects it up front — the same 400 a non-numeric value gets,
833+ // which is what the schema's comment always claimed.
834+ it.effect("400s a fractional limit or offset instead of failing in the driver", () =>
835+ withDb((db) =>
836+ Effect.gen(function* () {
837+ yield* seed(db);
838+ const web = yield* webHandlerFor(
839+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize),
840+ );
841+
842+ for (const path of [
843+ "/admin/users?limit=1.5",
844+ "/admin/users?offset=0.5",
845+ "/admin/users/with-connections?limit=1.5",
846+ // Still 400s for the reasons that already held.
847+ "/admin/users?limit=abc",
848+ "/admin/users?limit=0",
849+ "/admin/users?limit=501",
850+ ]) {
851+ expect((yield* get(web, path, ORG_A)).status, path).toBe(400);
852+ }
853+
854+ // The integral neighbours are unaffected.
855+ expect((yield* get(web, "/admin/users?limit=1&offset=0", ORG_A)).status).toBe(200);
856+ }),
857+ ),
858+ );
859+
860+ // -------------------------------------------------------------------------
861+ // Single-user reads, by opaque id and by email.
862+ // -------------------------------------------------------------------------
863+
864+ const singleUserWeb = (db: SqliteTestFumaDb, resolved?: string[]) =>
865+ webHandlerFor(
866+ stubProvider(
867+ (tenant) => platformExecutorFor(db, tenant),
868+ headerAuthorize,
869+ stubUserDirectory(resolved ? { resolved } : {}),
870+ ),
871+ );
872+
873+ it.effect("returns one user with identity and connections in a single call", () =>
874+ withDb((db) =>
875+ Effect.gen(function* () {
876+ yield* seed(db);
877+ const web = yield* singleUserWeb(db);
878+
879+ const response = yield* get(web, `/admin/users/${USER_A1}`, ORG_A);
880+ expect(response.status).toBe(200);
881+ const body = yield* jsonOf<UserBody>(response);
882+
883+ // The whole point of the endpoint: identity AND connections together,
884+ // so a per-user check needs exactly one request.
885+ expect(body.user.externalId).toBe(USER_A1);
886+ expect(body.user.email).toBe(A1_EMAIL_STORED);
887+ expect(body.user.displayName).toBe("User A1");
888+ expect(typeof body.user.lastSeenAt).toBe("number");
889+ expect(body.user.connections.map((connection) => connection.integration)).toEqual([
890+ "github",
891+ ]);
892+ expect(body.user.connections[0]?.oauthScope).toBe("repo read:user");
893+ expect(body.user.connections[0]?.lastHealth?.status).toBe("healthy");
894+ }),
895+ ),
896+ );
897+
898+ it.effect("finds the same user by email, case-insensitively", () =>
899+ withDb((db) =>
900+ Effect.gen(function* () {
901+ yield* seed(db);
902+ const resolved: string[] = [];
903+ const web = yield* singleUserWeb(db, resolved);
904+
905+ // Three spellings of one address: as stored, all-lower, and shouted.
906+ for (const spelling of [A1_EMAIL_STORED, A1_EMAIL, A1_EMAIL.toUpperCase()]) {
907+ const response = yield* get(web, `/admin/users/${encodeURIComponent(spelling)}`, ORG_A);
908+ expect(response.status, `${spelling} should resolve`).toBe(200);
909+ const body = yield* jsonOf<UserBody>(response);
910+ expect(body.user.externalId).toBe(USER_A1);
911+ expect(body.user.connections.map((connection) => connection.integration)).toEqual([
912+ "github",
913+ ]);
914+ }
915+
916+ // The seam normalizes before the resolver ever sees the value, so a
917+ // host implementation never has to repeat the rule for the QUERY side.
918+ expect(resolved).toEqual([A1_EMAIL, A1_EMAIL, A1_EMAIL]);
919+ }),
920+ ),
921+ );
922+
923+ it.effect("404s an unknown id and an unknown email, distinctly from 401 and 403", () =>
924+ withDb((db) =>
925+ Effect.gen(function* () {
926+ yield* seed(db);
927+ const web = yield* singleUserWeb(db);
928+
929+ const unknownId = yield* get(web, "/admin/users/user_nobody", ORG_A);
930+ expect(unknownId.status).toBe(404);
931+ const unknownEmail = yield* get(
932+ web,
933+ `/admin/users/${encodeURIComponent("nobody@users.test")}`,
934+ ORG_A,
935+ );
936+ expect(unknownEmail.status).toBe(404);
937+
938+ // The distinct error is what lets a caller tell "no footprint" from
939+ // "bad credential" without parsing a message. Asserted on the encoded
940+ // body, which is what a consumer actually receives.
941+ expect(yield* Effect.promise(() => unknownId.text())).toContain("AdminUserNotFound");
942+
943+ // And 404 must not echo the identifier back.
944+ const raw = yield* Effect.promise(() => unknownEmail.text());
945+ expect(raw).not.toContain("nobody@users.test");
946+ }),
947+ ),
948+ );
949+
950+ it.effect("404s a user of ANOTHER tenant rather than revealing them", () =>
951+ withDb((db) =>
952+ Effect.gen(function* () {
953+ yield* seed(db);
954+ const web = yield* singleUserWeb(db);
955+
956+ // B's user exists, but not for A — the same answer as a nonexistent id,
957+ // which is what keeps the admin plane tenant-isolated.
958+ expect((yield* get(web, `/admin/users/${USER_B1}`, ORG_A)).status).toBe(404);
959+ expect((yield* get(web, `/admin/users/${USER_B1}`, "Bearer org_b_key")).status).toBe(200);
960+ }),
961+ ),
962+ );
963+
964+ // The decision documented on the contract: the admin plane reports FOOTPRINT,
965+ // not org membership. A directory member who never touched executor is a 404,
966+ // not identity-with-empty-connections — otherwise a caller gating on this
967+ // endpoint would treat a never-onboarded invitee as onboarded.
968+ it.effect("404s an email the directory knows but who has no subject row", () =>
969+ withDb((db) =>
970+ Effect.gen(function* () {
971+ // Seed connections/tenant B but NOT a subject row for the directory's
972+ // only known member, so the directory resolves and storage does not.
973+ yield* touchSubject(db.db, { tenant: TENANT_B, externalId: USER_B1 });
974+ const web = yield* singleUserWeb(db);
975+
976+ const response = yield* get(web, `/admin/users/${encodeURIComponent(A1_EMAIL)}`, ORG_A);
977+ expect(response.status, "known to the directory, unknown to this tenant").toBe(404);
978+ expect(yield* Effect.promise(() => response.text())).toContain("AdminUserNotFound");
979+ }),
980+ ),
981+ );
982+
983+ it.effect("filters the bulk lists by email, and returns an empty list for an unknown one", () =>
984+ withDb((db) =>
985+ Effect.gen(function* () {
986+ yield* seed(db);
987+ const web = yield* singleUserWeb(db);
988+
989+ const filtered = yield* jsonOf<UsersBody>(
990+ yield* get(
991+ web,
992+ `/admin/users?email=${encodeURIComponent(A1_EMAIL.toUpperCase())}`,
993+ ORG_A,
994+ ),
995+ );
996+ expect(filtered.users.map((user) => user.externalId)).toEqual([USER_A1]);
997+
998+ const joined = yield* jsonOf<UsersWithConnectionsBody>(
999+ yield* get(
1000+ web,
1001+ `/admin/users/with-connections?email=${encodeURIComponent(A1_EMAIL)}`,
1002+ ORG_A,
1003+ ),
1004+ );
1005+ expect(joined.users.map((user) => user.externalId)).toEqual([USER_A1]);
1006+ expect(joined.users[0]?.connections.map((c) => c.integration)).toEqual(["github"]);
1007+
1008+ // An unknown email is an empty page, never an unfiltered one — the
1009+ // failure mode that would quietly hand back the whole tenant.
1010+ const unknown = yield* jsonOf<UsersBody>(
1011+ yield* get(web, "/admin/users?email=nobody%40users.test", ORG_A),
1012+ );
1013+ expect(unknown.users).toEqual([]);
1014+ }),
1015+ ),
1016+ );
1017+
1018+ // A resolver OUTAGE must not read as "no such user": that is a wrong answer an
1019+ // operator would act on. Contrast with the identity join, which degrades to
1020+ // unnamed rows precisely because it is decoration.
1021+ it.effect("500s when the email resolver fails, rather than reporting no match", () =>
1022+ withDb((db) =>
1023+ Effect.gen(function* () {
1024+ yield* seed(db);
1025+ const web = yield* webHandlerFor(
1026+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, {
1027+ resolveEmail: () => Effect.fail(new DirectoryUnavailable({ message: "down" })),
1028+ }),
1029+ );
1030+
1031+ expect(
1032+ (yield* get(web, `/admin/users/${encodeURIComponent(A1_EMAIL)}`, ORG_A)).status,
1033+ ).toBe(500);
1034+ expect((yield* get(web, `/admin/users?email=${A1_EMAIL}`, ORG_A)).status).toBe(500);
1035+ }),
1036+ ),
1037+ );
1038+
1039+ // `externalId` is opaque by contract, so the "@" test is a routing hint and
1040+ // not a parse: an id that happens to contain "@" must still be findable.
1041+ it.effect("falls back to a literal id lookup for an id containing @", () =>
1042+ withDb((db) =>
1043+ Effect.gen(function* () {
1044+ const oddId = "user@odd";
1045+ yield* touchSubject(db.db, { tenant: TENANT_A, externalId: oddId });
1046+ const web = yield* singleUserWeb(db);
1047+
1048+ const response = yield* get(web, `/admin/users/${encodeURIComponent(oddId)}`, ORG_A);
1049+ expect(response.status).toBe(200);
1050+ expect((yield* jsonOf<UserBody>(response)).user.externalId).toBe(oddId);
1051+ }),
1052+ ),
1053+ );
1054+
1055+ it.effect("still resolves /with-connections as the list, not as a user id", () =>
1056+ withDb((db) =>
1057+ Effect.gen(function* () {
1058+ yield* seed(db);
1059+ const web = yield* singleUserWeb(db);
1060+
1061+ // The router-ordering invariant, asserted rather than trusted: the
1062+ // static segment must keep winning now that `/:identifier` exists.
1063+ const body = yield* jsonOf<UsersWithConnectionsBody>(
1064+ yield* get(web, "/admin/users/with-connections", ORG_A),
1065+ );
1066+ expect(body.users.map((user) => user.externalId)).toEqual([USER_A1, USER_A2]);
1067+ }),
1068+ ),
1069+ );
1070+});
packages/core/api/src/admin/api.ts+355−0
@@ -0,0 +1,355 @@
1+// ---------------------------------------------------------------------------
2+// Admin Users HTTP API — the public, tenant-wide operator surface.
3+//
4+// The product plane (`/api/*`) answers "what can I, this member, reach". This
5+// one answers the OWNER's question: "who are my users, and what have they
6+// connected". It reads the SDK's platform view (`executor.admin`, opt-in via
7+// `platformView: true`), which is read-only by construction — the owner policy
8+// rejects writes at `reach: "tenant"` — so every endpoint here is a GET.
9+//
10+// VOCABULARY: this is the translation seam. Internal code says subject / tenant
11+// / reach; everything public-facing says users / owners. `AdminSubject
12+// .externalId` becomes `externalId` on a `users` collection, and nothing below
13+// leaks the word "subject".
14+//
15+// FIELD DISCIPLINE: the response shapes are 1:1 with the SDK's hand-picked
16+// admin allowlist (`AdminSubject` / `AdminConnection`), NOT a projection of the
17+// underlying rows. No credential material of any kind may appear — no item ids,
18+// no refresh ids, no oauth client secrets, no tokens. `oauthScope` is a summary
19+// of ACCESS (which scopes were granted), never a token. Adding a field here is
20+// a deliberate act that has to be argued against `platform-view.test.ts`'s
21+// forbidden-field list.
22+//
23+// The allowlist is applied per FIELD, not per column: a column may be allowed
24+// while the object inside it is not. `lastHealth` is the live case — the SDK's
25+// `HealthCheckResult` carries upstream-derived content (`identity`, `detail`,
26+// `responseSample`), so this contract narrows it to `AdminConnectionHealth`
27+// rather than forwarding the nested object. Any future nested field arrives
28+// under the same rule: project it, don't re-export it.
29+//
30+// Auth is applied by each host's own admin middleware (cloud: an org-scoped API
31+// key or an admin session member; self-host: a Better Auth owner/admin member),
32+// so this contract carries no provider-specific auth scheme — the same shape
33+// the Account API uses.
34+// ---------------------------------------------------------------------------
35+
36+import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
37+import { Schema } from "effect";
38+
39+import { ConnectionName, HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared";
40+
41+// ---------------------------------------------------------------------------
42+// Errors
43+// ---------------------------------------------------------------------------
44+
45+export class AdminUsersError extends Schema.TaggedErrorClass<AdminUsersError>()(
46+ "AdminUsersError",
47+ { message: Schema.String },
48+ { httpApiStatus: 500 },
49+) {}
50+
51+export class AdminUsersUnauthorized extends Schema.TaggedErrorClass<AdminUsersUnauthorized>()(
52+ "AdminUsersUnauthorized",
53+ {},
54+ { httpApiStatus: 401 },
55+) {}
56+
57+export class AdminUsersForbidden extends Schema.TaggedErrorClass<AdminUsersForbidden>()(
58+ "AdminUsersForbidden",
59+ {},
60+ { httpApiStatus: 403 },
61+) {}
62+
63+/**
64+ * No user of THIS tenant matches the identifier. Distinct from 401/403 on
65+ * purpose: an operator polling one user needs "this person has no footprint
66+ * here" to be a different, non-alarming answer from "your credential is
67+ * wrong". Carries no echo of the identifier — an admin credential is already
68+ * scoped to one tenant, but reflecting input into an error body is a habit
69+ * worth not having.
70+ *
71+ * Deliberately does NOT distinguish "never existed" from "exists in the host
72+ * directory but never touched executor": see the `getUser` docs below.
73+ */
74+export class AdminUserNotFound extends Schema.TaggedErrorClass<AdminUserNotFound>()(
75+ "AdminUserNotFound",
76+ {},
77+ { httpApiStatus: 404 },
78+) {}
79+
80+// ---------------------------------------------------------------------------
81+// Response schemas — the public projection of the SDK's admin shapes.
82+// ---------------------------------------------------------------------------
83+
84+/**
85+ * One user of the tenant. Mirrors `AdminSubject` with the public vocabulary.
86+ *
87+ * `lastSeenAt` is epoch ms of the last sighting on the request path, and is
88+ * deliberately COARSE: the writer (`touchSubject`) only rewrites it past a
89+ * throttle interval (1 hour by default), so treat it as "active around then",
90+ * never as a precise last-activity timestamp. `null` means never seen on a
91+ * request — a user can earn a row at connection-create before any sighting.
92+ */
93+export const AdminUser = Schema.Struct({
94+ /** The host-auth principal id (cloud: the WorkOS user id). Opaque — it also
95+ * carries host sentinels like "local", so nothing may parse it. */
96+ externalId: Schema.String,
97+ /** Epoch ms the user was first recorded under this tenant. */
98+ createdAt: Schema.Number,
99+ lastSeenAt: Schema.NullOr(Schema.Number),
100+ /** User lifecycle. Unconstrained until the lifecycle values are defined;
101+ * `null` means no state recorded (which is every row today). */
102+ status: Schema.NullOr(Schema.String),
103+ /**
104+ * The host's email for this principal, joined server-side from the host's own
105+ * member directory (cloud: WorkOS; self-host: Better Auth). `null` whenever
106+ * the host cannot resolve the id — the member left the org while their
107+ * connections remain, the directory read failed, or the id is a host sentinel
108+ * like "local" that names no member at all.
109+ *
110+ * NOT credential material and not part of the storage projection: it is a
111+ * directory lookup keyed by `externalId`, which is why it can be absent on a
112+ * row whose other fields are complete. Nothing may treat its presence as an
113+ * authorization signal.
114+ */
115+ email: Schema.NullOr(Schema.String),
116+ /** The host's human name for this principal (cloud: given + family name;
117+ * self-host: the Better Auth `name`). `null` on the same terms as `email`,
118+ * and independently — a directory can hold one without the other. */
119+ displayName: Schema.NullOr(Schema.String),
120+});
121+
122+/**
123+ * The admin plane's view of a stored health verdict — a deliberate PROJECTION
124+ * of the SDK's `HealthCheckResult`, not that type re-exported.
125+ *
126+ * WHY THIS IS ITS OWN SCHEMA. `HealthCheckResult` is the OWNER's view of their
127+ * own probe, and it carries content lifted from the upstream provider's actual
128+ * response: `identity` (the display value extracted from `identityField` —
129+ * typically the account's email AT THE PROVIDER), `detail` (a raw diagnostic
130+ * message, which for an HTTP failure can quote the upstream body), and
131+ * `responseSample` (up to 25 scalar leaves of the real response body, ~120
132+ * chars each, via `extractResponseFields`). That is fine on the product plane,
133+ * where the caller IS the credential's owner. It is not fine here: this plane
134+ * reports on OTHER people's connections, so forwarding the whole nested object
135+ * would hand an operator another user's upstream account identity and sampled
136+ * response content. The per-column allowlist in `admin/reads.ts` cannot catch
137+ * that on its own — it allowlists the COLUMN and then forwards everything
138+ * inside it — so the narrowing is expressed here, in the contract.
139+ *
140+ * WHAT SURVIVES. `status` answers the only question this plane asks ("is it
141+ * healthy"), and `checkedAt` says how stale that answer is. Both are derived
142+ * verdicts about the connection, never content from the provider's response.
143+ * `httpStatus` is deliberately dropped as well: it is a diagnostic for the
144+ * credential's owner, and adding fields here has to be argued against the
145+ * forbidden-field lists in `platform-view.test.ts` and `admin-users.test.ts`.
146+ */
147+export const AdminConnectionHealth = Schema.Struct({
148+ /** The stored verdict: healthy / expired / degraded / unknown. */
149+ status: HealthStatus,
150+ /** Epoch ms the check ran, so an operator can tell a fresh verdict from a
151+ * stale one. */
152+ checkedAt: Schema.Number,
153+});
154+
155+/**
156+ * A connection as the platform view sees it: enough to answer "what has this
157+ * user connected, and is it healthy", and nothing that could resolve a
158+ * credential — or name the upstream account. Mirrors `AdminConnection`, except
159+ * that `lastHealth` is narrowed to `AdminConnectionHealth` (see above).
160+ */
161+export const AdminUserConnection = Schema.Struct({
162+ owner: Owner,
163+ integration: IntegrationSlug,
164+ name: ConnectionName,
165+ /** The scope set the provider actually granted, space-delimited as recorded
166+ * at connect/refresh. Null for static credentials. A summary of ACCESS —
167+ * never a token. */
168+ oauthScope: Schema.NullOr(Schema.String),
169+ /** The stored health verdict, PROJECTED to status + checkedAt. Never the
170+ * SDK's full `HealthCheckResult`: see `AdminConnectionHealth`. */
171+ lastHealth: Schema.NullOr(AdminConnectionHealth),
172+});
173+
174+/** A user joined with every connection they own in the tenant. */
175+export const AdminUserWithConnections = Schema.Struct({
176+ ...AdminUser.fields,
177+ connections: Schema.Array(AdminUserConnection),
178+});
179+
180+export const AdminUsersResponse = Schema.Struct({
181+ users: Schema.Array(AdminUser),
182+});
183+
184+export const AdminUserConnectionsResponse = Schema.Struct({
185+ connections: Schema.Array(AdminUserConnection),
186+});
187+
188+export const AdminUsersWithConnectionsResponse = Schema.Struct({
189+ users: Schema.Array(AdminUserWithConnections),
190+});
191+
192+/** One user, identity and connections together. Wrapped in `{ user }` rather
193+ * than returned bare so the response can grow a sibling field without a
194+ * breaking change — the same reason every list here is wrapped. */
195+export const AdminUserResponse = Schema.Struct({
196+ user: AdminUserWithConnections,
197+});
198+
199+// ---------------------------------------------------------------------------
200+// Params / query
201+// ---------------------------------------------------------------------------
202+
203+const AdminUserParams = { externalId: Schema.String };
204+
205+/**
206+ * The single-user path parameter, which names a user by EITHER identifier the
207+ * operator has: the opaque `externalId`, or their email.
208+ *
209+ * WHY ONE PARAM AND NOT A `?email=` VARIANT. The alternative — `GET
210+ * /admin/users?email=` returning the matched user — would make one endpoint
211+ * answer with two different shapes (a list, or one user), which is exactly the
212+ * thing the group comment below rules out: this repo's query params FILTER a
213+ * fixed shape, they never switch it. Two identifiers for one resource is what
214+ * a path parameter is for, and it keeps "fetch the user I am asking about" as
215+ * one route with one response schema and one 404.
216+ *
217+ * DISAMBIGUATION is by shape — an identifier containing "@" is tried as an
218+ * email — but that is a ROUTING HINT, not a parse of the id. `externalId` is
219+ * opaque by contract, so the resolver falls back to a literal id lookup when
220+ * the email interpretation matches nothing. No host mints an id containing
221+ * "@" today (cloud: `user_...`; self-host: a Better Auth id; sentinels like
222+ * "local"), so the fallback is a safety net rather than a live path — but it
223+ * means the opacity rule is never actually violated.
224+ *
225+ * Note the value arrives URL-DECODED, so a client sends `alice%40example.com`
226+ * and the handler sees `alice@example.com`.
227+ */
228+const AdminUserIdentifierParams = { identifier: Schema.String };
229+
230+// Paging, mirroring `AdminListSubjectsOptions`. Query params arrive as strings,
231+// so decode them here rather than making every handler interpret raw strings —
232+// a non-numeric, FRACTIONAL, or out-of-range value is a 400 from the contract,
233+// not a silently-ignored filter and not a value handed to the driver.
234+//
235+// `isInt` is load-bearing, not decoration: `FiniteFromString` alone accepts
236+// "1.5", and a fractional limit reached the driver as a `datatype mismatch`
237+// 500. A row count is an integer by nature, so rejecting the fraction is the
238+// honest answer rather than rounding a caller's request into something they
239+// did not ask for.
240+//
241+// OMITTING these is not the same as passing 0. The SDK applies its own default
242+// page size (`ADMIN_DEFAULT_PAGE_SIZE`, 100) and clamps to
243+// `ADMIN_MAX_PAGE_SIZE` (500, the `maximum` below), so every response is a
244+// bounded page — including `?offset=` with no `limit=`, which is a valid
245+// request that pages through the default-sized window.
246+//
247+// `email` is an exact-match FILTER on the fixed list shape (the response is
248+// still a `users` array, empty when nothing matches), which is how every other
249+// list here uses query params. It selects a specific principal rather than
250+// narrowing a scan, so paging over a filtered result is not meaningful — it is
251+// still honored so the endpoint has one set of semantics, not two.
252+//
253+// Carried as a plain string: the trim + lower-case normalization lives at the
254+// handler seam (`normalizeEmail`), which is also where the single-user path
255+// parameter is normalized, so both entry points share ONE rule rather than a
256+// schema transform on one and hand-rolled code on the other.
257+const AdminListQuery = Schema.Struct({
258+ limit: Schema.optional(
259+ Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })),
260+ ),
261+ offset: Schema.optional(
262+ Schema.FiniteFromString.check(
263+ Schema.isInt(),
264+ Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
265+ ),
266+ ),
267+ email: Schema.optional(Schema.String),
268+});
269+
270+// ---------------------------------------------------------------------------
271+// Group
272+// ---------------------------------------------------------------------------
273+
274+/**
275+ * The admin users group, mounted at `/admin/*` by each host.
276+ *
277+ * The joined view is its OWN path (`/admin/users/with-connections`) rather than
278+ * an `?include=connections` flag on `/admin/users`, because the two return
279+ * different row shapes. A query flag that changes the response schema can't be
280+ * expressed as one typed endpoint — the repo's list endpoints use query params
281+ * only to FILTER a fixed shape (`/tools`, `/connections`), never to switch it.
282+ * It sorts before `/admin/users/:externalId/...` in the router either way, and
283+ * "with-connections" is not a valid opaque principal id.
284+ *
285+ * ROUTER ORDERING, re-verified now that `/admin/users/:identifier` exists and
286+ * collides with `/admin/users/with-connections` at the same path depth. The
287+ * router is find-my-way (`HttpRouter` builds on effect's vendored copy), a
288+ * radix tree that ranks a STATIC segment above a parametric one at the same
289+ * position regardless of registration order. Probed directly against the
290+ * pinned build with the parametric route registered FIRST — the worst case —
291+ * and `/admin/users/with-connections` still resolves to the list endpoint,
292+ * while `/admin/users/user_123` and `/admin/users/alice%40example.test` both
293+ * resolve to the single-user endpoint. The original comment's claim therefore
294+ * still holds, and the endpoint order below is documentation rather than the
295+ * thing that makes it work. Registering `:identifier` here and `:externalId`
296+ * on the nested route is likewise fine: the names differ but they sit at the
297+ * same position, and the tree matches on position, not on name.
298+ */
299+export const AdminUsersApi = HttpApiGroup.make("adminUsers")
300+ .add(
301+ HttpApiEndpoint.get("listUsers", "/admin/users", {
302+ query: AdminListQuery,
303+ success: AdminUsersResponse,
304+ error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden],
305+ }),
306+ )
307+ .add(
308+ HttpApiEndpoint.get("listUsersWithConnections", "/admin/users/with-connections", {
309+ query: AdminListQuery,
310+ success: AdminUsersWithConnectionsResponse,
311+ error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden],
312+ }),
313+ )
314+ .add(
315+ HttpApiEndpoint.get("listUserConnections", "/admin/users/:externalId/connections", {
316+ params: AdminUserParams,
317+ success: AdminUserConnectionsResponse,
318+ error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden],
319+ }),
320+ )
321+ /**
322+ * One user by `externalId` OR email, identity and connections in a single
323+ * call — the read a per-user check makes, which operators run far more often
324+ * than the bulk list.
325+ *
326+ * NOT-FOUND SEMANTICS. `AdminUserNotFound` (404) means "no subject row under
327+ * this tenant matches", and that is the answer even when the host directory
328+ * DOES know the email. A member who was invited but never touched executor
329+ * has no footprint on this plane, and the admin plane exists to report
330+ * footprint: "who are my users and what have they connected". Returning
331+ * directory identity with an empty `connections` array would answer a
332+ * question this API was not asked — it would make "is a member of the org"
333+ * and "is a user of executor" the same answer, and a caller gating on that
334+ * would treat a never-onboarded invitee as onboarded. 404 keeps the two
335+ * distinct, and the org's membership roster is already the account plane's
336+ * job (`/account/members`). The bulk `?email=` filter agrees: it returns an
337+ * empty list, never a synthesized row.
338+ */
339+ .add(
340+ HttpApiEndpoint.get("getUser", "/admin/users/:identifier", {
341+ params: AdminUserIdentifierParams,
342+ success: AdminUserResponse,
343+ error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden, AdminUserNotFound],
344+ }),
345+ );
346+
347+/**
348+ * Standalone HttpApi wrapping just the admin users group. Hosts mount THIS as
349+ * an extension route (like the account API) rather than adding the group to
350+ * `CoreExecutorApi`: the core API is served behind the execution-stack
351+ * middleware, which binds a product-view executor to one acting subject. The
352+ * admin plane needs the opposite (no subject, tenant reach), so it gets its own
353+ * mount and its own per-host auth.
354+ */
355+export const AdminUsersHttpApi = HttpApi.make("executor-admin-users").add(AdminUsersApi);
packages/core/api/src/admin/handlers.ts+69−0
@@ -0,0 +1,69 @@
1+import { HttpApiBuilder } from "effect/unstable/httpapi";
2+import { HttpServerRequest } from "effect/unstable/http";
3+import { Effect } from "effect";
4+
5+import { AdminUsersHttpApi } from "./api";
6+import { normalizeEmail } from "./reads";
7+import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service";
8+
9+// ---------------------------------------------------------------------------
10+// Shared, provider-neutral handlers for the Admin Users API. They do nothing
11+// but read the request headers + paging and delegate to the injected
12+// `AdminUsersProvider`, so cloud and self-host serve identical routes — only
13+// the authorization impl differs. The neutral errors map to their HTTP statuses
14+// (401/403/500) via the contract annotations.
15+// ---------------------------------------------------------------------------
16+
17+const requestHeaders = Effect.map(
18+ HttpServerRequest.HttpServerRequest.asEffect(),
19+ (request): AdminUsersHeaders => ({ ...request.headers }),
20+);
21+
22+// The contract decodes `limit`/`offset` to numbers, but leaves them optional.
23+// Spread only the keys that are present so the SDK's own defaults apply rather
24+// than an explicit `undefined` overriding them.
25+// `email` is normalized here rather than in the contract schema, so the filter
26+// and the single-user path parameter share ONE rule (`normalizeEmail`).
27+const listOptions = (query: {
28+ readonly limit?: number | undefined;
29+ readonly offset?: number | undefined;
30+ readonly email?: string | undefined;
31+}): AdminUsersListOptions => ({
32+ ...(query.limit === undefined ? {} : { limit: query.limit }),
33+ ...(query.offset === undefined ? {} : { offset: query.offset }),
34+ ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }),
35+});
36+
37+export const AdminUsersHandlers = HttpApiBuilder.group(
38+ AdminUsersHttpApi,
39+ "adminUsers",
40+ (handlers) =>
41+ handlers
42+ .handle("listUsers", ({ query }) =>
43+ Effect.gen(function* () {
44+ const headers = yield* requestHeaders;
45+ return yield* (yield* AdminUsersProvider).listUsers(headers, listOptions(query));
46+ }),
47+ )
48+ .handle("listUsersWithConnections", ({ query }) =>
49+ Effect.gen(function* () {
50+ const headers = yield* requestHeaders;
51+ return yield* (yield* AdminUsersProvider).listUsersWithConnections(
52+ headers,
53+ listOptions(query),
54+ );
55+ }),
56+ )
57+ .handle("listUserConnections", ({ params }) =>
58+ Effect.gen(function* () {
59+ const headers = yield* requestHeaders;
60+ return yield* (yield* AdminUsersProvider).listUserConnections(headers, params.externalId);
61+ }),
62+ )
63+ .handle("getUser", ({ params }) =>
64+ Effect.gen(function* () {
65+ const headers = yield* requestHeaders;
66+ return yield* (yield* AdminUsersProvider).getUser(headers, params.identifier);
67+ }),
68+ ),
69+);
packages/core/api/src/admin/reads.ts+348−0
@@ -0,0 +1,348 @@
1+// ---------------------------------------------------------------------------
2+// The READ half of the admin users surface, shared by every host.
3+//
4+// A host's `AdminUsersProvider` owns AUTHORIZATION (who may look) and delegates
5+// the actual reads here, so the projection from the SDK's platform view onto
6+// the wire shapes exists exactly ONCE. That matters because the projection IS
7+// the field-discipline boundary: if each host mapped rows itself, a credential
8+// field could leak into one host's responses and not the other's.
9+//
10+// Every mapping below is explicit rather than a spread. A spread would silently
11+// forward any field a future SDK change adds to `AdminSubject`/`AdminConnection`
12+// — the opposite of an allowlist.
13+// ---------------------------------------------------------------------------
14+
15+import { Effect } from "effect";
16+
17+import type {
18+ AdminConnection,
19+ AdminSubject,
20+ AdminSubjectWithConnections,
21+ Executor,
22+ ExecutorAdmin,
23+} from "@executor-js/sdk";
24+
25+import {
26+ AdminUserNotFound,
27+ AdminUsersError,
28+ type AdminUserConnectionsResponse,
29+ type AdminUserResponse,
30+ type AdminUsersResponse,
31+ type AdminUsersWithConnectionsResponse,
32+} from "./api";
33+import type { AdminUsersListOptions } from "./service";
34+
35+/**
36+ * Narrow an executor to its platform view. `admin` is present only when the
37+ * executor was built with `platformView: true`; a host that reaches these reads
38+ * with a product-view executor is a wiring bug, so it fails loudly as a 500
39+ * rather than silently returning an empty tenant (which would read as "this
40+ * owner has no users").
41+ */
42+export const platformViewOf = (executor: Executor): Effect.Effect<ExecutorAdmin, AdminUsersError> =>
43+ executor.admin
44+ ? Effect.succeed(executor.admin)
45+ : Effect.fail(
46+ new AdminUsersError({
47+ message: "Admin reads require an executor built with the platform view enabled",
48+ }),
49+ );
50+
51+// Storage faults are the only failure these reads can raise. They surface as a
52+// 500 with a flat message — the cause carries the detail into the host's error
53+// capture, and is deliberately not echoed to an API consumer.
54+const readFailed = (what: string) => () =>
55+ new AdminUsersError({ message: `Failed to list ${what}` });
56+
57+// ---------------------------------------------------------------------------
58+// Host identity join
59+// ---------------------------------------------------------------------------
60+
61+/** What a host's member directory can say about one principal. Both halves are
62+ * independently nullable: a directory can hold an email with no name. */
63+export interface AdminUserIdentity {
64+ readonly email: string | null;
65+ readonly displayName: string | null;
66+}
67+
68+/**
69+ * The ONE email normalization rule: shared by the `?email=` filter, the
70+ * single-user path parameter, and every host's own resolver (which must apply
71+ * it to the DIRECTORY value before comparing).
72+ *
73+ * Case-insensitive because the two hosts disagree about what they store:
74+ * Better Auth lower-cases every email it writes, while WorkOS preserves the
75+ * casing it was given. Lower-casing BOTH sides is the only rule that answers
76+ * the same way on both. The local part of an address is technically
77+ * case-sensitive per RFC 5321, but no directory this reads treats it that way,
78+ * and an operator typing "Alice@..." means the same person.
79+ */
80+export const normalizeEmail = (email: string): string => email.trim().toLowerCase();
81+
82+/**
83+ * A host's member directory, keyed by the SAME id space the subject table
84+ * records in `external_id`.
85+ *
86+ * That id is the host-auth principal id — cloud binds the WorkOS `user_...`,
87+ * self-host binds the Better Auth `user.id` — so the join key on both hosts is
88+ * the member list's `userId`, NEVER its `id` (a membership-row id: `om_...` on
89+ * cloud, the `member` row on self-host, both of which join to nothing).
90+ *
91+ * Called ONCE per request with the whole page of ids, so a host implements it
92+ * as one directory read joined in memory rather than a lookup per user. A host
93+ * that cannot resolve identities at all simply passes no directory, and every
94+ * row reports absent identity.
95+ */
96+export type AdminIdentityDirectory = (
97+ externalIds: readonly string[],
98+) => Effect.Effect<ReadonlyMap<string, AdminUserIdentity>, unknown>;
99+
100+/**
101+ * The REVERSE of the join above: email → the host-auth principal id, so an
102+ * operator can name a user by the identifier they actually have.
103+ *
104+ * The email arrives already trimmed and lower-cased by the contract, and an
105+ * implementation must compare against a lower-cased directory value — neither
106+ * host guarantees the casing it stores, so case-insensitivity is the seam's
107+ * rule and not a per-host accident.
108+ *
109+ * `null` means the directory knows no such member. That is NOT the same as
110+ * "not a user of this tenant": a resolved id may still have no subject row,
111+ * which the reads below treat as absent (see `getUser`). Failures are the
112+ * caller's to interpret — unlike the decorative identity join, a lookup that
113+ * cannot run must not silently become "no such user".
114+ */
115+export type AdminEmailResolver = (email: string) => Effect.Effect<string | null, unknown>;
116+
117+/** Both directions of a host's member directory. Optional as a whole (a host
118+ * with no directory reports unnamed rows and cannot resolve emails), and
119+ * optional per direction. */
120+export interface AdminUserDirectory {
121+ readonly identities?: AdminIdentityDirectory;
122+ readonly resolveEmail?: AdminEmailResolver;
123+}
124+
125+/** Identity is decoration on an operator view, not part of the answer: a
126+ * directory outage must degrade to unnamed rows, never fail the read the
127+ * operator actually asked for. Logged once per failed page — the cause carries
128+ * the detail, and the caller learns nothing from it. */
129+const resolveIdentities = (
130+ directory: AdminIdentityDirectory | undefined,
131+ externalIds: readonly string[],
132+): Effect.Effect<ReadonlyMap<string, AdminUserIdentity>> => {
133+ if (!directory || externalIds.length === 0) return Effect.succeed(new Map());
134+ return directory(externalIds).pipe(
135+ Effect.catchCause((cause) =>
136+ Effect.as(
137+ Effect.logWarning("admin users: host identity lookup failed; reporting users unnamed", {
138+ cause,
139+ }),
140+ new Map<string, AdminUserIdentity>(),
141+ ),
142+ ),
143+ );
144+};
145+
146+const ABSENT_IDENTITY: AdminUserIdentity = { email: null, displayName: null };
147+
148+/**
149+ * `AdminSubject` → the public `AdminUser` shape.
150+ *
151+ * `createdAt` crosses the wire as epoch ms (the SDK hands back a `Date`), which
152+ * matches how every other "when did this happen" field on the API is carried
153+ * and keeps the whole response JSON-native.
154+ *
155+ * `email`/`displayName` come from the host directory rather than storage, so an
156+ * id the directory doesn't know — a member who left the org while their
157+ * connections remain, or a host sentinel like "local" that names no member —
158+ * reports absent identity beside otherwise complete row data.
159+ */
160+const toUser = (subject: AdminSubject, identities: ReadonlyMap<string, AdminUserIdentity>) => {
161+ const identity = identities.get(subject.externalId) ?? ABSENT_IDENTITY;
162+ return {
163+ externalId: subject.externalId,
164+ createdAt: subject.createdAt.getTime(),
165+ lastSeenAt: subject.lastSeenAt,
166+ status: subject.status,
167+ email: identity.email,
168+ displayName: identity.displayName,
169+ };
170+};
171+
172+/**
173+ * The stored health verdict → the admin plane's `AdminConnectionHealth`.
174+ *
175+ * NESTED OBJECTS GET THE SAME TREATMENT AS COLUMNS. `HealthCheckResult` is the
176+ * owner's view of their own probe and carries content taken from the UPSTREAM
177+ * provider's response: `identity` (the value at the configured `identityField`
178+ * — usually the account's email at the provider), `detail` (a raw diagnostic
179+ * that can quote an upstream error body), and `responseSample` (a bounded
180+ * sample of the real response body's scalar leaves). This plane reports on
181+ * OTHER people's connections, so none of that may travel; forwarding
182+ * `connection.lastHealth` whole is exactly the leak this function exists to
183+ * prevent — the column allowlist below would still have "passed" while every
184+ * upstream field rode along inside it.
185+ *
186+ * Two fields survive, both of them verdicts ABOUT the connection rather than
187+ * content FROM it: `status` and `checkedAt`. Written as an explicit
188+ * construction, never a spread, for the same reason the row mappings are.
189+ */
190+const toHealth = (health: AdminConnection["lastHealth"]) =>
191+ health === null ? null : { status: health.status, checkedAt: health.checkedAt };
192+
193+/**
194+ * `AdminConnection` → the public `AdminUserConnection` shape.
195+ *
196+ * `subject` is dropped: on `/admin/users/:externalId/connections` it is the
197+ * path parameter, and on the joined view it is the enclosing user's own id, so
198+ * carrying it would be redundant on both. Everything kept here is either an
199+ * identifier or a health/access summary — never credential material, and never
200+ * upstream account content (see `toHealth`).
201+ */
202+const toConnection = (connection: AdminConnection) => ({
203+ owner: connection.owner,
204+ integration: connection.integration,
205+ name: connection.name,
206+ oauthScope: connection.oauthScope,
207+ lastHealth: toHealth(connection.lastHealth),
208+});
209+
210+const toUserWithConnections = (
211+ subject: AdminSubjectWithConnections,
212+ identities: ReadonlyMap<string, AdminUserIdentity>,
213+) => ({
214+ ...toUser(subject, identities),
215+ connections: subject.connections.map(toConnection),
216+});
217+
218+/**
219+ * Accept the legacy one-way directory or the two-way one, so a host that only
220+ * joins identities keeps working unchanged.
221+ */
222+const asDirectory = (
223+ directory: AdminIdentityDirectory | AdminUserDirectory | undefined,
224+): AdminUserDirectory =>
225+ directory === undefined
226+ ? {}
227+ : typeof directory === "function"
228+ ? { identities: directory }
229+ : directory;
230+
231+/**
232+ * Turn an `?email=` filter into the id to keep, or `null` for "nothing can
233+ * match" (unknown email, or a host with no resolver — an email filter no host
234+ * can answer must return nothing, never an unfiltered page).
235+ *
236+ * A resolver FAILURE is a 500, not an empty page: silently reporting "no such
237+ * user" because a directory was unreachable is the kind of wrong answer an
238+ * operator would act on.
239+ */
240+const resolveEmailFilter = (
241+ directory: AdminUserDirectory,
242+ email: string,
243+): Effect.Effect<string | null, AdminUsersError> => {
244+ const resolve = directory.resolveEmail;
245+ if (!resolve) return Effect.succeed(null);
246+ return resolve(email).pipe(
247+ Effect.mapError(() => new AdminUsersError({ message: "Failed to resolve the email address" })),
248+ );
249+};
250+
251+/** Keep only the subject naming `externalId`. The filter is applied AFTER the
252+ * page read, so it narrows the requested page rather than scanning past it —
253+ * see the contract's note on paging a filtered result. */
254+const filterToId = <T extends { readonly externalId: string }>(
255+ subjects: readonly T[],
256+ externalId: string | null,
257+): readonly T[] =>
258+ externalId === null ? [] : subjects.filter((subject) => subject.externalId === externalId);
259+
260+export const listUsers = (
261+ admin: ExecutorAdmin,
262+ options: AdminUsersListOptions,
263+ directory?: AdminIdentityDirectory | AdminUserDirectory,
264+): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> =>
265+ Effect.gen(function* () {
266+ const dir = asDirectory(directory);
267+ const wanted =
268+ options.email === undefined ? undefined : yield* resolveEmailFilter(dir, options.email);
269+ const all = yield* admin.listSubjects(options).pipe(Effect.mapError(readFailed("users")));
270+ const subjects = wanted === undefined ? all : filterToId(all, wanted);
271+ // One directory read for the page that was actually returned, joined in
272+ // memory — never a lookup per user.
273+ const identities = yield* resolveIdentities(
274+ dir.identities,
275+ subjects.map((subject) => subject.externalId),
276+ );
277+ return { users: subjects.map((subject) => toUser(subject, identities)) };
278+ });
279+
280+export const listUsersWithConnections = (
281+ admin: ExecutorAdmin,
282+ options: AdminUsersListOptions,
283+ directory?: AdminIdentityDirectory | AdminUserDirectory,
284+): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> =>
285+ Effect.gen(function* () {
286+ const dir = asDirectory(directory);
287+ const wanted =
288+ options.email === undefined ? undefined : yield* resolveEmailFilter(dir, options.email);
289+ const all = yield* admin
290+ .listSubjectsWithConnections(options)
291+ .pipe(Effect.mapError(readFailed("users")));
292+ const subjects = wanted === undefined ? all : filterToId(all, wanted);
293+ const identities = yield* resolveIdentities(
294+ dir.identities,
295+ subjects.map((subject) => subject.externalId),
296+ );
297+ return { users: subjects.map((subject) => toUserWithConnections(subject, identities)) };
298+ });
299+
300+/**
301+ * One user by `externalId` OR email, with connections.
302+ *
303+ * RESOLUTION ORDER. An identifier containing "@" is tried as an email first;
304+ * if the directory knows no such member, or the id it resolves to has no
305+ * subject row, the identifier is then tried as a literal `externalId`. That
306+ * fallback is what keeps `externalId` opaque: no host mints an id containing
307+ * "@" today, but the contract promises nothing may parse the id, so the "@"
308+ * test is only ever a routing hint that costs one extra keyed read in a case
309+ * that does not currently arise.
310+ *
311+ * A directory hit with no subject row is `AdminUserNotFound`, the same as an
312+ * unknown identifier — the plane reports footprint, not org membership. See
313+ * the `getUser` contract docs for why.
314+ */
315+export const getUser = (
316+ admin: ExecutorAdmin,
317+ identifier: string,
318+ directory?: AdminIdentityDirectory | AdminUserDirectory,
319+): Effect.Effect<typeof AdminUserResponse.Type, AdminUsersError | AdminUserNotFound> =>
320+ Effect.gen(function* () {
321+ const dir = asDirectory(directory);
322+ const read = (externalId: string) =>
323+ admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("user")));
324+
325+ const subject = yield* identifier.includes("@")
326+ ? Effect.gen(function* () {
327+ const resolved = yield* resolveEmailFilter(dir, normalizeEmail(identifier));
328+ const byEmail = resolved === null ? null : yield* read(resolved);
329+ return byEmail ?? (yield* read(identifier));
330+ })
331+ : read(identifier);
332+
333+ if (subject === null) return yield* new AdminUserNotFound();
334+
335+ // Identity is joined through the SAME batched seam the list uses, so the
336+ // single read reports exactly the fields the list would for this row.
337+ const identities = yield* resolveIdentities(dir.identities, [subject.externalId]);
338+ return { user: toUserWithConnections(subject, identities) };
339+ });
340+
341+export const listUserConnections = (
342+ admin: ExecutorAdmin,
343+ externalId: string,
344+): Effect.Effect<typeof AdminUserConnectionsResponse.Type, AdminUsersError> =>
345+ admin.listSubjectConnections(externalId).pipe(
346+ Effect.map((connections) => ({ connections: connections.map(toConnection) })),
347+ Effect.mapError(readFailed("connections")),
348+ );
packages/core/api/src/admin/service.ts+81−0
@@ -0,0 +1,81 @@
1+// ---------------------------------------------------------------------------
2+// AdminUsersProvider — the provider seam behind the neutral Admin Users API.
3+//
4+// The shared `AdminUsersHandlers` are generic: they read the request headers,
5+// authorize through this service, and project the SDK's platform view onto the
6+// wire shapes. Each host implements the AUTHORIZATION half differently:
7+// - cloud → an org-scoped API key (PlatformAuth) OR an admin session member
8+// - self-host → a Better Auth owner/admin member of the single org
9+// while the READ half is identical everywhere (`executor.admin`).
10+//
11+// This mirrors `AccountProvider`: one set of handlers, one contract, per-host
12+// implementations. Methods take the raw request headers (cookie / bearer) so
13+// the implementation can resolve and authorize the caller itself — the admin
14+// plane is NOT behind the execution-stack middleware, because that middleware
15+// binds a product-view executor to one acting subject and the admin plane needs
16+// a subject-less, tenant-reach one.
17+// ---------------------------------------------------------------------------
18+
19+import { Context, type Effect } from "effect";
20+
21+import {
22+ type AdminUserNotFound,
23+ type AdminUsersError,
24+ type AdminUsersForbidden,
25+ type AdminUsersUnauthorized,
26+ AdminUserResponse,
27+ AdminUsersResponse,
28+ AdminUserConnectionsResponse,
29+ AdminUsersWithConnectionsResponse,
30+} from "./api";
31+
32+export type AdminUsersHeaders = Record<string, string>;
33+
34+/** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus
35+ * the contract's `?email=`. The email arrives already trimmed and lower-cased
36+ * by the contract schema, so a provider never re-normalizes it. */
37+export interface AdminUsersListOptions {
38+ readonly limit?: number;
39+ readonly offset?: number;
40+ readonly email?: string;
41+}
42+
43+type User = typeof AdminUserResponse.Type;
44+type Users = typeof AdminUsersResponse.Type;
45+type UserConnections = typeof AdminUserConnectionsResponse.Type;
46+type UsersWithConnections = typeof AdminUsersWithConnectionsResponse.Type;
47+
48+/** Every admin read: authorized, and failing with the neutral triple. */
49+type Authorized<A> = Effect.Effect<
50+ A,
51+ AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden
52+>;
53+
54+export interface AdminUsersProviderShape {
55+ readonly listUsers: (
56+ headers: AdminUsersHeaders,
57+ options: AdminUsersListOptions,
58+ ) => Authorized<Users>;
59+ readonly listUsersWithConnections: (
60+ headers: AdminUsersHeaders,
61+ options: AdminUsersListOptions,
62+ ) => Authorized<UsersWithConnections>;
63+ readonly listUserConnections: (
64+ headers: AdminUsersHeaders,
65+ externalId: string,
66+ ) => Authorized<UserConnections>;
67+ /** One user by `externalId` or email. Adds 404 to the neutral triple — the
68+ * only read here that can honestly say "no such user of yours". */
69+ readonly getUser: (
70+ headers: AdminUsersHeaders,
71+ identifier: string,
72+ ) => Effect.Effect<
73+ User,
74+ AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden | AdminUserNotFound
75+ >;
76+}
77+
78+export class AdminUsersProvider extends Context.Service<
79+ AdminUsersProvider,
80+ AdminUsersProviderShape
81+>()("@executor-js/api/AdminUsersProvider") {}
packages/core/api/src/client.ts+13−0
@@ -14,3 +14,16 @@ export { · 13 unmodified lines
1414 AccountNoOrganization,
1515 AccountUnauthorized,
1616 } from "./account/api";
17+export {
18+ AdminUsersApi,
19+ AdminUsersHttpApi,
20+ AdminUsersError,
21+ AdminUsersForbidden,
22+ AdminUsersUnauthorized,
23+ AdminUser,
24+ AdminUserConnection,
25+ AdminUserWithConnections,
26+ AdminUsersResponse,
27+ AdminUserConnectionsResponse,
28+ AdminUsersWithConnectionsResponse,
29+} from "./admin/api";
packages/core/api/src/index.ts+16−0
@@ -46,6 +46,7 @@ export { · 45 unmodified lines
4646 AccountMeResponse,
4747 ApiKeySummary,
4848 ApiKeysResponse,
49+ OrgApiKeysResponse,
4950 CreateApiKeyBody,
5051 CreatedApiKeyResponse,
5152 OrgMember,
@@ -60,6 +61,21 @@ export { · 8 unmodified lines
6061 UpdateOrgNameResponse,
6162 SuccessResponse,
6263 } from "./account/api";
64+export {
65+ AdminUsersApi,
66+ AdminUsersHttpApi,
67+ AdminUsersError,
68+ AdminUsersForbidden,
69+ AdminUsersUnauthorized,
70+ AdminUserNotFound,
71+ AdminUser,
72+ AdminUserConnection,
73+ AdminUserWithConnections,
74+ AdminUserResponse,
75+ AdminUsersResponse,
76+ AdminUserConnectionsResponse,
77+ AdminUsersWithConnectionsResponse,
78+} from "./admin/api";
6379 export {
6480 RESERVED_ORG_SLUGS,
6581 generateOrgSlug,
packages/core/api/src/server.ts+22−0
@@ -18,6 +18,25 @@ export { · 17 unmodified lines
1818 } from "./plugin-routes";
1919 export { AccountProvider, type AccountProviderShape, type AccountHeaders } from "./account/service";
2020 export { AccountHandlers } from "./account/handlers";
21+export {
22+ AdminUsersProvider,
23+ type AdminUsersProviderShape,
24+ type AdminUsersHeaders,
25+ type AdminUsersListOptions,
26+} from "./admin/service";
27+export { AdminUsersHandlers } from "./admin/handlers";
28+export {
29+ platformViewOf,
30+ listUsers as listAdminUsers,
31+ listUsersWithConnections as listAdminUsersWithConnections,
32+ listUserConnections as listAdminUserConnections,
33+ getUser as getAdminUser,
34+ normalizeEmail as normalizeAdminUserEmail,
35+ type AdminEmailResolver,
36+ type AdminIdentityDirectory,
37+ type AdminUserDirectory,
38+ type AdminUserIdentity,
39+} from "./admin/reads";
2140 export { requestScopedMiddleware } from "./server/request-scoped";
2241 export { RouterConfigLive } from "./server/router-config";
2342 export { consoleErrorCapture } from "./server/console-error-capture";
@@ -53,6 +72,7 @@ export { · 29 unmodified lines
5372 } from "./server/executor-fuma-db";
5473 export {
5574 makeScopedExecutor,
75+ makePlatformExecutor,
5676 HostConfig,
5777 PluginsProvider,
5878 RequestWebOrigin,
@@ -89,10 +109,12 @@ export { · 30 unmodified lines
89109 export {
90110 makeProtectedApiLayer,
91111 makeAccountApiLayer,
112+ makeAdminUsersApiLayer,
92113 accountProviderMiddlewareLayer,
93114 toApiHandler,
94115 type MakeProtectedApiLayerOptions,
95116 type MakeAccountApiLayerOptions,
117+ type MakeAdminUsersApiLayerOptions,
96118 type ApiHandler,
97119 } from "./server/host-foundation";
98120 export {
packages/core/api/src/server/host-foundation.ts+42−0
@@ -40,6 +40,8 @@ import { observabilityMiddleware, type ErrorCapture } from "../observability"; · 39 unmodified lines
4040 import { AccountHttpApi } from "../account/api";
4141 import { AccountHandlers } from "../account/handlers";
4242 import type { AccountProvider } from "../account/service";
43+import { AdminUsersHttpApi } from "../admin/api";
44+import { AdminUsersHandlers } from "../admin/handlers";
4345 import { composePluginApi, composePluginHandlerLayer } from "../plugin-routes";
4446 import { CoreHandlers } from "../handlers";
4547 import { requestScopedMiddleware } from "./request-scoped";
@@ -179,6 +181,46 @@ export const accountProviderMiddlewareLayer = ( · 133 unmodified lines
179181 accountProviderLayer: Layer.Layer<AccountProvider>,
180182 ) => requestScopedMiddleware(accountProviderLayer).layer;
181183
184+// ---------------------------------------------------------------------------
185+// Admin Users API
186+// ---------------------------------------------------------------------------
187+
188+export interface MakeAdminUsersApiLayerOptions {
189+ /**
190+ * Optional prefixed `HttpRouter` view, matching the protected API's prefix so
191+ * the admin routes register on the same `/api`-prefixed router.
192+ */
193+ readonly router?: RouterLayer;
194+}
195+
196+/**
197+ * Mount the shared, provider-neutral `AdminUsersHandlers` (the tenant-wide
198+ * users + connections reads) behind a per-request `AdminUsersProvider`.
199+ *
200+ * Structurally identical to `makeAccountApiLayer`, and for the same reason: an
201+ * HttpApi handler's service requirement is NOT erased by a plain
202+ * `Layer.provide` on the builder layer (it leaks into the app layer's
203+ * requirements and breaks the host build), so the provider arrives through a
204+ * per-request router middleware.
205+ *
206+ * This is mounted as an app EXTENSION route rather than added to
207+ * `CoreExecutorApi`, because the core API is served behind the execution-stack
208+ * middleware — which authenticates a member `Principal` and binds a
209+ * product-view executor to them. The admin plane's callers (an org API key with
210+ * no member behind it) and its executor (subject-less, tenant reach) are both
211+ * the opposite of that, so it gets its own mount and its own auth.
212+ */
213+export const makeAdminUsersApiLayer = <MOut, ME, MR>(
214+ adminUsersProviderMiddleware: Layer.Layer<MOut, ME, MR>,
215+ options: MakeAdminUsersApiLayerOptions = {},
216+) => {
217+ const base = HttpApiBuilder.layer(AdminUsersHttpApi).pipe(
218+ Layer.provide(AdminUsersHandlers),
219+ Layer.provide(adminUsersProviderMiddleware),
220+ );
221+ return options.router ? base.pipe(Layer.provide(options.router)) : base;
222+};
223+
182224 // ---------------------------------------------------------------------------
183225 // App-layer web-handler binding
184226 // ---------------------------------------------------------------------------
packages/core/api/src/server/scoped-executor.ts+82−1
@@ -42,7 +42,11 @@ import { · 41 unmodified lines
4242 type Executor,
4343 type StorageFailure,
4444 } from "@executor-js/sdk";
45−import { makeHostedFetch, makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal";
45+import {
46+ makeHostedFetch,
47+ makeHostedHttpClientLayer,
48+ touchSubject,
49+} from "@executor-js/sdk/host-internal";
4650
4751 import { DbProvider } from "./executor-fuma-db";
4852
@@ -281,8 +285,85 @@ export const makeScopedExecutor = < · 232 unmodified lines
281285 includeProviders: config.exposeCredentialProviders ?? true,
282286 },
283287 }).pipe(Effect.withSpan("executor.stack.create_executor"));
288+ // Record the sighting. THIS is the seam every HTTP request and MCP session
289+ // on every host passes through, so it is where the `subject` table gets
290+ // populated: a principal earns a row the first time it authenticates,
291+ // whether or not it ever connects anything. Throttled (`touchSubject` only
292+ // rewrites `last_seen_at` past a coarse interval) and non-fatal by
293+ // construction — it returns `Effect<void>`, so a lost sighting logs and the
294+ // request proceeds. `accountId` is passed verbatim, sentinels ("local")
295+ // included.
296+ yield* touchSubject(db, { tenant: organizationId, externalId: accountId });
297+
284298 // The seam erases the plugin tuple type; the caller re-narrows via the
285299 // `TPlugins` phantom. Runtime shape is identical to a typed
286300 // `createExecutor({ plugins })` call.
287301 return executor as Executor<TPlugins>;
288302 });
303+
304+// ---------------------------------------------------------------------------
305+// makePlatformExecutor — the subject-less, read-only sibling of
306+// `makeScopedExecutor`, for the `/admin/*` plane.
307+//
308+// An org-level caller (a WorkOS org-scoped API key, or an owner/admin acting on
309+// the whole workspace) has NO acting member, so there is no honest subject to
310+// bind. This builds the executor that shape implies: `{ tenant, subject:
311+// undefined, platformView: true }`.
312+//
313+// WHAT "READ-ONLY" MEANS HERE, PRECISELY. `platformView: true` puts
314+// `writes: "denied"` on the executor's base owner-policy context and
315+// `reach: "tenant"` on the `admin` handle's. So:
316+// - EVERY surface on this executor — `admin`, and the ordinary `connections`
317+// / `policies` / `integrations` / `oauth` alike — is refused every create,
318+// update and delete by the owner policy. That is enforced at the storage
319+// boundary, not by this factory remembering to be careful.
320+// - Only the `admin` handle READS tenant-wide. The ordinary surfaces keep
321+// bound reach, so they see the tenant's org rows and nothing of any
322+// member's — widening them would expose every subject's `connection` row,
323+// credential item ids included, to surfaces that never needed them.
324+// Read-only is therefore total; tenant-wide visibility is not, and deliberately
325+// so.
326+//
327+// Three deliberate differences from `makeScopedExecutor`:
328+// - no `subject`, so no `owner: "user"` rows resolve implicitly and the
329+// product view is untouched;
330+// - no `touchSubject`, because there is no principal to record a sighting
331+// for. An org key must never mint a `subject` row — that row would show up
332+// in the very list this plane serves, as a user that does not exist;
333+// - no OAuth redirect / core-tools wiring. Those exist to drive interactive
334+// connect flows on behalf of a member; an admin read has neither.
335+// ---------------------------------------------------------------------------
336+
337+export const makePlatformExecutor = (
338+ organizationId: string,
339+): Effect.Effect<Executor, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
340+ Effect.gen(function* () {
341+ const { db, blobs } = yield* DbProvider.asEffect().pipe(
342+ Effect.withSpan("executor.platform.db_provider"),
343+ );
344+ const { plugins: pluginsFactory } = yield* PluginsProvider.asEffect().pipe(
345+ Effect.withSpan("executor.platform.plugins_provider"),
346+ );
347+ const config = yield* HostConfig.asEffect().pipe(
348+ Effect.withSpan("executor.platform.host_config"),
349+ );
350+
351+ // The plugin set still has to be built: the platform view reads the
352+ // `connection` table, whose rows name integrations a plugin owns, and the
353+ // executor's construction validates against the registered plugin set.
354+ const plugins = yield* Effect.sync(() => pluginsFactory()).pipe(
355+ Effect.withSpan("executor.platform.plugins.init"),
356+ );
357+ const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork };
358+
359+ return yield* createExecutor({
360+ tenant: Tenant.make(organizationId),
361+ db,
362+ blobs,
363+ plugins,
364+ httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions),
365+ fetch: makeHostedFetch(hostedHttpOptions),
366+ onElicitation: "accept-all",
367+ platformView: true,
368+ }).pipe(Effect.withSpan("executor.platform.create_executor"));
369+ });
packages/core/sdk/src/core-schema.ts+50−3
@@ -5,6 +5,7 @@ import { StorageError, type FumaRow } from "./fuma-runtime"; · 4 unmodified lines
55 import {
66 assertOwnerPatch,
77 assertOwnerWritable,
8+ assertReachReadOnly,
89 executorOwnerPolicyName,
910 executorTenantPolicyName,
1011 executorUnscopedPolicyName,
@@ -67,6 +68,10 @@ const tenantExecutorTable = <const TColumns extends UserColumns>( · 56 unmodified lines
6768 name: executorTenantPolicyName,
6869 onRead: ({ builder, context }) => builder("tenant", "=", context.tenant),
6970 onCreate: ({ values, context }) => {
71+ // Tenant-scoped reads are already tenant-wide, so reach doesn't widen
72+ // them — but the platform view must stay read-only on EVERY table it can
73+ // reach, and `subject` is one of these.
74+ assertReachReadOnly(name, "write", context);
7075 if (values.tenant !== context.tenant) {
7176 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB table policy callbacks are promise callbacks, not Effect effects
7277 throw new StorageError({
@@ -75,8 +80,14 @@ const tenantExecutorTable = <const TColumns extends UserColumns>( · 2 unmodified lines
7580 });
7681 }
7782 },
78− onUpdate: ({ builder, context }) => builder("tenant", "=", context.tenant),
79− onDelete: ({ builder, context }) => builder("tenant", "=", context.tenant),
83+ onUpdate: ({ builder, context }) => {
84+ assertReachReadOnly(name, "write", context);
85+ return builder("tenant", "=", context.tenant);
86+ },
87+ onDelete: ({ builder, context }) => {
88+ assertReachReadOnly(name, "delete", context);
89+ return builder("tenant", "=", context.tenant);
90+ },
8091 });
8192 };
8293
@@ -104,7 +115,13 @@ const ownedExecutorTable = <const TColumns extends UserColumns>( · 21 unmodified lines
104115 assertOwnerPatch(name, create, context);
105116 return ownerVisibility(builder, context);
106117 },
107− onDelete: ({ builder, context }) => ownerVisibility(builder, context),
118+ // A delete carries no values to assert against, so the reach guard is the
119+ // ONLY thing standing between a widened (platform-view) context and a
120+ // tenant-wide delete — `ownerVisibility` would happily match every row.
121+ onDelete: ({ builder, context }) => {
122+ assertReachReadOnly(name, "delete", context);
123+ return ownerVisibility(builder, context);
124+ },
108125 });
109126 };
110127
@@ -151,6 +168,35 @@ export const coreTables = defineTables({ · 40 unmodified lines
151168 ["tenant", "slug"],
152169 ),
153170
171+ // The join between a host's identity system (WorkOS accounts, Better Auth
172+ // members, the local single-user sentinel) and the `subject` partition key
173+ // smeared across the owned tables. NOT an identity system of its own: the
174+ // hosts stay authoritative for names, emails, and membership. This table
175+ // only records that a principal has been seen under this tenant, so a user
176+ // who has no connection row is still answerable.
177+ //
178+ // Tenant-scoped, not owner-scoped, on purpose: any executor bound to the
179+ // tenant may read it, which is what an operator-level (cross-subject) view
180+ // needs, and it costs zero policy changes.
181+ subject: tenantExecutorTable(
182+ "subject",
183+ {
184+ // The host-auth principal id (cloud: the WorkOS accountId). Opaque here
185+ // — it also carries host sentinels like "local", so nothing may parse it.
186+ external_id: keyColumn("external_id"),
187+ created_at: dateColumn("created_at"),
188+ // Epoch ms of the last sighting on the request path. Nullable so a row
189+ // can be created (e.g. at connection-create) before any sighting is
190+ // recorded; bigint rather than a timestamp to match the other
191+ // "last X happened at" columns here (`tools_synced_at`, `expires_at`).
192+ last_seen_at: nullableBigintColumn("last_seen_at"),
193+ // Subject lifecycle. Left as an unconstrained nullable string until the
194+ // lifecycle values are actually defined; null means "no state recorded".
195+ status: nullableTextColumn("status"),
196+ },
197+ ["tenant", "external_id"],
198+ ),
199+
154200 // THE saved credential, one per (owner, integration, name). Resolves each named
155201 // input via `provider` + the `item_ids` map (variable → provider item id). A
156202 // single-secret connection is `{ "token": <id> }`; an apiKey method with two
@@ -338,6 +384,7 @@ export const coreSchema = coreTables; · 181 unmodified lines
338384 export type CoreSchema = typeof coreTables;
339385
340386 export type IntegrationRow = FumaRow<CoreSchema["integration"]>;
387+export type SubjectRow = FumaRow<CoreSchema["subject"]>;
341388 export type ConnectionRow = FumaRow<CoreSchema["connection"]>;
342389 export type OAuthClientRow = FumaRow<CoreSchema["oauth_client"]>;
343390 export type OAuthSessionRow = FumaRow<CoreSchema["oauth_session"]>;
packages/core/sdk/src/executor.ts+328−1
@@ -107,6 +107,7 @@ import { · 106 unmodified lines
107107 type UpdateToolPolicyInput,
108108 } from "./policies";
109109 import type { CredentialProvider, ProviderEntry } from "./provider";
110+import { touchSubject } from "./subject-registry";
110111 import type {
111112 AnyPlugin,
112113 Elicit,
@@ -368,6 +369,15 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 255 unmodified lines
368369 readonly resolve: (address: ToolAddress) => Effect.Effect<EffectivePolicy, StorageFailure>;
369370 };
370371
372+ /**
373+ * The PLATFORM VIEW: read-only, tenant-wide reads across every subject.
374+ * Present only when the executor was built with `platformView: true`
375+ * (default off) — every other surface on this executor stays bound to the
376+ * single `{ tenant, subject }` product view and is unaffected by this one.
377+ * Internal admin surface; the public HTTP shape is a separate concern.
378+ */
379+ readonly admin?: ExecutorAdmin;
380+
371381 readonly execute: (
372382 address: ToolAddress,
373383 args: unknown,
@@ -377,6 +387,148 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 3 unmodified lines
377387 readonly close: () => Effect.Effect<void, StorageFailure>;
378388 } & PluginExtensions<TPlugins>;
379389
390+// ---------------------------------------------------------------------------
391+// The platform view — internal admin reads.
392+//
393+// Everything else on the executor is the PRODUCT view: bound to one
394+// { tenant, subject }. This is the read-only escape hatch beside it, reading
395+// the same store through a `reach: "tenant"` context so it can answer
396+// "who exists under this tenant, and what have they connected".
397+//
398+// Vocabulary note: internal code says subject/tenant/reach. Anything
399+// public-facing says users/owners — the translation happens at the HTTP edge,
400+// not here.
401+//
402+// FIELD DISCIPLINE: these shapes are a hand-picked allowlist, not a projection
403+// of the row. Nothing secret-bearing may appear — no `item_ids`, no
404+// `refresh_item_id`, no oauth client secrets, no credential material of any
405+// kind. Adding a field here is a deliberate act.
406+// ---------------------------------------------------------------------------
407+
408+/** One principal seen under the tenant (a row of the `subject` table). */
409+export interface AdminSubject {
410+ /** The host-auth principal id. Opaque — it also carries host sentinels
411+ * like "local", so nothing may parse it. */
412+ readonly externalId: string;
413+ readonly createdAt: Date;
414+ /** Epoch ms of the last sighting on the request path; null when never seen
415+ * (a subject can be created at connection-create before any sighting). */
416+ readonly lastSeenAt: number | null;
417+ readonly status: string | null;
418+}
419+
420+/** A connection as the platform view sees it: enough to answer "what has this
421+ * user connected and is it healthy", and nothing that could resolve a
422+ * credential. */
423+export interface AdminConnection {
424+ readonly owner: Owner;
425+ /** The owning principal — `null` for org-owned connections, which belong to
426+ * the tenant rather than to any one user. */
427+ readonly subject: string | null;
428+ readonly integration: IntegrationSlug;
429+ readonly name: ConnectionName;
430+ /** The scope set the provider actually granted, space-delimited as recorded
431+ * at connect/refresh. Null for static credentials. A summary of ACCESS —
432+ * never a token. */
433+ readonly oauthScope: string | null;
434+ readonly lastHealth: HealthCheckResult | null;
435+}
436+
437+/** A subject together with every connection it owns in the tenant. */
438+export interface AdminSubjectWithConnections extends AdminSubject {
439+ readonly connections: readonly AdminConnection[];
440+}
441+
442+export interface AdminListSubjectsOptions {
443+ readonly limit?: number;
444+ readonly offset?: number;
445+}
446+
447+/**
448+ * Page size applied when a caller names none. Every admin list is BOUNDED:
449+ * `listSubjects()` with no arguments is the obvious call, and unbounded it
450+ * returns every subject in the tenant — which `listSubjectsWithConnections`
451+ * then turns into one sequential connection query PER SUBJECT, inside a single
452+ * request, over a per-request socket on cloud. A default is what keeps the
453+ * no-args call honest; a caller who wants more asks for more, up to
454+ * {@link ADMIN_MAX_PAGE_SIZE}.
455+ *
456+ * 100 rather than the maximum: large enough that no realistic operator UI pages
457+ * twice for a first screen, small enough that the joined read's fan-out stays a
458+ * bounded cost even at its worst.
459+ */
460+export const ADMIN_DEFAULT_PAGE_SIZE = 100;
461+
462+/** Hard ceiling on an admin page, matching the HTTP contract's `limit` maximum.
463+ * A larger `limit` is clamped rather than honored. */
464+export const ADMIN_MAX_PAGE_SIZE = 500;
465+
466+/**
467+ * Normalize paging for every admin list.
468+ *
469+ * THREE things happen here, each fixing a real failure:
470+ * - a `limit` is ALWAYS produced. Drizzle's SQLite dialect emits OFFSET only
471+ * alongside LIMIT, and SQLite rejects a bare OFFSET, so `{ offset: 25 }`
472+ * was a syntax error on every SQLite host (local, self-host, D1).
473+ * - the limit is clamped to `[1, ADMIN_MAX_PAGE_SIZE]`, so no caller can ask
474+ * for an unbounded scan.
475+ * - both values are floored to integers. The HTTP contract rejects a
476+ * fractional `?limit=` outright (a 400, not a coerced value); this is the
477+ * SDK-level backstop so no driver ever receives a fraction and answers with
478+ * `datatype mismatch`.
479+ */
480+const normalizeAdminPaging = (
481+ options: AdminListSubjectsOptions | undefined,
482+): { readonly limit: number; readonly offset: number } => {
483+ const requested = options?.limit ?? ADMIN_DEFAULT_PAGE_SIZE;
484+ const limit = Math.min(Math.max(Math.floor(requested), 1), ADMIN_MAX_PAGE_SIZE);
485+ const offset = Math.max(Math.floor(options?.offset ?? 0), 0);
486+ return { limit, offset };
487+};
488+
489+export interface ExecutorAdmin {
490+ /** One page of subjects under the tenant, oldest first (stable: ties break on
491+ * `external_id`). ALWAYS bounded: no arguments means
492+ * {@link ADMIN_DEFAULT_PAGE_SIZE} rows from offset 0, and `limit` is clamped
493+ * to {@link ADMIN_MAX_PAGE_SIZE}. There is no way to ask for every subject
494+ * in one call. */
495+ readonly listSubjects: (
496+ options?: AdminListSubjectsOptions,
497+ ) => Effect.Effect<readonly AdminSubject[], StorageFailure>;
498+ /**
499+ * One subject by its `external_id`, or `null` when the tenant has no such
500+ * row. A keyed read on the `(tenant, external_id)` unique index rather than
501+ * a filtered `listSubjects`, because the caller asking for ONE principal
502+ * must not pay for a tenant-wide scan — this is the read behind a per-user
503+ * check that runs far more often than the bulk list.
504+ *
505+ * `null` is a normal answer, not a failure: it means "this tenant has never
506+ * recorded that principal". Distinguishing that from a storage fault is the
507+ * whole point of the nullable return.
508+ */
509+ readonly getSubject: (externalId: string) => Effect.Effect<AdminSubject | null, StorageFailure>;
510+ /** Every connection in the tenant owned by `externalId`. Org-owned
511+ * connections are NOT attributed to a user and are excluded. */
512+ readonly listSubjectConnections: (
513+ externalId: string,
514+ ) => Effect.Effect<readonly AdminConnection[], StorageFailure>;
515+ /** `listSubjects` joined with each subject's connections — one connection
516+ * query per subject IN THE PAGE, sequentially. The paging bound is what
517+ * makes that fan-out affordable: it is capped at
518+ * {@link ADMIN_DEFAULT_PAGE_SIZE} round trips by default and
519+ * {@link ADMIN_MAX_PAGE_SIZE} at worst, never "every subject in the
520+ * tenant". */
521+ readonly listSubjectsWithConnections: (
522+ options?: AdminListSubjectsOptions,
523+ ) => Effect.Effect<readonly AdminSubjectWithConnections[], StorageFailure>;
524+ /** `getSubject` joined with that subject's connections, in ONE call — the
525+ * shape a per-user check needs. `null` on the same terms as `getSubject`,
526+ * and no connection read is issued when the subject row is absent. */
527+ readonly getSubjectWithConnections: (
528+ externalId: string,
529+ ) => Effect.Effect<AdminSubjectWithConnections | null, StorageFailure>;
530+}
531+
380532 export interface ExecutorDb {
381533 readonly db: FumaDb<any>;
382534 readonly close?: () => Effect.Effect<void, StorageFailure> | Promise<void> | void;
@@ -447,6 +599,25 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 64 unmodified lines
447599 * config-revision re-sync still apply).
448600 */
449601 readonly toolsSyncTtlMs?: number | null;
602+ /**
603+ * Opt into the PLATFORM VIEW: a read-only, tenant-wide `executor.admin`
604+ * surface that reads across every subject in the tenant (see
605+ * {@link ExecutorAdmin}). Default OFF — `admin` is simply absent, so the
606+ * escape hatch has to be asked for by a host that has authorized an
607+ * org-level caller.
608+ *
609+ * Enabling it makes the WHOLE executor read-only, not just `admin`: the base
610+ * owner context carries `writes: "denied"`, so `connections`, `policies`,
611+ * `integrations` and `oauth` refuse every create/update/delete at the storage
612+ * boundary. An executor built for an org-level caller is an observer, and
613+ * `admin` being its only tenant-wide surface is not the same as it being its
614+ * only guarded one.
615+ *
616+ * READS still differ by surface: only `admin` is tenant-wide. Every other
617+ * surface stays bound to `{ tenant, subject }` — widening them would expose
618+ * every subject's connection rows, credential item ids included.
619+ */
620+ readonly platformView?: boolean;
450621 }
451622
452623 /** Default freshness window for remote-catalog connections (see
@@ -1369,7 +1540,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 916 unmodified lines
13691540 catch: (cause) => storageFailureFromUnknown("Failed to validate executor tables", cause),
13701541 });
13711542
1372− const ownerContext: ExecutorOwnerPolicyContext = { tenant, subject };
1543+ // The platform view is read-only ACROSS THE WHOLE EXECUTOR, not just on the
1544+ // `admin` handle: `writes: "denied"` rides on the base context, so
1545+ // `connections`, `policies`, `integrations` and `oauth` are guarded by the
1546+ // owner policy too. Without it a platform executor is subject-less but
1547+ // still bound to the tenant's ORG partition, and could create/update/delete
1548+ // every `owner: "org"` row through those ordinary surfaces.
1549+ //
1550+ // Deliberately NOT `reach: "tenant"` here — that would widen this context's
1551+ // reads to every subject's `connection` rows, credential item ids included.
1552+ // Reach stays "bound"; only the write axis changes. See
1553+ // `ExecutorOwnerPolicyContext.writes`.
1554+ const ownerContext: ExecutorOwnerPolicyContext = {
1555+ tenant,
1556+ subject,
1557+ ...(config.platformView === true ? { writes: "denied" as const } : {}),
1558+ };
13731559 const rootDb = withQueryContext(rootDbUntyped, ownerContext);
13741560 const fuma = makeFumaClient(rootDb);
13751561 const core = makeCoreDb(fuma);
@@ -2503,6 +2689,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1127 unmodified lines
25032689 }),
25042690 );
25052691
2692+ // Record the sighting. The request seam (`makeScopedExecutor`) already
2693+ // does this for every hosted call, so this is the belt for direct
2694+ // SDK/CLI callers that never pass through it — a connecting principal
2695+ // must always have a subject row. Outside
2696+ // the transaction above: bookkeeping must not roll back the
2697+ // connection, and `touchSubject` cannot fail. No-ops on a pure-org
2698+ // executor (no principal to record), including for `owner: "org"`
2699+ // connections created by a bound member.
2700+ yield* touchSubject(rootDbUntyped, { tenant, externalId: subject });
2701+
25062702 const ref: ConnectionRef = {
25072703 owner: input.owner,
25082704 integration: input.integration,
@@ -4160,6 +4356,136 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1651 unmodified lines
41604356 }
41614357 }
41624358
4359+ // ------------------------------------------------------------------
4360+ // Platform view — read-only, tenant-wide admin reads (opt-in).
4361+ //
4362+ // A SECOND handle over the same root db, bound to the same tenant but at
4363+ // `reach: "tenant"`. Deriving it here rather than re-scoping `rootDb`
4364+ // keeps the product view untouched: the bound handle above never learns
4365+ // about reach, so it cannot drift into the platform view. Writes through
4366+ // this handle are rejected by the owner policy, so "read-only" is enforced
4367+ // at the storage boundary, not by this module's discipline.
4368+ //
4369+ // The base context is ALREADY `writes: "denied"` whenever the platform view
4370+ // is on (see `ownerContext`); this handle adds tenant reach on top. The two
4371+ // axes are separate on purpose — this is the only surface that gets the
4372+ // widened reads, while read-only covers all of them.
4373+ // ------------------------------------------------------------------
4374+
4375+ const makeAdmin = (): ExecutorAdmin => {
4376+ const platformCore = makeCoreDb(
4377+ makeFumaClient(
4378+ withQueryContext(rootDbUntyped, {
4379+ ...ownerContext,
4380+ reach: "tenant",
4381+ } satisfies ExecutorOwnerPolicyContext),
4382+ ),
4383+ );
4384+
4385+ const rowToAdminSubject = (row: CoreRow<"subject">): AdminSubject => ({
4386+ externalId: row.external_id,
4387+ createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at),
4388+ // bigint on drivers that return one, and a blob on SQLite — hence the
4389+ // ORM read rather than raw SQL (see `subject-registry.ts`).
4390+ lastSeenAt: row.last_seen_at == null ? null : Number(row.last_seen_at),
4391+ status: row.status ?? null,
4392+ });
4393+
4394+ const rowToAdminConnection = (row: ConnectionRow): AdminConnection => {
4395+ const owner = row.owner as Owner;
4396+ return {
4397+ owner,
4398+ // Org rows carry the empty-string sentinel, not a principal.
4399+ subject: owner === "org" ? null : row.subject,
4400+ integration: IntegrationSlug.make(row.integration),
4401+ name: ConnectionName.make(row.name),
4402+ oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),
4403+ lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)),
4404+ };
4405+ };
4406+
4407+ const listSubjects = (
4408+ options?: AdminListSubjectsOptions,
4409+ ): Effect.Effect<readonly AdminSubject[], StorageFailure> => {
4410+ // Always both, always integers — see `normalizeAdminPaging`. Passing
4411+ // them through independently produced a bare OFFSET, which SQLite
4412+ // rejects outright.
4413+ const { limit, offset } = normalizeAdminPaging(options);
4414+ return platformCore
4415+ .findMany("subject", {
4416+ // Oldest first, ties broken on the unique key so the order is
4417+ // total and paging can't repeat or skip a row.
4418+ orderBy: [
4419+ ["created_at", "asc"],
4420+ ["external_id", "asc"],
4421+ ],
4422+ limit,
4423+ offset,
4424+ })
4425+ .pipe(Effect.map((rows) => rows.map(rowToAdminSubject)));
4426+ };
4427+
4428+ // Keyed on `(tenant, external_id)` — the table's unique index. No
4429+ // `tenant` clause here: the tenant policy adds it to every read, the
4430+ // same way `touchSubject` relies on it.
4431+ const getSubject = (externalId: string): Effect.Effect<AdminSubject | null, StorageFailure> =>
4432+ platformCore
4433+ .findFirst("subject", { where: (b: AnyCb) => b("external_id", "=", externalId) })
4434+ .pipe(Effect.map((row) => (row === null ? null : rowToAdminSubject(row))));
4435+
4436+ const listSubjectConnections = (
4437+ externalId: string,
4438+ ): Effect.Effect<readonly AdminConnection[], StorageFailure> =>
4439+ platformCore
4440+ .findMany("connection", {
4441+ // `owner: "user"` explicitly: an org connection's `subject` is the
4442+ // empty-string sentinel, and attributing those to a user would be
4443+ // a lie in every host that has one.
4444+ where: (b: AnyCb) => b.and(b("owner", "=", "user"), b("subject", "=", externalId)),
4445+ orderBy: [
4446+ ["integration", "asc"],
4447+ ["name", "asc"],
4448+ ],
4449+ })
4450+ .pipe(Effect.map((rows) => rows.map(rowToAdminConnection)));
4451+
4452+ const listSubjectsWithConnections = (
4453+ options?: AdminListSubjectsOptions,
4454+ ): Effect.Effect<readonly AdminSubjectWithConnections[], StorageFailure> =>
4455+ Effect.gen(function* () {
4456+ const subjects = yield* listSubjects(options);
4457+ return yield* Effect.forEach(subjects, (entry) =>
4458+ listSubjectConnections(entry.externalId).pipe(
4459+ Effect.map((connections) => ({ ...entry, connections })),
4460+ ),
4461+ );
4462+ });
4463+
4464+ // Absent subject short-circuits: no connection query is issued for a
4465+ // principal the tenant never recorded.
4466+ const getSubjectWithConnections = (
4467+ externalId: string,
4468+ ): Effect.Effect<AdminSubjectWithConnections | null, StorageFailure> =>
4469+ Effect.gen(function* () {
4470+ const subject = yield* getSubject(externalId);
4471+ if (subject === null) return null;
4472+ const connections = yield* listSubjectConnections(externalId);
4473+ return { ...subject, connections };
4474+ });
4475+
4476+ return {
4477+ listSubjects,
4478+ getSubject,
4479+ listSubjectConnections,
4480+ listSubjectsWithConnections,
4481+ getSubjectWithConnections,
4482+ };
4483+ };
4484+
4485+ // Default OFF: without the opt-in there is no `admin` key at all, so the
4486+ // tenant-wide handle is never even constructed.
4487+ const admin = config.platformView === true ? makeAdmin() : undefined;
4488+
41634489 // ------------------------------------------------------------------
41644490 // close
41654491 // ------------------------------------------------------------------
@@ -4231,6 +4557,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 65 unmodified lines
42314557 remove: policiesRemove,
42324558 resolve: policiesResolve,
42334559 },
4560+ ...(admin ? { admin } : {}),
42344561 execute,
42354562 close,
42364563 };
packages/core/sdk/src/host-internal.ts+14−0
@@ -20,6 +20,13 @@ · 19 unmodified lines
2020 // endpoint SSRF guard + default fetch timeout. The SDK's own OAuth flow uses
2121 // them; the host's connection-identity handler reuses the same guard/timeout
2222 // when fetching OIDC userinfo, so they surface here (not the plugin barrel).
23+// - `touchSubject`: the sole writer of the `subject` table. The SDK writes a
24+// sighting at connection-create; the host layer writes one per request in
25+// `makeScopedExecutor`. Not a plugin-author concern.
26+// `resetSubjectTouchCache` comes with it: `touchSubject` keeps a
27+// process-local memory of who it already filed, so a test that seeds the
28+// same principal against a fresh database has to clear it or the second
29+// seed is skipped as a repeat sighting.
2330 // ---------------------------------------------------------------------------
2431
2532 export {
@@ -31,6 +38,13 @@ export { · 5 unmodified lines
3138
3239 export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers";
3340
41+export {
42+ DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS,
43+ resetSubjectTouchCache,
44+ touchSubject,
45+ type TouchSubjectInput,
46+} from "./subject-registry";
47+
3448 export {
3549 createExecutorFumaDb,
3650 type CreateExecutorFumaDbOptions,
packages/core/sdk/src/index.ts+10−0
@@ -142,6 +142,7 @@ export { · 141 unmodified lines
142142 TOOL_POLICY_ACTIONS,
143143 type CoreSchema,
144144 type IntegrationRow,
145+ type SubjectRow,
145146 type ConnectionRow,
146147 type OAuthClientRow,
147148 type OAuthSessionRow,
@@ -160,6 +161,8 @@ export { · 12 unmodified lines
160161 executorOwnerPolicyName,
161162 executorUnscopedPolicyName,
162163 type ExecutorOwnerPolicyContext,
164+ type ExecutorReach,
165+ type ExecutorWrites,
163166 } from "./owner-policy";
164167
165168 // Provider item-id owner grammar — the partition credential providers file
@@ -342,7 +345,14 @@ export { · 176 unmodified lines
342345 // local/cloud DB bring-up). Its definition stays here because `createExecutor`
343346 // uses it; the host surface (`@executor-js/api/server`) re-exports it.
344347 export {
348+ ADMIN_DEFAULT_PAGE_SIZE,
349+ ADMIN_MAX_PAGE_SIZE,
350+ type AdminConnection,
351+ type AdminListSubjectsOptions,
352+ type AdminSubject,
353+ type AdminSubjectWithConnections,
345354 type Executor,
355+ type ExecutorAdmin,
346356 type ExecutorConfig,
347357 type ExecutorDb,
348358 type ExecutorDbFactory,
packages/core/sdk/src/owner-policy-reach.test.ts+318−0
@@ -0,0 +1,318 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { withQueryContext } from "@executor-js/fumadb/query";
4+
5+import { collectTables } from "./executor";
6+import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
7+
8+// The platform view is `reach: "tenant"` on the owner-policy context: reads
9+// widen from {this subject + org rows} to {the whole tenant}, and writes do not
10+// widen at all. These tests pin BOTH halves against the real SQLite bring-up —
11+// the widening is only useful if the not-widening is airtight.
12+
13+const TENANT = "t1";
14+const OTHER_TENANT = "t2";
15+const SUBJECT_A = "user_a";
16+const SUBJECT_B = "user_b";
17+
18+const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> =>
19+ Effect.runPromise(
20+ Effect.acquireUseRelease(
21+ Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
22+ (db) => Effect.promise(() => body(db)),
23+ (db) => Effect.promise(() => db.close()),
24+ ),
25+ );
26+
27+/** Insert a connection row directly, bypassing the policy — these tests are
28+ * about what a context may SEE, so the seeding must be able to write rows the
29+ * reader is not bound to. */
30+const insertConnection = (
31+ db: SqliteTestFumaDb,
32+ row: {
33+ readonly rowId: string;
34+ readonly tenant: string;
35+ readonly owner: string;
36+ readonly subject: string;
37+ readonly name: string;
38+ },
39+): Promise<unknown> =>
40+ db.client.execute({
41+ sql: `INSERT INTO connection (
42+ row_id, tenant, owner, subject, integration, name, template, provider,
43+ item_ids, created_at, updated_at
44+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
45+ args: [
46+ row.rowId,
47+ row.tenant,
48+ row.owner,
49+ row.subject,
50+ "github",
51+ row.name,
52+ "apiKey",
53+ "memory",
54+ JSON.stringify({ token: `item-${row.rowId}` }),
55+ Date.now(),
56+ Date.now(),
57+ ],
58+ });
59+
60+const seedTwoSubjects = async (db: SqliteTestFumaDb): Promise<void> => {
61+ await insertConnection(db, {
62+ rowId: "c-a",
63+ tenant: TENANT,
64+ owner: "user",
65+ subject: SUBJECT_A,
66+ name: "a-personal",
67+ });
68+ await insertConnection(db, {
69+ rowId: "c-b",
70+ tenant: TENANT,
71+ owner: "user",
72+ subject: SUBJECT_B,
73+ name: "b-personal",
74+ });
75+ await insertConnection(db, {
76+ rowId: "c-org",
77+ tenant: TENANT,
78+ owner: "org",
79+ subject: "",
80+ name: "shared",
81+ });
82+ // Another tenant entirely — never visible at any reach.
83+ await insertConnection(db, {
84+ rowId: "c-other",
85+ tenant: OTHER_TENANT,
86+ owner: "user",
87+ subject: SUBJECT_A,
88+ name: "other-tenant",
89+ });
90+};
91+
92+describe("owner policy reach", () => {
93+ it.effect("bound reach still sees only the subject's own rows plus org rows", () =>
94+ Effect.promise(() =>
95+ withDb(async (db) => {
96+ await seedTwoSubjects(db);
97+
98+ const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });
99+ const rows = await bound.findMany("connection", {});
100+
101+ expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]);
102+ }),
103+ ),
104+ );
105+
106+ it.effect("tenant reach reads across every subject in the tenant", () =>
107+ Effect.promise(() =>
108+ withDb(async (db) => {
109+ await seedTwoSubjects(db);
110+
111+ const platform = withQueryContext(db.db, {
112+ tenant: TENANT,
113+ subject: SUBJECT_A,
114+ reach: "tenant",
115+ });
116+ const rows = await platform.findMany("connection", {});
117+
118+ // The other subject's rows are now visible — that is the whole point.
119+ expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "b-personal", "shared"]);
120+ }),
121+ ),
122+ );
123+
124+ it.effect("tenant reach NEVER reads across tenants", () =>
125+ Effect.promise(() =>
126+ withDb(async (db) => {
127+ await seedTwoSubjects(db);
128+
129+ const platform = withQueryContext(db.db, {
130+ tenant: TENANT,
131+ subject: SUBJECT_A,
132+ reach: "tenant",
133+ });
134+ const rows = await platform.findMany("connection", {});
135+
136+ // `tenant` is the one clause reach never relaxes.
137+ expect(rows.every((row) => row.tenant === TENANT)).toBe(true);
138+ expect(rows.map((row) => row.name)).not.toContain("other-tenant");
139+ }),
140+ ),
141+ );
142+
143+ it.effect("tenant reach with a null subject reads the whole tenant, not just org rows", () =>
144+ Effect.promise(() =>
145+ withDb(async (db) => {
146+ await seedTwoSubjects(db);
147+
148+ // The shape an org-level API key produces: no bound subject at all.
149+ const platform = withQueryContext(db.db, {
150+ tenant: TENANT,
151+ subject: null,
152+ reach: "tenant",
153+ });
154+ const rows = await platform.findMany("connection", {});
155+
156+ expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "b-personal", "shared"]);
157+ }),
158+ ),
159+ );
160+
161+ // -------------------------------------------------------------------------
162+ // The security property: reach widens READS ONLY.
163+ // -------------------------------------------------------------------------
164+
165+ it.effect("tenant reach cannot create rows", () =>
166+ Effect.promise(() =>
167+ withDb(async (db) => {
168+ const platform = withQueryContext(db.db, {
169+ tenant: TENANT,
170+ subject: SUBJECT_A,
171+ reach: "tenant",
172+ });
173+
174+ await expect(
175+ platform.create("connection", {
176+ tenant: TENANT,
177+ owner: "user",
178+ subject: SUBJECT_A,
179+ integration: "github",
180+ name: "written-by-platform-view",
181+ template: "apiKey",
182+ provider: "memory",
183+ item_ids: { token: "item-x" },
184+ created_at: new Date(),
185+ updated_at: new Date(),
186+ }),
187+ ).rejects.toThrow(/platform view is read-only/);
188+ }),
189+ ),
190+ );
191+
192+ it.effect("tenant reach cannot update another subject's rows", () =>
193+ Effect.promise(() =>
194+ withDb(async (db) => {
195+ await seedTwoSubjects(db);
196+
197+ const platform = withQueryContext(db.db, {
198+ tenant: TENANT,
199+ subject: SUBJECT_A,
200+ reach: "tenant",
201+ });
202+
203+ // Without the reach guard the widened read condition would MATCH
204+ // subject B's row and this update would land on it.
205+ await expect(
206+ platform.updateMany("connection", {
207+ where: (b) => b("name", "=", "b-personal"),
208+ set: { identity_label: "hijacked" },
209+ }),
210+ ).rejects.toThrow(/platform view is read-only/);
211+
212+ const rows = await db.client.execute(
213+ "SELECT identity_label FROM connection WHERE row_id = 'c-b'",
214+ );
215+ expect(rows.rows[0]?.["identity_label"]).toBeNull();
216+ }),
217+ ),
218+ );
219+
220+ it.effect("tenant reach cannot delete another subject's rows", () =>
221+ Effect.promise(() =>
222+ withDb(async (db) => {
223+ await seedTwoSubjects(db);
224+
225+ const platform = withQueryContext(db.db, {
226+ tenant: TENANT,
227+ subject: SUBJECT_A,
228+ reach: "tenant",
229+ });
230+
231+ await expect(
232+ platform.deleteMany("connection", { where: (b) => b("name", "=", "b-personal") }),
233+ ).rejects.toThrow(/platform view is read-only/);
234+
235+ const rows = await db.client.execute("SELECT row_id FROM connection ORDER BY row_id");
236+ expect(rows.rows.map((row) => String(row["row_id"]))).toEqual([
237+ "c-a",
238+ "c-b",
239+ "c-org",
240+ "c-other",
241+ ]);
242+ }),
243+ ),
244+ );
245+
246+ it.effect("tenant reach cannot write the tenant-scoped subject table", () =>
247+ Effect.promise(() =>
248+ withDb(async (db) => {
249+ const platform = withQueryContext(db.db, {
250+ tenant: TENANT,
251+ subject: SUBJECT_A,
252+ reach: "tenant",
253+ });
254+
255+ // `subject` is tenant-scoped, so its reads were already tenant-wide —
256+ // the read-only property still has to hold there.
257+ await expect(
258+ platform.create("subject", {
259+ tenant: TENANT,
260+ external_id: SUBJECT_B,
261+ created_at: new Date(),
262+ last_seen_at: Date.now(),
263+ status: null,
264+ }),
265+ ).rejects.toThrow(/platform view is read-only/);
266+ }),
267+ ),
268+ );
269+
270+ it.effect("bound reach writes are unaffected by the reach guard", () =>
271+ Effect.promise(() =>
272+ withDb(async (db) => {
273+ // The guard must not disturb the product view: an ordinary bound
274+ // context writes exactly as before.
275+ const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });
276+
277+ await bound.create("connection", {
278+ tenant: TENANT,
279+ owner: "user",
280+ subject: SUBJECT_A,
281+ integration: "github",
282+ name: "written-by-bound-view",
283+ template: "apiKey",
284+ provider: "memory",
285+ item_ids: { token: "item-y" },
286+ created_at: new Date(),
287+ updated_at: new Date(),
288+ });
289+
290+ const rows = await bound.findMany("connection", {});
291+ expect(rows.map((row) => row.name)).toEqual(["written-by-bound-view"]);
292+ }),
293+ ),
294+ );
295+
296+ it.effect("a bound context still cannot write outside its own subject", () =>
297+ Effect.promise(() =>
298+ withDb(async (db) => {
299+ const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A });
300+
301+ await expect(
302+ bound.create("connection", {
303+ tenant: TENANT,
304+ owner: "user",
305+ subject: SUBJECT_B,
306+ integration: "github",
307+ name: "cross-subject",
308+ template: "apiKey",
309+ provider: "memory",
310+ item_ids: { token: "item-z" },
311+ created_at: new Date(),
312+ updated_at: new Date(),
313+ }),
314+ ).rejects.toThrow(/outside the bound subject/);
315+ }),
316+ ),
317+ );
318+});
packages/core/sdk/src/owner-policy.ts+78−1
@@ -25,11 +25,47 @@ export const ORG_SUBJECT = ""; · 24 unmodified lines
2525
2626 const unscopedExecutorTables = new Set(["blob"]);
2727
28+/** How far a context's READS reach. Writes ignore this entirely — see
29+ * `assertOwnerWritable`. */
30+export type ExecutorReach =
31+ /** The product view: this subject's rows plus the tenant's org rows. */
32+ | "bound"
33+ /** The platform view: every row in the tenant, whatever subject owns it.
34+ * READ-ONLY by construction — it widens `ownerVisibilityCondition` and
35+ * nothing else. */
36+ | "tenant";
37+
38+/** Whether a context may mutate at all. Orthogonal to `reach`: reach says how
39+ * far reads SEE, this says whether writes are permitted. */
40+export type ExecutorWrites =
41+ /** The default: writes are allowed, bounded by `assertOwnerWritable`. */
42+ | "allowed"
43+ /** Every create/update/delete is rejected outright, at every reach. */
44+ | "denied";
45+
2846 export interface ExecutorOwnerPolicyContext {
2947 readonly tenant: string;
3048 /** The acting member, or null for a pure-org executor (no `owner:"user"`
3149 * reads/writes are allowed when null). */
3250 readonly subject: string | null;
51+ /** Read reach; defaults to `"bound"`. A `"tenant"`-reach context is the
52+ * platform view: it sees the whole tenant but writes exactly as a bound
53+ * context does, so it can never mutate another subject's rows. */
54+ readonly reach?: ExecutorReach;
55+ /**
56+ * Write permission; defaults to `"allowed"`. `"denied"` makes the context
57+ * read-only at the STORAGE boundary, independently of reach.
58+ *
59+ * WHY THIS IS SEPARATE FROM `reach`. A tenant-reach context is always
60+ * write-denied (a widened write would hit every subject in the tenant), so
61+ * `reach: "tenant"` implies this. But the converse does not hold: the
62+ * platform executor needs its ORDINARY, bound-reach surfaces to be read-only
63+ * too, and it must NOT get tenant reach on them — widening those reads would
64+ * hand every subject's `connection` row, credential item ids included, to a
65+ * surface that only ever needed the admin projection. So read-only-ness is
66+ * its own axis rather than a side effect of reach.
67+ */
68+ readonly writes?: ExecutorWrites;
3369 }
3470
3571 type AnyConditionBuilder = ConditionBuilder<Record<string, AnyColumn>>;
@@ -51,11 +87,20 @@ const requireContext = ( · 15 unmodified lines
5187 };
5288
5389 /** The rows the bound `{ tenant, subject }` may see/mutate: org rows in the
54− * tenant, plus this subject's own user rows. */
90+ * tenant, plus this subject's own user rows.
91+ *
92+ * Under `reach: "tenant"` (the platform view) this widens to the whole tenant:
93+ * every subject's rows, not just the bound one. That is a READ widening only —
94+ * the policy uses this condition for `onRead`/`onUpdate`/`onDelete` filtering,
95+ * but the write assertions below reject a tenant-reach context outright, so a
96+ * widened context can never be the one doing the mutating. */
5597 export const ownerVisibilityCondition = (
5698 builder: AnyConditionBuilder,
5799 context: ExecutorOwnerPolicyContext,
58100 ): Condition | boolean => {
101+ // The platform view: partition by tenant alone. Still never cross-tenant —
102+ // `tenant` is the one clause that is NEVER relaxed, at any reach.
103+ if (context.reach === "tenant") return builder("tenant", "=", context.tenant);
59104 const orgClause = builder.and(
60105 builder("tenant", "=", context.tenant),
61106 builder("owner", "=", "org"),
@@ -69,6 +114,36 @@ export const ownerVisibilityCondition = ( · 7 unmodified lines
69114 return builder.or(orgClause, userClause);
70115 };
71116
117+/**
118+ * THE security property of the platform view: it never mutates. Two contexts
119+ * are read-only, for two different reasons, and this one guard covers both:
120+ *
121+ * - `reach: "tenant"` — `ownerVisibilityCondition` is also what filters
122+ * `onUpdate`/`onDelete`, so a widened context reaching any mutation would
123+ * silently mutate EVERY subject in the tenant.
124+ * - `writes: "denied"` — an explicitly read-only context. The platform
125+ * executor's ordinary (bound-reach) surfaces carry this: they are not
126+ * widened, so the reach clause above would not catch them, yet a
127+ * subject-less platform executor still binds to the tenant's org partition
128+ * and could otherwise create, update and delete every `owner: "org"` row
129+ * in it through `connections` / `policies` / `integrations` / `oauth`.
130+ *
131+ * Every write path calls this first and fails loudly — a write arriving on a
132+ * read-only handle is a programmer error, not something to quietly demote to
133+ * bound behavior.
134+ */
135+export const assertReachReadOnly = (
136+ tableName: string,
137+ access: string,
138+ context: ExecutorOwnerPolicyContext | undefined,
139+): void => {
140+ if (context === undefined) return;
141+ if (context.reach !== "tenant" && context.writes !== "denied") return;
142+ policyViolation(
143+ `Storage ${access} on table "${tableName}" is not allowed: the platform view is read-only.`,
144+ );
145+};
146+
72147 /** Assert a create/upsert writes a row inside the bound partition. */
73148 export const assertOwnerWritable = (
74149 tableName: string,
@@ -76,6 +151,7 @@ export const assertOwnerWritable = ( · 1 unmodified line
76151 context: ExecutorOwnerPolicyContext | undefined,
77152 ): void => {
78153 const ctx = requireContext(tableName, "write", context);
154+ assertReachReadOnly(tableName, "write", ctx);
79155 if (values.tenant !== ctx.tenant) {
80156 policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`);
81157 }
@@ -106,6 +182,7 @@ export const assertOwnerPatch = ( · 24 unmodified lines
106182 context: ExecutorOwnerPolicyContext | undefined,
107183 ): void => {
108184 const ctx = requireContext(tableName, "write", context);
185+ assertReachReadOnly(tableName, "write", ctx);
109186 if (!patch) return;
110187 if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) {
111188 policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`);
packages/core/sdk/src/platform-view.test.ts+814−0
@@ -0,0 +1,814 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import {
5+ ADMIN_DEFAULT_PAGE_SIZE,
6+ ADMIN_MAX_PAGE_SIZE,
7+ collectTables,
8+ createExecutor,
9+ type Executor,
10+} from "./executor";
11+import { StorageError } from "./fuma-runtime";
12+import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
13+import {
14+ ConnectionName,
15+ IntegrationSlug,
16+ OAuthClientSlug,
17+ ProviderItemId,
18+ ProviderKey,
19+ Subject,
20+ Tenant,
21+} from "./ids";
22+import { definePlugin } from "./plugin";
23+import { resetSubjectTouchCache, touchSubject } from "./subject-registry";
24+
25+// The platform view: `executor.admin`, an OPT-IN read-only surface that reads
26+// across every subject in the tenant. Written against the real SQLite bring-up
27+// so the tenant-reach policy, the bigint `last_seen_at` round-trip, and the
28+// field allowlist are all live.
29+
30+const TENANT = "t1";
31+const OTHER_TENANT = "t2";
32+const SUBJECT_A = "user_a";
33+const SUBJECT_B = "user_b";
34+
35+/** Every column on `connection` that could resolve, or help resolve, a
36+ * credential. NONE of these may appear on an admin shape — enumerated so a
37+ * future column addition has to be argued with this list. */
38+const FORBIDDEN_CONNECTION_FIELDS = [
39+ "item_ids",
40+ "itemIds",
41+ "refresh_item_id",
42+ "refreshItemId",
43+ "provider",
44+ "provider_state",
45+ "providerState",
46+ "oauth_token_url",
47+ "oauthTokenUrl",
48+ "template",
49+ "client_secret",
50+ "clientSecret",
51+ "secret",
52+ "token",
53+] as const;
54+
55+/**
56+ * The upstream-derived halves of a stored health verdict: the account identity
57+ * extracted from the configured `identityField` (typically the user's email AT
58+ * the provider), a raw diagnostic that can quote an upstream error body, and a
59+ * bounded sample of the real response body.
60+ *
61+ * These are NOT on the list above, and that is deliberate rather than an
62+ * oversight. This layer is the in-process platform view, not a wire shape:
63+ * `AdminConnection.lastHealth` is the full `HealthCheckResult`, and the
64+ * narrowing to status + checkedAt happens where the response is BUILT, in
65+ * `@executor-js/api`'s `admin/reads.ts` → `AdminConnectionHealth`. Listing them
66+ * as forbidden top-level connection keys would pass without testing anything,
67+ * because they never were top-level keys — they live nested inside
68+ * `lastHealth`. That vacuous-green assertion is precisely the failure mode this
69+ * comment exists to prevent.
70+ *
71+ * So the assertion below pins the SEAM instead: this layer DOES carry them, and
72+ * anyone who "fixes" the leak here rather than at the projection has moved the
73+ * boundary. The absence assertions live in `packages/core/api`'s
74+ * `admin-users.test.ts`, against real HTTP responses.
75+ */
76+const UPSTREAM_HEALTH_FIELDS = ["identity", "detail", "responseSample"] as const;
77+
78+const withDb = <A, E>(body: (db: SqliteTestFumaDb) => Effect.Effect<A, E>): Effect.Effect<A, E> =>
79+ Effect.acquireUseRelease(
80+ Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
81+ body,
82+ (db) => Effect.promise(() => db.close()),
83+ );
84+
85+const insertConnection = (
86+ db: SqliteTestFumaDb,
87+ row: {
88+ readonly rowId: string;
89+ readonly tenant: string;
90+ readonly owner: string;
91+ readonly subject: string;
92+ readonly integration: string;
93+ readonly name: string;
94+ readonly oauthScope?: string | null;
95+ },
96+): Effect.Effect<void> =>
97+ Effect.promise(async () => {
98+ await db.client.execute({
99+ sql: `INSERT INTO connection (
100+ row_id, tenant, owner, subject, integration, name, template, provider,
101+ item_ids, refresh_item_id, oauth_scope, last_health, created_at, updated_at
102+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
103+ args: [
104+ row.rowId,
105+ row.tenant,
106+ row.owner,
107+ row.subject,
108+ row.integration,
109+ row.name,
110+ "oauth2",
111+ "memory",
112+ // Deliberately secret-bearing: the assertions below prove these never
113+ // leave the storage layer.
114+ JSON.stringify({ token: `SECRET-item-${row.rowId}` }),
115+ `SECRET-refresh-${row.rowId}`,
116+ row.oauthScope ?? null,
117+ // A full stored verdict, upstream-derived fields included — that is
118+ // what a real health check writes. This layer is entitled to read them
119+ // back (see UPSTREAM_HEALTH_FIELDS); the API's projection is what keeps
120+ // them off the wire.
121+ JSON.stringify({
122+ status: "healthy",
123+ checkedAt: 1_700_000_000_000,
124+ identity: `UPSTREAM-identity-${row.rowId}@provider.test`,
125+ detail: `UPSTREAM-detail-${row.rowId}`,
126+ responseSample: [{ path: "user.email", value: `UPSTREAM-sample-${row.rowId}` }],
127+ }),
128+ Date.now(),
129+ Date.now(),
130+ ],
131+ });
132+ });
133+
134+/** A tenant-scoped `integration` row, inserted raw. Needed because
135+ * `integrations.remove` short-circuits on an absent slug — without a real row
136+ * the call returns success having touched no storage, which would make a
137+ * read-only assertion vacuous. */
138+const insertIntegration = (db: SqliteTestFumaDb, slug: string): Effect.Effect<void> =>
139+ Effect.promise(async () => {
140+ await db.client.execute({
141+ sql: `INSERT INTO integration (
142+ row_id, tenant, slug, plugin_id, name, description, config, can_remove,
143+ can_refresh, created_at, updated_at
144+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
145+ args: [
146+ `i-${slug}`,
147+ TENANT,
148+ slug,
149+ "demo",
150+ slug,
151+ null,
152+ JSON.stringify({}),
153+ 1,
154+ 0,
155+ Date.now(),
156+ Date.now(),
157+ ],
158+ });
159+ });
160+
161+/** Seed two users and an org connection under `t1`, plus an untouchable other
162+ * tenant. Subjects go in through `touchSubject` (the only legitimate writer);
163+ * connections go in raw, because no single bound executor could have written
164+ * another subject's rows. */
165+const seed = (db: SqliteTestFumaDb): Effect.Effect<void> =>
166+ Effect.gen(function* () {
167+ // `touchSubject` keeps a process-local memory of principals it already
168+ // filed, so a second seed against a FRESH database would be skipped as a
169+ // repeat sighting and leave the table empty. Each seed is a new world.
170+ resetSubjectTouchCache();
171+
172+ yield* touchSubject(db.db, { tenant: TENANT, externalId: SUBJECT_A });
173+ yield* touchSubject(db.db, { tenant: TENANT, externalId: SUBJECT_B });
174+ yield* touchSubject(db.db, { tenant: OTHER_TENANT, externalId: "user_elsewhere" });
175+
176+ yield* insertConnection(db, {
177+ rowId: "c-a1",
178+ tenant: TENANT,
179+ owner: "user",
180+ subject: SUBJECT_A,
181+ integration: "github",
182+ name: "personal",
183+ oauthScope: "repo read:user",
184+ });
185+ yield* insertConnection(db, {
186+ rowId: "c-a2",
187+ tenant: TENANT,
188+ owner: "user",
189+ subject: SUBJECT_A,
190+ integration: "linear",
191+ name: "work",
192+ });
193+ yield* insertConnection(db, {
194+ rowId: "c-b1",
195+ tenant: TENANT,
196+ owner: "user",
197+ subject: SUBJECT_B,
198+ integration: "github",
199+ name: "b-personal",
200+ oauthScope: "repo",
201+ });
202+ yield* insertConnection(db, {
203+ rowId: "c-org",
204+ tenant: TENANT,
205+ owner: "org",
206+ subject: "",
207+ integration: "stripe",
208+ name: "shared",
209+ });
210+ yield* insertConnection(db, {
211+ rowId: "c-other",
212+ tenant: OTHER_TENANT,
213+ owner: "user",
214+ subject: SUBJECT_A,
215+ integration: "github",
216+ name: "other-tenant",
217+ });
218+ });
219+
220+/**
221+ * A plugin contributing one writable credential provider.
222+ *
223+ * `oauth.createClient` stores the client secret out-of-band and fails BEFORE
224+ * touching storage when no writable provider is registered. Without this, the
225+ * read-only assertion below would pass on the wrong error and prove nothing —
226+ * so the provider is here to make the write actually reach the owner policy.
227+ */
228+const providerPlugin = definePlugin(() => {
229+ const store = new Map<string, string>();
230+ return {
231+ id: "memory-provider" as const,
232+ storage: () => ({}),
233+ credentialProviders: [
234+ {
235+ key: ProviderKey.make("memory"),
236+ writable: true,
237+ get: (id: ProviderItemId) => Effect.sync(() => store.get(String(id)) ?? null),
238+ set: (id: ProviderItemId, value: string) =>
239+ Effect.sync(() => void store.set(String(id), value)),
240+ delete: (id: ProviderItemId) => Effect.sync(() => void store.delete(String(id))),
241+ },
242+ ],
243+ };
244+})();
245+
246+const makePlatformExecutor = (
247+ db: SqliteTestFumaDb,
248+ options?: { readonly platformView?: boolean; readonly subject?: string | null },
249+): Effect.Effect<Executor, never> =>
250+ createExecutor({
251+ tenant: Tenant.make(TENANT),
252+ ...(options?.subject === null ? {} : { subject: Subject.make(options?.subject ?? SUBJECT_A) }),
253+ db: db.db,
254+ plugins: [providerPlugin],
255+ onElicitation: "accept-all",
256+ ...(options?.platformView === false ? {} : { platformView: true }),
257+ }).pipe(Effect.orDie);
258+
259+const requireAdmin = (executor: Executor) => {
260+ const admin = executor.admin;
261+ if (!admin) return Effect.die("expected the platform view to be enabled");
262+ return Effect.succeed(admin);
263+};
264+
265+describe("platform view — admin.listSubjects", () => {
266+ it.effect("lists every subject in the tenant and no other tenant's", () =>
267+ withDb((db) =>
268+ Effect.gen(function* () {
269+ yield* seed(db);
270+ const executor = yield* makePlatformExecutor(db);
271+ const admin = yield* requireAdmin(executor);
272+
273+ const subjects = yield* admin.listSubjects();
274+
275+ // `user_elsewhere` lives under t2 — invisible at any reach.
276+ expect(subjects.map((entry) => entry.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]);
277+ }),
278+ ),
279+ );
280+
281+ it.effect("returns created_at and the bigint last_seen_at through the ORM", () =>
282+ withDb((db) =>
283+ Effect.gen(function* () {
284+ const before = Date.now();
285+ yield* seed(db);
286+ const executor = yield* makePlatformExecutor(db);
287+ const admin = yield* requireAdmin(executor);
288+
289+ const subjects = yield* admin.listSubjects();
290+ const entry = subjects.find((row) => row.externalId === SUBJECT_A);
291+
292+ expect(entry?.createdAt).toBeInstanceOf(Date);
293+ // A raw SQL read would hand back an unusable blob here.
294+ expect(typeof entry?.lastSeenAt).toBe("number");
295+ expect(entry?.lastSeenAt ?? 0).toBeGreaterThanOrEqual(before);
296+ expect(entry?.status).toBeNull();
297+ }),
298+ ),
299+ );
300+
301+ it.effect("pages with a stable order", () =>
302+ withDb((db) =>
303+ Effect.gen(function* () {
304+ yield* seed(db);
305+ const executor = yield* makePlatformExecutor(db);
306+ const admin = yield* requireAdmin(executor);
307+
308+ const all = yield* admin.listSubjects();
309+ const first = yield* admin.listSubjects({ limit: 1 });
310+ const second = yield* admin.listSubjects({ limit: 1, offset: 1 });
311+
312+ expect(all).toHaveLength(2);
313+ expect(first.map((entry) => entry.externalId)).toEqual([all[0]?.externalId]);
314+ expect(second.map((entry) => entry.externalId)).toEqual([all[1]?.externalId]);
315+ }),
316+ ),
317+ );
318+
319+ // Drizzle's SQLite dialect emits OFFSET only alongside LIMIT, and SQLite
320+ // rejects a bare OFFSET — so forwarding the two independently made
321+ // `{ offset }` a SQL syntax error on every SQLite host. The default limit is
322+ // what makes an offset-only page a legal query at all.
323+ it.effect("accepts an offset with no limit", () =>
324+ withDb((db) =>
325+ Effect.gen(function* () {
326+ yield* seed(db);
327+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
328+
329+ const all = yield* admin.listSubjects();
330+ const skipped = yield* admin.listSubjects({ offset: 1 });
331+
332+ expect(skipped.map((entry) => entry.externalId)).toEqual([all[1]?.externalId]);
333+ // Past the end is an empty page, not an error.
334+ expect(yield* admin.listSubjects({ offset: 50 })).toEqual([]);
335+ expect(yield* admin.listSubjectsWithConnections({ offset: 1 })).toHaveLength(1);
336+ }),
337+ ),
338+ );
339+
340+ // A fraction is a 400 at the HTTP contract, but the SDK is a public entry
341+ // point of its own: a fractional limit reaching the driver is a `datatype
342+ // mismatch` failure, so it is floored here rather than forwarded.
343+ it.effect("floors a fractional limit and offset instead of failing the query", () =>
344+ withDb((db) =>
345+ Effect.gen(function* () {
346+ yield* seed(db);
347+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
348+
349+ const all = yield* admin.listSubjects();
350+
351+ expect(yield* admin.listSubjects({ limit: 1.5 })).toEqual([all[0]]);
352+ expect(yield* admin.listSubjects({ offset: 1.9 })).toEqual([all[1]]);
353+ }),
354+ ),
355+ );
356+
357+ // The no-args call is the one an API consumer writes by default, and
358+ // `listSubjectsWithConnections` turns each returned row into its own
359+ // sequential connection query. Unbounded, that is the whole tenant inside one
360+ // request.
361+ it.effect("bounds an unpaged call to the default page size, and clamps past the maximum", () =>
362+ withDb((db) =>
363+ Effect.gen(function* () {
364+ // One more subject than a default page holds, so "no arguments" and
365+ // "everything" are distinguishable.
366+ for (let index = 0; index < ADMIN_DEFAULT_PAGE_SIZE + 5; index += 1) {
367+ yield* touchSubject(db.db, {
368+ tenant: TENANT,
369+ // Zero-padded: `external_id` breaks `created_at` ties, and these
370+ // rows are written inside the same millisecond.
371+ externalId: `user_${String(index).padStart(4, "0")}`,
372+ });
373+ }
374+
375+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
376+
377+ expect(yield* admin.listSubjects()).toHaveLength(ADMIN_DEFAULT_PAGE_SIZE);
378+ expect(yield* admin.listSubjectsWithConnections()).toHaveLength(ADMIN_DEFAULT_PAGE_SIZE);
379+ // A caller may ask for more, up to the ceiling — and no further.
380+ expect(yield* admin.listSubjects({ limit: ADMIN_MAX_PAGE_SIZE * 10 })).toHaveLength(
381+ ADMIN_DEFAULT_PAGE_SIZE + 5,
382+ );
383+ }),
384+ ),
385+ );
386+});
387+
388+// ---------------------------------------------------------------------------
389+// The platform view is read-only across EVERY surface, not just `admin`.
390+//
391+// A platform executor is subject-less but still bound to the tenant's ORG
392+// partition, so before `writes: "denied"` its ordinary product surfaces formed
393+// an unguarded pure-org executor: it could create, update and delete every
394+// `owner: "org"` row in the tenant through `connections`, `policies`,
395+// `integrations` and `oauth`, while only the `admin` handle was actually
396+// guarded. These pin that the guarantee now covers the whole executor.
397+// ---------------------------------------------------------------------------
398+
399+/**
400+ * The owner policy refuses a write by failing the storage effect. `Effect.flip`
401+ * makes a SUCCEEDING write a test failure outright rather than something to
402+ * assert on afterwards.
403+ *
404+ * The message is checked too, not just the failure: several of these surfaces
405+ * can fail for unrelated reasons BEFORE reaching storage (an absent row, a
406+ * missing credential provider), and such a failure would let the assertion pass
407+ * without the policy ever having been consulted.
408+ */
409+const expectWriteRefused = <A, E extends { readonly _tag: string }>(
410+ effect: Effect.Effect<A, E | StorageError>,
411+) =>
412+ // The surfaces under test fail with different unions (StorageFailure,
413+ // ConnectionNotFoundError, …). The refusal this pins is always a
414+ // StorageError, so the effect is widened for the catch; `orDie` below still
415+ // turns every OTHER tag into a test failure, and a write that SUCCEEDS dies
416+ // rather than reaching an assertion — so the hole can never pass silently.
417+ (effect as Effect.Effect<A, StorageError>).pipe(
418+ Effect.flatMap(() => Effect.die("expected the platform view to refuse this write")),
419+ Effect.catchTag("StorageError", (error: StorageError) => {
420+ expect(error.message).toContain("read-only");
421+ return Effect.void;
422+ }),
423+ Effect.orDie,
424+ );
425+
426+describe("platform view — read-only across every surface", () => {
427+ it.effect("refuses org-row writes through policies and oauth", () =>
428+ withDb((db) =>
429+ Effect.gen(function* () {
430+ yield* seed(db);
431+ const executor = yield* makePlatformExecutor(db, { subject: null });
432+
433+ yield* expectWriteRefused(
434+ executor.policies.create({ owner: "org", pattern: "tools.*", action: "block" }),
435+ );
436+ yield* expectWriteRefused(
437+ executor.oauth.createClient({
438+ owner: "org",
439+ slug: OAuthClientSlug.make("intruder"),
440+ authorizationUrl: "https://acme.test/authorize",
441+ tokenUrl: "https://acme.test/token",
442+ grant: "authorization_code",
443+ clientId: "intruder-id",
444+ clientSecret: "intruder-secret",
445+ }),
446+ );
447+
448+ // Nothing landed.
449+ expect(yield* executor.policies.list()).toEqual([]);
450+ expect(yield* executor.oauth.listClients()).toEqual([]);
451+ }),
452+ ),
453+ );
454+
455+ // A DELETE is the sharpest case: it carries no values to check against, so
456+ // the reach/writes guard is the only thing between this executor and the
457+ // tenant's org rows.
458+ it.effect("refuses to delete the tenant's org rows", () =>
459+ withDb((db) =>
460+ Effect.gen(function* () {
461+ yield* seed(db);
462+ // A real `integration` row: `integrations.remove` returns success
463+ // without touching storage when the slug is absent, so asserting
464+ // against a missing row would prove nothing.
465+ yield* insertIntegration(db, "stripe");
466+ const executor = yield* makePlatformExecutor(db, { subject: null });
467+
468+ yield* expectWriteRefused(
469+ executor.connections.remove({
470+ owner: "org",
471+ integration: IntegrationSlug.make("stripe"),
472+ name: ConnectionName.make("shared"),
473+ }),
474+ );
475+ yield* expectWriteRefused(
476+ executor.oauth.removeClient("org", OAuthClientSlug.make("anything")),
477+ );
478+ yield* expectWriteRefused(executor.integrations.remove(IntegrationSlug.make("stripe")));
479+
480+ // Every row survived.
481+ const admin = yield* requireAdmin(executor);
482+ expect(yield* admin.listSubjects()).toHaveLength(2);
483+ const connections = yield* Effect.promise(() =>
484+ db.client.execute({ sql: `SELECT name FROM connection WHERE owner = 'org'`, args: [] }),
485+ );
486+ expect(connections.rows).toHaveLength(1);
487+ const integrations = yield* Effect.promise(() =>
488+ db.client.execute({ sql: `SELECT slug FROM integration`, args: [] }),
489+ );
490+ expect(integrations.rows).toHaveLength(1);
491+ }),
492+ ),
493+ );
494+
495+ // Read-only is the WRITE axis only. The ordinary surfaces keep BOUND reach on
496+ // purpose: widening them would hand every subject's connection row —
497+ // credential item ids included — to a surface that only ever needed the admin
498+ // projection.
499+ it.effect("does not widen the ordinary surfaces' reads to other subjects", () =>
500+ withDb((db) =>
501+ Effect.gen(function* () {
502+ yield* seed(db);
503+ const executor = yield* makePlatformExecutor(db, { subject: null });
504+
505+ const connections = yield* executor.connections.list().pipe(Effect.orDie);
506+
507+ // Only the tenant's org row: A's and B's user connections stay invisible
508+ // to this surface, even though `admin` can see both.
509+ expect(connections.map((entry) => entry.name)).toEqual(["shared"]);
510+ }),
511+ ),
512+ );
513+
514+ // The guarantee must be exactly as narrow as advertised: an executor WITHOUT
515+ // the opt-in is a normal product executor and writes normally. A read-only
516+ // default would be a silent behavior change for every existing host.
517+ it.effect("leaves an ordinary product executor fully writable", () =>
518+ withDb((db) =>
519+ Effect.gen(function* () {
520+ yield* seed(db);
521+ yield* insertIntegration(db, "stripe");
522+ const executor = yield* makePlatformExecutor(db, {
523+ platformView: false,
524+ subject: SUBJECT_A,
525+ });
526+
527+ const policy = yield* executor.policies
528+ .create({ owner: "org", pattern: "tools.*", action: "block" })
529+ .pipe(Effect.orDie);
530+ expect(policy.pattern).toBe("tools.*");
531+
532+ yield* executor.oauth
533+ .createClient({
534+ owner: "org",
535+ slug: OAuthClientSlug.make("legit"),
536+ authorizationUrl: "https://acme.test/authorize",
537+ tokenUrl: "https://acme.test/token",
538+ grant: "authorization_code",
539+ clientId: "legit-id",
540+ clientSecret: "legit-secret",
541+ })
542+ .pipe(Effect.orDie);
543+ expect(yield* executor.policies.list().pipe(Effect.orDie)).toHaveLength(1);
544+ expect(yield* executor.oauth.listClients().pipe(Effect.orDie)).toHaveLength(1);
545+
546+ yield* executor.connections
547+ .remove({
548+ owner: "org",
549+ integration: IntegrationSlug.make("stripe"),
550+ name: ConnectionName.make("shared"),
551+ })
552+ .pipe(Effect.orDie);
553+ yield* executor.integrations.remove(IntegrationSlug.make("stripe")).pipe(Effect.orDie);
554+
555+ const connections = yield* Effect.promise(() =>
556+ db.client.execute({ sql: `SELECT name FROM connection WHERE owner = 'org'`, args: [] }),
557+ );
558+ expect(connections.rows).toHaveLength(0);
559+ const integrations = yield* Effect.promise(() =>
560+ db.client.execute({ sql: `SELECT slug FROM integration`, args: [] }),
561+ );
562+ expect(integrations.rows).toHaveLength(0);
563+ }),
564+ ),
565+ );
566+});
567+
568+describe("platform view — admin.listSubjectConnections", () => {
569+ it.effect("lists another subject's connections across the tenant", () =>
570+ withDb((db) =>
571+ Effect.gen(function* () {
572+ yield* seed(db);
573+ // Bound to A; reading B is exactly what the product view cannot do.
574+ const executor = yield* makePlatformExecutor(db, { subject: SUBJECT_A });
575+ const admin = yield* requireAdmin(executor);
576+
577+ const connections = yield* admin.listSubjectConnections(SUBJECT_B);
578+
579+ expect(connections.map((entry) => entry.name)).toEqual(["b-personal"]);
580+ expect(connections[0]?.integration).toBe("github");
581+ expect(connections[0]?.owner).toBe("user");
582+ expect(connections[0]?.subject).toBe(SUBJECT_B);
583+ expect(connections[0]?.oauthScope).toBe("repo");
584+ expect(connections[0]?.lastHealth?.status).toBe("healthy");
585+ }),
586+ ),
587+ );
588+
589+ it.effect("does not attribute org-owned connections to a user", () =>
590+ withDb((db) =>
591+ Effect.gen(function* () {
592+ yield* seed(db);
593+ const executor = yield* makePlatformExecutor(db);
594+ const admin = yield* requireAdmin(executor);
595+
596+ const forA = yield* admin.listSubjectConnections(SUBJECT_A);
597+
598+ // The org's `stripe/shared` belongs to the tenant, not to A.
599+ expect(forA.map((entry) => entry.integration).sort()).toEqual(["github", "linear"]);
600+ expect(forA.every((entry) => entry.owner === "user")).toBe(true);
601+ }),
602+ ),
603+ );
604+
605+ it.effect("never returns credential-bearing fields", () =>
606+ withDb((db) =>
607+ Effect.gen(function* () {
608+ yield* seed(db);
609+ const executor = yield* makePlatformExecutor(db);
610+ const admin = yield* requireAdmin(executor);
611+
612+ const connections = yield* admin.listSubjectConnections(SUBJECT_A);
613+ expect(connections.length).toBeGreaterThan(0);
614+
615+ for (const connection of connections) {
616+ const keys = Object.keys(connection);
617+ for (const forbidden of FORBIDDEN_CONNECTION_FIELDS) {
618+ expect(keys).not.toContain(forbidden);
619+ }
620+ // Belt and braces: no seeded secret value appears anywhere in the
621+ // serialized shape, whatever it is keyed under.
622+ expect(JSON.stringify(connection)).not.toContain("SECRET-");
623+ }
624+
625+ // The SEAM, pinned. This layer carries the whole stored verdict —
626+ // upstream account content and all — because it is in-process and the
627+ // caller is the platform itself. The narrowing to status + checkedAt is
628+ // `@executor-js/api`'s `AdminConnectionHealth`, applied where the
629+ // RESPONSE is built. If this assertion ever fails because the fields
630+ // stopped arriving here, the projection in `admin/reads.ts` is no
631+ // longer the thing holding the line and the API's own absence tests
632+ // have quietly become vacuous.
633+ const health = connections[0]?.lastHealth;
634+ for (const field of UPSTREAM_HEALTH_FIELDS) {
635+ expect(
636+ Object.keys(health ?? {}),
637+ `the platform view still carries "${field}"; the API projection is the boundary`,
638+ ).toContain(field);
639+ }
640+ }),
641+ ),
642+ );
643+});
644+
645+describe("platform view — admin.listSubjectsWithConnections", () => {
646+ it.effect("joins each subject to its own connections", () =>
647+ withDb((db) =>
648+ Effect.gen(function* () {
649+ yield* seed(db);
650+ const executor = yield* makePlatformExecutor(db);
651+ const admin = yield* requireAdmin(executor);
652+
653+ const rows = yield* admin.listSubjectsWithConnections();
654+ const byId = new Map(rows.map((row) => [row.externalId, row]));
655+
656+ expect(rows.map((row) => row.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]);
657+ expect(
658+ byId
659+ .get(SUBJECT_A)
660+ ?.connections.map((c) => c.name)
661+ .sort(),
662+ ).toEqual(["personal", "work"]);
663+ expect(byId.get(SUBJECT_B)?.connections.map((c) => c.name)).toEqual(["b-personal"]);
664+ }),
665+ ),
666+ );
667+
668+ it.effect("never returns credential-bearing fields", () =>
669+ withDb((db) =>
670+ Effect.gen(function* () {
671+ yield* seed(db);
672+ const executor = yield* makePlatformExecutor(db);
673+ const admin = yield* requireAdmin(executor);
674+
675+ const rows = yield* admin.listSubjectsWithConnections();
676+
677+ for (const row of rows) {
678+ for (const connection of row.connections) {
679+ const keys = Object.keys(connection);
680+ for (const forbidden of FORBIDDEN_CONNECTION_FIELDS) {
681+ expect(keys).not.toContain(forbidden);
682+ }
683+ }
684+ }
685+ expect(JSON.stringify(rows)).not.toContain("SECRET-");
686+ }),
687+ ),
688+ );
689+
690+ it.effect("works for a pure-org executor with no bound subject", () =>
691+ withDb((db) =>
692+ Effect.gen(function* () {
693+ yield* seed(db);
694+ // The shape an org-level API key produces: platform view on, no
695+ // subject bound at all.
696+ const executor = yield* makePlatformExecutor(db, { subject: null });
697+ const admin = yield* requireAdmin(executor);
698+
699+ const rows = yield* admin.listSubjectsWithConnections();
700+
701+ expect(rows.map((row) => row.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]);
702+ expect(rows.flatMap((row) => row.connections)).toHaveLength(3);
703+ }),
704+ ),
705+ );
706+});
707+
708+describe("platform view — admin.getSubject", () => {
709+ it.effect("reads one subject by external_id", () =>
710+ withDb((db) =>
711+ Effect.gen(function* () {
712+ yield* seed(db);
713+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
714+
715+ const subject = yield* admin.getSubject(SUBJECT_B);
716+
717+ expect(subject?.externalId).toBe(SUBJECT_B);
718+ // The same field discipline as the list: a keyed read is not a
719+ // different projection.
720+ expect(Object.keys(subject!).sort()).toEqual([
721+ "createdAt",
722+ "externalId",
723+ "lastSeenAt",
724+ "status",
725+ ]);
726+ }),
727+ ),
728+ );
729+
730+ it.effect("returns null for an unknown id and for another tenant's subject", () =>
731+ withDb((db) =>
732+ Effect.gen(function* () {
733+ yield* seed(db);
734+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
735+
736+ expect(yield* admin.getSubject("user_nobody")).toBeNull();
737+ // `user_elsewhere` exists — under t2. The tenant policy filters the
738+ // keyed read exactly as it filters the list.
739+ expect(yield* admin.getSubject("user_elsewhere")).toBeNull();
740+ }),
741+ ),
742+ );
743+
744+ it.effect("joins connections in one call, and issues none for an absent subject", () =>
745+ withDb((db) =>
746+ Effect.gen(function* () {
747+ yield* seed(db);
748+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
749+
750+ const joined = yield* admin.getSubjectWithConnections(SUBJECT_A);
751+ expect(joined?.externalId).toBe(SUBJECT_A);
752+ expect(joined?.connections.map((entry) => entry.integration).sort()).toEqual([
753+ "github",
754+ "linear",
755+ ]);
756+ // Org-owned rows belong to the tenant, not to any user, so the
757+ // keyed join excludes them exactly as the list join does.
758+ expect(joined?.connections.every((entry) => entry.owner === "user")).toBe(true);
759+
760+ expect(yield* admin.getSubjectWithConnections("user_nobody")).toBeNull();
761+ }),
762+ ),
763+ );
764+
765+ it.effect("never returns credential-bearing fields on the keyed join", () =>
766+ withDb((db) =>
767+ Effect.gen(function* () {
768+ yield* seed(db);
769+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
770+
771+ const joined = yield* admin.getSubjectWithConnections(SUBJECT_A);
772+
773+ for (const connection of joined!.connections) {
774+ const keys = Object.keys(connection);
775+ for (const forbidden of FORBIDDEN_CONNECTION_FIELDS) {
776+ expect(keys).not.toContain(forbidden);
777+ }
778+ }
779+ expect(JSON.stringify(joined)).not.toContain("SECRET-");
780+ }),
781+ ),
782+ );
783+});
784+
785+describe("platform view — default off", () => {
786+ it.effect("an executor without the opt-in has no admin surface", () =>
787+ withDb((db) =>
788+ Effect.gen(function* () {
789+ yield* seed(db);
790+ const executor = yield* makePlatformExecutor(db, { platformView: false });
791+
792+ expect(executor.admin).toBeUndefined();
793+ }),
794+ ),
795+ );
796+
797+ it.effect("the product view still reads only the bound subject when the opt-in is on", () =>
798+ withDb((db) =>
799+ Effect.gen(function* () {
800+ yield* seed(db);
801+ // Enabling the platform view must not widen ANY existing surface.
802+ const executor = yield* makePlatformExecutor(db, { subject: SUBJECT_A });
803+
804+ const connections = yield* executor.connections.list().pipe(Effect.orDie);
805+
806+ expect(connections.map((entry) => entry.name).sort()).toEqual([
807+ "personal",
808+ "shared",
809+ "work",
810+ ]);
811+ }),
812+ ),
813+ );
814+});
packages/core/sdk/src/subject-registry.test.ts+418−0
@@ -0,0 +1,418 @@
1+import { beforeEach, describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { withQueryContext } from "@executor-js/fumadb/query";
4+
5+import { collectTables } from "./executor";
6+import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids";
7+import type { ExecutorOwnerPolicyContext } from "./owner-policy";
8+import { definePlugin } from "./plugin";
9+import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
10+import { resetSubjectTouchCache, touchSubject } from "./subject-registry";
11+import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
12+
13+// `touchSubject` is the only writer of the `subject` table. Written against the
14+// real SQLite bring-up (the same statements local/self-host/D1 boot with) so
15+// the tenant policy, the unique index, and the bigint round-trip are all live.
16+
17+const TENANT = "t1";
18+const OTHER_TENANT = "t2";
19+const THROTTLE_MS = 60_000;
20+
21+const withDb = <A, E>(body: (db: SqliteTestFumaDb) => Effect.Effect<A, E>): Effect.Effect<A, E> =>
22+ Effect.acquireUseRelease(
23+ Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
24+ body,
25+ (db) => Effect.promise(() => db.close()),
26+ );
27+
28+// Read the way PR 1.3's readers will: through a tenant-bound query context.
29+// `last_seen_at` is a bigint column stored as a SQLite blob, so a raw SQL read
30+// would hand back an unusable buffer — the ORM owns the round-trip.
31+const scopedTo = (db: SqliteTestFumaDb, tenant: string) =>
32+ withQueryContext(db.db, {
33+ tenant,
34+ subject: null,
35+ } satisfies ExecutorOwnerPolicyContext);
36+
37+const storedSubjects = (
38+ db: SqliteTestFumaDb,
39+ tenant = TENANT,
40+): Effect.Effect<readonly { readonly externalId: string; readonly lastSeenMs: number | null }[]> =>
41+ Effect.promise(async () => {
42+ const rows = await scopedTo(db, tenant).findMany("subject", {
43+ orderBy: ["external_id", "asc"],
44+ });
45+ return rows.map((row) => ({
46+ externalId: String(row.external_id),
47+ lastSeenMs: row.last_seen_at == null ? null : Number(row.last_seen_at),
48+ }));
49+ });
50+
51+// `touchSubject` remembers, per process, which principals it already filed
52+// inside the throttle window. Every case below reuses the same tenant/principal
53+// against a FRESH database, so that memory has to be dropped between them or a
54+// later case would skip the query its assertion depends on.
55+beforeEach(() => {
56+ resetSubjectTouchCache();
57+});
58+
59+describe("touchSubject", () => {
60+ it.effect("creates the row on first sight", () =>
61+ withDb((db) =>
62+ Effect.gen(function* () {
63+ const before = Date.now();
64+ yield* touchSubject(db.db, { tenant: TENANT, externalId: "user_a" });
65+
66+ const rows = yield* storedSubjects(db);
67+ expect(rows).toHaveLength(1);
68+ expect(rows[0]?.externalId).toBe("user_a");
69+ // A first sighting IS a sighting: `last_seen_at` is stamped, not left
70+ // null for the next request to fill in.
71+ expect(rows[0]?.lastSeenMs).toBeGreaterThanOrEqual(before);
72+ }),
73+ ),
74+ );
75+
76+ it.effect("leaves last_seen_at alone while the sighting is inside the throttle", () =>
77+ withDb((db) =>
78+ Effect.gen(function* () {
79+ yield* touchSubject(db.db, {
80+ tenant: TENANT,
81+ externalId: "user_a",
82+ lastSeenThrottleMs: THROTTLE_MS,
83+ });
84+ const [first] = yield* storedSubjects(db);
85+
86+ // The hot path: N requests in quick succession must cost zero writes,
87+ // so the persisted stamp is unchanged afterwards.
88+ yield* Effect.forEach([1, 2, 3], () =>
89+ touchSubject(db.db, {
90+ tenant: TENANT,
91+ externalId: "user_a",
92+ lastSeenThrottleMs: THROTTLE_MS,
93+ }),
94+ );
95+
96+ const rows = yield* storedSubjects(db);
97+ expect(rows).toHaveLength(1);
98+ expect(rows[0]?.lastSeenMs).toBe(first?.lastSeenMs);
99+ }),
100+ ),
101+ );
102+
103+ it.effect("bumps last_seen_at once the sighting falls outside the throttle", () =>
104+ withDb((db) =>
105+ Effect.gen(function* () {
106+ yield* touchSubject(db.db, { tenant: TENANT, externalId: "user_a" });
107+ const [first] = yield* storedSubjects(db);
108+
109+ // Age the row rather than sleeping through the interval. A throttle of
110+ // zero would pass even if the staleness comparison were inverted; a row
111+ // aged well past the interval only passes if it reads the right way.
112+ const stale = (first?.lastSeenMs ?? 0) - 10 * THROTTLE_MS;
113+ yield* Effect.promise(() =>
114+ scopedTo(db, TENANT).updateMany("subject", {
115+ where: (b) => b("external_id", "=", "user_a"),
116+ set: { last_seen_at: BigInt(stale) },
117+ }),
118+ );
119+ // Backdating the row stands in for elapsed time, and elapsed time ages
120+ // the process-local sighting memory too — otherwise this would assert
121+ // against a clock the memory never saw. Expiring it is what the passage
122+ // of time does; the assertion below is unchanged.
123+ resetSubjectTouchCache();
124+
125+ yield* touchSubject(db.db, {
126+ tenant: TENANT,
127+ externalId: "user_a",
128+ lastSeenThrottleMs: THROTTLE_MS,
129+ });
130+
131+ const rows = yield* storedSubjects(db);
132+ expect(rows).toHaveLength(1);
133+ expect(rows[0]?.lastSeenMs).toBeGreaterThan(stale);
134+ }),
135+ ),
136+ );
137+
138+ it.effect("issues no query at all for a repeat sighting inside the throttle", () =>
139+ withDb((db) =>
140+ Effect.gen(function* () {
141+ // THE regression this guards: the request seam runs `touchSubject` on
142+ // every HTTP request and MCP session, so a sighting that still costs an
143+ // indexed read per request adds a round-trip to every request in the
144+ // product. Dropping the table after the first sighting makes any
145+ // further query fail loudly — and `touchSubject` swallows storage
146+ // failures, so a surviving query would be invisible in the row state.
147+ // Counting statements is the only way to see it.
148+ yield* touchSubject(db.db, {
149+ tenant: TENANT,
150+ externalId: "user_a",
151+ lastSeenThrottleMs: THROTTLE_MS,
152+ });
153+
154+ let queries = 0;
155+ const client = db.client as { execute: (...args: never[]) => unknown };
156+ const execute = client.execute.bind(client) as (...args: never[]) => unknown;
157+ client.execute = (...args: never[]) => {
158+ queries += 1;
159+ return execute(...args);
160+ };
161+
162+ yield* Effect.forEach([1, 2, 3], () =>
163+ touchSubject(db.db, {
164+ tenant: TENANT,
165+ externalId: "user_a",
166+ lastSeenThrottleMs: THROTTLE_MS,
167+ }),
168+ );
169+
170+ client.execute = execute as typeof client.execute;
171+ expect(queries).toBe(0);
172+ }),
173+ ),
174+ );
175+
176+ it.effect("re-reads once the remembered sighting ages past the throttle", () =>
177+ withDb((db) =>
178+ Effect.gen(function* () {
179+ // The memory must expire with the throttle, not pin a principal as
180+ // "recently seen" forever: `last_seen_at` still has to advance for a
181+ // principal who keeps calling across windows.
182+ yield* touchSubject(db.db, {
183+ tenant: TENANT,
184+ externalId: "user_a",
185+ lastSeenThrottleMs: THROTTLE_MS,
186+ });
187+ const [first] = yield* storedSubjects(db);
188+
189+ // A zero throttle makes every remembered sighting instantly stale,
190+ // which is what a caller crossing the window observes.
191+ yield* touchSubject(db.db, {
192+ tenant: TENANT,
193+ externalId: "user_a",
194+ lastSeenThrottleMs: 0,
195+ });
196+
197+ const rows = yield* storedSubjects(db);
198+ expect(rows).toHaveLength(1);
199+ expect(rows[0]?.lastSeenMs).toBeGreaterThanOrEqual(first?.lastSeenMs ?? 0);
200+ }),
201+ ),
202+ );
203+
204+ it.effect("remembers a first sighting per tenant, not per principal", () =>
205+ withDb((db) =>
206+ Effect.gen(function* () {
207+ // One account acting in two orgs is two rows. A memory keyed on the
208+ // principal alone would file the first tenant and skip the second,
209+ // losing the row the other tenant's admin view depends on.
210+ yield* touchSubject(db.db, {
211+ tenant: TENANT,
212+ externalId: "user_a",
213+ lastSeenThrottleMs: THROTTLE_MS,
214+ });
215+ yield* touchSubject(db.db, {
216+ tenant: OTHER_TENANT,
217+ externalId: "user_a",
218+ lastSeenThrottleMs: THROTTLE_MS,
219+ });
220+
221+ expect((yield* storedSubjects(db, OTHER_TENANT)).map((row) => row.externalId)).toEqual([
222+ "user_a",
223+ ]);
224+ }),
225+ ),
226+ );
227+
228+ it.effect("does not remember a sighting that failed to persist", () =>
229+ withDb((db) =>
230+ Effect.gen(function* () {
231+ // A storage failure is swallowed so the request survives. It must not
232+ // also be recorded as a successful sighting, or the principal would
233+ // stay unfiled for a whole throttle window after the fault cleared.
234+ yield* Effect.promise(() => db.client.execute("DROP TABLE subject"));
235+ yield* touchSubject(db.db, {
236+ tenant: TENANT,
237+ externalId: "user_a",
238+ lastSeenThrottleMs: THROTTLE_MS,
239+ });
240+
241+ yield* Effect.promise(() =>
242+ db.client.execute(
243+ "CREATE TABLE subject (row_id text PRIMARY KEY NOT NULL, tenant text NOT NULL, external_id text NOT NULL, created_at integer NOT NULL, last_seen_at blob, status text)",
244+ ),
245+ );
246+ yield* touchSubject(db.db, {
247+ tenant: TENANT,
248+ externalId: "user_a",
249+ lastSeenThrottleMs: THROTTLE_MS,
250+ });
251+
252+ expect((yield* storedSubjects(db)).map((row) => row.externalId)).toEqual(["user_a"]);
253+ }),
254+ ),
255+ );
256+
257+ it.effect("records nothing for a pure-org executor", () =>
258+ withDb((db) =>
259+ Effect.gen(function* () {
260+ yield* touchSubject(db.db, { tenant: TENANT, externalId: null });
261+ expect(yield* storedSubjects(db)).toEqual([]);
262+ }),
263+ ),
264+ );
265+
266+ it.effect("records the host sentinel subject id", () =>
267+ withDb((db) =>
268+ Effect.gen(function* () {
269+ // apps/local binds every request to the literal subject "local". It is
270+ // a real principal here, not a placeholder to skip.
271+ yield* touchSubject(db.db, { tenant: TENANT, externalId: "local" });
272+ expect((yield* storedSubjects(db)).map((row) => row.externalId)).toEqual(["local"]);
273+ }),
274+ ),
275+ );
276+
277+ it.effect("files the same principal separately under each tenant", () =>
278+ withDb((db) =>
279+ Effect.gen(function* () {
280+ // The tenant written is always the one bound to the touch, so the
281+ // tenant policy's create check can never reject it — and one account in
282+ // two orgs is two rows under two tenants, not a unique-index collision.
283+ yield* touchSubject(db.db, { tenant: TENANT, externalId: "user_a" });
284+ yield* touchSubject(db.db, {
285+ tenant: OTHER_TENANT,
286+ externalId: "user_a",
287+ });
288+
289+ expect((yield* storedSubjects(db, TENANT)).map((row) => row.externalId)).toEqual([
290+ "user_a",
291+ ]);
292+ expect((yield* storedSubjects(db, OTHER_TENANT)).map((row) => row.externalId)).toEqual([
293+ "user_a",
294+ ]);
295+ }),
296+ ),
297+ );
298+
299+ it.effect("survives a storage failure without failing its caller", () =>
300+ withDb((db) =>
301+ Effect.gen(function* () {
302+ // A sighting is bookkeeping. A storage failure must not take down the
303+ // request that produced it — `touchSubject` logs and returns void.
304+ // Dropping the table is the real shape of the failure this guards
305+ // against (a host whose bring-up hasn't reached the new table yet).
306+ yield* Effect.promise(() => db.client.execute("DROP TABLE subject"));
307+
308+ yield* touchSubject(db.db, { tenant: TENANT, externalId: "user_a" });
309+ }),
310+ ),
311+ );
312+});
313+
314+// ---------------------------------------------------------------------------
315+// The second writer: `connections.create`. The request seam covers every hosted
316+// call, so this is the belt for a direct SDK/CLI caller that never passes
317+// through it.
318+// ---------------------------------------------------------------------------
319+
320+const INTEG = IntegrationSlug.make("vercel");
321+const TEMPLATE = AuthTemplateSlug.make("apiKey");
322+
323+const demoPlugin = definePlugin(() => ({
324+ id: "demo" as const,
325+ storage: () => ({}),
326+ resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("deploy") }] }),
327+ invokeTool: () => Effect.succeed(null),
328+ extension: (ctx) => ({
329+ seed: () =>
330+ ctx.core.integrations.register({
331+ slug: INTEG,
332+ description: "Vercel",
333+ config: {},
334+ }),
335+ }),
336+}))();
337+
338+const plugins = [memoryCredentialsPlugin(), demoPlugin] as const;
339+
340+const subjectsOf = (
341+ harness: { readonly config: { readonly db: unknown } },
342+ tenant: string,
343+): Effect.Effect<readonly string[]> =>
344+ Effect.promise(async () => {
345+ const rows = await withQueryContext(harness.config.db as never, {
346+ tenant,
347+ subject: null,
348+ } satisfies ExecutorOwnerPolicyContext).findMany("subject", {});
349+ return rows.map((row) => String(row.external_id));
350+ });
351+
352+describe("connections.create subject sighting", () => {
353+ it.effect("records the bound subject", () =>
354+ Effect.scoped(
355+ Effect.gen(function* () {
356+ const harness = yield* makeTestWorkspaceHarness({
357+ plugins,
358+ tenant: TENANT,
359+ });
360+ yield* harness.executor.demo.seed();
361+ yield* harness.executor.connections.create({
362+ owner: "user",
363+ name: ConnectionName.make("main"),
364+ integration: INTEG,
365+ template: TEMPLATE,
366+ value: "secret-token",
367+ });
368+
369+ expect(yield* subjectsOf(harness, TENANT)).toEqual([harness.subject]);
370+ }),
371+ ),
372+ );
373+
374+ it.effect("records the bound subject even for an org-owned connection", () =>
375+ Effect.scoped(
376+ Effect.gen(function* () {
377+ // The row names the acting principal, not the connection's owner tier:
378+ // a member who only ever creates org connections still exists.
379+ const harness = yield* makeTestWorkspaceHarness({
380+ plugins,
381+ tenant: TENANT,
382+ });
383+ yield* harness.executor.demo.seed();
384+ yield* harness.executor.connections.create({
385+ owner: "org",
386+ name: ConnectionName.make("shared"),
387+ integration: INTEG,
388+ template: TEMPLATE,
389+ value: "secret-token",
390+ });
391+
392+ expect(yield* subjectsOf(harness, TENANT)).toEqual([harness.subject]);
393+ }),
394+ ),
395+ );
396+
397+ it.effect("records nothing when the executor has no subject", () =>
398+ Effect.scoped(
399+ Effect.gen(function* () {
400+ const harness = yield* makeTestWorkspaceHarness({
401+ plugins,
402+ tenant: TENANT,
403+ subject: null,
404+ });
405+ yield* harness.executor.demo.seed();
406+ yield* harness.executor.connections.create({
407+ owner: "org",
408+ name: ConnectionName.make("shared"),
409+ integration: INTEG,
410+ template: TEMPLATE,
411+ value: "secret-token",
412+ });
413+
414+ expect(yield* subjectsOf(harness, TENANT)).toEqual([]);
415+ }),
416+ ),
417+ );
418+});
packages/core/sdk/src/subject-registry.ts+0−0

Binary file not shown.

packages/core/sdk/src/subject-table.test.ts+103−0
@@ -0,0 +1,103 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { withQueryContext } from "@executor-js/fumadb/query";
4+
5+import { collectTables } from "./executor";
6+import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
7+
8+// The subject table has no readers or writers yet (it is populated in a later
9+// change), so this pins the two properties the schema alone is responsible
10+// for: the boot bring-up actually creates it, and it is TENANT-scoped rather
11+// than owner-scoped — any executor bound to the tenant may read it, which is
12+// what a cross-subject view needs. Written against the real SQLite bring-up
13+// (`createSqliteTestFumaDb` runs the same statements the local/self-host/D1
14+// hosts boot with) instead of asserting on the table definition object.
15+
16+const TENANT = "t1";
17+const OTHER_TENANT = "t2";
18+
19+const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> =>
20+ Effect.runPromise(
21+ Effect.acquireUseRelease(
22+ Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
23+ (db) => Effect.promise(() => body(db)),
24+ (db) => Effect.promise(() => db.close()),
25+ ),
26+ );
27+
28+const insertSubject = (
29+ db: SqliteTestFumaDb,
30+ row: { readonly rowId: string; readonly tenant: string; readonly externalId: string },
31+): Promise<unknown> =>
32+ db.client.execute({
33+ sql: `INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status)
34+ VALUES (?, ?, ?, ?, ?, ?)`,
35+ args: [row.rowId, row.tenant, row.externalId, Date.now(), null, null],
36+ });
37+
38+describe("subject table", () => {
39+ it.effect("is created by the runtime schema bring-up", () =>
40+ Effect.promise(() =>
41+ withDb(async (db) => {
42+ const info = await db.client.execute("PRAGMA table_info('subject')");
43+ const columns = info.rows.map((row) => String(row["name"]));
44+ expect(columns).toEqual(
45+ expect.arrayContaining([
46+ "row_id",
47+ "tenant",
48+ "external_id",
49+ "created_at",
50+ "last_seen_at",
51+ "status",
52+ ]),
53+ );
54+ }),
55+ ),
56+ );
57+
58+ it.effect("keys one row per (tenant, external_id)", () =>
59+ Effect.promise(() =>
60+ withDb(async (db) => {
61+ await insertSubject(db, { rowId: "s1", tenant: TENANT, externalId: "user_a" });
62+
63+ // The same principal id under a DIFFERENT tenant is a different row —
64+ // external ids are only unique within their tenant.
65+ await insertSubject(db, { rowId: "s2", tenant: OTHER_TENANT, externalId: "user_a" });
66+
67+ await expect(
68+ insertSubject(db, { rowId: "s3", tenant: TENANT, externalId: "user_a" }),
69+ ).rejects.toThrow();
70+
71+ const rows = await db.client.execute("SELECT row_id FROM subject ORDER BY row_id");
72+ expect(rows.rows.map((row) => String(row["row_id"]))).toEqual(["s1", "s2"]);
73+ }),
74+ ),
75+ );
76+
77+ it.effect("tolerates the host sentinel subject id", () =>
78+ Effect.promise(() =>
79+ withDb(async (db) => {
80+ // Local/self-host bind every request to the literal subject "local";
81+ // nothing may assume external ids are opaque provider identifiers.
82+ await insertSubject(db, { rowId: "s1", tenant: TENANT, externalId: "local" });
83+ const rows = await db.client.execute("SELECT external_id FROM subject");
84+ expect(rows.rows.map((row) => String(row["external_id"]))).toEqual(["local"]);
85+ }),
86+ ),
87+ );
88+
89+ it.effect("reads only within the bound tenant", () =>
90+ Effect.promise(() =>
91+ withDb(async (db) => {
92+ await insertSubject(db, { rowId: "s1", tenant: TENANT, externalId: "user_a" });
93+ await insertSubject(db, { rowId: "s2", tenant: OTHER_TENANT, externalId: "user_b" });
94+
95+ // No `subject` in the context: a pure-org executor still reads the
96+ // table, which is the whole point of tenant scoping.
97+ const scoped = withQueryContext(db.db, { tenant: TENANT, subject: null });
98+ const visible = await scoped.findMany("subject", {});
99+ expect(visible.map((row) => row.external_id)).toEqual(["user_a"]);
100+ }),
101+ ),
102+ );
103+});
packages/react/src/api/account-atoms.tsx+19−5
@@ -28,11 +28,25 @@ export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey") · 27 unmodified lines
2828
2929 // ── Organization members ─────────────────────────────────────────────────────
3030
31−export const orgMembersAtom = Atom.refreshOnWindowFocus(
32− AccountApiClient.query("account", "listMembers", {
33− timeToLive: "30 seconds",
34− reactivityKeys: [ReactivityKey.orgMembers],
35− }),
31+// `refreshOnWindowFocus` is BROWSER-ONLY: its signal atom subscribes to
32+// `window`/`document.visibilityState` the first time the atom is read, so
33+// evaluating it on a server throws `ReferenceError: window is not defined` —
34+// synchronously, in render, where no Suspense boundary can absorb it.
35+//
36+// `withServerValueInitial` gives the atom an explicit server snapshot
37+// (`AsyncResult.initial(true)`) that `useAtomValue`'s `getServerSnapshot` reads
38+// INSTEAD of evaluating the atom, so the read function never runs server-side.
39+// Every reader already handles the initial state (it is the ordinary loading
40+// state), and the client refetches on mount. Without this, any SSR'd tree that
41+// reads the member list — cloud's shell does, to decide the admin nav — fails
42+// the whole document render.
43+export const orgMembersAtom = Atom.withServerValueInitial(
44+ Atom.refreshOnWindowFocus(
45+ AccountApiClient.query("account", "listMembers", {
46+ timeToLive: "30 seconds",
47+ reactivityKeys: [ReactivityKey.orgMembers],
48+ }),
49+ ),
3650 );
3751
3852 export const orgRolesAtom = AccountApiClient.query("account", "listRoles", {
packages/react/src/api/admin-atoms.tsx+53−0
@@ -0,0 +1,53 @@
1+import * as Atom from "effect/unstable/reactivity/Atom";
2+
3+import { AdminApiClient } from "./admin-client";
4+import { ReactivityKey } from "./reactivity-keys";
5+
6+// ---------------------------------------------------------------------------
7+// Admin atoms — the tenant-wide users view behind `/admin/users*`.
8+//
9+// Read-only by construction: the admin plane serves GETs only (the owner policy
10+// rejects writes at tenant reach), so there are no mutations here and every
11+// atom carries the same reactivity key.
12+//
13+// Paging is part of the atom identity, so each page is its own cache entry and
14+// stepping back to a visited page is instant. `Atom.family` (not a bare arrow)
15+// because the page component re-derives the key object on every render — a
16+// fresh atom per render would refetch in a loop.
17+// ---------------------------------------------------------------------------
18+
19+/** How many users one page of the list shows. Well inside the contract's
20+ * 1..500 bound; the joined endpoint reads per-user connections, so a modest
21+ * page keeps that join cheap. */
22+export const ADMIN_USERS_PAGE_SIZE = 25;
23+
24+export interface AdminUsersPage {
25+ readonly limit: number;
26+ readonly offset: number;
27+}
28+
29+/**
30+ * One page of users joined with their connections — what the list renders.
31+ *
32+ * Asks for one row MORE than the page size so the pager can tell "there is a
33+ * next page" from "this is the last one" without a count endpoint (the contract
34+ * returns no total). The page component drops the extra row.
35+ */
36+export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) =>
37+ AdminApiClient.query("adminUsers", "listUsersWithConnections", {
38+ query: { limit: page.limit + 1, offset: page.offset },
39+ timeToLive: "30 seconds",
40+ reactivityKeys: [ReactivityKey.adminUsers],
41+ }),
42+);
43+
44+/** One user's connections, for the detail pane. A separate read rather than a
45+ * slice of the list so opening a user is correct even when the list page it
46+ * came from has since gone stale. */
47+export const adminUserConnectionsAtom = Atom.family((externalId: string) =>
48+ AdminApiClient.query("adminUsers", "listUserConnections", {
49+ params: { externalId },
50+ timeToLive: "30 seconds",
51+ reactivityKeys: [ReactivityKey.adminUsers],
52+ }),
53+);
packages/react/src/api/admin-client.tsx+53−0
@@ -0,0 +1,53 @@
1+import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3+import { AdminUsersHttpApi } from "@executor-js/api/client";
4+import * as Effect from "effect/Effect";
5+
6+import { reportApiClientInfrastructureCause } from "./client";
7+import {
8+ EXECUTOR_ORG_HEADER,
9+ getActiveOrgSlug,
10+ getExecutorApiBaseUrl,
11+ getExecutorServerAuthorizationHeader,
12+} from "./server-connection";
13+
14+// ---------------------------------------------------------------------------
15+// Shared admin client — the tenant-wide `/admin/users*` operator surface.
16+//
17+// A third AtomHttpApi service alongside `ExecutorApiClient` (the product plane:
18+// "what can I reach") and `AccountApiClient` (identity + org membership). This
19+// one answers the OWNER's question: who are my users and what have they
20+// connected. Its paths are `/admin/*` with no `/api` — `transformClient`
21+// prepends the API base, exactly like the account client.
22+//
23+// Both hosts that serve it (cloud: an org API key or an admin-role session;
24+// self-host: a Better Auth owner/admin) implement identical routes, so one
25+// client works for both. Requests carry the same session cookie the browser
26+// sends anyway; the org header scopes cloud to the org the console URL is on.
27+//
28+// `reportApiClientInfrastructureCause` (not `handleApiClientCause`) on purpose:
29+// a 401 here means "not an admin of this tenant", which must render a denied
30+// state on the page — it must NOT pop the local-auth gate the core client
31+// raises when the whole product plane is unreachable.
32+// ---------------------------------------------------------------------------
33+
34+const AdminApiClient = AtomHttpApi.Service<"AdminApiClient">()("AdminApiClient", {
35+ api: AdminUsersHttpApi,
36+ httpClient: FetchHttpClient.layer,
37+ transformClient: HttpClient.mapRequest((request) => {
38+ let next = HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl());
39+ const authorization = getExecutorServerAuthorizationHeader();
40+ if (authorization) {
41+ next = HttpClientRequest.setHeader(next, "authorization", authorization);
42+ }
43+ // Scope to the org the console URL is on (see server-connection).
44+ const orgSlug = getActiveOrgSlug();
45+ if (orgSlug) {
46+ next = HttpClientRequest.setHeader(next, EXECUTOR_ORG_HEADER, orgSlug);
47+ }
48+ return next;
49+ }),
50+ transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
51+});
52+
53+export { AdminApiClient };
packages/react/src/api/reactivity-keys.tsx+4−0
@@ -36,6 +36,10 @@ export const ReactivityKey = { · 35 unmodified lines
3636 orgInfo: "org:info",
3737 apiKeys: "api-keys",
3838 auth: "auth",
39+ /** The tenant-wide admin users view. Read-only today (the admin plane has no
40+ * writes), so nothing invalidates it — it exists so the pages refresh
41+ * together and so a future lifecycle mutation has a key to publish. */
42+ adminUsers: "admin:users",
3943 } as const;
4044
4145 /** Mutations that add/remove/refresh an integration also affect tool listings. */
packages/react/src/console-routes.test.ts+24−0
@@ -1,4 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { RESERVED_ORG_SLUGS } from "@executor-js/api";
23
34 import { CONSOLE_ROUTE_PATHS, ORG_SLUG_SEGMENT, consoleRoutes } from "./console-routes";
45 import { routeTree } from "./routes/routeTree.gen";
@@ -52,6 +53,29 @@ describe("console route contract", () => { · 47 unmodified lines
5253 expect(JSON.stringify(withoutSecrets)).not.toContain("secrets.tsx");
5354 });
5455
56+ it("every console root segment is a reserved org slug", () => {
57+ // Both lists live in this repo but in different packages, and only their
58+ // COMBINATION is a bug: an org may claim any slug the reserved set does not
59+ // hold, and the org scope is the FIRST path segment, so an org slugged
60+ // `toolkits` shadows `/toolkits` for its own members — silently, since both
61+ // URLs resolve to a real route. `toolkits` shipped unreserved for exactly
62+ // this reason: the route was added and the reservation was not.
63+ //
64+ // Derived from CONSOLE_ROUTE_PATHS rather than hardcoded, so a console
65+ // route added tomorrow fails here until it is reserved.
66+ const rootSegments = CONSOLE_ROUTE_PATHS.map((path) => path.split("/")[1] ?? "")
67+ // "/" (the index) has no segment, and `$param` roots are not literal
68+ // segments an org slug could collide with.
69+ .filter((segment) => segment !== "" && !segment.startsWith("$"));
70+ expect(rootSegments.length, "the contract has literal root segments to check").toBeGreaterThan(
71+ 0,
72+ );
73+ const unreserved = [...new Set(rootSegments)].filter(
74+ (segment) => !RESERVED_ORG_SLUGS.has(segment),
75+ );
76+ expect(unreserved, "console root segments an org could claim and shadow").toEqual([]);
77+ });
78+
5579 it("orgScoped extras mount inside the org scope", () => {
5680 const [scope] = consoleRoutes({
5781 dir: "shared",
packages/react/src/console-routes.ts+10−0
@@ -34,11 +34,13 @@ export const ORG_SLUG_SEGMENT = "{-$orgSlug}"; · 33 unmodified lines
3434 * are these paths prefixed with `/{-$orgSlug}`. */
3535 export const CONSOLE_ROUTE_PATHS = [
3636 "/",
37+ "/connect/$integrationSlug",
3738 "/integrations/$namespace",
3839 "/integrations/add/$pluginKey",
3940 "/policies",
4041 "/secrets",
4142 "/tools",
43+ "/users",
4244 "/toolkits",
4345 "/toolkits/$toolkitSlug",
4446 "/resume/$executionId",
@@ -62,6 +64,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 17 unmodified lines
6264 const file = (name: string): string => `${options.dir}/${name}`;
6365 const entries: ReadonlyArray<readonly [ConsoleRoutePath, VirtualRouteNode]> = [
6466 ["/", index(file("index.tsx"))],
67+ [
68+ "/connect/$integrationSlug",
69+ route("/connect/$integrationSlug", file("connect.$integrationSlug.tsx")),
70+ ],
6571 [
6672 "/integrations/$namespace",
6773 route("/integrations/$namespace", file("integrations.$namespace.tsx")),
@@ -73,6 +79,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 5 unmodified lines
7379 ["/policies", route("/policies", file("policies.tsx"))],
7480 ["/secrets", route("/secrets", file("secrets.tsx"))],
7581 ["/tools", route("/tools", file("tools.tsx"))],
82+ // The admin users view. Hosts that don't serve `/admin/users*` (local /
83+ // desktop, cloudflare) exclude it — the page would only ever render its
84+ // denied state there.
85+ ["/users", route("/users", file("users.tsx"))],
7686 ["/toolkits", route("/toolkits", file("toolkits.tsx"))],
7787 ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],
7888 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],
packages/react/src/lib/admin-access.test.ts+52−0
@@ -0,0 +1,52 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { isTenantAdminMember, type TenantMemberRow } from "./admin-access";
4+
5+const member = (overrides: Partial<TenantMemberRow> = {}): TenantMemberRow => ({
6+ role: "member",
7+ status: "active",
8+ isCurrentUser: false,
9+ ...overrides,
10+});
11+
12+describe("isTenantAdminMember", () => {
13+ it("an active admin of the tenant may see the admin sections", () => {
14+ expect(isTenantAdminMember([member({ role: "admin", isCurrentUser: true })])).toBe(true);
15+ });
16+
17+ // Self-host (Better Auth) issues `owner` where cloud (WorkOS) issues `admin`;
18+ // both hosts' admin middleware accept either.
19+ it("self-host's owner role counts too", () => {
20+ expect(isTenantAdminMember([member({ role: "owner", isCurrentUser: true })])).toBe(true);
21+ });
22+
23+ it("a plain member does not", () => {
24+ expect(isTenantAdminMember([member({ role: "member", isCurrentUser: true })])).toBe(false);
25+ });
26+
27+ // A pending invite carries a role but no access yet — treating it as admin
28+ // would show a section the server refuses.
29+ it("a pending admin invite is not yet an admin", () => {
30+ expect(
31+ isTenantAdminMember([member({ role: "admin", status: "pending", isCurrentUser: true })]),
32+ ).toBe(false);
33+ });
34+
35+ it("another member's admin role says nothing about the viewer", () => {
36+ expect(
37+ isTenantAdminMember([
38+ member({ role: "admin", isCurrentUser: false }),
39+ member({ role: "member", isCurrentUser: true }),
40+ ]),
41+ ).toBe(false);
42+ });
43+
44+ it("fails closed when the list has no row for the viewer", () => {
45+ expect(isTenantAdminMember([])).toBe(false);
46+ expect(isTenantAdminMember([member({ role: "admin" })])).toBe(false);
47+ });
48+
49+ it("an unknown role is not an admin", () => {
50+ expect(isTenantAdminMember([member({ role: "billing", isCurrentUser: true })])).toBe(false);
51+ });
52+});
packages/react/src/lib/admin-access.ts+46−0
@@ -0,0 +1,46 @@
1+// ---------------------------------------------------------------------------
2+// Who may see the admin sections.
3+//
4+// There are two independent gates and both are load-bearing:
5+//
6+// 1. NAV VISIBILITY (this file). The console has no role on the session —
7+// `AccountMeResponse` carries a user and an organization, not a role — so
8+// the only client-side role signal available today is the member list:
9+// find the row flagged `isCurrentUser` and read its role. Cloud's org page
10+// already derives its danger zone this way; this generalizes it.
11+// 2. ACCESS ITSELF (the page). The server decides. Every admin endpoint is
12+// gated per host (cloud: an org API key or an admin-role session;
13+// self-host: a Better Auth owner/admin), and the page renders a denied
14+// state on 401/403 regardless of what this file concluded.
15+//
16+// The nav gate is a convenience, not security: it exists so a plain member
17+// isn't shown a section that would only refuse them. A member who guesses the
18+// URL hits gate 2, which is the real one.
19+// ---------------------------------------------------------------------------
20+
21+/** Roles that may read the tenant-wide admin plane. Cloud (WorkOS) issues
22+ * `admin` / `member`; self-host (Better Auth) issues `owner` / `admin` /
23+ * `member`. Both hosts' admin middleware accept exactly this set. */
24+const TENANT_ADMIN_ROLES: ReadonlySet<string> = new Set(["admin", "owner"]);
25+
26+/** A member row as `/account/members` returns it, typed structurally. */
27+export interface TenantMemberRow {
28+ readonly role: string;
29+ readonly status: string;
30+ readonly isCurrentUser: boolean;
31+}
32+
33+/**
34+ * Whether the current member may read the admin plane, per the member list.
35+ *
36+ * Only an ACTIVE membership counts: a pending invite carries a role but no
37+ * access yet, and treating it as admin would show a section the server refuses.
38+ * A list with no `isCurrentUser` row (self-host before the org is populated, a
39+ * host that doesn't serve members) is not an admin — the safe default, since
40+ * the page's own denied state is the honest surface for that case.
41+ */
42+export const isTenantAdminMember = (members: readonly TenantMemberRow[]): boolean =>
43+ members.some(
44+ (member) =>
45+ member.isCurrentUser && member.status === "active" && TENANT_ADMIN_ROLES.has(member.role),
46+ );
packages/react/src/lib/admin-users-display.test.ts+326−0
@@ -0,0 +1,326 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import type { IntegrationSlug } from "@executor-js/sdk/shared";
3+
4+import {
5+ adminUserCopyableEmail,
6+ adminUserTitle,
7+ connectionHealthStatus,
8+ connectLinkUrl,
9+ formatLastSeen,
10+ integrationConnectionStates,
11+ isConnectableIntegration,
12+ isLocalSubject,
13+ lastSeenTitle,
14+ pageNumber,
15+ shortenExternalId,
16+ splitPage,
17+ type AdminCatalogRow,
18+ type AdminConnectionRow,
19+} from "./admin-users-display";
20+
21+const slug = (value: string): IntegrationSlug => value as IntegrationSlug;
22+
23+/** A catalog row for a normal, connectable integration. `kind` is the owning
24+ * plugin's key on a real row, so "openapi" is what the catalog actually says. */
25+const catalogRow = (value: string, kind = "openapi"): AdminCatalogRow => ({
26+ slug: slug(value),
27+ kind,
28+});
29+
30+/** The built-in Executor row, exactly as a real catalog read returns it:
31+ * `kind: "built-in"` with `authMethods: []` (verified against
32+ * `executor.integrations.list()`). */
33+const builtInRow: AdminCatalogRow = { slug: slug("executor"), kind: "built-in" };
34+
35+const connection = (overrides: Partial<AdminConnectionRow> = {}): AdminConnectionRow => ({
36+ owner: "user",
37+ integration: slug("acme"),
38+ name: "default",
39+ oauthScope: null,
40+ lastHealth: null,
41+ ...overrides,
42+});
43+
44+describe("formatLastSeen", () => {
45+ const now = Date.UTC(2026, 6, 27, 12, 0, 0);
46+
47+ it("never seen reads as Never, not as a stale timestamp", () => {
48+ expect(formatLastSeen(null, now)).toBe("Never");
49+ });
50+
51+ // The writer throttles lastSeenAt to ~1h, so anything inside that window is
52+ // one bucket. Reporting minutes would claim precision the field doesn't have.
53+ it("collapses everything inside the throttle window to one honest bucket", () => {
54+ expect(formatLastSeen(now - 60_000, now)).toBe("Within the hour");
55+ expect(formatLastSeen(now - 45 * 60_000, now)).toBe("Within the hour");
56+ expect(formatLastSeen(now, now)).toBe("Within the hour");
57+ });
58+
59+ it("counts hours and days above the window, hedged with About", () => {
60+ expect(formatLastSeen(now - 60 * 60_000, now)).toBe("About 1 hour ago");
61+ expect(formatLastSeen(now - 5 * 60 * 60_000, now)).toBe("About 5 hours ago");
62+ expect(formatLastSeen(now - 24 * 60 * 60_000, now)).toBe("About 1 day ago");
63+ expect(formatLastSeen(now - 9 * 24 * 60 * 60_000, now)).toBe("About 9 days ago");
64+ });
65+
66+ it("falls back to an absolute date once relative wording stops being useful", () => {
67+ expect(formatLastSeen(now - 200 * 24 * 60 * 60_000, now)).not.toContain("ago");
68+ });
69+
70+ it("the tooltip carries the exact instant and the coarseness caveat", () => {
71+ expect(lastSeenTitle(null)).toBe("Never seen on a request");
72+ expect(lastSeenTitle(now)).toContain("at most once an hour");
73+ });
74+});
75+
76+describe("externalId display", () => {
77+ it("recognizes the single-player host sentinel", () => {
78+ expect(isLocalSubject("local")).toBe(true);
79+ expect(isLocalSubject("user_01JABCDEF")).toBe(false);
80+ });
81+
82+ it("leaves short ids exactly as the server sent them", () => {
83+ expect(shortenExternalId("user_01JAB")).toBe("user_01JAB");
84+ });
85+
86+ // Truncation is visual only — the id is opaque, so both ends are kept and two
87+ // ids differing in the middle stay distinguishable.
88+ it("shortens long ids from the middle, keeping both ends", () => {
89+ const long = "user_01JABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
90+ const short = shortenExternalId(long, 21);
91+ expect(short.length).toBe(21);
92+ expect(short.startsWith("user_01JAB")).toBe(true);
93+ expect(short.endsWith("0123456789")).toBe(true);
94+ expect(short).toContain("…");
95+ });
96+});
97+
98+describe("adminUserTitle", () => {
99+ // The email is what an operator invites, searches and supports against, so it
100+ // leads — and the opaque id drops to the supporting line rather than vanishing.
101+ it("leads with the email and keeps the id as the supporting line", () => {
102+ expect(
103+ adminUserTitle({ externalId: "user_01JAB", email: "ada@e2e.test", displayName: "Ada L" }),
104+ ).toEqual({ primary: "ada@e2e.test", secondary: "user_01JAB", primaryIsId: false });
105+ });
106+
107+ it("falls back to the display name when the host resolved no email", () => {
108+ expect(adminUserTitle({ externalId: "user_01JAB", email: null, displayName: "Ada L" })).toEqual(
109+ { primary: "Ada L", secondary: "user_01JAB", primaryIsId: false },
110+ );
111+ });
112+
113+ // The pre-identity rendering, unchanged: one mono id line and no subtitle. A
114+ // row the host cannot name must not degrade to a gap where a name would be.
115+ it("renders exactly the old id-only row when the host resolved neither", () => {
116+ expect(adminUserTitle({ externalId: "user_01JAB", email: null, displayName: null })).toEqual({
117+ primary: "user_01JAB",
118+ secondary: null,
119+ primaryIsId: true,
120+ });
121+ });
122+
123+ // A member who left the org while their connections remain arrives with the
124+ // fields simply missing rather than explicitly null.
125+ it("treats missing identity fields the same as null ones", () => {
126+ expect(adminUserTitle({ externalId: "user_01JAB" }).primaryIsId).toBe(true);
127+ });
128+
129+ // A directory that stores empty strings must not produce a blank headline.
130+ it("does not let a blank email or name become the headline", () => {
131+ expect(adminUserTitle({ externalId: "user_01JAB", email: " ", displayName: "" })).toEqual({
132+ primary: "user_01JAB",
133+ secondary: null,
134+ primaryIsId: true,
135+ });
136+ });
137+
138+ it("spells out the single-player host sentinel, which names no member", () => {
139+ expect(adminUserTitle({ externalId: "local", email: null, displayName: null })).toEqual({
140+ primary: "local",
141+ secondary: null,
142+ primaryIsId: true,
143+ });
144+ });
145+});
146+
147+describe("adminUserCopyableEmail", () => {
148+ it("offers the email when the host resolved one", () => {
149+ expect(adminUserCopyableEmail({ externalId: "user_01JAB", email: "ada@e2e.test" })).toBe(
150+ "ada@e2e.test",
151+ );
152+ });
153+
154+ // A name is not an address: a copy button that yields something unusable is
155+ // worse than no button, so a name-only row offers nothing to copy.
156+ it("offers nothing when only a display name is known", () => {
157+ expect(
158+ adminUserCopyableEmail({ externalId: "user_01JAB", email: null, displayName: "Ada L" }),
159+ ).toBeNull();
160+ });
161+
162+ it("treats a blank email as absent", () => {
163+ expect(adminUserCopyableEmail({ externalId: "user_01JAB", email: " " })).toBeNull();
164+ });
165+});
166+
167+describe("connectionHealthStatus", () => {
168+ it("reads the stored verdict", () => {
169+ expect(connectionHealthStatus(connection({ lastHealth: { status: "expired" } }))).toBe(
170+ "expired",
171+ );
172+ });
173+
174+ it("a never-probed connection is unchecked, not healthy", () => {
175+ expect(connectionHealthStatus(connection({ lastHealth: null }))).toBe("unknown");
176+ });
177+});
178+
179+describe("isConnectableIntegration", () => {
180+ it("a built-in integration has no connect flow to send anyone to", () => {
181+ expect(isConnectableIntegration(builtInRow)).toBe(false);
182+ // The product page pins the reserved slug as well as the kind
183+ // (`integration-detail.tsx`), so both spellings are refused.
184+ expect(isConnectableIntegration({ slug: slug("executor"), kind: "openapi" })).toBe(false);
185+ });
186+
187+ // The trap: an openapi/mcp/graphql integration can carry `authMethods: []`
188+ // and STILL be connectable, because its accounts panel supplies
189+ // `createCustomMethod`. Empty auth methods must never be read as "no connect
190+ // flow" — only `built-in` means that.
191+ it("a normal integration stays connectable regardless of declared auth methods", () => {
192+ expect(isConnectableIntegration(catalogRow("acme"))).toBe(true);
193+ expect(isConnectableIntegration(catalogRow("beta", "mcp"))).toBe(true);
194+ expect(isConnectableIntegration(catalogRow("gamma", "graphql"))).toBe(true);
195+ });
196+});
197+
198+describe("integrationConnectionStates", () => {
199+ it("shows the whole catalog, marking what this user has connected", () => {
200+ const states = integrationConnectionStates(
201+ [catalogRow("acme"), catalogRow("beta"), catalogRow("gamma")],
202+ [connection({ integration: slug("beta") })],
203+ );
204+ expect(states.map((state) => state.integration)).toEqual(["beta", "acme", "gamma"]);
205+ expect(states.map((state) => state.connected)).toEqual([true, false, false]);
206+ });
207+
208+ it("keeps an available integration listed even when nobody connected it", () => {
209+ const states = integrationConnectionStates([catalogRow("acme")], []);
210+ expect(states).toEqual([{ integration: "acme", connected: false, connections: [] }]);
211+ });
212+
213+ // The reported bug: the built-in Executor integration appeared under "not
214+ // connected" with a copyable link to a flow that does not exist. It is not a
215+ // slot, not a denominator, and not a link.
216+ it("drops a non-connectable integration from the view and the counts entirely", () => {
217+ const states = integrationConnectionStates(
218+ [catalogRow("acme"), builtInRow],
219+ [connection({ integration: slug("acme") })],
220+ );
221+ expect(states.map((state) => state.integration)).toEqual(["acme"]);
222+ });
223+
224+ it("a catalog of nothing but non-connectable integrations yields no slots at all", () => {
225+ expect(integrationConnectionStates([builtInRow], [])).toEqual([]);
226+ });
227+
228+ // Data honesty beats the filter: if a user somehow HOLDS a connection on a
229+ // non-connectable integration, hiding it would misreport what they have.
230+ it("still reports a held connection on a non-connectable integration, as connected", () => {
231+ const states = integrationConnectionStates(
232+ [catalogRow("acme"), builtInRow],
233+ [connection({ integration: slug("executor"), name: "coreTools" })],
234+ );
235+ expect(states.map((state) => state.integration)).toEqual(["executor", "acme"]);
236+ expect(states.map((state) => state.connected)).toEqual([true, false]);
237+ });
238+
239+ // Dropping the user's own credential because the catalog read missed it would
240+ // under-report what they actually hold.
241+ it("still reports a connection whose integration is missing from the catalog", () => {
242+ const states = integrationConnectionStates([], [connection({ integration: slug("ghost") })]);
243+ expect(states.map((state) => state.integration)).toEqual(["ghost"]);
244+ expect(states[0]?.connected).toBe(true);
245+ });
246+
247+ it("groups every connection a user holds on one integration", () => {
248+ const states = integrationConnectionStates(
249+ [catalogRow("acme")],
250+ [
251+ connection({ integration: slug("acme"), name: "work" }),
252+ connection({ integration: slug("acme"), name: "personal" }),
253+ ],
254+ );
255+ expect(states[0]?.connections.map((row) => row.name)).toEqual(["work", "personal"]);
256+ });
257+
258+ it("orders connected first, then alphabetically, so the order is stable per user", () => {
259+ const states = integrationConnectionStates(
260+ [catalogRow("zulu"), catalogRow("alpha"), catalogRow("mike")],
261+ [connection({ integration: slug("zulu") })],
262+ );
263+ expect(states.map((state) => state.integration)).toEqual(["zulu", "alpha", "mike"]);
264+ });
265+});
266+
267+describe("connectLinkUrl", () => {
268+ // The org segment is the SENDING admin's. A recipient in several orgs would
269+ // otherwise land in whichever their session defaults to and connect the
270+ // credential in the wrong workspace — succeeding, silently, in the wrong place.
271+ it("carries the sending admin's org so a multi-org recipient lands in the right one", () => {
272+ expect(connectLinkUrl(slug("acme"), "https://console.example.com", "north-wind")).toBe(
273+ "https://console.example.com/north-wind/connect/acme",
274+ );
275+ });
276+
277+ // Self-host and local have no orgs, so there is no segment to carry and the
278+ // bare form is the whole route.
279+ it("builds the bare form on a host with no org slug", () => {
280+ expect(connectLinkUrl(slug("acme"), "https://console.example.com")).toBe(
281+ "https://console.example.com/connect/acme",
282+ );
283+ expect(connectLinkUrl(slug("acme"), "https://console.example.com", undefined)).toBe(
284+ "https://console.example.com/connect/acme",
285+ );
286+ // An empty or whitespace slug is absence, not an empty path segment — it
287+ // must never render "//connect/acme".
288+ expect(connectLinkUrl(slug("acme"), "https://console.example.com", "")).toBe(
289+ "https://console.example.com/connect/acme",
290+ );
291+ expect(connectLinkUrl(slug("acme"), "https://console.example.com", " ")).toBe(
292+ "https://console.example.com/connect/acme",
293+ );
294+ });
295+
296+ it("does not double the separator when the origin carries a trailing slash", () => {
297+ expect(connectLinkUrl(slug("acme"), "https://console.example.com/")).toBe(
298+ "https://console.example.com/connect/acme",
299+ );
300+ expect(connectLinkUrl(slug("acme"), "https://console.example.com/", "north-wind")).toBe(
301+ "https://console.example.com/north-wind/connect/acme",
302+ );
303+ });
304+});
305+
306+describe("paging", () => {
307+ // The contract returns no total, so the page over-fetches by one row and the
308+ // extra row IS the answer to "is there a next page".
309+ it("an over-fetched page yields the page's rows plus a next-page signal", () => {
310+ expect(splitPage([1, 2, 3, 4], 3)).toEqual({ rows: [1, 2, 3], hasNext: true });
311+ });
312+
313+ it("a page that came back short is the last page", () => {
314+ expect(splitPage([1, 2], 3)).toEqual({ rows: [1, 2], hasNext: false });
315+ });
316+
317+ it("an exactly-full page without the extra row is also the last page", () => {
318+ expect(splitPage([1, 2, 3], 3)).toEqual({ rows: [1, 2, 3], hasNext: false });
319+ });
320+
321+ it("numbers pages from one", () => {
322+ expect(pageNumber(0, 25)).toBe(1);
323+ expect(pageNumber(25, 25)).toBe(2);
324+ expect(pageNumber(75, 25)).toBe(4);
325+ });
326+});
packages/react/src/lib/admin-users-display.ts+283−0
@@ -0,0 +1,283 @@
1+import type { HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared";
2+
3+// ---------------------------------------------------------------------------
4+// Pure display logic for the admin Users section.
5+//
6+// Everything here is a plain function over wire data so it can be tested
7+// without mounting the console (the package's testing doctrine: extract the
8+// logic, test the logic). The components below it only arrange the results.
9+// ---------------------------------------------------------------------------
10+
11+/** The connection shape the admin plane returns (`AdminUserConnection`), typed
12+ * structurally so these helpers don't depend on the schema module. */
13+export interface AdminConnectionRow {
14+ readonly owner: Owner;
15+ readonly integration: IntegrationSlug;
16+ readonly name: string;
17+ readonly oauthScope: string | null;
18+ readonly lastHealth: { readonly status: HealthStatus } | null;
19+}
20+
21+// ── Last seen ───────────────────────────────────────────────────────────────
22+
23+const MINUTE = 60_000;
24+const HOUR = 60 * MINUTE;
25+const DAY = 24 * HOUR;
26+
27+/**
28+ * Relative "last seen", phrased no more precisely than the field deserves.
29+ *
30+ * The writer throttles `lastSeenAt` to roughly an hour, so a sub-hour delta is
31+ * NOT evidence the user was here five minutes ago — it only means the sighting
32+ * landed within the last throttle window. Everything under an hour therefore
33+ * collapses to one honest bucket instead of counting minutes, and `null` (never
34+ * seen on a request — a user earns a row at connection-create, before any
35+ * sighting) reads as "Never", not "unknown".
36+ */
37+export const formatLastSeen = (lastSeenAt: number | null, now: number = Date.now()): string => {
38+ if (lastSeenAt === null) return "Never";
39+ const delta = now - lastSeenAt;
40+ if (delta < HOUR) return "Within the hour";
41+ if (delta < DAY) {
42+ const hours = Math.floor(delta / HOUR);
43+ return hours === 1 ? "About 1 hour ago" : `About ${hours} hours ago`;
44+ }
45+ const days = Math.floor(delta / DAY);
46+ if (days === 1) return "About 1 day ago";
47+ if (days < 30) return `About ${days} days ago`;
48+ return new Intl.DateTimeFormat(undefined, {
49+ month: "short",
50+ day: "numeric",
51+ year: "numeric",
52+ }).format(new Date(lastSeenAt));
53+};
54+
55+/** The tooltip behind {@link formatLastSeen}: the exact recorded instant plus
56+ * the coarseness caveat, so an operator reading "Within the hour" can see what
57+ * the row actually stores. */
58+export const lastSeenTitle = (lastSeenAt: number | null): string =>
59+ lastSeenAt === null
60+ ? "Never seen on a request"
61+ : `Recorded ${new Date(lastSeenAt).toLocaleString()} — updated at most once an hour`;
62+
63+/** Absolute date, for the created column. */
64+export const formatAdminDate = (epochMs: number): string =>
65+ new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(
66+ new Date(epochMs),
67+ );
68+
69+// ── Identity ────────────────────────────────────────────────────────────────
70+
71+/**
72+ * How a user's opaque `externalId` is shown.
73+ *
74+ * The id is opaque by contract — nothing may parse it — so it is rendered whole,
75+ * in mono, and only visually shortened when it is long enough to break the
76+ * column. The full value stays available via the title attribute and the copy
77+ * button. `"local"` is a host sentinel for the single-player subject and reads
78+ * better spelled out.
79+ */
80+export const LOCAL_SUBJECT_ID = "local";
81+
82+export const isLocalSubject = (externalId: string): boolean => externalId === LOCAL_SUBJECT_ID;
83+
84+/** Visual truncation only — never a parse. Keeps both ends so two ids that
85+ * differ in the middle stay distinguishable. */
86+export const shortenExternalId = (externalId: string, max = 28): string => {
87+ if (externalId.length <= max) return externalId;
88+ const head = Math.ceil((max - 1) / 2);
89+ const tail = Math.floor((max - 1) / 2);
90+ return `${externalId.slice(0, head)}…${externalId.slice(externalId.length - tail)}`;
91+};
92+
93+/** The identity half of a user row, as the admin plane reports it. Both fields
94+ * are independently nullable: the host resolved one, the other, or neither. */
95+export interface AdminUserIdentityRow {
96+ readonly externalId: string;
97+ readonly email?: string | null;
98+ readonly displayName?: string | null;
99+}
100+
101+/**
102+ * How one user is titled and subtitled.
103+ *
104+ * The primary line is whatever an operator would actually recognize — the email
105+ * first, since that is what they invite, search and support against; the
106+ * display name only when there is no email; and the id itself when the host
107+ * resolved neither. The secondary line carries the `externalId` whenever the
108+ * primary line is NOT already the id, so the opaque principal is always one
109+ * glance away without ever being the headline for a user who has a name.
110+ *
111+ * When identity is absent the result is exactly the pre-identity rendering — one
112+ * mono id line and no subtitle — so an unresolvable row (a member who left the
113+ * org while their connections remain, a host sentinel like "local", a directory
114+ * outage) degrades to what this table always showed rather than to a gap.
115+ *
116+ * `primaryIsId` is what tells the component which line gets mono/metadata
117+ * styling, so the caller never has to re-derive the same decision.
118+ */
119+export interface AdminUserTitle {
120+ readonly primary: string;
121+ readonly secondary: string | null;
122+ readonly primaryIsId: boolean;
123+}
124+
125+export const adminUserTitle = (user: AdminUserIdentityRow): AdminUserTitle => {
126+ const email = user.email?.trim();
127+ const displayName = user.displayName?.trim();
128+ const idLabel = isLocalSubject(user.externalId) ? LOCAL_SUBJECT_ID : user.externalId;
129+ const named = email || displayName;
130+ if (!named) return { primary: idLabel, secondary: null, primaryIsId: true };
131+ return { primary: named, secondary: idLabel, primaryIsId: false };
132+};
133+
134+/** The email an operator can copy off a row, or `null` when the host resolved
135+ * none. Deliberately NOT the display name: a name is not an address, and a
136+ * copy button that yields something unusable is worse than no button. */
137+export const adminUserCopyableEmail = (user: AdminUserIdentityRow): string | null => {
138+ const email = user.email?.trim();
139+ return email ? email : null;
140+};
141+
142+// ── Connections ─────────────────────────────────────────────────────────────
143+
144+/** The health status a row displays. A connection that was never probed carries
145+ * no verdict, which is `unknown` in the shared vocabulary. */
146+export const connectionHealthStatus = (connection: AdminConnectionRow): HealthStatus =>
147+ connection.lastHealth?.status ?? "unknown";
148+
149+/** The catalog row this view needs: the slug it marks, plus the `kind` that
150+ * says whether connecting is even a thing one can do to it. Structural so
151+ * these helpers stay independent of the catalog schema module. */
152+export interface AdminCatalogRow {
153+ readonly slug: IntegrationSlug;
154+ readonly kind: string;
155+}
156+
157+/** The built-in integration's reserved slug. `kind` is the general signal, but
158+ * the product page pins the slug too, so this mirrors it exactly. */
159+const BUILT_IN_INTEGRATION_SLUG = "executor";
160+
161+/**
162+ * Whether an integration has a connect flow at all.
163+ *
164+ * Deliberately NOT "declares no auth methods": an openapi/mcp/graphql
165+ * integration can carry `authMethods: []` and still be connectable, because its
166+ * accounts panel passes `createCustomMethod` and the user defines the method
167+ * themselves (`accounts-section.tsx`: `canAddConnection = methods.length > 0 ||
168+ * createCustomMethod !== undefined`). Empty auth methods therefore means "no
169+ * method declared yet", not "cannot be connected".
170+ *
171+ * The real gate is the one the product's integration detail page applies before
172+ * any of that — `integration-detail.tsx`: `isBuiltInIntegration = namespace ===
173+ * "executor" || integrationData?.kind === "built-in"`, which removes the whole
174+ * Accounts tab rather than merely disabling a button. A built-in integration is
175+ * a plugin-declared static source with a synthetic connection and no
176+ * credential: there is nothing to connect and no flow to send anyone to.
177+ */
178+export const isConnectableIntegration = (row: AdminCatalogRow): boolean =>
179+ row.kind !== "built-in" && String(row.slug) !== BUILT_IN_INTEGRATION_SLUG;
180+
181+/**
182+ * The available-vs-connected view for one user: every *connectable* integration
183+ * in the tenant's catalog, marked with whether this user has connected it.
184+ *
185+ * "Available" is the catalog, so an integration nobody has connected still gets
186+ * a (greyed) slot — that absence is the point of the view. Sorted connected
187+ * first so a dense grid leads with signal, then by slug for a stable order
188+ * across users.
189+ *
190+ * Non-connectable integrations are dropped entirely — from the slots, from the
191+ * counts, and from the connect links. Listing the built-in under "not
192+ * connected" would invite an admin to send a link to a flow that doesn't exist.
193+ * The one exception is data honesty: if a user somehow HOLDS a connection on
194+ * one, it is reported as connected rather than hidden.
195+ *
196+ * Org-owned connections are deliberately excluded from the API's per-user
197+ * results, so anything appearing here is genuinely this user's own.
198+ */
199+export interface IntegrationConnectionState {
200+ readonly integration: IntegrationSlug;
201+ readonly connected: boolean;
202+ readonly connections: readonly AdminConnectionRow[];
203+}
204+
205+export const integrationConnectionStates = (
206+ catalog: readonly AdminCatalogRow[],
207+ connections: readonly AdminConnectionRow[],
208+): readonly IntegrationConnectionState[] => {
209+ const byIntegration = new Map<IntegrationSlug, AdminConnectionRow[]>();
210+ for (const connection of connections) {
211+ const existing = byIntegration.get(connection.integration);
212+ if (existing) existing.push(connection);
213+ else byIntegration.set(connection.integration, [connection]);
214+ }
215+ // A user can hold a connection on an integration the catalog read didn't
216+ // return (a catalog the viewer can't see, or one removed since). Showing it
217+ // as connected is more honest than dropping the user's own credential — and
218+ // an unknown slug is assumed connectable, since only the catalog can say
219+ // otherwise.
220+ const slugs = new Set<IntegrationSlug>([
221+ ...catalog.filter(isConnectableIntegration).map((row) => row.slug),
222+ ...byIntegration.keys(),
223+ ]);
224+ return [...slugs]
225+ .map((integration) => ({
226+ integration,
227+ connected: byIntegration.has(integration),
228+ connections: byIntegration.get(integration) ?? [],
229+ }))
230+ .sort((a, b) => {
231+ if (a.connected !== b.connected) return a.connected ? -1 : 1;
232+ return a.integration.localeCompare(b.integration);
233+ });
234+};
235+
236+// ── Connect links ───────────────────────────────────────────────────────────
237+
238+/**
239+ * The connect deep link for an integration, as an absolute URL for THIS
240+ * deployment — something an admin can paste to a user who needs to connect.
241+ *
242+ * The org segment is load-bearing and must be the SENDING admin's org. A
243+ * recipient who belongs to several orgs would otherwise land in whichever one
244+ * their session defaults to and connect the credential in the wrong workspace —
245+ * silently, since the flow itself would succeed. The admin page is org-scoped,
246+ * so the correct org is known at the call site and is carried in the link
247+ * rather than left to the recipient's session to guess.
248+ *
249+ * The slug is optional because the route's `{-$orgSlug}` segment is: a host that
250+ * renders no segment yields the bare `/connect/<slug>` form, which is the whole
251+ * route there. (Both cloud and self-host do canonicalize onto a slug in
252+ * practice — self-host onto `default` — so the prefixed form is the common
253+ * case.) It is rendered as a copyable URL rather than an in-console link
254+ * because it targets the recipient's session, not the admin's.
255+ */
256+export const connectLinkUrl = (
257+ integration: IntegrationSlug,
258+ origin: string,
259+ orgSlug?: string | undefined,
260+): string => {
261+ const base = origin.replace(/\/+$/, "");
262+ const org = orgSlug?.trim();
263+ return org ? `${base}/${org}/connect/${integration}` : `${base}/connect/${integration}`;
264+};
265+
266+// ── Paging ──────────────────────────────────────────────────────────────────
267+
268+/**
269+ * Split an over-fetched page into the rows to render plus whether another page
270+ * exists. The contract returns no total count, so "is there a next page" is
271+ * answered by asking for one row more than the page holds.
272+ */
273+export const splitPage = <A>(
274+ rows: readonly A[],
275+ pageSize: number,
276+): { readonly rows: readonly A[]; readonly hasNext: boolean } => ({
277+ rows: rows.slice(0, pageSize),
278+ hasNext: rows.length > pageSize,
279+});
280+
281+/** The 1-based page number an offset lands on, for the mono pager label. */
282+export const pageNumber = (offset: number, pageSize: number): number =>
283+ Math.floor(offset / pageSize) + 1;
packages/react/src/multiplayer/use-admin-nav.tsx+53−0
@@ -0,0 +1,53 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3+
4+import { orgMembersAtom } from "../api/account-atoms";
5+import { isTenantAdminMember, type TenantMemberRow } from "../lib/admin-access";
6+import type { ShellNavItem } from "./shell";
7+
8+// ---------------------------------------------------------------------------
9+// Admin-only nav items.
10+//
11+// The console has no role on the session (`/account/me` returns a user and an
12+// organization, no role), so the current member's role is read off the member
13+// list — the same derivation cloud's org page already uses for its danger zone.
14+// Hosts pass their admin-only entries through this hook, which drops them for
15+// anyone the list doesn't show as an active admin/owner.
16+//
17+// This hides a section; it does not protect one. The page behind each item
18+// renders a denied state off the server's own 401/403, which is the gate that
19+// matters. Failing closed here is deliberate: while the member list is loading
20+// or unavailable, showing nothing is better than flashing a section that will
21+// refuse.
22+//
23+// That fail-closed default is also what makes this hook SSR-safe. `orgMembersAtom`
24+// carries an explicit server value (see `api/account-atoms`), so on the server it
25+// reads as initial without evaluating — the role is simply UNKNOWN during SSR,
26+// which lands on `false` here and resolves client-side once the list arrives.
27+// The hook must never suspend or throw: it runs above the routed outlet, so
28+// anything it raises takes down the whole server-rendered document.
29+// ---------------------------------------------------------------------------
30+
31+/** Whether the current member reads as an admin/owner of this tenant. */
32+export const useIsTenantAdmin = (): boolean => {
33+ const members = useAtomValue(orgMembersAtom);
34+ return AsyncResult.match(members, {
35+ onInitial: () => false,
36+ onFailure: () => false,
37+ onSuccess: ({ value }) => isTenantAdminMember(value.members as readonly TenantMemberRow[]),
38+ });
39+};
40+
41+/**
42+ * Append admin-only nav items to a host's nav, for admins only.
43+ *
44+ * Takes both lists so hosts keep one nav definition and the ordering stays
45+ * theirs — the admin entries land wherever the host places the call.
46+ */
47+export const useAdminNavItems = (
48+ base: ReadonlyArray<ShellNavItem>,
49+ adminOnly: ReadonlyArray<ShellNavItem>,
50+): ReadonlyArray<ShellNavItem> => {
51+ const isAdmin = useIsTenantAdmin();
52+ return isAdmin ? [...base, ...adminOnly] : base;
53+};
packages/react/src/pages/admin-users.tsx+692−0
@@ -0,0 +1,692 @@
1+import { useState } from "react";
2+import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
3+import { useParams } from "@tanstack/react-router";
4+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5+import * as Cause from "effect/Cause";
6+import * as Option from "effect/Option";
7+import * as Predicate from "effect/Predicate";
8+import type { HealthStatus, Integration, IntegrationSlug } from "@executor-js/sdk/shared";
9+import { useIntegrationPlugins } from "@executor-js/sdk/client";
10+
11+import {
12+ ADMIN_USERS_PAGE_SIZE,
13+ adminUserConnectionsAtom,
14+ adminUsersWithConnectionsAtom,
15+} from "../api/admin-atoms";
16+import { integrationsOptimisticAtom } from "../api/atoms";
17+import { ownerLabel } from "../api/owner-display";
18+import { Button } from "../components/button";
19+import { CopyButton } from "../components/copy-button";
20+import { ErrorState } from "../components/error-state";
21+import {
22+ IntegrationFavicon,
23+ integrationInferredUrl,
24+ integrationPresetIconUrl,
25+} from "../components/integration-favicon";
26+import { PageContainer, PageHeader } from "../components/page";
27+import {
28+ Sheet,
29+ SheetContent,
30+ SheetDescription,
31+ SheetHeader,
32+ SheetTitle,
33+} from "../components/sheet";
34+import { Skeleton } from "../components/skeleton";
35+import {
36+ adminUserCopyableEmail,
37+ adminUserTitle,
38+ connectionHealthStatus,
39+ connectLinkUrl,
40+ formatAdminDate,
41+ formatLastSeen,
42+ integrationConnectionStates,
43+ lastSeenTitle,
44+ pageNumber,
45+ shortenExternalId,
46+ splitPage,
47+ type AdminCatalogRow,
48+ type AdminConnectionRow,
49+ type AdminUserIdentityRow,
50+} from "../lib/admin-users-display";
51+import {
52+ HEALTH_INDICATOR_COLOR,
53+ HEALTH_STATUS_LABEL,
54+ HEALTH_TEXT_CLASS,
55+} from "../lib/health-display";
56+import { isAsyncResultLoading } from "../lib/async-result";
57+import { useExecutorDocumentTitle } from "../lib/document-title";
58+
59+// ---------------------------------------------------------------------------
60+// Admin · Users — the tenant-wide operator view.
61+//
62+// The product plane answers "what can I reach"; this page answers the owner's
63+// question: who are my users, and what has each of them connected. It reads the
64+// admin plane only (`/admin/users*`), which is read-only by construction, so
65+// there is nothing to mutate here — every affordance is a read or a copy.
66+//
67+// ACCESS: the server is the gate. A plain member's request comes back 403 (401
68+// with no session), and the page renders an explicit denial rather than an
69+// empty table, so "you may not see this" never reads as "there is nobody here".
70+// The nav item is separately hidden from non-admins (see lib/admin-access), but
71+// that is convenience — this is the surface that actually refuses.
72+//
73+// The row is a button rather than a link: a user has no page of its own, and a
74+// detail sheet keeps the list's paging position while an operator scans several
75+// users in a row.
76+// ---------------------------------------------------------------------------
77+
78+type AdminUserRow = {
79+ readonly externalId: string;
80+ readonly createdAt: number;
81+ readonly lastSeenAt: number | null;
82+ readonly status: string | null;
83+ /** Joined server-side from the host's member directory. Null whenever the
84+ * host couldn't resolve the id — see `adminUserTitle` for what that renders
85+ * as. */
86+ readonly email: string | null;
87+ readonly displayName: string | null;
88+ readonly connections: readonly AdminConnectionRow[];
89+};
90+
91+/** 401/403 from the admin plane. Both mean "not an operator here" and get the
92+ * same denial: the distinction (no session vs wrong role) is the server's, and
93+ * a signed-in member reading "unauthorized" would only confuse. */
94+const isAccessDenied = (cause: Cause.Cause<unknown>): boolean =>
95+ Option.match(Cause.findErrorOption(cause), {
96+ onNone: () => false,
97+ onSome: (error) =>
98+ Predicate.isTagged(error, "AdminUsersForbidden") ||
99+ Predicate.isTagged(error, "AdminUsersUnauthorized"),
100+ });
101+
102+// ── Access denied ───────────────────────────────────────────────────────────
103+
104+function AccessDenied() {
105+ return (
106+ <div className="rounded-lg border border-border bg-card p-8">
107+ <p className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
108+ Admin only
109+ </p>
110+ <h2 className="mt-2 text-base font-semibold text-foreground">
111+ You don&apos;t have access to this workspace&apos;s users
112+ </h2>
113+ <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
114+ Reading every user and what they&apos;ve connected requires an admin role. Ask an admin of
115+ this workspace if you need it.
116+ </p>
117+ </div>
118+ );
119+}
120+
121+// ── Health ──────────────────────────────────────────────────────────────────
122+
123+/** The shared health vocabulary, rendered from a stored verdict.
124+ *
125+ * Deliberately NOT `IntegrationHealthSummary`: that component probes upstream
126+ * on mount through the product plane, which is both the wrong plane here (an
127+ * admin is not the credential's owner) and the wrong behavior (opening an
128+ * admin view must not fire traffic at every user's provider). This reads the
129+ * `lastHealth` the admin payload already carries. */
130+function HealthIndicator(props: { readonly status: HealthStatus }) {
131+ const label = HEALTH_STATUS_LABEL[props.status];
132+ return (
133+ <span className="flex shrink-0 items-center gap-1.5" title={`Status: ${label}`}>
134+ <span
135+ className={`font-mono text-[11px] font-medium uppercase tracking-[0.08em] ${HEALTH_TEXT_CLASS[props.status]}`}
136+ >
137+ {label}
138+ </span>
139+ <span
140+ aria-label={`Status: ${label}`}
141+ className={`size-2 rounded-full ${HEALTH_INDICATOR_COLOR[props.status].dot}`}
142+ />
143+ </span>
144+ );
145+}
146+
147+// ── Integration icons ───────────────────────────────────────────────────────
148+
149+/** The catalog joined into a favicon lookup. The admin payload carries only a
150+ * slug per connection (no kind, no display URL — the platform view hands out
151+ * nothing it doesn't have to), so brand marks come from the tenant's own
152+ * integration catalog, which the admin can read on the product plane. */
153+type IconLookup = (integration: IntegrationSlug) => {
154+ readonly icon: string | null;
155+ readonly url: string | undefined;
156+ readonly name: string;
157+};
158+
159+const useIntegrationIcons = (): IconLookup => {
160+ const catalog = useAtomValue(integrationsOptimisticAtom);
161+ const integrationPlugins = useIntegrationPlugins();
162+ const integrations = Option.getOrElse(
163+ AsyncResult.value(catalog),
164+ (): readonly Integration[] => [],
165+ );
166+
167+ return (integration) => {
168+ const slug = String(integration);
169+ const found = integrations.find((row) => String(row.slug) === slug);
170+ const name = found?.name || slug;
171+ return {
172+ icon: found
173+ ? integrationPresetIconUrl(
174+ { id: slug, kind: found.kind, name, url: found.displayUrl },
175+ integrationPlugins,
176+ )
177+ : null,
178+ url: found?.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined,
179+ name,
180+ };
181+ };
182+};
183+
184+/** The catalog rows this view needs — slug plus kind, since `kind` is what says
185+ * whether an integration can be connected at all (see
186+ * `isConnectableIntegration`). Empty while the catalog loads, which degrades to
187+ * "connected only" rather than blocking the users table on a second request. */
188+const useCatalogRows = (): readonly AdminCatalogRow[] => {
189+ const catalog = useAtomValue(integrationsOptimisticAtom);
190+ return Option.match(AsyncResult.value(catalog), {
191+ onNone: (): readonly AdminCatalogRow[] => [],
192+ onSome: (integrations: readonly Integration[]) =>
193+ integrations.map((row) => ({ slug: row.slug, kind: row.kind })),
194+ });
195+};
196+
197+/**
198+ * The connection summary: one slot per integration in the catalog, lit when
199+ * this user has connected it and greyed when they haven't.
200+ *
201+ * Showing the not-connected slots is the point — the grid answers "what is
202+ * available and what has this person actually set up", which a list of only
203+ * their connections cannot.
204+ */
205+function ConnectionGrid(props: {
206+ readonly catalog: readonly AdminCatalogRow[];
207+ readonly connections: readonly AdminConnectionRow[];
208+ readonly icons: IconLookup;
209+}) {
210+ const states = integrationConnectionStates(props.catalog, props.connections);
211+ if (states.length === 0) {
212+ return <span className="font-mono text-[11px] text-muted-foreground">No integrations</span>;
213+ }
214+
215+ const connectedCount = states.filter((state) => state.connected).length;
216+
217+ return (
218+ <div className="flex min-w-0 items-center gap-1.5">
219+ <div className="flex min-w-0 flex-wrap items-center gap-1">
220+ {states.slice(0, 8).map((state) => {
221+ const { icon, url, name } = props.icons(state.integration);
222+ return (
223+ <span
224+ key={state.integration}
225+ // State on data-* (the repo convention) so the connected/not-
226+ // connected distinction is readable without parsing classes.
227+ data-slot="admin-user-integration"
228+ data-integration={String(state.integration)}
229+ data-connected={state.connected ? "true" : "false"}
230+ title={`${name} — ${state.connected ? "connected" : "not connected"}`}
231+ className={
232+ state.connected
233+ ? "flex size-5 items-center justify-center"
234+ : "flex size-5 items-center justify-center opacity-25 grayscale"
235+ }
236+ >
237+ <IntegrationFavicon
238+ icon={icon}
239+ integrationId={String(state.integration)}
240+ url={url}
241+ size={16}
242+ />
243+ </span>
244+ );
245+ })}
246+ </div>
247+ <span
248+ data-slot="admin-user-connection-count"
249+ className="shrink-0 font-mono text-[11px] text-muted-foreground"
250+ title={`${connectedCount} of ${states.length} integrations connected`}
251+ >
252+ {connectedCount}/{states.length}
253+ </span>
254+ </div>
255+ );
256+}
257+
258+// ── Identity ────────────────────────────────────────────────────────────────
259+
260+/**
261+ * The user cell: who this is, then the opaque id underneath.
262+ *
263+ * The host joins email/name onto each row server-side, so the headline is
264+ * something an operator recognizes and the `externalId` becomes the supporting
265+ * line — still mono, still carrying the full value in its title, still under the
266+ * same `data-slot` it always had, because the id is what identifies a row to
267+ * anything reading this table. When the host resolved no identity the cell
268+ * collapses back to exactly the single mono id line this table has always shown.
269+ *
270+ * NO copy button lives here: the row itself is a `<button>` (a user has no page
271+ * of its own, so the row opens a detail sheet), and a button inside a button is
272+ * invalid markup that swallows its own clicks. The copy affordances live one
273+ * click away in the sheet header, on the same values.
274+ */
275+function UserIdentityCell(props: { readonly user: AdminUserIdentityRow }) {
276+ const title = adminUserTitle(props.user);
277+
278+ if (title.primaryIsId) {
279+ return (
280+ <span
281+ data-slot="admin-user-id"
282+ className="min-w-0 truncate font-mono text-sm text-foreground"
283+ title={props.user.externalId}
284+ >
285+ {shortenExternalId(title.primary)}
286+ </span>
287+ );
288+ }
289+
290+ return (
291+ <span className="flex min-w-0 flex-col gap-0.5">
292+ <span
293+ data-slot="admin-user-name"
294+ className="truncate text-sm text-foreground"
295+ title={title.primary}
296+ >
297+ {title.primary}
298+ </span>
299+ <span
300+ data-slot="admin-user-id"
301+ className="truncate font-mono text-[11px] text-muted-foreground"
302+ title={props.user.externalId}
303+ >
304+ {shortenExternalId(props.user.externalId)}
305+ </span>
306+ </span>
307+ );
308+}
309+
310+/**
311+ * The sheet header: the same title treatment as the row, plus the copy
312+ * affordances the row can't carry.
313+ *
314+ * Two separate buttons rather than one, because they yield different things and
315+ * an operator wants a specific one: the email is what they paste into a mail
316+ * client or a support ticket, and the `externalId` is what they paste into a log
317+ * search or an API call. A single "copy" would force them to guess which.
318+ *
319+ * The email button is absent — not disabled — when the host resolved no email,
320+ * since there is nothing to put on the clipboard. The id button is always
321+ * present, whether the id is the headline (no identity resolved) or the
322+ * supporting line: every user has an id by definition, and it is the value that
323+ * identifies them to logs and to the API.
324+ */
325+function UserDetailTitle(props: { readonly user: AdminUserRow }) {
326+ const title = adminUserTitle(props.user);
327+ const email = adminUserCopyableEmail(props.user);
328+
329+ return (
330+ <div className="flex min-w-0 flex-col gap-1">
331+ <div className="flex min-w-0 items-center gap-1">
332+ <SheetTitle
333+ data-slot="admin-user-detail-title"
334+ className={
335+ title.primaryIsId ? "truncate font-mono text-sm" : "truncate text-sm font-semibold"
336+ }
337+ title={title.primary}
338+ >
339+ {title.primary}
340+ </SheetTitle>
341+ {email ? (
342+ <CopyButton value={email} label="Copy email" />
343+ ) : (
344+ title.primaryIsId && <CopyButton value={props.user.externalId} label="Copy ID" />
345+ )}
346+ </div>
347+ {title.secondary !== null && (
348+ <div className="flex min-w-0 items-center gap-1">
349+ <span
350+ data-slot="admin-user-detail-id"
351+ className="truncate font-mono text-[11px] text-muted-foreground"
352+ title={props.user.externalId}
353+ >
354+ {props.user.externalId}
355+ </span>
356+ <CopyButton value={props.user.externalId} label="Copy ID" />
357+ </div>
358+ )}
359+ </div>
360+ );
361+}
362+
363+// ── Detail sheet ────────────────────────────────────────────────────────────
364+
365+function UserDetail(props: {
366+ readonly user: AdminUserRow;
367+ readonly catalog: readonly AdminCatalogRow[];
368+ readonly icons: IconLookup;
369+ readonly orgSlug: string | undefined;
370+}) {
371+ const result = useAtomValue(adminUserConnectionsAtom(props.user.externalId));
372+ const refresh = useAtomRefresh(adminUserConnectionsAtom(props.user.externalId));
373+ // The connect link targets the recipient's own session, so it is built for
374+ // this deployment's origin and copied out — never navigated to from here. It
375+ // carries THIS admin's org so a multi-org recipient connects in the workspace
376+ // the link came from, not whichever one their session defaults to.
377+ const origin = globalThis.window?.location?.origin ?? "";
378+
379+ if (isAsyncResultLoading(result)) {
380+ return (
381+ <div className="flex flex-col gap-2 p-6">
382+ {[0, 1, 2].map((row) => (
383+ <Skeleton key={row} className="h-12" />
384+ ))}
385+ </div>
386+ );
387+ }
388+
389+ return AsyncResult.match(result, {
390+ onInitial: () => (
391+ <div className="flex flex-col gap-2 p-6">
392+ {[0, 1, 2].map((row) => (
393+ <Skeleton key={row} className="h-12" />
394+ ))}
395+ </div>
396+ ),
397+ onFailure: (failure) =>
398+ isAccessDenied(failure.cause) ? (
399+ <div className="p-6">
400+ <AccessDenied />
401+ </div>
402+ ) : (
403+ <div className="p-6">
404+ <ErrorState message="Couldn't load this user's connections" onRetry={refresh} />
405+ </div>
406+ ),
407+ onSuccess: ({ value }) => {
408+ const states = integrationConnectionStates(props.catalog, value.connections);
409+ const connected = states.filter((state) => state.connected);
410+ const available = states.filter((state) => !state.connected);
411+
412+ return (
413+ <div className="flex flex-col gap-8 overflow-y-auto p-6">
414+ <section>
415+ <h3 className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
416+ Connected · {connected.length}
417+ </h3>
418+ {connected.length === 0 ? (
419+ <p className="mt-3 text-sm leading-6 text-muted-foreground">
420+ This user hasn&apos;t connected anything yet. Send them a connect link below to get
421+ started.
422+ </p>
423+ ) : (
424+ <div className="mt-3 overflow-hidden rounded-lg border border-border bg-card">
425+ {connected.flatMap((state) =>
426+ state.connections.map((connection) => {
427+ const { icon, url, name } = props.icons(connection.integration);
428+ return (
429+ <div
430+ key={`${connection.integration}/${connection.owner}/${connection.name}`}
431+ className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-b-0"
432+ >
433+ <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center">
434+ <IntegrationFavicon
435+ icon={icon}
436+ integrationId={String(connection.integration)}
437+ url={url}
438+ size={16}
439+ />
440+ </span>
441+ <div className="min-w-0 flex-1">
442+ <div className="flex items-center gap-2">
443+ <p className="truncate text-sm font-medium text-foreground">{name}</p>
444+ <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
445+ {ownerLabel(connection.owner)}
446+ </span>
447+ </div>
448+ <p className="mt-1 truncate font-mono text-xs text-muted-foreground">
449+ {connection.name}
450+ </p>
451+ {connection.oauthScope ? (
452+ <p
453+ className="mt-1.5 break-words font-mono text-[11px] leading-5 text-muted-foreground"
454+ title={connection.oauthScope}
455+ >
456+ {connection.oauthScope}
457+ </p>
458+ ) : (
459+ <p className="mt-1.5 font-mono text-[11px] text-muted-foreground">
460+ No OAuth scope — static credential
461+ </p>
462+ )}
463+ </div>
464+ <HealthIndicator status={connectionHealthStatus(connection)} />
465+ </div>
466+ );
467+ }),
468+ )}
469+ </div>
470+ )}
471+ </section>
472+
473+ <section>
474+ <h3 className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
475+ Not connected · {available.length}
476+ </h3>
477+ <p className="mt-2 text-sm leading-6 text-muted-foreground">
478+ Copy a link and send it to this user — it opens the connect flow in their own session.
479+ </p>
480+ {available.length === 0 ? (
481+ <p className="mt-3 text-sm leading-6 text-muted-foreground">
482+ This user has connected every integration in the catalog.
483+ </p>
484+ ) : (
485+ <div className="mt-3 overflow-hidden rounded-lg border border-border bg-card">
486+ {available.map((state) => {
487+ const { icon, url, name } = props.icons(state.integration);
488+ const link = connectLinkUrl(state.integration, origin, props.orgSlug);
489+ return (
490+ <div
491+ key={state.integration}
492+ className="flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0"
493+ >
494+ <span className="flex size-5 shrink-0 items-center justify-center opacity-40 grayscale">
495+ <IntegrationFavicon
496+ icon={icon}
497+ integrationId={String(state.integration)}
498+ url={url}
499+ size={16}
500+ />
501+ </span>
502+ <div className="min-w-0 flex-1">
503+ <p className="truncate text-sm text-foreground">{name}</p>
504+ <p
505+ data-slot="admin-user-connect-link"
506+ className="truncate font-mono text-[11px] text-muted-foreground"
507+ >
508+ {link}
509+ </p>
510+ </div>
511+ <CopyButton value={link} label="Copy link" />
512+ </div>
513+ );
514+ })}
515+ </div>
516+ )}
517+ </section>
518+ </div>
519+ );
520+ },
521+ });
522+}
523+
524+// ── Page ────────────────────────────────────────────────────────────────────
525+
526+export function AdminUsersPage() {
527+ useExecutorDocumentTitle("Users");
528+ const [offset, setOffset] = useState(0);
529+ const [selected, setSelected] = useState<AdminUserRow | null>(null);
530+
531+ const page = { limit: ADMIN_USERS_PAGE_SIZE, offset };
532+ const result = useAtomValue(adminUsersWithConnectionsAtom(page));
533+ const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page));
534+ const catalog = useCatalogRows();
535+ const icons = useIntegrationIcons();
536+ // The route is `/{-$orgSlug}/users`: the segment is OPTIONAL, so this reads
537+ // `undefined` on any host that renders no segment and the link falls back to
538+ // the bare form. Both cloud and self-host do canonicalize onto a slug (self-
539+ // host onto `default`), so in practice the prefixed form is what ships — but
540+ // the optional param is the contract, not an assumption about the host.
541+ const { orgSlug } = useParams({ strict: false }) as { orgSlug?: string };
542+
543+ const header = (
544+ <PageHeader
545+ title="Users"
546+ description="Everyone who has used this workspace, and what each of them has connected."
547+ />
548+ );
549+
550+ const loading = (
551+ <div className="flex flex-col gap-2">
552+ {[0, 1, 2, 3].map((row) => (
553+ <Skeleton key={row} className="h-14" />
554+ ))}
555+ </div>
556+ );
557+
558+ return (
559+ <PageContainer>
560+ {header}
561+
562+ {isAsyncResultLoading(result)
563+ ? loading
564+ : AsyncResult.match(result, {
565+ onInitial: () => loading,
566+ onFailure: (failure) =>
567+ isAccessDenied(failure.cause) ? (
568+ <AccessDenied />
569+ ) : (
570+ <ErrorState message="Couldn't load users" onRetry={refresh} />
571+ ),
572+ onSuccess: ({ value }) => {
573+ const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE);
574+
575+ if (rows.length === 0) {
576+ return (
577+ <div className="rounded-lg border border-dashed border-border bg-card p-8">
578+ <h2 className="text-base font-semibold text-foreground">
579+ {offset === 0 ? "No users yet" : "No users on this page"}
580+ </h2>
581+ <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
582+ {offset === 0
583+ ? "A user appears here the first time they reach this workspace or connect an account."
584+ : "Go back a page to see this workspace's users."}
585+ </p>
586+ {offset > 0 && (
587+ <Button
588+ variant="outline"
589+ size="sm"
590+ className="mt-4"
591+ onClick={() => setOffset(Math.max(0, offset - ADMIN_USERS_PAGE_SIZE))}
592+ >
593+ Previous
594+ </Button>
595+ )}
596+ </div>
597+ );
598+ }
599+
600+ return (
601+ <>
602+ <div
603+ className="overflow-hidden rounded-lg border border-border bg-card"
604+ data-slot="admin-users-table"
605+ >
606+ <div className="grid grid-cols-[1fr_auto] gap-4 border-b border-border px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground md:grid-cols-[1.6fr_0.8fr_0.9fr_1.1fr]">
607+ <span>User</span>
608+ <span className="hidden md:block">Created</span>
609+ <span className="hidden md:block">Last seen</span>
610+ <span className="text-right md:text-left">Connections</span>
611+ </div>
612+ {rows.map((user: AdminUserRow) => (
613+ // oxlint-disable-next-line react/forbid-elements
614+ <button
615+ key={user.externalId}
616+ type="button"
617+ onClick={() => setSelected(user)}
618+ data-slot="admin-user-row"
619+ className="grid w-full grid-cols-[1fr_auto] items-center gap-4 border-b border-border px-4 py-4 text-left transition-colors last:border-b-0 hover:bg-accent md:grid-cols-[1.6fr_0.8fr_0.9fr_1.1fr]"
620+ >
621+ <UserIdentityCell user={user} />
622+ <span className="hidden font-mono text-xs text-muted-foreground md:block">
623+ {formatAdminDate(user.createdAt)}
624+ </span>
625+ <span
626+ className="hidden font-mono text-xs text-muted-foreground md:block"
627+ title={lastSeenTitle(user.lastSeenAt)}
628+ >
629+ {formatLastSeen(user.lastSeenAt)}
630+ </span>
631+ <span className="justify-self-end md:justify-self-start">
632+ <ConnectionGrid
633+ catalog={catalog}
634+ connections={user.connections}
635+ icons={icons}
636+ />
637+ </span>
638+ </button>
639+ ))}
640+ </div>
641+
642+ {(hasNext || offset > 0) && (
643+ <div className="mt-4 flex items-center justify-between">
644+ <span className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
645+ Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)}
646+ </span>
647+ <div className="flex items-center gap-2">
648+ <Button
649+ variant="outline"
650+ size="sm"
651+ disabled={offset === 0}
652+ onClick={() => setOffset(Math.max(0, offset - ADMIN_USERS_PAGE_SIZE))}
653+ >
654+ Previous
655+ </Button>
656+ <Button
657+ variant="outline"
658+ size="sm"
659+ disabled={!hasNext}
660+ onClick={() => setOffset(offset + ADMIN_USERS_PAGE_SIZE)}
661+ >
662+ Next
663+ </Button>
664+ </div>
665+ </div>
666+ )}
667+ </>
668+ );
669+ },
670+ })}
671+
672+ <Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
673+ <SheetContent className="w-full gap-0 p-0 sm:max-w-xl">
674+ {selected && (
675+ <>
676+ <SheetHeader className="border-b border-border">
677+ <UserDetailTitle user={selected} />
678+ <SheetDescription>
679+ Created {formatAdminDate(selected.createdAt)} · last seen{" "}
680+ <span title={lastSeenTitle(selected.lastSeenAt)}>
681+ {formatLastSeen(selected.lastSeenAt).toLowerCase()}
682+ </span>
683+ </SheetDescription>
684+ </SheetHeader>
685+ <UserDetail user={selected} catalog={catalog} icons={icons} orgSlug={orgSlug} />
686+ </>
687+ )}
688+ </SheetContent>
689+ </Sheet>
690+ </PageContainer>
691+ );
692+}
packages/react/src/pages/connect-integration.tsx+109−0
@@ -0,0 +1,109 @@
1+import { useEffect } from "react";
2+import { Link, useNavigate } from "@tanstack/react-router";
3+import { useAtomValue, useAtomRefresh } from "@effect/atom-react";
4+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5+import { IntegrationSlug } from "@executor-js/sdk/shared";
6+
7+import { integrationAtom, integrationsOptimisticAtom } from "../api/atoms";
8+import { ErrorState } from "../components/error-state";
9+import { Skeleton } from "../components/skeleton";
10+import { isAsyncResultLoading } from "../lib/async-result";
11+import { useExecutorDocumentTitle } from "../lib/document-title";
12+
13+// ---------------------------------------------------------------------------
14+// `/connect/<integration slug>` — the stable, shareable deep link that drops a
15+// person straight into the connect flow for ONE integration ("connect your
16+// Linear") instead of the dashboard.
17+//
18+// This page owns no connect UI of its own. It resolves the slug against the
19+// tenant's catalog and forwards into the integration detail page's existing
20+// account handoff (`?addAccount=1`), which is the same surface the detail
21+// page's own "Add connection" button opens — multi-method tab strips, OAuth
22+// popups, existing-connection lists and "add another account" all come for
23+// free, and stay correct as that flow evolves.
24+//
25+// Deliberately keyed on the INTEGRATION SLUG, not a plugin id: the slug is the
26+// catalog identity a customer can write down, whereas plugin routes are on
27+// their way out. The redirect is `replace: true` so the deep link does not
28+// linger in history and Back leaves the connect flow rather than bouncing.
29+// ---------------------------------------------------------------------------
30+
31+export function ConnectIntegrationPage(props: { readonly integrationSlug: string }) {
32+ const { integrationSlug } = props;
33+ const slug = IntegrationSlug.make(integrationSlug);
34+ const integration = useAtomValue(integrationAtom(slug));
35+ const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
36+ const navigate = useNavigate();
37+
38+ useExecutorDocumentTitle("Connect");
39+
40+ // The catalog read is a client-side find over the integrations list, so an
41+ // unknown slug resolves to Success(null) rather than a failure — the missing
42+ // case is a not-found state, and only a genuine fetch failure is an error.
43+ const resolved = AsyncResult.isSuccess(integration) ? integration.value : null;
44+ const loading = isAsyncResultLoading(integration);
45+ const failed = AsyncResult.isFailure(integration);
46+ const missing = !loading && !failed && resolved === null;
47+
48+ useEffect(() => {
49+ if (resolved === null) return;
50+ void navigate({
51+ to: "/{-$orgSlug}/integrations/$namespace",
52+ params: { namespace: String(resolved.slug) },
53+ // The NUMBER 1, so the router serializes the canonical `?addAccount=1`
54+ // that agent-generated handoff links already use (a string would be
55+ // quoted as `%221%22`).
56+ search: { tab: "accounts", addAccount: 1 },
57+ replace: true,
58+ });
59+ }, [resolved, navigate]);
60+
61+ if (failed) {
62+ return (
63+ <ConnectStateFrame>
64+ <ErrorState message="Failed to load integrations" onRetry={refreshIntegrations} />
65+ </ConnectStateFrame>
66+ );
67+ }
68+
69+ if (missing) {
70+ return (
71+ <ConnectStateFrame>
72+ <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
73+ <p className="mb-1 text-sm font-medium text-foreground/70">
74+ Unknown integration: {integrationSlug}
75+ </p>
76+ <p className="mb-5 text-xs text-muted-foreground">
77+ This integration is not in your workspace.
78+ </p>
79+ <Link
80+ to="/{-$orgSlug}"
81+ className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
82+ >
83+ Back to integrations
84+ </Link>
85+ </div>
86+ </ConnectStateFrame>
87+ );
88+ }
89+
90+ // Loading, or resolved and mid-redirect: the same quiet placeholder, so the
91+ // hand-off never flashes an empty frame between catalog load and navigation.
92+ return (
93+ <ConnectStateFrame>
94+ <div className="space-y-3">
95+ <Skeleton className="h-8 w-56" />
96+ <Skeleton className="h-4 w-80" />
97+ <Skeleton className="h-32 w-full" />
98+ </div>
99+ </ConnectStateFrame>
100+ );
101+}
102+
103+function ConnectStateFrame(props: { readonly children: React.ReactNode }) {
104+ return (
105+ <div className="relative min-h-0 flex-1 overflow-y-auto">
106+ <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">{props.children}</div>
107+ </div>
108+ );
109+}
packages/react/src/pages/integration-detail.tsx+10−4
@@ -60,6 +60,10 @@ type ToolRow = { · 59 unmodified lines
6060 export function IntegrationDetailPage(props: {
6161 namespace: string;
6262 tab?: IntegrationDetailSearchTab;
63+ /** Route-validated `addAccount=1`: open the add-connection flow on arrival.
64+ * Set by the `/connect/<slug>` deep link, which navigates client-side and so
65+ * cannot rely on the raw location search below. */
66+ addAccount?: boolean;
6367 }) {
6468 const { namespace } = props;
6569 const slug = IntegrationSlug.make(namespace);
@@ -122,9 +126,11 @@ export function IntegrationDetailPage(props: { · 56 unmodified lines
122126 const canRefresh = integrationData?.canRefresh ?? false;
123127 const canRemove = integrationData?.canRemove ?? false;
124128 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {
125− if (locationSearch.length === 0) return null;
126129 const search = new URLSearchParams(locationSearch);
127− if (search.get("addAccount") !== "1") return null;
130+ // The route-validated flag and the raw `addAccount=1` are the same request;
131+ // either one opens the flow. The extra prefill fields below are read from
132+ // the raw search, which is populated on the full page loads that carry them.
133+ if (!props.addAccount && search.get("addAccount") !== "1") return null;
128134 const owner = search.get("owner");
129135 const template = search.get("template");
130136 const label = search.get("label");
@@ -149,13 +155,13 @@ export function IntegrationDetailPage(props: { · 18 unmodified lines
149155 };
150156 })();
151157 return {
152− key: locationSearch,
158+ key: `${String(props.addAccount)}:${locationSearch}`,
153159 ...(owner === "org" || owner === "user" ? { owner } : {}),
154160 ...(template != null && template.length > 0 ? { template } : {}),
155161 ...(label != null && label.length > 0 ? { label } : {}),
156162 ...(oauthClient !== undefined ? { oauthClient } : {}),
157163 };
158− }, [locationSearch]);
164+ }, [locationSearch, props.addAccount]);
159165 const accountHandoff = manualAccountHandoff ?? urlAccountHandoff;
160166
161167 useEffect(() => {
packages/react/src/routes/connect-deep-link.test.ts+121−0
@@ -0,0 +1,121 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import {
3+ createMemoryHistory,
4+ createRootRoute,
5+ createRoute,
6+ createRouter,
7+} from "@tanstack/react-router";
8+
9+import { CONSOLE_ROUTE_PATHS } from "../console-routes";
10+import { isAddAccountRequested } from "./integrations.$namespace";
11+
12+// Routing semantics of the `/connect/<integration slug>` deep link — the URL a
13+// customer hands to an end user ("connect your Linear"). The page component is
14+// exercised end-to-end in e2e/scenarios/connect-deep-link.test.ts; this locks
15+// the URL contract itself: which shapes match, what the params resolve to, and
16+// that the org-slug prefix round-trips.
17+
18+type RouteMatch = { readonly routeId: string; readonly params: Record<string, string> };
19+type SpikeRouter = {
20+ matchRoutes(pathname: string): ReadonlyArray<RouteMatch>;
21+ buildLocation(options: unknown): { readonly href: string };
22+};
23+
24+const CONNECT_ROUTE_ID = "/{-$orgSlug}/connect/$integrationSlug";
25+
26+const make = (): SpikeRouter => {
27+ const rootRoute = createRootRoute();
28+ const orgScope = createRoute({ getParentRoute: () => rootRoute, path: "/{-$orgSlug}" });
29+ const indexRoute = createRoute({ getParentRoute: () => orgScope, path: "/" });
30+ const connectRoute = createRoute({
31+ getParentRoute: () => orgScope,
32+ path: "/connect/$integrationSlug",
33+ });
34+ const nsRoute = createRoute({
35+ getParentRoute: () => orgScope,
36+ path: "/integrations/$namespace",
37+ });
38+ const routeTree = rootRoute.addChildren([
39+ orgScope.addChildren([indexRoute, connectRoute, nsRoute]),
40+ ]);
41+ // A test-local tree is not the package's registered console tree, so widen
42+ // away the global Register types — this asserts runtime semantics.
43+ // oxlint-disable-next-line executor/no-double-cast -- boundary: a test-local route tree must escape the package-global router Register types
44+ return createRouter({
45+ routeTree,
46+ history: createMemoryHistory({ initialEntries: ["/"] }),
47+ }) as unknown as SpikeRouter;
48+};
49+
50+describe("connect deep link", () => {
51+ it("is part of the shared console route contract", () => {
52+ // Every host mounts the contract, so the deep link exists on cloud,
53+ // self-host, cloudflare and local alike.
54+ expect(CONSOLE_ROUTE_PATHS).toContain("/connect/$integrationSlug");
55+ });
56+
57+ it("matches the bare link a customer would generate", () => {
58+ const matches = make().matchRoutes("/connect/linear");
59+ const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID);
60+ expect(match?.params).toEqual({ integrationSlug: "linear" });
61+ });
62+
63+ it("matches the org-prefixed form without eating the slug", () => {
64+ const matches = make().matchRoutes("/acme/connect/linear");
65+ const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID);
66+ expect(match?.params).toEqual({ orgSlug: "acme", integrationSlug: "linear" });
67+ });
68+
69+ it("keeps slugs that look like other console words intact", () => {
70+ // The integration slug is a free-form catalog id; "tools" or "integrations"
71+ // as a slug must still land on connect, not on those pages.
72+ for (const slug of ["tools", "integrations", "connect"]) {
73+ const match = make()
74+ .matchRoutes(`/connect/${slug}`)
75+ .find((m) => m.routeId === CONNECT_ROUTE_ID);
76+ expect(match?.params).toEqual({ integrationSlug: slug });
77+ }
78+ });
79+
80+ it("builds the link with and without an org slug", () => {
81+ const router = make();
82+ expect(
83+ router.buildLocation({ to: CONNECT_ROUTE_ID, params: { integrationSlug: "linear" } }).href,
84+ ).toBe("/connect/linear");
85+ expect(
86+ router.buildLocation({
87+ to: CONNECT_ROUTE_ID,
88+ params: { orgSlug: "acme", integrationSlug: "linear" },
89+ }).href,
90+ ).toBe("/acme/connect/linear");
91+ });
92+
93+ it("forwards into the integration's existing connect flow", () => {
94+ // The page redirects here rather than rendering its own connect UI; the
95+ // handoff is carried as validated search so it survives client navigation.
96+ // The number 1 keeps the canonical `?addAccount=1` that agent-generated
97+ // handoff links already use — a string would serialize as `%221%22`.
98+ const href = make().buildLocation({
99+ to: "/{-$orgSlug}/integrations/$namespace",
100+ params: { namespace: "linear" },
101+ search: { tab: "accounts", addAccount: 1 },
102+ }).href;
103+ expect(href).toBe("/integrations/linear?tab=accounts&addAccount=1");
104+ });
105+});
106+
107+describe("addAccount flag", () => {
108+ it("accepts every spelling the two link sources produce", () => {
109+ // A full page load parses `?addAccount=1` to the number; a client-side
110+ // navigation may carry the string. Both mean "open the connect flow".
111+ expect(isAddAccountRequested(1)).toBe(true);
112+ expect(isAddAccountRequested("1")).toBe(true);
113+ expect(isAddAccountRequested(true)).toBe(true);
114+ });
115+
116+ it("ignores anything else instead of failing the page", () => {
117+ for (const value of [undefined, null, 0, "0", "", "banana", {}]) {
118+ expect(isAddAccountRequested(value)).toBe(false);
119+ }
120+ });
121+});
packages/react/src/routes/connect.$integrationSlug.tsx+10−0
@@ -0,0 +1,10 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { ConnectIntegrationPage } from "../pages/connect-integration";
4+
5+export const Route = createFileRoute("/{-$orgSlug}/connect/$integrationSlug")({
6+ component: () => {
7+ const { integrationSlug } = Route.useParams();
8+ return <ConnectIntegrationPage integrationSlug={integrationSlug} />;
9+ },
10+});
packages/react/src/routes/integrations.$namespace.tsx+20−1
@@ -4,21 +4,40 @@ import { createFileRoute } from "@tanstack/react-router"; · 3 unmodified lines
44 import { IntegrationDetailPage } from "../pages/integration-detail";
55 import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";
66
7+// `addAccount=1` opens the add-connection flow on arrival. It is declared here
8+// (rather than only read off `window.location`) because unlisted keys do not
9+// survive validation: a CLIENT-SIDE navigation carrying the handoff — the
10+// `/connect/<slug>` deep link — would otherwise have it stripped before the
11+// page could see it. The richer handoff fields stay URL-only; they arrive on
12+// full page loads from agent-generated links.
13+//
14+// Typed as unknown and coerced, not as a literal, for two reasons. A URL flag
15+// must never hard-fail a page: a hand-edited `?addAccount=banana` should be
16+// ignored, not rendered as a validation error. And the router's search
17+// serializer quotes a string "1" (`addAccount=%221%22`) while leaving the
18+// number 1 bare, so accepting both is what keeps ONE canonical URL —
19+// `?addAccount=1` — shared by deep links and agent-generated handoff links.
720 const SearchParams = Schema.toStandardSchemaV1(
821 Schema.Struct({
922 tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),
23+ addAccount: Schema.optional(Schema.Unknown),
1024 }),
1125 );
1226
27+/** The truthy spellings of the `addAccount` URL flag. */
28+export const isAddAccountRequested = (value: unknown): boolean =>
29+ value === 1 || value === "1" || value === true;
30+
1331 export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({
1432 validateSearch: SearchParams,
1533 component: () => {
1634 const { namespace } = Route.useParams();
17− const { tab } = Route.useSearch();
35+ const { tab, addAccount } = Route.useSearch();
1836 return (
1937 <IntegrationDetailPage
2038 namespace={namespace}
2139 tab={tab as IntegrationDetailSearchTab | undefined}
40+ addAccount={isAddAccountRequested(addAccount)}
2241 />
2342 );
2443 },
packages/react/src/routes/routeTree.gen.ts+43−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010
1111 import { Route as rootRouteImport } from './__root'
1212 import { Route as DotIndexRouteImport } from './index'
13+import { Route as DotUsersRouteImport } from './users'
1314 import { Route as DotToolsRouteImport } from './tools'
1415 import { Route as DotToolkitsRouteImport } from './toolkits'
1516 import { Route as DotSecretsRouteImport } from './secrets'
@@ -17,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 1 unmodified line
1718 import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
1819 import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
1920 import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
21+import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'
2022 import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'
2123 import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey'
2224
@@ -25,6 +27,11 @@ const DotIndexRoute = DotIndexRouteImport.update({ · 2 unmodified lines
2527 path: '/{-$orgSlug}/',
2628 getParentRoute: () => rootRouteImport,
2729 } as any)
30+const DotUsersRoute = DotUsersRouteImport.update({
31+ id: '/{-$orgSlug}/users',
32+ path: '/{-$orgSlug}/users',
33+ getParentRoute: () => rootRouteImport,
34+} as any)
2835 const DotToolsRoute = DotToolsRouteImport.update({
2936 id: '/{-$orgSlug}/tools',
3037 path: '/{-$orgSlug}/tools',
@@ -62,6 +69,12 @@ const DotIntegrationsDotnamespaceRoute = · 31 unmodified lines
6269 path: '/{-$orgSlug}/integrations/$namespace',
6370 getParentRoute: () => rootRouteImport,
6471 } as any)
72+const DotConnectDotintegrationSlugRoute =
73+ DotConnectDotintegrationSlugRouteImport.update({
74+ id: '/{-$orgSlug}/connect/$integrationSlug',
75+ path: '/{-$orgSlug}/connect/$integrationSlug',
76+ getParentRoute: () => rootRouteImport,
77+ } as any)
6578 const DotPluginsDotpluginIdDotsplatRoute =
6679 DotPluginsDotpluginIdDotsplatRouteImport.update({
6780 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -80,7 +93,9 @@ export interface FileRoutesByFullPath { · 12 unmodified lines
8093 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
8194 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
8295 '/{-$orgSlug}/tools': typeof DotToolsRoute
96+ '/{-$orgSlug}/users': typeof DotUsersRoute
8397 '/{-$orgSlug}/': typeof DotIndexRoute
98+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
8499 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
85100 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
86101 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -92,7 +107,9 @@ export interface FileRoutesByTo { · 5 unmodified lines
92107 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
93108 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
94109 '/{-$orgSlug}/tools': typeof DotToolsRoute
110+ '/{-$orgSlug}/users': typeof DotUsersRoute
95111 '/{-$orgSlug}': typeof DotIndexRoute
112+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
96113 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
97114 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
98115 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -105,7 +122,9 @@ export interface FileRoutesById { · 6 unmodified lines
105122 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
106123 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
107124 '/{-$orgSlug}/tools': typeof DotToolsRoute
125+ '/{-$orgSlug}/users': typeof DotUsersRoute
108126 '/{-$orgSlug}/': typeof DotIndexRoute
127+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
109128 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
110129 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
111130 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -119,7 +138,9 @@ export interface FileRouteTypes { · 7 unmodified lines
119138 | '/{-$orgSlug}/secrets'
120139 | '/{-$orgSlug}/toolkits'
121140 | '/{-$orgSlug}/tools'
141+ | '/{-$orgSlug}/users'
122142 | '/{-$orgSlug}/'
143+ | '/{-$orgSlug}/connect/$integrationSlug'
123144 | '/{-$orgSlug}/integrations/$namespace'
124145 | '/{-$orgSlug}/resume/$executionId'
125146 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -131,7 +152,9 @@ export interface FileRouteTypes { · 5 unmodified lines
131152 | '/{-$orgSlug}/secrets'
132153 | '/{-$orgSlug}/toolkits'
133154 | '/{-$orgSlug}/tools'
155+ | '/{-$orgSlug}/users'
134156 | '/{-$orgSlug}'
157+ | '/{-$orgSlug}/connect/$integrationSlug'
135158 | '/{-$orgSlug}/integrations/$namespace'
136159 | '/{-$orgSlug}/resume/$executionId'
137160 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -143,7 +166,9 @@ export interface FileRouteTypes { · 5 unmodified lines
143166 | '/{-$orgSlug}/secrets'
144167 | '/{-$orgSlug}/toolkits'
145168 | '/{-$orgSlug}/tools'
169+ | '/{-$orgSlug}/users'
146170 | '/{-$orgSlug}/'
171+ | '/{-$orgSlug}/connect/$integrationSlug'
147172 | '/{-$orgSlug}/integrations/$namespace'
148173 | '/{-$orgSlug}/resume/$executionId'
149174 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -156,7 +181,9 @@ export interface RootRouteChildren { · 6 unmodified lines
156181 DotSecretsRoute: typeof DotSecretsRoute
157182 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
158183 DotToolsRoute: typeof DotToolsRoute
184+ DotUsersRoute: typeof DotUsersRoute
159185 DotIndexRoute: typeof DotIndexRoute
186+ DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
160187 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
161188 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute
162189 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -172,6 +199,13 @@ declare module '@tanstack/react-router' { · 9 unmodified lines
172199 preLoaderRoute: typeof DotIndexRouteImport
173200 parentRoute: typeof rootRouteImport
174201 }
202+ '/{-$orgSlug}/users': {
203+ id: '/{-$orgSlug}/users'
204+ path: '/{-$orgSlug}/users'
205+ fullPath: '/{-$orgSlug}/users'
206+ preLoaderRoute: typeof DotUsersRouteImport
207+ parentRoute: typeof rootRouteImport
208+ }
175209 '/{-$orgSlug}/tools': {
176210 id: '/{-$orgSlug}/tools'
177211 path: '/{-$orgSlug}/tools'
@@ -221,6 +255,13 @@ declare module '@tanstack/react-router' { · 43 unmodified lines
221255 preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport
222256 parentRoute: typeof rootRouteImport
223257 }
258+ '/{-$orgSlug}/connect/$integrationSlug': {
259+ id: '/{-$orgSlug}/connect/$integrationSlug'
260+ path: '/{-$orgSlug}/connect/$integrationSlug'
261+ fullPath: '/{-$orgSlug}/connect/$integrationSlug'
262+ preLoaderRoute: typeof DotConnectDotintegrationSlugRouteImport
263+ parentRoute: typeof rootRouteImport
264+ }
224265 '/{-$orgSlug}/plugins/$pluginId/$': {
225266 id: '/{-$orgSlug}/plugins/$pluginId/$'
226267 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -255,7 +296,9 @@ const rootRouteChildren: RootRouteChildren = { · 28 unmodified lines
255296 DotSecretsRoute: DotSecretsRoute,
256297 DotToolkitsRoute: DotToolkitsRouteWithChildren,
257298 DotToolsRoute: DotToolsRoute,
299+ DotUsersRoute: DotUsersRoute,
258300 DotIndexRoute: DotIndexRoute,
301+ DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
259302 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
260303 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,
261304 DotIntegrationsDotaddDotpluginKeyRoute:
packages/react/src/routes/users.tsx+7−0
@@ -0,0 +1,7 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { AdminUsersPage } from "../pages/admin-users";
4+
5+export const Route = createFileRoute("/{-$orgSlug}/users")({
6+ component: () => <AdminUsersPage />,
7+});
scripts/check-route-trees.ts+138−0
@@ -0,0 +1,138 @@
1+#!/usr/bin/env bun
2+/**
3+ * Asserts that every committed `routeTree.gen.ts` matches what its generator
4+ * produces from the current route contract.
5+ *
6+ * The route trees are GENERATED (`bun run routes:gen` in each package, from
7+ * `tsr.routes.ts` / `consoleRoutes()`) but COMMITTED, because the build and the
8+ * type-level `Link to=...` checking both read them off disk. That makes them a
9+ * classic drift surface: change the route contract, forget to regenerate, and
10+ * the committed tree still compiles — the routes it lacks simply don't exist at
11+ * runtime.
12+ *
13+ * Nothing else catches it. The generated files open with a suppression pragma
14+ * that opts them out of type checking entirely, so `typecheck` cannot see a
15+ * missing route; lint doesn't read them semantically; and unit tests import
16+ * the very tree they are drifting against. This bug class has already escaped
17+ * once and shipped a live 404 for a route that was in the contract but not in
18+ * the committed tree.
19+ *
20+ * So: regenerate every tree into place, diff against git, and restore. The
21+ * generators are pure functions of the checked-in route config, so a clean
22+ * checkout is a no-op and any diff is real drift.
23+ *
24+ * Usage:
25+ * bun run scripts/check-route-trees.ts
26+ */
27+import { spawnSync } from "node:child_process";
28+import { readFileSync, writeFileSync } from "node:fs";
29+import { resolve } from "node:path";
30+
31+const repoRoot = resolve(import.meta.dir, "..");
32+
33+/**
34+ * Every package with a committed route tree, as (workspace dir, tree path)
35+ * pairs. Each dir has a `routes:gen` script; the tree is the file it writes.
36+ */
37+const TREES = [
38+ { dir: "packages/react", tree: "packages/react/src/routes/routeTree.gen.ts" },
39+ { dir: "packages/app", tree: "packages/app/src/routeTree.gen.ts" },
40+ { dir: "apps/cloud", tree: "apps/cloud/src/routeTree.gen.ts" },
41+ { dir: "apps/host-selfhost", tree: "apps/host-selfhost/web/routeTree.gen.ts" },
42+ { dir: "apps/host-cloudflare", tree: "apps/host-cloudflare/web/routeTree.gen.ts" },
43+] as const;
44+
45+const run = (command: string, args: readonly string[], cwd: string) =>
46+ spawnSync(command, [...args], { cwd, encoding: "utf8" });
47+
48+/**
49+ * Compare on ROUTE CONTENT, not byte-for-byte.
50+ *
51+ * Two generators write these files: each package's `routes:gen` script, and the
52+ * TanStack vite plugin during `dev`. They agree on every route, import, and
53+ * type — they disagree only on blank lines in the trailing `declare module`
54+ * block. Comparing raw bytes would therefore fail on any checkout where a dev
55+ * server had run, which is a false alarm about formatting, not the drift this
56+ * guards (a route present in the contract and missing from the tree). Blank
57+ * lines cannot encode a route, so dropping them loses no signal.
58+ */
59+const routeContent = (source: string): string =>
60+ source
61+ .split("\n")
62+ .filter((line) => line.trim() !== "")
63+ .join("\n");
64+
65+// Snapshot the committed content so a failed run leaves the checkout exactly as
66+// it found it — this script must be safe to run on a dirty working tree.
67+const before = new Map<string, string>();
68+for (const { tree } of TREES) {
69+ before.set(tree, readFileSync(resolve(repoRoot, tree), "utf8"));
70+}
71+
72+const restore = () => {
73+ for (const [tree, content] of before) writeFileSync(resolve(repoRoot, tree), content);
74+};
75+
76+/** The lines each side has and the other does not — enough to name the route
77+ * that drifted, without pulling in a diff library for a CI guard. */
78+const lineDiff = (committed: string, regenerated: string): string => {
79+ const committedLines = new Set(committed.split("\n"));
80+ const regeneratedLines = new Set(regenerated.split("\n"));
81+ const missing = [...regeneratedLines].filter((line) => !committedLines.has(line));
82+ const extra = [...committedLines].filter((line) => !regeneratedLines.has(line));
83+ const show = (label: string, lines: readonly string[]) =>
84+ lines.length === 0
85+ ? []
86+ : [` ${label}:`, ...lines.slice(0, 20).map((line) => ` ${line}`)];
87+ return [
88+ ...show("in the regenerated tree but NOT committed (missing routes)", missing),
89+ ...show("committed but NOT regenerated (stale routes)", extra),
90+ ].join("\n");
91+};
92+
93+type Failure = { readonly tree: string; readonly detail: string };
94+const failures: Failure[] = [];
95+
96+for (const { dir, tree } of TREES) {
97+ const generated = run("bun", ["run", "routes:gen"], resolve(repoRoot, dir));
98+ if (generated.status !== 0) {
99+ failures.push({
100+ tree,
101+ detail: `\`bun run routes:gen\` failed in ${dir}:\n${generated.stderr || generated.stdout}`,
102+ });
103+ continue;
104+ }
105+ const after = readFileSync(resolve(repoRoot, tree), "utf8");
106+ const committed = routeContent(before.get(tree) ?? "");
107+ const regenerated = routeContent(after);
108+ if (regenerated !== committed) {
109+ // Show the actual drift rather than just naming the file: what changed is
110+ // usually the whole answer (a route added to the contract, or removed).
111+ // Diffed against the SNAPSHOT rather than via `git diff`, so this reports
112+ // correctly even when the committed file was already dirty.
113+ failures.push({
114+ tree,
115+ detail: `regenerating produced a different tree:\n${lineDiff(committed, regenerated)}`,
116+ });
117+ }
118+}
119+
120+restore();
121+
122+if (failures.length > 0) {
123+ const lines = failures.map((f) => ` - ${f.tree}: ${f.detail}`).join("\n\n");
124+ console.error(
125+ `\nRoute-tree check FAILED (${failures.length} tree(s) out of date):\n${lines}\n\n` +
126+ "Cause: a committed routeTree.gen.ts does not match what its generator\n" +
127+ "produces from the current route contract — the contract changed and the\n" +
128+ "tree was not regenerated. These files opt out of type checking, so the\n" +
129+ "typecheck job cannot catch this; the app just serves a 404 for the\n" +
130+ "missing route.\n\n" +
131+ "Fix: regenerate and commit the trees:\n" +
132+ TREES.map(({ dir }) => ` bun run --cwd ${dir} routes:gen`).join("\n") +
133+ "\n",
134+ );
135+ process.exit(1);
136+}
137+
138+console.log(`Route-tree check passed: ${TREES.length} generated tree(s) match their contract.`);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t