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
136 136
137 - run: bun run typecheck137 - run: bun run typecheck
138 138
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
139 test:145 test:
140 name: Test146 name: Test
141 runs-on: blacksmith-4vcpu-ubuntu-2404147 runs-on: blacksmith-4vcpu-ubuntu-2404
apps/cloud/drizzle/0012_woozy_shiva.sql+10−0
@@ -0,0 +1,10 @@
1CREATE 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
10CREATE 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
85 "when": 1784581232732,85 "when": 1784581232732,
86 "tag": "0011_tense_caretaker",86 "tag": "0011_tense_caretaker",
87 "breakpoints": true87 "breakpoints": true
88 },
89 {
90 "idx": 12,
91 "version": "7",
92 "when": 1785193313601,
93 "tag": "0012_woozy_shiva",
94 "breakpoints": true
88 }95 }
89 ]96 ]
90}97}
apps/cloud/src/account/org-api-key-revoke.node.test.ts+192−0
@@ -0,0 +1,192 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { AccountProvider } from "@executor-js/api/server";
5import { AccountError, AccountForbidden } from "@executor-js/api";
6
7import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
8import { UserStoreService } from "../auth/context";
9import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
10import { AutumnService } from "../extensions/billing/service";
11import { 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
33const ORG = "org_123";
34const ADMIN = "user_admin";
35const MEMBER = "user_member";
36const ORG_KEY = "key_org_1";
37const USER_KEY = "key_user_1";
38const createdAt = new Date("2026-01-01T00:00:00.000Z");
39
40const 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. */
51const 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
79const 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
107const 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 */
118const 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
154describe("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
246 return { success: true };246 return { success: true };
247 }),247 }),
248 248
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
249 listMembers: (headers) =>295 listMembers: (headers) =>
250 Effect.gen(function* () {296 Effect.gen(function* () {
251 const { session, org } = yield* requireOrganization(headers);297 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
28import { HttpRouter } from "effect/unstable/http";
29import { Context, Effect, Layer, Option } from "effect";
30
31import {
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";
51import {
52 AdminUserNotFound,
53 AdminUsersError,
54 AdminUsersForbidden,
55 AdminUsersUnauthorized,
56} from "@executor-js/api";
57import type { Executor } from "@executor-js/sdk";
58
59import { ApiKeyService } from "../auth/api-keys";
60import { UserStoreService } from "../auth/context";
61import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
62import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
63import { WorkOSClient } from "../auth/workos";
64import { DbService } from "../db/db";
65import { 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 */
74const 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 */
129const 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 */
152const 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 */
214export 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. */
237const 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 */
252const 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 */
281export 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`.
329const 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 */
349export 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 @@
1import { expect, it } from "@effect/vitest";
2import { Data, Effect, Layer } from "effect";
3
4import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
5import { 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
23const 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. */
28class 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. */
35const 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
43const 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
71const 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
77it.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
88it.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
98it.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
110it.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
13 Effect.succeed(13 Effect.succeed(
14 value === "valid_user_key"14 value === "valid_user_key"
15 ? {15 ? {
16 scope: "user" as const,
16 accountId: "user_123",17 accountId: "user_123",
17 organizationId: "org_123",18 organizationId: "org_123",
18 keyId: "api_key_123",19 keyId: "api_key_123",
@@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines
22 listUserKeys: () => Effect.succeed([]),23 listUserKeys: () => Effect.succeed([]),
23 createUserKey: () => Effect.die("protected API auth test does not create API keys"),24 createUserKey: () => Effect.die("protected API auth test does not create API keys"),
24 revokeUserKey: () => Effect.void,25 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"),
25});29});
26 30
27const stubWorkOS = Layer.succeed(31const 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
40 listUserKeys: () => Effect.succeed([]),40 listUserKeys: () => Effect.succeed([]),
41 createUserKey: () => Effect.die("JWT auth test does not create API keys"),41 createUserKey: () => Effect.die("JWT auth test does not create API keys"),
42 revokeUserKey: () => Effect.void,42 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"),
43});46});
44 47
45const stubWorkOS = Layer.succeed(48const stubWorkOS = Layer.succeed(
apps/cloud/src/auth/api-keys.node.test.ts+23−8
@@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { · 41 unmodified lines
42 });42 });
43 43
44 expect(principal).toEqual({44 expect(principal).toEqual({
45 scope: "user",
45 accountId: "user_123",46 accountId: "user_123",
46 organizationId: "org_123",47 organizationId: "org_123",
47 keyId: "api_key_123",48 keyId: "api_key_123",
@@ -66,15 +67,9 @@ describe("ApiKeyService.WorkOS", () => { · 18 unmodified lines
66 }),67 }),
67 );68 );
68 69
69 it.effect("rejects missing, organization-owned, and org-less keys", () =>70 it.effect("rejects missing and org-less keys", () =>
70 Effect.gen(function* () {71 Effect.gen(function* () {
71 const missing = yield* validate({ apiKey: null });72 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 });
78 const orgLess = yield* validate({73 const orgLess = yield* validate({
79 apiKey: {74 apiKey: {
80 id: "api_key_no_org",75 id: "api_key_no_org",
@@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines
83 });78 });
84 79
85 expect(missing).toBeNull();80 expect(missing).toBeNull();
86 expect(orgOwned).toBeNull();
87 expect(orgLess).toBeNull();81 expect(orgLess).toBeNull();
88 }),82 }),
89 );83 );
90 84
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
91 it.effect("lists and creates user-owned keys", () =>106 it.effect("lists and creates user-owned keys", () =>
92 Effect.gen(function* () {107 Effect.gen(function* () {
93 const program = Effect.gen(function* () {108 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
3import { ApiKeyManagementError } from "./errors";3import { ApiKeyManagementError } from "./errors";
4import { WorkOSClient } from "./workos";4import { WorkOSClient } from "./workos";
5 5
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 */
14export type ApiKeyScope = "user" | "org";
15
6/** The owner an api key resolves to — NOT a full {@link Principal} (no email /16/** The owner an api key resolves to — NOT a full {@link Principal} (no email /
7 * name / roles), so it carries an honest, distinct name. */17 * name / roles), so it carries an honest, distinct name. */
8export type ApiKeyOwner = {18export 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;
10 readonly organizationId: string;24 readonly organizationId: string;
11 readonly keyId: string;25 readonly keyId: string;
12};26};
@@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines
28 readonly cause: unknown;42 readonly cause: unknown;
29}> {}43}> {}
30 44
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 */
51export 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.
31const UserApiKeyOwner = Schema.Struct({60const UserApiKeyOwner = Schema.Struct({
32 type: Schema.Literal("user"),61 type: Schema.Literal("user"),
33 id: Schema.String,62 id: Schema.String,
@@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line
35 organization_id: Schema.optional(Schema.String),64 organization_id: Schema.optional(Schema.String),
36});65});
37 66
67const OrgApiKeyOwner = Schema.Struct({
68 type: Schema.Literal("organization"),
69 id: Schema.String,
70});
71
72const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]);
73
38const ApiKey = Schema.Struct({74const ApiKey = Schema.Struct({
39 id: Schema.String,75 id: Schema.String,
40 owner: UserApiKeyOwner,76 owner: UserApiKeyOwner,
@@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines
49 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),85 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),
50});86});
51 87
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.
92const ValidatedApiKey = Schema.Struct({
93 id: Schema.String,
94 owner: WorkOSApiKeyOwner,
95});
96
52const ValidateApiKeyResponse = Schema.Struct({97const ValidateApiKeyResponse = Schema.Struct({
53 apiKey: Schema.NullOr(ApiKey),98 apiKey: Schema.NullOr(ValidatedApiKey),
54});99});
55 100
56const RawCreatedApiKey = Schema.Struct({101const RawCreatedApiKey = Schema.Struct({
@@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines
72 data: Schema.Array(ApiKey),117 data: Schema.Array(ApiKey),
73});118});
74 119
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.
125const 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
139const 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.
149const ListOrgApiKeysResponse = Schema.Struct({
150 data: Schema.Array(Schema.Unknown),
151});
152
153const CreateOrgApiKeyResponse = Schema.Union([
154 RawCreatedOrgApiKey,
155 Schema.Struct({ apiKey: RawCreatedOrgApiKey }),
156 Schema.Struct({ api_key: RawCreatedOrgApiKey }),
157]);
158
75const CreateApiKeyResponse = Schema.Union([159const CreateApiKeyResponse = Schema.Union([
76 RawCreatedApiKey,160 RawCreatedApiKey,
77 Schema.Struct({ apiKey: RawCreatedApiKey }),161 Schema.Struct({ apiKey: RawCreatedApiKey }),
@@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines
81const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);165const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);
82const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);166const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);
83const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);167const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);
168const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse);
169const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey);
170const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse);
171
172const 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};
84 192
85const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>193const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>
86 Option.match(decodeValidateApiKeyResponse(value), {194 Option.match(decodeValidateApiKeyResponse(value), {
87 onNone: () => null,195 onNone: () => null,
88 onSome: ({ apiKey }) => {196 onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null),
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 },
98 });197 });
99 198
100const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {199const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {
@@ -120,6 +219,53 @@ const listFromResponse = (value: unknown): readonly ApiKeySummary[] => · 19 unmodified lines
120 }),219 }),
121 });220 });
122 221
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.
226const 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
241const 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
258const 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
123const createdFromResponse = (value: unknown): CreatedApiKey | null =>269const createdFromResponse = (value: unknown): CreatedApiKey | null =>
124 Option.match(decodeCreateApiKeyResponse(value), {270 Option.match(decodeCreateApiKeyResponse(value), {
125 onNone: () => null,271 onNone: () => null,
@@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines
147 readonly revokeUserKey: (input: {293 readonly revokeUserKey: (input: {
148 readonly keyId: string;294 readonly keyId: string;
149 }) => Effect.Effect<void, ApiKeyManagementError>;295 }) => 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>;
150 }318 }
151>()("@executor-js/cloud/ApiKeyService") {319>()("@executor-js/cloud/ApiKeyService") {
152 static WorkOS = Layer.effect(this)(320 static WorkOS = Layer.effect(this)(
@@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines
177 workos345 workos
178 .deleteApiKey(keyId)346 .deleteApiKey(keyId)
179 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))),347 .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 }),
180 };382 };
181 }),383 }),
182 );384 );
apps/cloud/src/auth/org-api-key-auth.node.test.ts+153−0
@@ -0,0 +1,153 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { ApiKeyService } from "./api-keys";
5import { UserStoreService } from "./context";
6import { WorkOSClient, type WorkOSClientService } from "./workos";
7import { 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
14const createdAt = new Date("2026-01-01T00:00:00.000Z");
15
16const 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
44const 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
64const 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
92const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers);
93
94const bearer = (token: string) =>
95 new Request("https://executor.test/api/tools", {
96 headers: { authorization: `Bearer ${token}` },
97 });
98
99describe("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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys";
5import { ApiKeyManagementError } from "./errors";
6import { 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
25const ORG = "org_123";
26
27const 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
39const 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
51const serviceWith = (overrides: Record<string, unknown>) =>
52 ApiKeyService.asEffect().pipe(
53 Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))),
54 );
55
56describe("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
195describe("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
29 listUserKeys: () => Effect.succeed([]),29 listUserKeys: () => Effect.succeed([]),
30 createUserKey: () => Effect.die("not used"),30 createUserKey: () => Effect.die("not used"),
31 revokeUserKey: () => Effect.void,31 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"),
32});35});
33 36
34const stubWorkOS = Layer.succeed(37const stubWorkOS = Layer.succeed(
apps/cloud/src/auth/workos-auth-provider.ts+90−10
@@ -47,6 +47,7 @@ import { · 46 unmodified lines
47 authorizeOrganization,47 authorizeOrganization,
48 authorizeOrganizationSelector,48 authorizeOrganizationSelector,
49 orgSelectorFromRequest,49 orgSelectorFromRequest,
50 resolveOrganization,
50} from "./organization";51} from "./organization";
51import { UserStoreService } from "./context";52import { UserStoreService } from "./context";
52import { sealedSessionDisplayName } from "./middleware";53import { sealedSessionDisplayName } from "./middleware";
@@ -103,6 +104,13 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 50 unmodified lines
103 code: "no_organization",104 code: "no_organization",
104 message: "No organization in access token",105 message: "No organization in access token",
105};106};
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.
110const ORG_KEY_ON_PRODUCT_SURFACE = {
111 code: "invalid_api_key",
112 message: "Organization API keys cannot be used on this endpoint",
113};
106 114
107// A bearer value with three dot-separated segments is a JWT (a WorkOS access115// A bearer value with three dot-separated segments is a JWT (a WorkOS access
108// token from the CLI device-login); anything else is treated as an API key.116// 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
151 });159 });
152 160
153/**161/**
154 * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT162 * What an ORG-level API key resolves to: the platform view. Deliberately NOT a
155 * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS163 * `Principal` — a `Principal` names an acting member (`accountId: string`), and
156 * API key. Returns `null` when there is no `Authorization` header, so the164 * an org key has none. Keeping it a separate shape means no product code path
157 * caller falls through to the sealed-session path. (Kept the historical name,165 * can accidentally treat it as a member, and the executor built from it binds
158 * the re-export and resolver tests reference it.)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 */
171export 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. */
181export type BearerAuth = Principal | PlatformAuth | null;
182
183export 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.
159 */196 */
160export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) =>197export 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> =>
161 Effect.gen(function* () {205 Effect.gen(function* () {
162 const authHeader = request.headers.get("authorization");206 const authHeader = request.headers.get("authorization");
163 if (!authHeader) return null;207 if (!authHeader) return null;
@@ -172,7 +216,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines
172 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);216 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);
173 217
174 const apiKeys = yield* ApiKeyService;218 const apiKeys = yield* ApiKeyService;
175 const principal = yield* apiKeys219 const owner = yield* apiKeys
176 .validate(value)220 .validate(value)
177 .pipe(221 .pipe(
178 Effect.catchTag("ApiKeyValidationError", () =>222 Effect.catchTag("ApiKeyValidationError", () =>
@@ -180,13 +224,28 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 1 unmodified line
180 ),224 ),
181 );225 );
182 226
183 if (!principal) return yield* new Unauthorized(INVALID_API_KEY);227 if (!owner) return yield* new Unauthorized(INVALID_API_KEY);
184 228
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);
186 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);245 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);
187 246
188 return {247 return {
189 accountId: principal.accountId,248 accountId: owner.accountId,
190 organizationId: org.id,249 organizationId: org.id,
191 organizationName: org.name,250 organizationName: org.name,
192 organizationSlug: org.slug,251 organizationSlug: org.slug,
@@ -197,6 +256,27 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines
197 } satisfies Principal;256 } satisfies Principal;
198 });257 });
199 258
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 */
266export 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
200export const resolveSessionPrincipal = (request: Request) =>280export const resolveSessionPrincipal = (request: Request) =>
201 Effect.gen(function* () {281 Effect.gen(function* () {
202 const workos = yield* WorkOSClient;282 const workos = yield* WorkOSClient;
apps/cloud/src/auth/workos.ts+27−0
@@ -544,6 +544,33 @@ const make = Effect.gen(function* () { · 543 unmodified lines
544 return response.data;544 return response.data;
545 }),545 }),
546 546
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
547 deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)),574 deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)),
548 575
549 /** List organization memberships with user details. */576 /** 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
33 (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)],33 (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)],
34);34);
35 35
36export 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
36export const connection = pgTable(52export const connection = pgTable(
37 "connection",53 "connection",
38 {54 {
apps/cloud/src/db/org-deletion.test.ts+100−1
@@ -11,14 +11,21 @@ · 10 unmodified lines
11// - a second org's data is completely untouched11// - a second org's data is completely untouched
12// - the blob prefix match escapes LIKE wildcards (a `_` in the org id must12// - the blob prefix match escapes LIKE wildcards (a `_` in the org id must
13// not widen the match to a look-alike namespace)13// 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.
14 19
15import { describe, it, expect } from "@effect/vitest";20import { describe, it, expect } from "@effect/vitest";
16import { Effect } from "effect";21import { Effect } from "effect";
17import { eq, inArray } from "drizzle-orm";22import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm";
18 23
19import { DbService } from "./db";24import { DbService } from "./db";
20import type { DrizzleDb } from "./db";25import type { DrizzleDb } from "./db";
21import { makeUserStore } from "../auth/user-store";26import { makeUserStore } from "../auth/user-store";
27import * as cloudSchema from "./schema";
28import * as executorSchema from "./executor-schema";
22import { memberships, accounts } from "./schema";29import { memberships, accounts } from "./schema";
23import {30import {
24 blob,31 blob,
@@ -28,6 +35,7 @@ import { · 3 unmodified lines
28 oauth_client,35 oauth_client,
29 oauth_session,36 oauth_session,
30 plugin_storage,37 plugin_storage,
38 subject,
31 tool,39 tool,
32 tool_policy,40 tool_policy,
33} from "./executor-schema";41} from "./executor-schema";
@@ -131,6 +139,13 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { · 97 unmodified lines
131 subject: "s",139 subject: "s",
132 });140 });
133 141
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
134 const orgNs = `o:${tenant}/plugin`;149 const orgNs = `o:${tenant}/plugin`;
135 const userNs = `u:${tenant}:subject/plugin`;150 const userNs = `u:${tenant}:subject/plugin`;
136 await db.insert(blob).values({151 await db.insert(blob).values({
@@ -156,8 +171,25 @@ const TENANT_TABLES = [ · 19 unmodified lines
156 definition,171 definition,
157 tool_policy,172 tool_policy,
158 plugin_storage,173 plugin_storage,
174 subject,
159] as const;175] as const;
160 176
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.
180const 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
161const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => {193const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => {
162 let total = 0;194 let total = 0;
163 for (const table of TENANT_TABLES) {195 for (const table of TENANT_TABLES) {
@@ -249,3 +281,70 @@ describe("purgeOrganizationData", () => { · 85 unmodified lines
249 );281 );
250 }, 20_000);282 }, 20_000);
251});283});
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.
298describe("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
23 oauth_client,23 oauth_client,
24 oauth_session,24 oauth_session,
25 plugin_storage,25 plugin_storage,
26 subject,
26 tool,27 tool,
27 tool_policy,28 tool_policy,
28} from "./executor-schema";29} from "./executor-schema";
@@ -48,6 +49,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr · 19 unmodified lines
48 await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId));49 await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId));
49 await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId));50 await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId));
50 await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId));51 await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId));
52 await tx.delete(subject).where(eq(subject.tenant, organizationId));
51 53
52 // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by54 // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by
53 // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>`55 // 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
2import { HttpRouter, HttpServerResponse } from "effect/unstable/http";2import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
3import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";3import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
4 4
5import { AccountApi, AdminUsersApi } from "@executor-js/api";
6
5import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";7import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";
6import { OrgApi } from "../org/api";8import { OrgApi } from "../org/api";
7 9
8import { ProtectedCloudApi } from "../api/layers";10import { ProtectedCloudApi } from "../api/layers";
9 11
10export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi).add(CloudAuthApi).add(OrgApi);12export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi)
13 .add(CloudAuthApi)
14 .add(OrgApi)
15 .add(AccountApi)
16 .add(AdminUsersApi);
11 17
12const spec = OpenApi.fromApi(CloudOpenApi);18const spec = OpenApi.fromApi(CloudOpenApi);
13 19
apps/cloud/src/extensions/routes.ts+18−1
@@ -24,6 +24,7 @@ import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; · 23 unmodified lines
24import { HttpApiBuilder } from "effect/unstable/httpapi";24import { HttpApiBuilder } from "effect/unstable/httpapi";
25import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";25import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
26 26
27import { AccountApi, AdminUsersApi } from "@executor-js/api";
27import { requestScopedMiddleware } from "@executor-js/api/server";28import { requestScopedMiddleware } from "@executor-js/api/server";
28 29
29import { UserStoreService } from "../auth/context";30import { UserStoreService } from "../auth/context";
@@ -34,6 +35,7 @@ import { · 4 unmodified lines
34} from "../auth/handlers";35} from "../auth/handlers";
35import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";36import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";
36import { SessionAuthLive } from "../auth/middleware-live";37import { SessionAuthLive } from "../auth/middleware-live";
38import { makeCloudAdminUsersRoutes } from "../admin/admin-users-api";
37import { OrgApi, OrgHttpApi } from "../org/api";39import { OrgApi, OrgHttpApi } from "../org/api";
38import { orgAuthMiddleware } from "../org/auth-middleware";40import { orgAuthMiddleware } from "../org/auth-middleware";
39import { OrgHandlers } from "../org/handlers";41import { OrgHandlers } from "../org/handlers";
@@ -55,6 +57,8 @@ const apiPrefixedRouter = Layer.effect(HttpRouter.HttpRouter)( · 15 unmodified lines
55const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi)57const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi)
56 .add(CloudAuthApi)58 .add(CloudAuthApi)
57 .add(OrgApi)59 .add(OrgApi)
60 .add(AccountApi)
61 .add(AdminUsersApi)
58 .prefix("/api");62 .prefix("/api");
59 63
60const spec = OpenApi.fromApi(CloudOpenApi);64const spec = OpenApi.fromApi(CloudOpenApi);
@@ -100,5 +104,18 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 39 unmodified lines
100 104
101 const BillingRoutes = AutumnRoutesLive.pipe(Layer.provide(requestScopedMiddleware(rsLive).layer));105 const BillingRoutes = AutumnRoutesLive.pipe(Layer.provide(requestScopedMiddleware(rsLive).layer));
102 106
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;
104};121};
apps/cloud/src/mcp/auth.ts+11−0
@@ -248,6 +248,17 @@ export const McpAuthLive = Layer.effect( · 247 unmodified lines
248 return mcpUnauthorized("invalid_token", "The API key is invalid");248 return mcpUnauthorized("invalid_token", "The API key is invalid");
249 }249 }
250 250
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
251 yield* Effect.annotateCurrentSpan({262 yield* Effect.annotateCurrentSpan({
252 "mcp.auth.outcome": "verified",263 "mcp.auth.outcome": "verified",
253 "mcp.auth.credential_type": "api_key",264 "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
13import { Route as LoginRouteImport } from './routes/bare/login'13import { Route as LoginRouteImport } from './routes/bare/login'
14import { Route as CreateOrgRouteImport } from './routes/bare/create-org'14import { Route as CreateOrgRouteImport } from './routes/bare/create-org'
15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
18import { Route as SecretsRouteImport } from './routes/app/secrets'19import { Route as SecretsRouteImport } from './routes/app/secrets'
@@ -23,6 +24,7 @@ import { Route as ApiKeysRouteImport } from './routes/app/api-keys' · 4 unmodified lines
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
24import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'25import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'26import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
26import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'28import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'29import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
28 30
@@ -47,6 +49,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 18 unmodified lines
47 path: '/{-$orgSlug}/',49 path: '/{-$orgSlug}/',
48 getParentRoute: () => rootRouteImport,50 getParentRoute: () => rootRouteImport,
49 } as any)51 } as any)
52const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute =
53 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({
54 id: '/{-$orgSlug}/users',
55 path: '/{-$orgSlug}/users',
56 getParentRoute: () => rootRouteImport,
57 } as any)
50const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =58const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =
51 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({59 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({
52 id: '/{-$orgSlug}/tools',60 id: '/{-$orgSlug}/tools',
@@ -107,6 +115,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 54 unmodified lines
107 getParentRoute: () => rootRouteImport,115 getParentRoute: () => rootRouteImport,
108 } as any,116 } as any,
109 )117 )
118const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
119 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
120 {
121 id: '/{-$orgSlug}/connect/$integrationSlug',
122 path: '/{-$orgSlug}/connect/$integrationSlug',
123 getParentRoute: () => rootRouteImport,
124 } as any,
125 )
110const Billing_DotplansRoute = Billing_DotplansRouteImport.update({126const Billing_DotplansRoute = Billing_DotplansRouteImport.update({
111 id: '/{-$orgSlug}/billing_/plans',127 id: '/{-$orgSlug}/billing_/plans',
112 path: '/{-$orgSlug}/billing/plans',128 path: '/{-$orgSlug}/billing/plans',
@@ -132,8 +148,10 @@ export interface FileRoutesByFullPath { · 19 unmodified lines
132 '/{-$orgSlug}/secrets': typeof SecretsRoute148 '/{-$orgSlug}/secrets': typeof SecretsRoute
133 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren149 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
134 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute150 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
151 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
135 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute152 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
136 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute153 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
137 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
138 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute156 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
139 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute157 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -150,8 +168,10 @@ export interface FileRoutesByTo { · 10 unmodified lines
150 '/{-$orgSlug}/secrets': typeof SecretsRoute168 '/{-$orgSlug}/secrets': typeof SecretsRoute
151 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren169 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
152 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute170 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
171 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
153 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute172 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
154 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute173 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
174 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute175 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
156 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute176 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
157 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute177 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -169,8 +189,10 @@ export interface FileRoutesById { · 11 unmodified lines
169 '/{-$orgSlug}/secrets': typeof SecretsRoute189 '/{-$orgSlug}/secrets': typeof SecretsRoute
170 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren190 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
171 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute191 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
192 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
172 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute193 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
173 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute194 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
195 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
174 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute196 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
175 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute197 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute198 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -189,8 +211,10 @@ export interface FileRouteTypes { · 12 unmodified lines
189 | '/{-$orgSlug}/secrets'211 | '/{-$orgSlug}/secrets'
190 | '/{-$orgSlug}/toolkits'212 | '/{-$orgSlug}/toolkits'
191 | '/{-$orgSlug}/tools'213 | '/{-$orgSlug}/tools'
214 | '/{-$orgSlug}/users'
192 | '/{-$orgSlug}/'215 | '/{-$orgSlug}/'
193 | '/{-$orgSlug}/billing/plans'216 | '/{-$orgSlug}/billing/plans'
217 | '/{-$orgSlug}/connect/$integrationSlug'
194 | '/{-$orgSlug}/integrations/$namespace'218 | '/{-$orgSlug}/integrations/$namespace'
195 | '/{-$orgSlug}/resume/$executionId'219 | '/{-$orgSlug}/resume/$executionId'
196 | '/{-$orgSlug}/toolkits/$toolkitSlug'220 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -207,8 +231,10 @@ export interface FileRouteTypes { · 10 unmodified lines
207 | '/{-$orgSlug}/secrets'231 | '/{-$orgSlug}/secrets'
208 | '/{-$orgSlug}/toolkits'232 | '/{-$orgSlug}/toolkits'
209 | '/{-$orgSlug}/tools'233 | '/{-$orgSlug}/tools'
234 | '/{-$orgSlug}/users'
210 | '/{-$orgSlug}'235 | '/{-$orgSlug}'
211 | '/{-$orgSlug}/billing/plans'236 | '/{-$orgSlug}/billing/plans'
237 | '/{-$orgSlug}/connect/$integrationSlug'
212 | '/{-$orgSlug}/integrations/$namespace'238 | '/{-$orgSlug}/integrations/$namespace'
213 | '/{-$orgSlug}/resume/$executionId'239 | '/{-$orgSlug}/resume/$executionId'
214 | '/{-$orgSlug}/toolkits/$toolkitSlug'240 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -225,8 +251,10 @@ export interface FileRouteTypes { · 10 unmodified lines
225 | '/{-$orgSlug}/secrets'251 | '/{-$orgSlug}/secrets'
226 | '/{-$orgSlug}/toolkits'252 | '/{-$orgSlug}/toolkits'
227 | '/{-$orgSlug}/tools'253 | '/{-$orgSlug}/tools'
254 | '/{-$orgSlug}/users'
228 | '/{-$orgSlug}/'255 | '/{-$orgSlug}/'
229 | '/{-$orgSlug}/billing_/plans'256 | '/{-$orgSlug}/billing_/plans'
257 | '/{-$orgSlug}/connect/$integrationSlug'
230 | '/{-$orgSlug}/integrations/$namespace'258 | '/{-$orgSlug}/integrations/$namespace'
231 | '/{-$orgSlug}/resume/$executionId'259 | '/{-$orgSlug}/resume/$executionId'
232 | '/{-$orgSlug}/toolkits/$toolkitSlug'260 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -244,8 +272,10 @@ export interface RootRouteChildren { · 11 unmodified lines
244 SecretsRoute: typeof SecretsRoute272 SecretsRoute: typeof SecretsRoute
245 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren273 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
246 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
275 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
247 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
248 Billing_DotplansRoute: typeof Billing_DotplansRoute277 Billing_DotplansRoute: typeof Billing_DotplansRoute
278 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
249 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute279 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
250 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute280 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute
251 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute281 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -281,6 +311,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
281 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport311 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport
282 parentRoute: typeof rootRouteImport312 parentRoute: typeof rootRouteImport
283 }313 }
314 '/{-$orgSlug}/users': {
315 id: '/{-$orgSlug}/users'
316 path: '/{-$orgSlug}/users'
317 fullPath: '/{-$orgSlug}/users'
318 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport
319 parentRoute: typeof rootRouteImport
320 }
284 '/{-$orgSlug}/tools': {321 '/{-$orgSlug}/tools': {
285 id: '/{-$orgSlug}/tools'322 id: '/{-$orgSlug}/tools'
286 path: '/{-$orgSlug}/tools'323 path: '/{-$orgSlug}/tools'
@@ -351,6 +388,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines
351 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport388 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
352 parentRoute: typeof rootRouteImport389 parentRoute: typeof rootRouteImport
353 }390 }
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 }
354 '/{-$orgSlug}/billing_/plans': {398 '/{-$orgSlug}/billing_/plans': {
355 id: '/{-$orgSlug}/billing_/plans'399 id: '/{-$orgSlug}/billing_/plans'
356 path: '/{-$orgSlug}/billing/plans'400 path: '/{-$orgSlug}/billing/plans'
@@ -397,9 +441,13 @@ const rootRouteChildren: RootRouteChildren = { · 40 unmodified lines
397 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,441 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
398 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:442 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
399 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,443 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
444 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute:
445 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute,
400 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:446 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
401 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,447 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
402 Billing_DotplansRoute: Billing_DotplansRoute,448 Billing_DotplansRoute: Billing_DotplansRoute,
449 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
450 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
403 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:451 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
404 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,452 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
405 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,453 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,
@@ -411,11 +459,15 @@ export const routeTree = rootRouteImport · 5 unmodified lines
411 ._addFileTypes<FileRouteTypes>()459 ._addFileTypes<FileRouteTypes>()
412 460
413import type { getRouter } from './router.tsx'461import type { getRouter } from './router.tsx'
462
414import type { startInstance } from './start.ts'463import type { startInstance } from './start.ts'
464
415declare module '@tanstack/react-start' {465declare module '@tanstack/react-start' {
416 interface Register {466 interface Register {
417 ssr: true467 ssr: true
468
418 router: Awaited<ReturnType<typeof getRouter>>469 router: Awaited<ReturnType<typeof getRouter>>
470
419 config: Awaited<ReturnType<typeof startInstance.getOptions>>471 config: Awaited<ReturnType<typeof startInstance.getOptions>>
420 }472 }
421}473}
apps/cloud/src/web/shell.tsx+9−1
@@ -1,6 +1,7 @@
1import type React from "react";1import type React from "react";
2 2
3import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";3import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
4import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
4import { trackEvent } from "@executor-js/react/api/analytics";5import { trackEvent } from "@executor-js/react/api/analytics";
5import { AUTH_PATHS } from "../auth/api";6import { AUTH_PATHS } from "../auth/api";
6import { OrgMenuSlot } from "./components/org-menu-slot";7import { OrgMenuSlot } from "./components/org-menu-slot";
@@ -25,6 +26,12 @@ const navItems = [ · 18 unmodified lines
25 { to: "/billing", label: "Billing" },26 { to: "/billing", label: "Billing" },
26];27];
27 28
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.
33const adminNavItems = [{ to: "/users", label: "Users" }];
34
28// A top-level form POST, not fetch: the logout response 302s through the35// A top-level form POST, not fetch: the logout response 302s through the
29// WorkOS logout endpoint (ending the hosted AuthKit session) before landing36// WorkOS logout endpoint (ending the hosted AuthKit session) before landing
30// back home. A fetch would follow that chain invisibly inside the XHR and37// back home. A fetch would follow that chain invisibly inside the XHR and
@@ -39,10 +46,11 @@ const signOut = () => { · 8 unmodified lines
39};46};
40 47
41export function Shell(props: { readonly content?: React.ReactNode }) {48export function Shell(props: { readonly content?: React.ReactNode }) {
49 const items = useAdminNavItems(navItems, adminNavItems);
42 return (50 return (
43 <SharedShell51 <SharedShell
44 onSignOut={signOut}52 onSignOut={signOut}
45 navItems={navItems}53 navItems={items}
46 orgMenuSlot={<OrgMenuSlot />}54 orgMenuSlot={<OrgMenuSlot />}
47 supportSlot={<SupportSlot />}55 supportSlot={<SupportSlot />}
48 content={props.content}56 content={props.content}
apps/host-cloudflare/src/account/account-provider.ts+5−0
@@ -61,6 +61,11 @@ export const cloudflareAccountProvider = ( · 60 unmodified lines
61 listApiKeys: () => Effect.succeed({ apiKeys: [] }),61 listApiKeys: () => Effect.succeed({ apiKeys: [] }),
62 createApiKey: () => forbiddenWrite,62 createApiKey: () => forbiddenWrite,
63 revokeApiKey: () => forbiddenWrite,63 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,
64 listMembers: () => Effect.succeed({ members: [] }),69 listMembers: () => Effect.succeed({ members: [] }),
65 listRoles: () => Effect.succeed({ roles: [] }),70 listRoles: () => Effect.succeed({ roles: [] }),
66 inviteMember: () => forbiddenWrite,71 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
10// this app only owns its root (the Cloudflare-Access shell). To add10// this app only owns its root (the Cloudflare-Access shell). To add
11// app-specific routes, create web/routes/app and mount it inside the org11// app-specific routes, create web/routes/app and mount it inside the org
12// scope via `orgScoped: [physical("", "app")]` — the directory must exist.12// 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.
13export const routes = rootRoute("__root.tsx", [17export const routes = rootRoute("__root.tsx", [
14 ...consoleRoutes({ dir: "../../../../packages/react/src/routes" }),18 ...consoleRoutes({ dir: "../../../../packages/react/src/routes", exclude: ["/users"] }),
15]);19]);
apps/host-cloudflare/web/routeTree.gen.ts+25−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
22 23
@@ -75,6 +76,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 52 unmodified lines
75 getParentRoute: () => rootRouteImport,76 getParentRoute: () => rootRouteImport,
76 } as any,77 } as any,
77 )78 )
79const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
80 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
81 {
82 id: '/{-$orgSlug}/connect/$integrationSlug',
83 path: '/{-$orgSlug}/connect/$integrationSlug',
84 getParentRoute: () => rootRouteImport,
85 } as any,
86 )
78const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =87const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
79 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(88 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
80 {89 {
@@ -98,6 +107,7 @@ export interface FileRoutesByFullPath { · 17 unmodified lines
98 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren107 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
99 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute108 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
100 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute109 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
110 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
101 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute111 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
102 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute112 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
103 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute113 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -110,6 +120,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
110 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren120 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
111 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute121 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
112 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute122 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
123 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
113 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute124 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
114 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute125 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
115 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute126 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -123,6 +134,7 @@ export interface FileRoutesById { · 7 unmodified lines
123 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren134 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
124 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute135 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
125 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute136 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
137 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
126 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute138 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
127 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute139 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
128 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute140 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -137,6 +149,7 @@ export interface FileRouteTypes { · 8 unmodified lines
137 | '/{-$orgSlug}/toolkits'149 | '/{-$orgSlug}/toolkits'
138 | '/{-$orgSlug}/tools'150 | '/{-$orgSlug}/tools'
139 | '/{-$orgSlug}/'151 | '/{-$orgSlug}/'
152 | '/{-$orgSlug}/connect/$integrationSlug'
140 | '/{-$orgSlug}/integrations/$namespace'153 | '/{-$orgSlug}/integrations/$namespace'
141 | '/{-$orgSlug}/resume/$executionId'154 | '/{-$orgSlug}/resume/$executionId'
142 | '/{-$orgSlug}/toolkits/$toolkitSlug'155 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -149,6 +162,7 @@ export interface FileRouteTypes { · 6 unmodified lines
149 | '/{-$orgSlug}/toolkits'162 | '/{-$orgSlug}/toolkits'
150 | '/{-$orgSlug}/tools'163 | '/{-$orgSlug}/tools'
151 | '/{-$orgSlug}'164 | '/{-$orgSlug}'
165 | '/{-$orgSlug}/connect/$integrationSlug'
152 | '/{-$orgSlug}/integrations/$namespace'166 | '/{-$orgSlug}/integrations/$namespace'
153 | '/{-$orgSlug}/resume/$executionId'167 | '/{-$orgSlug}/resume/$executionId'
154 | '/{-$orgSlug}/toolkits/$toolkitSlug'168 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -161,6 +175,7 @@ export interface FileRouteTypes { · 6 unmodified lines
161 | '/{-$orgSlug}/toolkits'175 | '/{-$orgSlug}/toolkits'
162 | '/{-$orgSlug}/tools'176 | '/{-$orgSlug}/tools'
163 | '/{-$orgSlug}/'177 | '/{-$orgSlug}/'
178 | '/{-$orgSlug}/connect/$integrationSlug'
164 | '/{-$orgSlug}/integrations/$namespace'179 | '/{-$orgSlug}/integrations/$namespace'
165 | '/{-$orgSlug}/resume/$executionId'180 | '/{-$orgSlug}/resume/$executionId'
166 | '/{-$orgSlug}/toolkits/$toolkitSlug'181 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -174,6 +189,7 @@ export interface RootRouteChildren { · 7 unmodified lines
174 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren189 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
175 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute190 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
176 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute191 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
192 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
177 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute193 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
178 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute194 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
179 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute195 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -238,6 +254,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
238 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport254 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
239 parentRoute: typeof rootRouteImport255 parentRoute: typeof rootRouteImport
240 }256 }
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 }
241 '/{-$orgSlug}/plugins/$pluginId/$': {264 '/{-$orgSlug}/plugins/$pluginId/$': {
242 id: '/{-$orgSlug}/plugins/$pluginId/$'265 id: '/{-$orgSlug}/plugins/$pluginId/$'
243 path: '/{-$orgSlug}/plugins/$pluginId/$'266 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -281,6 +304,8 @@ const rootRouteChildren: RootRouteChildren = { · 37 unmodified lines
281 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,304 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
282 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:305 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
283 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,306 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
307 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
308 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
284 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:309 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
285 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,310 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
286 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:311 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
106 auth.api.deleteApiKey({ body: { keyId: apiKeyId }, headers: toHeaders(headers) }),106 auth.api.deleteApiKey({ body: { keyId: apiKeyId }, headers: toHeaders(headers) }),
107 ).pipe(Effect.as({ success: true })),107 ).pipe(Effect.as({ success: true })),
108 108
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
109 listMembers: (headers) =>136 listMembers: (headers) =>
110 Effect.gen(function* () {137 Effect.gen(function* () {
111 const resolved = yield* getSession(headers);138 const resolved = yield* getSession(headers);
apps/host-selfhost/src/admin/admin-escalation.node.test.ts+184−0
@@ -0,0 +1,184 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { afterAll, expect, test } from "@effect/vitest";
6
7import { 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
46process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-escalation-"));
47process.env.BETTER_AUTH_SECRET = "admin-escalation-secret-0123456789-abcdefg";
48process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@escalation.test";
49process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";
50
51const { makeSelfHostApiHandler } = await import("../app");
52const { handler, dispose } = await makeSelfHostApiHandler();
53afterAll(() => dispose());
54
55const BASE = "http://localhost:4788";
56
57const asMember = (token: string) => ({ authorization: `Bearer ${token}` });
58
59const 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
68const 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. */
75const 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
90test("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
172const 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
22import { HttpRouter } from "effect/unstable/http";
23import { Effect, Layer } from "effect";
24
25import {
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";
43import {
44 AdminUserNotFound,
45 AdminUsersError,
46 AdminUsersForbidden,
47 AdminUsersUnauthorized,
48} from "@executor-js/api";
49import type { Executor } from "@executor-js/sdk";
50
51import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
52import { requireInstanceAdmin } from "./require-admin";
53import { SelfHostDb, SelfHostDbProvider, type SelfHostDbHandle } from "../db/self-host-db";
54import { 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 */
63const 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 */
88const 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 */
105const 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
134const 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
153export 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
194export 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 */
207export 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 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { afterAll, expect, test } from "@effect/vitest";
6
7import { 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
19process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-users-"));
20process.env.BETTER_AUTH_SECRET = "admin-users-test-secret-0123456789-abcdefghij";
21process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@admin-users.test";
22process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";
23
24const { makeSelfHostApiHandler } = await import("../app");
25const { handler, dispose } = await makeSelfHostApiHandler();
26afterAll(() => dispose());
27
28const BASE = "http://localhost:4788";
29
30const 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
41const adminUsers = (token?: string, path = "/api/admin/users") =>
42 handler(
43 new Request(`${BASE}${path}`, {
44 headers: token ? { authorization: `Bearer ${token}` } : {},
45 }),
46 );
47
48test("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
10 type InviteCode as InviteCodeSchema,10 type InviteCode as InviteCodeSchema,
11} from "./api";11} from "./api";
12import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";12import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
13import { requireInstanceAdmin } from "./require-admin";
13import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db";14import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db";
14import {15import {
15 createInviteCode,16 createInviteCode,
@@ -22,9 +23,11 @@ import { · 6 unmodified lines
22// ---------------------------------------------------------------------------23// ---------------------------------------------------------------------------
23// Handlers for the self-host admin (invite-code) API. Every Promise-returning24// Handlers for the self-host admin (invite-code) API. Every Promise-returning
24// boundary (Better Auth, the libSQL store) is wrapped in Effect.tryPromise with25// 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// a typed failure — no raw try/catch, no Promise.catch. Each route is gated by
26// the caller must be an owner/admin member of the one org (resolved through the27// the SHARED `requireInstanceAdmin`: the caller must be an owner/admin member
27// org primitive's getActiveMember).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).
28// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
29 32
30const requestHeaders = Effect.map(33const requestHeaders = Effect.map(
@@ -32,18 +35,15 @@ const requestHeaders = Effect.map( · 1 unmodified line
32 (request): Headers => new Headers({ ...request.headers }),35 (request): Headers => new Headers({ ...request.headers }),
33);36);
34 37
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).
36const requireAdmin = (headers: Headers) =>41const requireAdmin = (headers: Headers) =>
37 Effect.gen(function* () {42 requireInstanceAdmin(headers).pipe(
38 const { auth } = yield* BetterAuth;43 Effect.mapError((denial) =>
39 const member = yield* Effect.tryPromise({44 denial === "unauthorized" ? new AdminUnauthorized() : new AdminForbidden(),
40 try: () => auth.api.getActiveMember({ headers }),45 ),
41 catch: () => new AdminError({ message: "Failed to resolve session" }),46 );
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 });
47 47
48const narrowRole = (role: string | undefined): InviteRole =>48const narrowRole = (role: string | undefined): InviteRole =>
49 role === "admin" ? "admin" : "member";49 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
48import { Effect } from "effect";
49
50import { 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 */
61export 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`. */
66export 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 */
78const 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 */
105export 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
9import { resolveAuthProviders } from "./auth";9import { resolveAuthProviders } from "./auth";
10import { selfHostDataMigrations } from "./db/data-migrations";10import { selfHostDataMigrations } from "./db/data-migrations";
11import { makeSelfHostAdminApiLayer } from "./admin/handlers";11import { makeSelfHostAdminApiLayer } from "./admin/handlers";
12import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api";
12import { makeSelfHostSystemApiLayer } from "./system/handlers";13import { makeSelfHostSystemApiLayer } from "./system/handlers";
13import { selfHostAccountMiddleware } from "./account";14import { selfHostAccountMiddleware } from "./account";
14import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config";15import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config";
@@ -117,6 +118,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 102 unmodified lines
117 HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),118 HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),
118 // App-local admin (invite-code) API, served under /api/admin/*.119 // App-local admin (invite-code) API, served under /api/admin/*.
119 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),120 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" }),
120 // Public system API: /api/health + /api/setup-status (unauthenticated).125 // Public system API: /api/health + /api/setup-status (unauthenticated).
121 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),126 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
122 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).127 // 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
110 // are re-emitted by the shared envelope (MCP clients probe the origin root,110 // are re-emitted by the shared envelope (MCP clients probe the origin root,
111 // not the /api/auth basePath).111 // not the /api/auth basePath).
112 plugins: [112 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 }),
114 admin(),133 admin(),
115 apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),134 apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
116 bearer(),135 bearer(),
apps/host-selfhost/src/auth/return-to.test.ts+45−1
@@ -1,6 +1,12 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to";3import {
4 isSafeReturnTo,
5 loginPath,
6 mcpAuthorizeResumeTarget,
7 postLoginTarget,
8 safeReturnTo,
9} from "./return-to";
4 10
5describe("isSafeReturnTo", () => {11describe("isSafeReturnTo", () => {
6 const safe = [12 const safe = [
@@ -73,6 +79,44 @@ describe("mcpAuthorizeResumeTarget", () => { · 66 unmodified lines
73 });79 });
74});80});
75 81
82describe("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
76describe("loginPath", () => {120describe("loginPath", () => {
77 it("omits returnTo for the root", () => {121 it("omits returnTo for the root", () => {
78 expect(loginPath("/")).toBe("/login");122 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
32 if (!params.get("client_id") || !params.get("redirect_uri")) return null;32 if (!params.get("client_id") || !params.get("redirect_uri")) return null;
33 return `${MCP_AUTHORIZE_PATH}?${params.toString()}`;33 return `${MCP_AUTHORIZE_PATH}?${params.toString()}`;
34};34};
35
36const 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 */
54export 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 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { Effect, Layer } from "effect";
6import { afterAll, beforeAll, expect, test } from "@effect/vitest";
7import { withQueryContext } from "@executor-js/fumadb/query";
8
9import { makeScopedExecutor } from "@executor-js/api/server";
10
11import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
12import { SelfHostScopedExecutorSeams } from "./execution";
13import 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
21const createScopedExecutor = (accountId: string, organizationId: string) =>
22 makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe(
23 Effect.provide(SelfHostScopedExecutorSeams),
24 );
25
26const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-"));
27process.env.EXECUTOR_DATA_DIR = dataDir;
28
29let dbLayer!: Layer.Layer<SelfHostDb>;
30let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined;
31
32beforeAll(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});
40afterAll(async () => {
41 await dbHandle?.close();
42});
43
44const 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
54test("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
69test("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
6 6
7import { authClient } from "./auth-client";7import { authClient } from "./auth-client";
8import { AuthLayout } from "./auth-layout";8import { AuthLayout } from "./auth-layout";
9import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to";9import { postLoginTarget } from "../src/auth/return-to";
10 10
11// Self-host login: email + password sign-in via Better Auth. On success we11// Self-host login: email + password sign-in via Better Auth. On success we
12// reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps12// 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
17// redeeming an invite — either the full /join/<code> link, or by entering the17// redeeming an invite — either the full /join/<code> link, or by entering the
18// code here ("Have an invite code?"), which forwards to the same join page.18// code here ("Have an invite code?"), which forwards to the same join page.
19export const LoginPage = () => {19export const LoginPage = () => {
20 const search = window.location.search;20 // Where to go after sign-in. The gate renders this page IN PLACE of the
21 // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we21 // requested route without navigating, so the live location is what carries a
22 // arrived from one (Better Auth redirects it here with the OAuth params),22 // deep link (`/connect/linear`) across sign-in — see `postLoginTarget`.
23 // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else23 const postLogin = postLoginTarget(window.location);
24 // land on the dashboard.
25 const postLogin =
26 mcpAuthorizeResumeTarget(search) ??
27 safeReturnTo(new URLSearchParams(search).get("returnTo")) ??
28 "/";
29 const [mode, setMode] = useState<"signin" | "code">("signin");24 const [mode, setMode] = useState<"signin" | "code">("signin");
30 const [email, setEmail] = useState("");25 const [email, setEmail] = useState("");
31 const [password, setPassword] = useState("");26 const [password, setPassword] = useState("");
apps/host-selfhost/web/routeTree.gen.ts+48−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 10
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'12import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
13import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
13import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'14import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
14import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'16import { 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
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'26import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
25 27
@@ -29,6 +31,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 3 unmodified lines
29 path: '/{-$orgSlug}/',31 path: '/{-$orgSlug}/',
30 getParentRoute: () => rootRouteImport,32 getParentRoute: () => rootRouteImport,
31 } as any)33 } as any)
34const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute =
35 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({
36 id: '/{-$orgSlug}/users',
37 path: '/{-$orgSlug}/users',
38 getParentRoute: () => rootRouteImport,
39 } as any)
32const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =40const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =
33 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({41 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({
34 id: '/{-$orgSlug}/tools',42 id: '/{-$orgSlug}/tools',
@@ -93,6 +101,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 58 unmodified lines
93 getParentRoute: () => rootRouteImport,101 getParentRoute: () => rootRouteImport,
94 } as any,102 } as any,
95 )103 )
104const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
105 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
106 {
107 id: '/{-$orgSlug}/connect/$integrationSlug',
108 path: '/{-$orgSlug}/connect/$integrationSlug',
109 getParentRoute: () => rootRouteImport,
110 } as any,
111 )
96const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =112const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
97 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(113 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
98 {114 {
@@ -118,7 +134,9 @@ export interface FileRoutesByFullPath { · 19 unmodified lines
118 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute134 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
119 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren135 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
120 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute136 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
137 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
121 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute138 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
139 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
122 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute140 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
123 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute141 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
124 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute142 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -133,7 +151,9 @@ export interface FileRoutesByTo { · 8 unmodified lines
133 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute151 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
134 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren152 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
135 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute153 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
154 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
136 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute155 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
156 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
137 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute157 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
138 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute158 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
139 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute159 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -149,7 +169,9 @@ export interface FileRoutesById { · 9 unmodified lines
149 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute169 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
150 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren170 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
151 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute171 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
172 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
152 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute173 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
174 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
153 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute175 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
154 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute176 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
155 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute177 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -166,7 +188,9 @@ export interface FileRouteTypes { · 10 unmodified lines
166 | '/{-$orgSlug}/secrets'188 | '/{-$orgSlug}/secrets'
167 | '/{-$orgSlug}/toolkits'189 | '/{-$orgSlug}/toolkits'
168 | '/{-$orgSlug}/tools'190 | '/{-$orgSlug}/tools'
191 | '/{-$orgSlug}/users'
169 | '/{-$orgSlug}/'192 | '/{-$orgSlug}/'
193 | '/{-$orgSlug}/connect/$integrationSlug'
170 | '/{-$orgSlug}/integrations/$namespace'194 | '/{-$orgSlug}/integrations/$namespace'
171 | '/{-$orgSlug}/resume/$executionId'195 | '/{-$orgSlug}/resume/$executionId'
172 | '/{-$orgSlug}/toolkits/$toolkitSlug'196 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -181,7 +205,9 @@ export interface FileRouteTypes { · 8 unmodified lines
181 | '/{-$orgSlug}/secrets'205 | '/{-$orgSlug}/secrets'
182 | '/{-$orgSlug}/toolkits'206 | '/{-$orgSlug}/toolkits'
183 | '/{-$orgSlug}/tools'207 | '/{-$orgSlug}/tools'
208 | '/{-$orgSlug}/users'
184 | '/{-$orgSlug}'209 | '/{-$orgSlug}'
210 | '/{-$orgSlug}/connect/$integrationSlug'
185 | '/{-$orgSlug}/integrations/$namespace'211 | '/{-$orgSlug}/integrations/$namespace'
186 | '/{-$orgSlug}/resume/$executionId'212 | '/{-$orgSlug}/resume/$executionId'
187 | '/{-$orgSlug}/toolkits/$toolkitSlug'213 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -196,7 +222,9 @@ export interface FileRouteTypes { · 8 unmodified lines
196 | '/{-$orgSlug}/secrets'222 | '/{-$orgSlug}/secrets'
197 | '/{-$orgSlug}/toolkits'223 | '/{-$orgSlug}/toolkits'
198 | '/{-$orgSlug}/tools'224 | '/{-$orgSlug}/tools'
225 | '/{-$orgSlug}/users'
199 | '/{-$orgSlug}/'226 | '/{-$orgSlug}/'
227 | '/{-$orgSlug}/connect/$integrationSlug'
200 | '/{-$orgSlug}/integrations/$namespace'228 | '/{-$orgSlug}/integrations/$namespace'
201 | '/{-$orgSlug}/resume/$executionId'229 | '/{-$orgSlug}/resume/$executionId'
202 | '/{-$orgSlug}/toolkits/$toolkitSlug'230 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -212,7 +240,9 @@ export interface RootRouteChildren { · 9 unmodified lines
212 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute240 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
213 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren241 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
214 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute242 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
243 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
215 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute244 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
245 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
216 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute246 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
217 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute247 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
218 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute248 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -228,6 +258,13 @@ declare module '@tanstack/react-router' { · 9 unmodified lines
228 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport258 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport
229 parentRoute: typeof rootRouteImport259 parentRoute: typeof rootRouteImport
230 }260 }
261 '/{-$orgSlug}/users': {
262 id: '/{-$orgSlug}/users'
263 path: '/{-$orgSlug}/users'
264 fullPath: '/{-$orgSlug}/users'
265 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport
266 parentRoute: typeof rootRouteImport
267 }
231 '/{-$orgSlug}/tools': {268 '/{-$orgSlug}/tools': {
232 id: '/{-$orgSlug}/tools'269 id: '/{-$orgSlug}/tools'
233 path: '/{-$orgSlug}/tools'270 path: '/{-$orgSlug}/tools'
@@ -298,6 +335,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines
298 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport335 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
299 parentRoute: typeof rootRouteImport336 parentRoute: typeof rootRouteImport
300 }337 }
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 }
301 '/{-$orgSlug}/plugins/$pluginId/$': {345 '/{-$orgSlug}/plugins/$pluginId/$': {
302 id: '/{-$orgSlug}/plugins/$pluginId/$'346 id: '/{-$orgSlug}/plugins/$pluginId/$'
303 path: '/{-$orgSlug}/plugins/$pluginId/$'347 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -342,8 +386,12 @@ const rootRouteChildren: RootRouteChildren = { · 38 unmodified lines
342 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,386 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
343 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:387 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
344 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,388 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
389 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute:
390 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute,
345 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:391 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
346 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,392 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
393 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
394 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
347 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:395 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
348 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,396 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
349 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:397 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
apps/host-selfhost/web/routes/__root.tsx+10−1
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16} from "@executor-js/react/components/card";16} from "@executor-js/react/components/card";
17import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";17import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";
18import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";18import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
19import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
19import { plugins as clientPlugins } from "virtual:executor/plugins-client";20import { plugins as clientPlugins } from "virtual:executor/plugins-client";
20 21
21import { authClient } from "../auth-client";22import { authClient } from "../auth-client";
@@ -46,6 +47,13 @@ const selfHostNavItems = [ · 24 unmodified lines
46 { to: "/admin", label: "Admin" },47 { to: "/admin", label: "Admin" },
47];48];
48 49
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.)
55const selfHostAdminNavItems = [{ to: "/users", label: "Users" }];
56
49const signOut = async () => {57const signOut = async () => {
50 await authClient.signOut();58 await authClient.signOut();
51 window.location.href = "/";59 window.location.href = "/";
@@ -150,12 +158,13 @@ function AuthGate({ children }: { children: ReactNode }) { · 98 unmodified lines
150function AuthenticatedApp() {158function AuthenticatedApp() {
151 const auth = useAuth();159 const auth = useAuth();
152 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;160 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
161 const navItems = useAdminNavItems(selfHostNavItems, selfHostAdminNavItems);
153 162
154 // Single-org instance: a bare URL canonicalizes onto the instance org's163 // Single-org instance: a bare URL canonicalizes onto the instance org's
155 // slug. There's only ever one org, so no other slug is reachable.164 // slug. There's only ever one org, so no other slug is reachable.
156 const gated = (165 const gated = (
157 <>166 <>
158 <Shell onSignOut={signOut} navItems={selfHostNavItems} />167 <Shell onSignOut={signOut} navItems={navItems} />
159 <Toaster />168 <Toaster />
160 </>169 </>
161 );170 );
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.
15import { randomBytes } from "node:crypto";
16
17import { expect } from "@effect/vitest";
18import { Effect } from "effect";
19import type { HttpApiClient } from "effect/unstable/httpapi";
20import { composePluginApi } from "@executor-js/api/server";
21import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
22import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
23
24import { scenario } from "../src/scenario";
25import { Api, Browser, Target } from "../src/services";
26import { accountIdOf, forBrowser, joinOrg } from "./support/session";
27
28const api = composePluginApi([openApiHttpPlugin()] as const);
29type Client = HttpApiClient.ForApi<typeof api>;
30
31declare 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
40const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
41
42/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
43const 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. */
54const 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
74const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
75
76scenario(
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).
24import { randomBytes } from "node:crypto";
25
26import { expect } from "@effect/vitest";
27import { Effect } from "effect";
28import type { HttpApiClient } from "effect/unstable/httpapi";
29import { AdminUsersHttpApi } from "@executor-js/api";
30import { composePluginApi } from "@executor-js/api/server";
31import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
32import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
33
34import { scenario } from "../src/scenario";
35import { Api, Target } from "../src/services";
36import { accountIdOf, cookieOf, joinOrg } from "./support/session";
37
38const api = composePluginApi([openApiHttpPlugin()] as const);
39type Client = HttpApiClient.ForApi<typeof api>;
40
41const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
42
43/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
44const 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. */
55const 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
75const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
76
77scenario(
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
243scenario(
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.
28import { randomBytes } from "node:crypto";
29
30import { expect } from "@effect/vitest";
31import { Effect } from "effect";
32import type { HttpApiClient } from "effect/unstable/httpapi";
33import { composePluginApi } from "@executor-js/api/server";
34import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
35import { IntegrationSlug } from "@executor-js/sdk/shared";
36
37import { scenario } from "../src/scenario";
38import { Api, Browser, Target } from "../src/services";
39import { activeOrg, forBrowser, joinOrg, organizationsOf } from "./support/session";
40
41const api = composePluginApi([openApiHttpPlugin()] as const);
42type Client = HttpApiClient.ForApi<typeof api>;
43
44/** The spec's title, which the console uses to name a saved connection
45 * ("Personal Ping API"). */
46const INTEGRATION_TITLE = "Ping API";
47
48/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
49const 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. */
61const 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
77scenario(
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.
20import { Effect } from "effect";
21
22import 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 */
39const ORG_SELECTOR_HEADER = "x-executor-organization";
40
41/** The session cookie header this identity authenticates API calls with. */
42export 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 */
50export 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. */
67export 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. */
92export 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. */
106export 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 */
123export 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. */
142export 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. */
154export 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. */
161export 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. */
172export 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. */
189export 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
35 expect(paths, "path-param routes are documented").toContain(35 expect(paths, "path-param routes are documented").toContain(
36 "/api/executions/{executionId}/resume",36 "/api/executions/{executionId}/resume",
37 );37 );
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}");
38 }),59 }),
39);60);
40 61
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
17import { randomBytes, randomUUID } from "node:crypto";
18
19import { expect } from "@effect/vitest";
20import { Effect } from "effect";
21import { AccountHttpApi } from "@executor-js/api";
22import { composePluginApi } from "@executor-js/api/server";
23import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24import {
25 AuthTemplateSlug,
26 ConnectionName,
27 IntegrationSlug,
28 ProviderItemId,
29} from "@executor-js/sdk/shared";
30
31import { scenario } from "../src/scenario";
32import { Api, Browser, Target } from "../src/services";
33
34const api = composePluginApi([openApiHttpPlugin()] as const);
35
36/** OpenAPI 3 spec with a single operation — enough to be a real catalog entry. */
37const 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
53scenario(
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.
11import { randomBytes } from "node:crypto";
12
13import { expect } from "@effect/vitest";
14import { Effect } from "effect";
15import type { HttpApiClient } from "effect/unstable/httpapi";
16import { composePluginApi } from "@executor-js/api/server";
17import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
18import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
19
20import { scenario } from "../src/scenario";
21import { createInvitedIdentity } from "../targets/selfhost";
22import { Api, Browser, Target } from "../src/services";
23
24const api = composePluginApi([openApiHttpPlugin()] as const);
25type Client = HttpApiClient.ForApi<typeof api>;
26
27declare 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
35const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
36
37/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
38const 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
48const 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
68scenario(
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.
27import { randomBytes } from "node:crypto";
28
29import { expect } from "@effect/vitest";
30import { Effect } from "effect";
31import type { Page } from "playwright";
32
33import { scenario } from "../src/scenario";
34import { Browser, Target } from "../src/services";
35import { createInvitedIdentity } from "../targets/selfhost";
36import 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. */
42const 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. */
46const 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. */
50const 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
55scenario(
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. */
162const 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
45 "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:changelog-stubs",45 "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:changelog-stubs",
46 "lint:changelog-stubs": "bun run scripts/check-changelog-stubs.ts",46 "lint:changelog-stubs": "bun run scripts/check-changelog-stubs.ts",
47 "check:patches": "bun run scripts/check-patched-deps.ts",47 "check:patches": "bun run scripts/check-patched-deps.ts",
48 "check:routes": "bun run scripts/check-route-trees.ts",
48 "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .",49 "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .",
49 "docs:snippets": "bun run scripts/generate-doc-snippets.ts",50 "docs:snippets": "bun run scripts/generate-doc-snippets.ts",
50 "docs:smoke:install": "bun run scripts/smoke-docs-install.ts",51 "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
17import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'17import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
18import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'18import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
19import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'19import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
20import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'
20import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'21import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'
21import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey'22import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey'
22 23
@@ -67,6 +68,12 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = · 44 unmodified lines
67 path: '/{-$orgSlug}/integrations/$namespace',68 path: '/{-$orgSlug}/integrations/$namespace',
68 getParentRoute: () => rootRouteImport,69 getParentRoute: () => rootRouteImport,
69 } as any)70 } as any)
71const DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute =
72 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport.update({
73 id: '/{-$orgSlug}/connect/$integrationSlug',
74 path: '/{-$orgSlug}/connect/$integrationSlug',
75 getParentRoute: () => rootRouteImport,
76 } as any)
70const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute =77const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
71 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({78 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({
72 id: '/{-$orgSlug}/plugins/$pluginId/$',79 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -88,6 +95,7 @@ export interface FileRoutesByFullPath { · 15 unmodified lines
88 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren95 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
89 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute96 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
90 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute97 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
98 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
91 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute99 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
92 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute100 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
93 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute101 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -100,6 +108,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
100 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren108 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
101 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute109 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
102 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute110 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
111 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
103 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute112 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
104 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute113 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
105 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute114 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -113,6 +122,7 @@ export interface FileRoutesById { · 7 unmodified lines
113 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren122 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
114 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute123 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
115 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute124 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
125 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
116 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute126 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
117 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute127 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
118 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute128 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -127,6 +137,7 @@ export interface FileRouteTypes { · 8 unmodified lines
127 | '/{-$orgSlug}/toolkits'137 | '/{-$orgSlug}/toolkits'
128 | '/{-$orgSlug}/tools'138 | '/{-$orgSlug}/tools'
129 | '/{-$orgSlug}/'139 | '/{-$orgSlug}/'
140 | '/{-$orgSlug}/connect/$integrationSlug'
130 | '/{-$orgSlug}/integrations/$namespace'141 | '/{-$orgSlug}/integrations/$namespace'
131 | '/{-$orgSlug}/resume/$executionId'142 | '/{-$orgSlug}/resume/$executionId'
132 | '/{-$orgSlug}/toolkits/$toolkitSlug'143 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -139,6 +150,7 @@ export interface FileRouteTypes { · 6 unmodified lines
139 | '/{-$orgSlug}/toolkits'150 | '/{-$orgSlug}/toolkits'
140 | '/{-$orgSlug}/tools'151 | '/{-$orgSlug}/tools'
141 | '/{-$orgSlug}'152 | '/{-$orgSlug}'
153 | '/{-$orgSlug}/connect/$integrationSlug'
142 | '/{-$orgSlug}/integrations/$namespace'154 | '/{-$orgSlug}/integrations/$namespace'
143 | '/{-$orgSlug}/resume/$executionId'155 | '/{-$orgSlug}/resume/$executionId'
144 | '/{-$orgSlug}/toolkits/$toolkitSlug'156 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -151,6 +163,7 @@ export interface FileRouteTypes { · 6 unmodified lines
151 | '/{-$orgSlug}/toolkits'163 | '/{-$orgSlug}/toolkits'
152 | '/{-$orgSlug}/tools'164 | '/{-$orgSlug}/tools'
153 | '/{-$orgSlug}/'165 | '/{-$orgSlug}/'
166 | '/{-$orgSlug}/connect/$integrationSlug'
154 | '/{-$orgSlug}/integrations/$namespace'167 | '/{-$orgSlug}/integrations/$namespace'
155 | '/{-$orgSlug}/resume/$executionId'168 | '/{-$orgSlug}/resume/$executionId'
156 | '/{-$orgSlug}/toolkits/$toolkitSlug'169 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -164,6 +177,7 @@ export interface RootRouteChildren { · 7 unmodified lines
164 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren177 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
165 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute178 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
166 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute179 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
180 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
167 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute181 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
168 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute182 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
169 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute183 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -228,6 +242,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
228 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport242 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport
229 parentRoute: typeof rootRouteImport243 parentRoute: typeof rootRouteImport
230 }244 }
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 }
231 '/{-$orgSlug}/plugins/$pluginId/$': {252 '/{-$orgSlug}/plugins/$pluginId/$': {
232 id: '/{-$orgSlug}/plugins/$pluginId/$'253 id: '/{-$orgSlug}/plugins/$pluginId/$'
233 path: '/{-$orgSlug}/plugins/$pluginId/$'254 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -270,6 +291,8 @@ const rootRouteChildren: RootRouteChildren = { · 36 unmodified lines
270 DotDotDotDotDotDotReactSrcRoutesToolsRoute,291 DotDotDotDotDotDotReactSrcRoutesToolsRoute,
271 DotDotDotDotDotDotReactSrcRoutesIndexRoute:292 DotDotDotDotDotDotReactSrcRoutesIndexRoute:
272 DotDotDotDotDotDotReactSrcRoutesIndexRoute,293 DotDotDotDotDotDotReactSrcRoutesIndexRoute,
294 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute:
295 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,
273 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:296 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:
274 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,297 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,
275 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:298 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:
packages/app/tsr.routes.ts+6−4
@@ -7,13 +7,15 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; · 6 unmodified lines
7// Shared console routes come from @executor-js/react (see its7// Shared console routes come from @executor-js/react (see its
8// console-routes.ts); this app owns its root and the local-specific routes8// console-routes.ts); this app owns its root and the local-specific routes
9// under src/routes/app. /secrets is excluded: the local app's variant shows9// under src/routes/app. /secrets is excluded: the local app's variant shows
10// credential-provider info. App routes mount INSIDE the shared `{-$orgSlug}`10// credential-provider info. /users is excluded because local serves no account
11// scope (local never sets the param, so URLs stay bare) so the override masks11// or admin API at all (see apps/local/src/app.ts) — there is no tenant to
12// the shared route id.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.
13export const routes = rootRoute("__root.tsx", [15export const routes = rootRoute("__root.tsx", [
14 ...consoleRoutes({16 ...consoleRoutes({
15 dir: "../../../react/src/routes",17 dir: "../../../react/src/routes",
16 exclude: ["/secrets"],18 exclude: ["/secrets", "/users"],
17 orgScoped: [physical("", "app")],19 orgScoped: [physical("", "app")],
18 }),20 }),
19]);21]);
packages/core/api/src/account/api.ts+37−0
@@ -97,6 +97,18 @@ export const CreatedApiKeyResponse = Schema.Struct({ · 96 unmodified lines
97 value: Schema.String,97 value: Schema.String,
98});98});
99 99
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 */
108export const OrgApiKeysResponse = Schema.Struct({
109 apiKeys: Schema.Array(ApiKeySummary),
110});
111
100export const OrgMember = Schema.Struct({112export const OrgMember = Schema.Struct({
101 id: Schema.String,113 id: Schema.String,
102 userId: Schema.String,114 userId: Schema.String,
@@ -194,6 +206,31 @@ export const AccountApi = HttpApiGroup.make("account") · 91 unmodified lines
194 error: [AccountError, AccountUnauthorized, AccountNoOrganization],206 error: [AccountError, AccountUnauthorized, AccountNoOrganization],
195 }),207 }),
196 )208 )
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 )
197 .add(234 .add(
198 HttpApiEndpoint.get("listMembers", "/account/members", {235 HttpApiEndpoint.get("listMembers", "/account/members", {
199 success: OrgMembersResponse,236 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
44 return yield* (yield* AccountProvider).revokeApiKey(headers, params.apiKeyId);44 return yield* (yield* AccountProvider).revokeApiKey(headers, params.apiKeyId);
45 }),45 }),
46 )46 )
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 )
47 .handle("listMembers", () =>65 .handle("listMembers", () =>
48 Effect.gen(function* () {66 Effect.gen(function* () {
49 const headers = yield* requestHeaders;67 const headers = yield* requestHeaders;
packages/core/api/src/account/org-slug.test.ts+28−1
@@ -57,7 +57,34 @@ describe("isValidOrgSlug", () => { · 56 unmodified lines
57 });57 });
58 58
59 it("reserves the segments routing depends on", () => {59 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 ]) {
61 expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true);88 expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true);
62 }89 }
63 });90 });
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
17 * predictably will):17 * predictably will):
18 * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost18 * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost
19 * envelope, cloudflare `run_worker_first`)19 * envelope, cloudflare `run_worker_first`)
20 * - Console routes: integrations, policies, secrets, tools, resume,20 * - Console routes: connect, integrations, policies, secrets, tools, users,
21 * plugins (the shared contract), plus host extras:21 * toolkits, resume, plugins (the shared contract, i.e.
22 * every entry in `CONSOLE_ROUTE_PATHS`), plus host extras:
22 * api-keys, org, billing, create-org, setup-mcp (cloud),23 * api-keys, org, billing, create-org, setup-mcp (cloud),
23 * admin, join, docs (selfhost), login (Better Auth24 * admin, join, docs (selfhost), login (Better Auth
24 * `mcp({ loginPage })` + cloud/selfhost login UX)25 * `mcp({ loginPage })` + cloud/selfhost login UX)
@@ -41,10 +42,12 @@ export const RESERVED_ORG_SLUGS: ReadonlySet<string> = new Set([ · 16 unmodified lines
41 "mcp",42 "mcp",
42 ".well-known",43 ".well-known",
43 // console + host routes44 // console + host routes
45 "connect",
44 "integrations",46 "integrations",
45 "policies",47 "policies",
46 "secrets",48 "secrets",
47 "tools",49 "tools",
50 "toolkits",
48 "resume",51 "resume",
49 "plugins",52 "plugins",
50 "api-keys",53 "api-keys",
packages/core/api/src/account/service.ts+12−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 type AccountUnauthorized,7 type AccountUnauthorized,
8 AccountMeResponse,8 AccountMeResponse,
9 ApiKeysResponse,9 ApiKeysResponse,
10 OrgApiKeysResponse,
10 CreatedApiKeyResponse,11 CreatedApiKeyResponse,
11 OrgMembersResponse,12 OrgMembersResponse,
12 OrgRolesResponse,13 OrgRolesResponse,
@@ -33,6 +34,7 @@ export type AccountHeaders = Record<string, string>; · 20 unmodified lines
33 34
34type Me = typeof AccountMeResponse.Type;35type Me = typeof AccountMeResponse.Type;
35type ApiKeys = typeof ApiKeysResponse.Type;36type ApiKeys = typeof ApiKeysResponse.Type;
37type OrgApiKeys = typeof OrgApiKeysResponse.Type;
36type CreatedApiKey = typeof CreatedApiKeyResponse.Type;38type CreatedApiKey = typeof CreatedApiKeyResponse.Type;
37type Members = typeof OrgMembersResponse.Type;39type Members = typeof OrgMembersResponse.Type;
38type Roles = typeof OrgRolesResponse.Type;40type Roles = typeof OrgRolesResponse.Type;
@@ -49,6 +51,16 @@ export interface AccountProviderShape { · 10 unmodified lines
49 readonly listApiKeys: (headers: AccountHeaders) => OrgScoped<ApiKeys>;51 readonly listApiKeys: (headers: AccountHeaders) => OrgScoped<ApiKeys>;
50 readonly createApiKey: (headers: AccountHeaders, name: string) => OrgScoped<CreatedApiKey>;52 readonly createApiKey: (headers: AccountHeaders, name: string) => OrgScoped<CreatedApiKey>;
51 readonly revokeApiKey: (headers: AccountHeaders, apiKeyId: string) => OrgScoped<Success>;53 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>;
52 readonly listMembers: (headers: AccountHeaders) => OrgScoped<Members>;64 readonly listMembers: (headers: AccountHeaders) => OrgScoped<Members>;
53 readonly listRoles: (headers: AccountHeaders) => OrgScoped<Roles>;65 readonly listRoles: (headers: AccountHeaders) => OrgScoped<Roles>;
54 readonly inviteMember: (66 readonly inviteMember: (
packages/core/api/src/admin/admin-users.test.ts+1070−0
@@ -0,0 +1,1070 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { HttpRouter, HttpServer } from "effect/unstable/http";
3import { describe, expect, it } from "@effect/vitest";
4import { Context, Data, Effect, Layer, type Scope } from "effect";
5
6import { Subject, Tenant, collectTables, createExecutor, type Executor } from "@executor-js/sdk";
7import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing";
8import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal";
9
10import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api";
11import { AdminUsersHandlers } from "./handlers";
12import { AdminUsersProvider, type AdminUsersHeaders } from "./service";
13import {
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
39const TENANT_A = "org_a";
40const TENANT_B = "org_b";
41const USER_A1 = "user_a1";
42const USER_A2 = "user_a2";
43const 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. */
48const 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.
83const 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
94const 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. */
155const 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
194const 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. */
203const 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 */
218const 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 */
269const 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.
282const 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
298const 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
314const jsonOf = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>);
315
316type UsersBody = {
317 readonly users: ReadonlyArray<{
318 readonly externalId: string;
319 readonly email: string | null;
320 readonly displayName: string | null;
321 }>;
322};
323type ConnectionsBody = {
324 readonly connections: ReadonlyArray<{ readonly integration: string; readonly name: string }>;
325};
326type 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};
340type 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
355const 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 */
366const 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. */
377const A1_EMAIL_STORED = "A1@Users.Test";
378const 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 */
388const 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. */
406class 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. */
411const failingDirectory: AdminIdentityDirectory = () =>
412 Effect.fail(new DirectoryUnavailable({ message: "member directory unavailable" }));
413
414describe("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
36import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
37import { Schema } from "effect";
38
39import { ConnectionName, HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared";
40
41// ---------------------------------------------------------------------------
42// Errors
43// ---------------------------------------------------------------------------
44
45export class AdminUsersError extends Schema.TaggedErrorClass<AdminUsersError>()(
46 "AdminUsersError",
47 { message: Schema.String },
48 { httpApiStatus: 500 },
49) {}
50
51export class AdminUsersUnauthorized extends Schema.TaggedErrorClass<AdminUsersUnauthorized>()(
52 "AdminUsersUnauthorized",
53 {},
54 { httpApiStatus: 401 },
55) {}
56
57export 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 */
74export 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 */
93export 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 */
147export 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 */
161export 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. */
175export const AdminUserWithConnections = Schema.Struct({
176 ...AdminUser.fields,
177 connections: Schema.Array(AdminUserConnection),
178});
179
180export const AdminUsersResponse = Schema.Struct({
181 users: Schema.Array(AdminUser),
182});
183
184export const AdminUserConnectionsResponse = Schema.Struct({
185 connections: Schema.Array(AdminUserConnection),
186});
187
188export 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. */
195export const AdminUserResponse = Schema.Struct({
196 user: AdminUserWithConnections,
197});
198
199// ---------------------------------------------------------------------------
200// Params / query
201// ---------------------------------------------------------------------------
202
203const 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 */
228const 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.
257const 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 */
299export 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 */
355export const AdminUsersHttpApi = HttpApi.make("executor-admin-users").add(AdminUsersApi);
packages/core/api/src/admin/handlers.ts+69−0
@@ -0,0 +1,69 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { HttpServerRequest } from "effect/unstable/http";
3import { Effect } from "effect";
4
5import { AdminUsersHttpApi } from "./api";
6import { normalizeEmail } from "./reads";
7import { 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
17const 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`).
27const 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
37export 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
15import { Effect } from "effect";
16
17import type {
18 AdminConnection,
19 AdminSubject,
20 AdminSubjectWithConnections,
21 Executor,
22 ExecutorAdmin,
23} from "@executor-js/sdk";
24
25import {
26 AdminUserNotFound,
27 AdminUsersError,
28 type AdminUserConnectionsResponse,
29 type AdminUserResponse,
30 type AdminUsersResponse,
31 type AdminUsersWithConnectionsResponse,
32} from "./api";
33import 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 */
42export 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.
54const 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. */
63export 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 */
80export 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 */
96export 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 */
115export 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. */
120export 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. */
129const 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
146const 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 */
160const 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 */
190const 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 */
202const 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
210const 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 */
222const 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 */
240const 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. */
254const 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
260export 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
280export 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 */
315export 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
341export 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
19import { Context, type Effect } from "effect";
20
21import {
22 type AdminUserNotFound,
23 type AdminUsersError,
24 type AdminUsersForbidden,
25 type AdminUsersUnauthorized,
26 AdminUserResponse,
27 AdminUsersResponse,
28 AdminUserConnectionsResponse,
29 AdminUsersWithConnectionsResponse,
30} from "./api";
31
32export 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. */
37export interface AdminUsersListOptions {
38 readonly limit?: number;
39 readonly offset?: number;
40 readonly email?: string;
41}
42
43type User = typeof AdminUserResponse.Type;
44type Users = typeof AdminUsersResponse.Type;
45type UserConnections = typeof AdminUserConnectionsResponse.Type;
46type UsersWithConnections = typeof AdminUsersWithConnectionsResponse.Type;
47
48/** Every admin read: authorized, and failing with the neutral triple. */
49type Authorized<A> = Effect.Effect<
50 A,
51 AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden
52>;
53
54export 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
78export 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
14 AccountNoOrganization,14 AccountNoOrganization,
15 AccountUnauthorized,15 AccountUnauthorized,
16} from "./account/api";16} from "./account/api";
17export {
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
46 AccountMeResponse,46 AccountMeResponse,
47 ApiKeySummary,47 ApiKeySummary,
48 ApiKeysResponse,48 ApiKeysResponse,
49 OrgApiKeysResponse,
49 CreateApiKeyBody,50 CreateApiKeyBody,
50 CreatedApiKeyResponse,51 CreatedApiKeyResponse,
51 OrgMember,52 OrgMember,
@@ -60,6 +61,21 @@ export { · 8 unmodified lines
60 UpdateOrgNameResponse,61 UpdateOrgNameResponse,
61 SuccessResponse,62 SuccessResponse,
62} from "./account/api";63} from "./account/api";
64export {
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";
63export {79export {
64 RESERVED_ORG_SLUGS,80 RESERVED_ORG_SLUGS,
65 generateOrgSlug,81 generateOrgSlug,
packages/core/api/src/server.ts+22−0
@@ -18,6 +18,25 @@ export { · 17 unmodified lines
18} from "./plugin-routes";18} from "./plugin-routes";
19export { AccountProvider, type AccountProviderShape, type AccountHeaders } from "./account/service";19export { AccountProvider, type AccountProviderShape, type AccountHeaders } from "./account/service";
20export { AccountHandlers } from "./account/handlers";20export { AccountHandlers } from "./account/handlers";
21export {
22 AdminUsersProvider,
23 type AdminUsersProviderShape,
24 type AdminUsersHeaders,
25 type AdminUsersListOptions,
26} from "./admin/service";
27export { AdminUsersHandlers } from "./admin/handlers";
28export {
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";
21export { requestScopedMiddleware } from "./server/request-scoped";40export { requestScopedMiddleware } from "./server/request-scoped";
22export { RouterConfigLive } from "./server/router-config";41export { RouterConfigLive } from "./server/router-config";
23export { consoleErrorCapture } from "./server/console-error-capture";42export { consoleErrorCapture } from "./server/console-error-capture";
@@ -53,6 +72,7 @@ export { · 29 unmodified lines
53} from "./server/executor-fuma-db";72} from "./server/executor-fuma-db";
54export {73export {
55 makeScopedExecutor,74 makeScopedExecutor,
75 makePlatformExecutor,
56 HostConfig,76 HostConfig,
57 PluginsProvider,77 PluginsProvider,
58 RequestWebOrigin,78 RequestWebOrigin,
@@ -89,10 +109,12 @@ export { · 30 unmodified lines
89export {109export {
90 makeProtectedApiLayer,110 makeProtectedApiLayer,
91 makeAccountApiLayer,111 makeAccountApiLayer,
112 makeAdminUsersApiLayer,
92 accountProviderMiddlewareLayer,113 accountProviderMiddlewareLayer,
93 toApiHandler,114 toApiHandler,
94 type MakeProtectedApiLayerOptions,115 type MakeProtectedApiLayerOptions,
95 type MakeAccountApiLayerOptions,116 type MakeAccountApiLayerOptions,
117 type MakeAdminUsersApiLayerOptions,
96 type ApiHandler,118 type ApiHandler,
97} from "./server/host-foundation";119} from "./server/host-foundation";
98export {120export {
packages/core/api/src/server/host-foundation.ts+42−0
@@ -40,6 +40,8 @@ import { observabilityMiddleware, type ErrorCapture } from "../observability"; · 39 unmodified lines
40import { AccountHttpApi } from "../account/api";40import { AccountHttpApi } from "../account/api";
41import { AccountHandlers } from "../account/handlers";41import { AccountHandlers } from "../account/handlers";
42import type { AccountProvider } from "../account/service";42import type { AccountProvider } from "../account/service";
43import { AdminUsersHttpApi } from "../admin/api";
44import { AdminUsersHandlers } from "../admin/handlers";
43import { composePluginApi, composePluginHandlerLayer } from "../plugin-routes";45import { composePluginApi, composePluginHandlerLayer } from "../plugin-routes";
44import { CoreHandlers } from "../handlers";46import { CoreHandlers } from "../handlers";
45import { requestScopedMiddleware } from "./request-scoped";47import { requestScopedMiddleware } from "./request-scoped";
@@ -179,6 +181,46 @@ export const accountProviderMiddlewareLayer = ( · 133 unmodified lines
179 accountProviderLayer: Layer.Layer<AccountProvider>,181 accountProviderLayer: Layer.Layer<AccountProvider>,
180) => requestScopedMiddleware(accountProviderLayer).layer;182) => requestScopedMiddleware(accountProviderLayer).layer;
181 183
184// ---------------------------------------------------------------------------
185// Admin Users API
186// ---------------------------------------------------------------------------
187
188export 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 */
213export 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
182// ---------------------------------------------------------------------------224// ---------------------------------------------------------------------------
183// App-layer web-handler binding225// App-layer web-handler binding
184// ---------------------------------------------------------------------------226// ---------------------------------------------------------------------------
packages/core/api/src/server/scoped-executor.ts+82−1
@@ -42,7 +42,11 @@ import { · 41 unmodified lines
42 type Executor,42 type Executor,
43 type StorageFailure,43 type StorageFailure,
44} from "@executor-js/sdk";44} from "@executor-js/sdk";
45import { makeHostedFetch, makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal";45import {
46 makeHostedFetch,
47 makeHostedHttpClientLayer,
48 touchSubject,
49} from "@executor-js/sdk/host-internal";
46 50
47import { DbProvider } from "./executor-fuma-db";51import { DbProvider } from "./executor-fuma-db";
48 52
@@ -281,8 +285,85 @@ export const makeScopedExecutor = < · 232 unmodified lines
281 includeProviders: config.exposeCredentialProviders ?? true,285 includeProviders: config.exposeCredentialProviders ?? true,
282 },286 },
283 }).pipe(Effect.withSpan("executor.stack.create_executor"));287 }).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
284 // The seam erases the plugin tuple type; the caller re-narrows via the298 // The seam erases the plugin tuple type; the caller re-narrows via the
285 // `TPlugins` phantom. Runtime shape is identical to a typed299 // `TPlugins` phantom. Runtime shape is identical to a typed
286 // `createExecutor({ plugins })` call.300 // `createExecutor({ plugins })` call.
287 return executor as Executor<TPlugins>;301 return executor as Executor<TPlugins>;
288 });302 });
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
337export 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
5import {5import {
6 assertOwnerPatch,6 assertOwnerPatch,
7 assertOwnerWritable,7 assertOwnerWritable,
8 assertReachReadOnly,
8 executorOwnerPolicyName,9 executorOwnerPolicyName,
9 executorTenantPolicyName,10 executorTenantPolicyName,
10 executorUnscopedPolicyName,11 executorUnscopedPolicyName,
@@ -67,6 +68,10 @@ const tenantExecutorTable = <const TColumns extends UserColumns>( · 56 unmodified lines
67 name: executorTenantPolicyName,68 name: executorTenantPolicyName,
68 onRead: ({ builder, context }) => builder("tenant", "=", context.tenant),69 onRead: ({ builder, context }) => builder("tenant", "=", context.tenant),
69 onCreate: ({ values, context }) => {70 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);
70 if (values.tenant !== context.tenant) {75 if (values.tenant !== context.tenant) {
71 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB table policy callbacks are promise callbacks, not Effect effects76 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB table policy callbacks are promise callbacks, not Effect effects
72 throw new StorageError({77 throw new StorageError({
@@ -75,8 +80,14 @@ const tenantExecutorTable = <const TColumns extends UserColumns>( · 2 unmodified lines
75 });80 });
76 }81 }
77 },82 },
78 onUpdate: ({ builder, context }) => builder("tenant", "=", context.tenant),83 onUpdate: ({ builder, context }) => {
79 onDelete: ({ builder, context }) => builder("tenant", "=", context.tenant),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 },
80 });91 });
81};92};
82 93
@@ -104,7 +115,13 @@ const ownedExecutorTable = <const TColumns extends UserColumns>( · 21 unmodified lines
104 assertOwnerPatch(name, create, context);115 assertOwnerPatch(name, create, context);
105 return ownerVisibility(builder, context);116 return ownerVisibility(builder, context);
106 },117 },
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 },
108 });125 });
109};126};
110 127
@@ -151,6 +168,35 @@ export const coreTables = defineTables({ · 40 unmodified lines
151 ["tenant", "slug"],168 ["tenant", "slug"],
152 ),169 ),
153 170
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
154 // THE saved credential, one per (owner, integration, name). Resolves each named200 // THE saved credential, one per (owner, integration, name). Resolves each named
155 // input via `provider` + the `item_ids` map (variable → provider item id). A201 // input via `provider` + the `item_ids` map (variable → provider item id). A
156 // single-secret connection is `{ "token": <id> }`; an apiKey method with two202 // single-secret connection is `{ "token": <id> }`; an apiKey method with two
@@ -338,6 +384,7 @@ export const coreSchema = coreTables; · 181 unmodified lines
338export type CoreSchema = typeof coreTables;384export type CoreSchema = typeof coreTables;
339 385
340export type IntegrationRow = FumaRow<CoreSchema["integration"]>;386export type IntegrationRow = FumaRow<CoreSchema["integration"]>;
387export type SubjectRow = FumaRow<CoreSchema["subject"]>;
341export type ConnectionRow = FumaRow<CoreSchema["connection"]>;388export type ConnectionRow = FumaRow<CoreSchema["connection"]>;
342export type OAuthClientRow = FumaRow<CoreSchema["oauth_client"]>;389export type OAuthClientRow = FumaRow<CoreSchema["oauth_client"]>;
343export type OAuthSessionRow = FumaRow<CoreSchema["oauth_session"]>;390export type OAuthSessionRow = FumaRow<CoreSchema["oauth_session"]>;
packages/core/sdk/src/executor.ts+328−1
@@ -107,6 +107,7 @@ import { · 106 unmodified lines
107 type UpdateToolPolicyInput,107 type UpdateToolPolicyInput,
108} from "./policies";108} from "./policies";
109import type { CredentialProvider, ProviderEntry } from "./provider";109import type { CredentialProvider, ProviderEntry } from "./provider";
110import { touchSubject } from "./subject-registry";
110import type {111import type {
111 AnyPlugin,112 AnyPlugin,
112 Elicit,113 Elicit,
@@ -368,6 +369,15 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 255 unmodified lines
368 readonly resolve: (address: ToolAddress) => Effect.Effect<EffectivePolicy, StorageFailure>;369 readonly resolve: (address: ToolAddress) => Effect.Effect<EffectivePolicy, StorageFailure>;
369 };370 };
370 371
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
371 readonly execute: (381 readonly execute: (
372 address: ToolAddress,382 address: ToolAddress,
373 args: unknown,383 args: unknown,
@@ -377,6 +387,148 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 3 unmodified lines
377 readonly close: () => Effect.Effect<void, StorageFailure>;387 readonly close: () => Effect.Effect<void, StorageFailure>;
378} & PluginExtensions<TPlugins>;388} & PluginExtensions<TPlugins>;
379 389
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). */
409export 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. */
423export 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. */
438export interface AdminSubjectWithConnections extends AdminSubject {
439 readonly connections: readonly AdminConnection[];
440}
441
442export 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 */
460export 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. */
464export 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 */
480const 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
489export 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
380export interface ExecutorDb {532export interface ExecutorDb {
381 readonly db: FumaDb<any>;533 readonly db: FumaDb<any>;
382 readonly close?: () => Effect.Effect<void, StorageFailure> | Promise<void> | void;534 readonly close?: () => Effect.Effect<void, StorageFailure> | Promise<void> | void;
@@ -447,6 +599,25 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 64 unmodified lines
447 * config-revision re-sync still apply).599 * config-revision re-sync still apply).
448 */600 */
449 readonly toolsSyncTtlMs?: number | null;601 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;
450}621}
451 622
452/** Default freshness window for remote-catalog connections (see623/** Default freshness window for remote-catalog connections (see
@@ -1369,7 +1540,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 916 unmodified lines
1369 catch: (cause) => storageFailureFromUnknown("Failed to validate executor tables", cause),1540 catch: (cause) => storageFailureFromUnknown("Failed to validate executor tables", cause),
1370 });1541 });
1371 1542
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 };
1373 const rootDb = withQueryContext(rootDbUntyped, ownerContext);1559 const rootDb = withQueryContext(rootDbUntyped, ownerContext);
1374 const fuma = makeFumaClient(rootDb);1560 const fuma = makeFumaClient(rootDb);
1375 const core = makeCoreDb(fuma);1561 const core = makeCoreDb(fuma);
@@ -2503,6 +2689,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1127 unmodified lines
2503 }),2689 }),
2504 );2690 );
2505 2691
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
2506 const ref: ConnectionRef = {2702 const ref: ConnectionRef = {
2507 owner: input.owner,2703 owner: input.owner,
2508 integration: input.integration,2704 integration: input.integration,
@@ -4160,6 +4356,136 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1651 unmodified lines
4160 }4356 }
4161 }4357 }
4162 4358
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
4163 // ------------------------------------------------------------------4489 // ------------------------------------------------------------------
4164 // close4490 // close
4165 // ------------------------------------------------------------------4491 // ------------------------------------------------------------------
@@ -4231,6 +4557,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 65 unmodified lines
4231 remove: policiesRemove,4557 remove: policiesRemove,
4232 resolve: policiesResolve,4558 resolve: policiesResolve,
4233 },4559 },
4560 ...(admin ? { admin } : {}),
4234 execute,4561 execute,
4235 close,4562 close,
4236 };4563 };
packages/core/sdk/src/host-internal.ts+14−0
@@ -20,6 +20,13 @@ · 19 unmodified lines
20// endpoint SSRF guard + default fetch timeout. The SDK's own OAuth flow uses20// endpoint SSRF guard + default fetch timeout. The SDK's own OAuth flow uses
21// them; the host's connection-identity handler reuses the same guard/timeout21// them; the host's connection-identity handler reuses the same guard/timeout
22// when fetching OIDC userinfo, so they surface here (not the plugin barrel).22// 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.
23// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
24 31
25export {32export {
@@ -31,6 +38,13 @@ export { · 5 unmodified lines
31 38
32export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers";39export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers";
33 40
41export {
42 DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS,
43 resetSubjectTouchCache,
44 touchSubject,
45 type TouchSubjectInput,
46} from "./subject-registry";
47
34export {48export {
35 createExecutorFumaDb,49 createExecutorFumaDb,
36 type CreateExecutorFumaDbOptions,50 type CreateExecutorFumaDbOptions,
packages/core/sdk/src/index.ts+10−0
@@ -142,6 +142,7 @@ export { · 141 unmodified lines
142 TOOL_POLICY_ACTIONS,142 TOOL_POLICY_ACTIONS,
143 type CoreSchema,143 type CoreSchema,
144 type IntegrationRow,144 type IntegrationRow,
145 type SubjectRow,
145 type ConnectionRow,146 type ConnectionRow,
146 type OAuthClientRow,147 type OAuthClientRow,
147 type OAuthSessionRow,148 type OAuthSessionRow,
@@ -160,6 +161,8 @@ export { · 12 unmodified lines
160 executorOwnerPolicyName,161 executorOwnerPolicyName,
161 executorUnscopedPolicyName,162 executorUnscopedPolicyName,
162 type ExecutorOwnerPolicyContext,163 type ExecutorOwnerPolicyContext,
164 type ExecutorReach,
165 type ExecutorWrites,
163} from "./owner-policy";166} from "./owner-policy";
164 167
165// Provider item-id owner grammar — the partition credential providers file168// Provider item-id owner grammar — the partition credential providers file
@@ -342,7 +345,14 @@ export { · 176 unmodified lines
342// local/cloud DB bring-up). Its definition stays here because `createExecutor`345// local/cloud DB bring-up). Its definition stays here because `createExecutor`
343// uses it; the host surface (`@executor-js/api/server`) re-exports it.346// uses it; the host surface (`@executor-js/api/server`) re-exports it.
344export {347export {
348 ADMIN_DEFAULT_PAGE_SIZE,
349 ADMIN_MAX_PAGE_SIZE,
350 type AdminConnection,
351 type AdminListSubjectsOptions,
352 type AdminSubject,
353 type AdminSubjectWithConnections,
345 type Executor,354 type Executor,
355 type ExecutorAdmin,
346 type ExecutorConfig,356 type ExecutorConfig,
347 type ExecutorDb,357 type ExecutorDb,
348 type ExecutorDbFactory,358 type ExecutorDbFactory,
packages/core/sdk/src/owner-policy-reach.test.ts+318−0
@@ -0,0 +1,318 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { withQueryContext } from "@executor-js/fumadb/query";
4
5import { collectTables } from "./executor";
6import { 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
13const TENANT = "t1";
14const OTHER_TENANT = "t2";
15const SUBJECT_A = "user_a";
16const SUBJECT_B = "user_b";
17
18const 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. */
30const 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
60const 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
92describe("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
25 25
26const unscopedExecutorTables = new Set(["blob"]);26const unscopedExecutorTables = new Set(["blob"]);
27 27
28/** How far a context's READS reach. Writes ignore this entirely — see
29 * `assertOwnerWritable`. */
30export 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. */
40export 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
28export interface ExecutorOwnerPolicyContext {46export interface ExecutorOwnerPolicyContext {
29 readonly tenant: string;47 readonly tenant: string;
30 /** The acting member, or null for a pure-org executor (no `owner:"user"`48 /** The acting member, or null for a pure-org executor (no `owner:"user"`
31 * reads/writes are allowed when null). */49 * reads/writes are allowed when null). */
32 readonly subject: string | null;50 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;
33}69}
34 70
35type AnyConditionBuilder = ConditionBuilder<Record<string, AnyColumn>>;71type AnyConditionBuilder = ConditionBuilder<Record<string, AnyColumn>>;
@@ -51,11 +87,20 @@ const requireContext = ( · 15 unmodified lines
51};87};
52 88
53/** The rows the bound `{ tenant, subject }` may see/mutate: org rows in the89/** 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. */
55export const ownerVisibilityCondition = (97export const ownerVisibilityCondition = (
56 builder: AnyConditionBuilder,98 builder: AnyConditionBuilder,
57 context: ExecutorOwnerPolicyContext,99 context: ExecutorOwnerPolicyContext,
58): Condition | boolean => {100): 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);
59 const orgClause = builder.and(104 const orgClause = builder.and(
60 builder("tenant", "=", context.tenant),105 builder("tenant", "=", context.tenant),
61 builder("owner", "=", "org"),106 builder("owner", "=", "org"),
@@ -69,6 +114,36 @@ export const ownerVisibilityCondition = ( · 7 unmodified lines
69 return builder.or(orgClause, userClause);114 return builder.or(orgClause, userClause);
70};115};
71 116
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 */
135export 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
72/** Assert a create/upsert writes a row inside the bound partition. */147/** Assert a create/upsert writes a row inside the bound partition. */
73export const assertOwnerWritable = (148export const assertOwnerWritable = (
74 tableName: string,149 tableName: string,
@@ -76,6 +151,7 @@ export const assertOwnerWritable = ( · 1 unmodified line
76 context: ExecutorOwnerPolicyContext | undefined,151 context: ExecutorOwnerPolicyContext | undefined,
77): void => {152): void => {
78 const ctx = requireContext(tableName, "write", context);153 const ctx = requireContext(tableName, "write", context);
154 assertReachReadOnly(tableName, "write", ctx);
79 if (values.tenant !== ctx.tenant) {155 if (values.tenant !== ctx.tenant) {
80 policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`);156 policyViolation(`Storage write on table "${tableName}" is outside the executor tenant.`);
81 }157 }
@@ -106,6 +182,7 @@ export const assertOwnerPatch = ( · 24 unmodified lines
106 context: ExecutorOwnerPolicyContext | undefined,182 context: ExecutorOwnerPolicyContext | undefined,
107): void => {183): void => {
108 const ctx = requireContext(tableName, "write", context);184 const ctx = requireContext(tableName, "write", context);
185 assertReachReadOnly(tableName, "write", ctx);
109 if (!patch) return;186 if (!patch) return;
110 if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) {187 if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) {
111 policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`);188 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import {
5 ADMIN_DEFAULT_PAGE_SIZE,
6 ADMIN_MAX_PAGE_SIZE,
7 collectTables,
8 createExecutor,
9 type Executor,
10} from "./executor";
11import { StorageError } from "./fuma-runtime";
12import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
13import {
14 ConnectionName,
15 IntegrationSlug,
16 OAuthClientSlug,
17 ProviderItemId,
18 ProviderKey,
19 Subject,
20 Tenant,
21} from "./ids";
22import { definePlugin } from "./plugin";
23import { 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
30const TENANT = "t1";
31const OTHER_TENANT = "t2";
32const SUBJECT_A = "user_a";
33const 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. */
38const 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 */
76const UPSTREAM_HEALTH_FIELDS = ["identity", "detail", "responseSample"] as const;
77
78const 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
85const 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. */
138const 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. */
165const 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 */
228const 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
246const 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
259const 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
265describe("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 */
409const 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
426describe("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
568describe("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
645describe("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
708describe("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
785describe("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 @@
1import { beforeEach, describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { withQueryContext } from "@executor-js/fumadb/query";
4
5import { collectTables } from "./executor";
6import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids";
7import type { ExecutorOwnerPolicyContext } from "./owner-policy";
8import { definePlugin } from "./plugin";
9import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
10import { resetSubjectTouchCache, touchSubject } from "./subject-registry";
11import { 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
17const TENANT = "t1";
18const OTHER_TENANT = "t2";
19const THROTTLE_MS = 60_000;
20
21const 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.
31const scopedTo = (db: SqliteTestFumaDb, tenant: string) =>
32 withQueryContext(db.db, {
33 tenant,
34 subject: null,
35 } satisfies ExecutorOwnerPolicyContext);
36
37const 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.
55beforeEach(() => {
56 resetSubjectTouchCache();
57});
58
59describe("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
320const INTEG = IntegrationSlug.make("vercel");
321const TEMPLATE = AuthTemplateSlug.make("apiKey");
322
323const 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
338const plugins = [memoryCredentialsPlugin(), demoPlugin] as const;
339
340const 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
352describe("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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { withQueryContext } from "@executor-js/fumadb/query";
4
5import { collectTables } from "./executor";
6import { 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
16const TENANT = "t1";
17const OTHER_TENANT = "t2";
18
19const 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
28const 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
38describe("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
28 28
29// ── Organization members ─────────────────────────────────────────────────────29// ── Organization members ─────────────────────────────────────────────────────
30 30
31export const orgMembersAtom = Atom.refreshOnWindowFocus(31// `refreshOnWindowFocus` is BROWSER-ONLY: its signal atom subscribes to
32 AccountApiClient.query("account", "listMembers", {32// `window`/`document.visibilityState` the first time the atom is read, so
33 timeToLive: "30 seconds",33// evaluating it on a server throws `ReferenceError: window is not defined` —
34 reactivityKeys: [ReactivityKey.orgMembers],34// synchronously, in render, where no Suspense boundary can absorb it.
35 }),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.
43export const orgMembersAtom = Atom.withServerValueInitial(
44 Atom.refreshOnWindowFocus(
45 AccountApiClient.query("account", "listMembers", {
46 timeToLive: "30 seconds",
47 reactivityKeys: [ReactivityKey.orgMembers],
48 }),
49 ),
36);50);
37 51
38export const orgRolesAtom = AccountApiClient.query("account", "listRoles", {52export const orgRolesAtom = AccountApiClient.query("account", "listRoles", {
packages/react/src/api/admin-atoms.tsx+53−0
@@ -0,0 +1,53 @@
1import * as Atom from "effect/unstable/reactivity/Atom";
2
3import { AdminApiClient } from "./admin-client";
4import { 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. */
22export const ADMIN_USERS_PAGE_SIZE = 25;
23
24export 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 */
36export 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. */
47export 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 @@
1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3import { AdminUsersHttpApi } from "@executor-js/api/client";
4import * as Effect from "effect/Effect";
5
6import { reportApiClientInfrastructureCause } from "./client";
7import {
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
34const 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
53export { AdminApiClient };
packages/react/src/api/reactivity-keys.tsx+4−0
@@ -36,6 +36,10 @@ export const ReactivityKey = { · 35 unmodified lines
36 orgInfo: "org:info",36 orgInfo: "org:info",
37 apiKeys: "api-keys",37 apiKeys: "api-keys",
38 auth: "auth",38 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",
39} as const;43} as const;
40 44
41/** Mutations that add/remove/refresh an integration also affect tool listings. */45/** Mutations that add/remove/refresh an integration also affect tool listings. */
packages/react/src/console-routes.test.ts+24−0
@@ -1,4 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { RESERVED_ORG_SLUGS } from "@executor-js/api";
2 3
3import { CONSOLE_ROUTE_PATHS, ORG_SLUG_SEGMENT, consoleRoutes } from "./console-routes";4import { CONSOLE_ROUTE_PATHS, ORG_SLUG_SEGMENT, consoleRoutes } from "./console-routes";
4import { routeTree } from "./routes/routeTree.gen";5import { routeTree } from "./routes/routeTree.gen";
@@ -52,6 +53,29 @@ describe("console route contract", () => { · 47 unmodified lines
52 expect(JSON.stringify(withoutSecrets)).not.toContain("secrets.tsx");53 expect(JSON.stringify(withoutSecrets)).not.toContain("secrets.tsx");
53 });54 });
54 55
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
55 it("orgScoped extras mount inside the org scope", () => {79 it("orgScoped extras mount inside the org scope", () => {
56 const [scope] = consoleRoutes({80 const [scope] = consoleRoutes({
57 dir: "shared",81 dir: "shared",
packages/react/src/console-routes.ts+10−0
@@ -34,11 +34,13 @@ export const ORG_SLUG_SEGMENT = "{-$orgSlug}"; · 33 unmodified lines
34 * are these paths prefixed with `/{-$orgSlug}`. */34 * are these paths prefixed with `/{-$orgSlug}`. */
35export const CONSOLE_ROUTE_PATHS = [35export const CONSOLE_ROUTE_PATHS = [
36 "/",36 "/",
37 "/connect/$integrationSlug",
37 "/integrations/$namespace",38 "/integrations/$namespace",
38 "/integrations/add/$pluginKey",39 "/integrations/add/$pluginKey",
39 "/policies",40 "/policies",
40 "/secrets",41 "/secrets",
41 "/tools",42 "/tools",
43 "/users",
42 "/toolkits",44 "/toolkits",
43 "/toolkits/$toolkitSlug",45 "/toolkits/$toolkitSlug",
44 "/resume/$executionId",46 "/resume/$executionId",
@@ -62,6 +64,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 17 unmodified lines
62 const file = (name: string): string => `${options.dir}/${name}`;64 const file = (name: string): string => `${options.dir}/${name}`;
63 const entries: ReadonlyArray<readonly [ConsoleRoutePath, VirtualRouteNode]> = [65 const entries: ReadonlyArray<readonly [ConsoleRoutePath, VirtualRouteNode]> = [
64 ["/", index(file("index.tsx"))],66 ["/", index(file("index.tsx"))],
67 [
68 "/connect/$integrationSlug",
69 route("/connect/$integrationSlug", file("connect.$integrationSlug.tsx")),
70 ],
65 [71 [
66 "/integrations/$namespace",72 "/integrations/$namespace",
67 route("/integrations/$namespace", file("integrations.$namespace.tsx")),73 route("/integrations/$namespace", file("integrations.$namespace.tsx")),
@@ -73,6 +79,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 5 unmodified lines
73 ["/policies", route("/policies", file("policies.tsx"))],79 ["/policies", route("/policies", file("policies.tsx"))],
74 ["/secrets", route("/secrets", file("secrets.tsx"))],80 ["/secrets", route("/secrets", file("secrets.tsx"))],
75 ["/tools", route("/tools", file("tools.tsx"))],81 ["/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"))],
76 ["/toolkits", route("/toolkits", file("toolkits.tsx"))],86 ["/toolkits", route("/toolkits", file("toolkits.tsx"))],
77 ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],87 ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],
78 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],88 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],
packages/react/src/lib/admin-access.test.ts+52−0
@@ -0,0 +1,52 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { isTenantAdminMember, type TenantMemberRow } from "./admin-access";
4
5const member = (overrides: Partial<TenantMemberRow> = {}): TenantMemberRow => ({
6 role: "member",
7 status: "active",
8 isCurrentUser: false,
9 ...overrides,
10});
11
12describe("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. */
24const TENANT_ADMIN_ROLES: ReadonlySet<string> = new Set(["admin", "owner"]);
25
26/** A member row as `/account/members` returns it, typed structurally. */
27export 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 */
42export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import type { IntegrationSlug } from "@executor-js/sdk/shared";
3
4import {
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
21const 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. */
25const 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()`). */
33const builtInRow: AdminCatalogRow = { slug: slug("executor"), kind: "built-in" };
34
35const 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
44describe("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
76describe("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
98describe("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
147describe("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
167describe("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
179describe("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
198describe("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
267describe("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
306describe("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 @@
1import 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. */
13export 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
23const MINUTE = 60_000;
24const HOUR = 60 * MINUTE;
25const 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 */
37export 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. */
58export 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. */
64export 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 */
80export const LOCAL_SUBJECT_ID = "local";
81
82export 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. */
86export 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. */
95export 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 */
119export interface AdminUserTitle {
120 readonly primary: string;
121 readonly secondary: string | null;
122 readonly primaryIsId: boolean;
123}
124
125export 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. */
137export 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. */
146export 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. */
152export 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. */
159const 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 */
178export 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 */
199export interface IntegrationConnectionState {
200 readonly integration: IntegrationSlug;
201 readonly connected: boolean;
202 readonly connections: readonly AdminConnectionRow[];
203}
204
205export 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 */
256export 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 */
273export 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. */
282export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3
4import { orgMembersAtom } from "../api/account-atoms";
5import { isTenantAdminMember, type TenantMemberRow } from "../lib/admin-access";
6import 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. */
32export 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 */
47export 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 @@
1import { useState } from "react";
2import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
3import { useParams } from "@tanstack/react-router";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Cause from "effect/Cause";
6import * as Option from "effect/Option";
7import * as Predicate from "effect/Predicate";
8import type { HealthStatus, Integration, IntegrationSlug } from "@executor-js/sdk/shared";
9import { useIntegrationPlugins } from "@executor-js/sdk/client";
10
11import {
12 ADMIN_USERS_PAGE_SIZE,
13 adminUserConnectionsAtom,
14 adminUsersWithConnectionsAtom,
15} from "../api/admin-atoms";
16import { integrationsOptimisticAtom } from "../api/atoms";
17import { ownerLabel } from "../api/owner-display";
18import { Button } from "../components/button";
19import { CopyButton } from "../components/copy-button";
20import { ErrorState } from "../components/error-state";
21import {
22 IntegrationFavicon,
23 integrationInferredUrl,
24 integrationPresetIconUrl,
25} from "../components/integration-favicon";
26import { PageContainer, PageHeader } from "../components/page";
27import {
28 Sheet,
29 SheetContent,
30 SheetDescription,
31 SheetHeader,
32 SheetTitle,
33} from "../components/sheet";
34import { Skeleton } from "../components/skeleton";
35import {
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";
51import {
52 HEALTH_INDICATOR_COLOR,
53 HEALTH_STATUS_LABEL,
54 HEALTH_TEXT_CLASS,
55} from "../lib/health-display";
56import { isAsyncResultLoading } from "../lib/async-result";
57import { 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
78type 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. */
94const 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
104function 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. */
130function 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. */
153type IconLookup = (integration: IntegrationSlug) => {
154 readonly icon: string | null;
155 readonly url: string | undefined;
156 readonly name: string;
157};
158
159const 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. */
188const 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 */
205function 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 */
275function 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 */
325function 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
365function 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
526export 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 @@
1import { useEffect } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomValue, useAtomRefresh } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { IntegrationSlug } from "@executor-js/sdk/shared";
6
7import { integrationAtom, integrationsOptimisticAtom } from "../api/atoms";
8import { ErrorState } from "../components/error-state";
9import { Skeleton } from "../components/skeleton";
10import { isAsyncResultLoading } from "../lib/async-result";
11import { 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
31export 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
103function 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
60export function IntegrationDetailPage(props: {60export function IntegrationDetailPage(props: {
61 namespace: string;61 namespace: string;
62 tab?: IntegrationDetailSearchTab;62 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;
63}) {67}) {
64 const { namespace } = props;68 const { namespace } = props;
65 const slug = IntegrationSlug.make(namespace);69 const slug = IntegrationSlug.make(namespace);
@@ -122,9 +126,11 @@ export function IntegrationDetailPage(props: { · 56 unmodified lines
122 const canRefresh = integrationData?.canRefresh ?? false;126 const canRefresh = integrationData?.canRefresh ?? false;
123 const canRemove = integrationData?.canRemove ?? false;127 const canRemove = integrationData?.canRemove ?? false;
124 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {128 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {
125 if (locationSearch.length === 0) return null;
126 const search = new URLSearchParams(locationSearch);129 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;
128 const owner = search.get("owner");134 const owner = search.get("owner");
129 const template = search.get("template");135 const template = search.get("template");
130 const label = search.get("label");136 const label = search.get("label");
@@ -149,13 +155,13 @@ export function IntegrationDetailPage(props: { · 18 unmodified lines
149 };155 };
150 })();156 })();
151 return {157 return {
152 key: locationSearch,158 key: `${String(props.addAccount)}:${locationSearch}`,
153 ...(owner === "org" || owner === "user" ? { owner } : {}),159 ...(owner === "org" || owner === "user" ? { owner } : {}),
154 ...(template != null && template.length > 0 ? { template } : {}),160 ...(template != null && template.length > 0 ? { template } : {}),
155 ...(label != null && label.length > 0 ? { label } : {}),161 ...(label != null && label.length > 0 ? { label } : {}),
156 ...(oauthClient !== undefined ? { oauthClient } : {}),162 ...(oauthClient !== undefined ? { oauthClient } : {}),
157 };163 };
158 }, [locationSearch]);164 }, [locationSearch, props.addAccount]);
159 const accountHandoff = manualAccountHandoff ?? urlAccountHandoff;165 const accountHandoff = manualAccountHandoff ?? urlAccountHandoff;
160 166
161 useEffect(() => {167 useEffect(() => {
packages/react/src/routes/connect-deep-link.test.ts+121−0
@@ -0,0 +1,121 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 createMemoryHistory,
4 createRootRoute,
5 createRoute,
6 createRouter,
7} from "@tanstack/react-router";
8
9import { CONSOLE_ROUTE_PATHS } from "../console-routes";
10import { 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
18type RouteMatch = { readonly routeId: string; readonly params: Record<string, string> };
19type SpikeRouter = {
20 matchRoutes(pathname: string): ReadonlyArray<RouteMatch>;
21 buildLocation(options: unknown): { readonly href: string };
22};
23
24const CONNECT_ROUTE_ID = "/{-$orgSlug}/connect/$integrationSlug";
25
26const 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
50describe("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
107describe("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 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ConnectIntegrationPage } from "../pages/connect-integration";
4
5export 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
4import { IntegrationDetailPage } from "../pages/integration-detail";4import { IntegrationDetailPage } from "../pages/integration-detail";
5import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";5import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";
6 6
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.
7const SearchParams = Schema.toStandardSchemaV1(20const SearchParams = Schema.toStandardSchemaV1(
8 Schema.Struct({21 Schema.Struct({
9 tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),22 tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),
23 addAccount: Schema.optional(Schema.Unknown),
10 }),24 }),
11);25);
12 26
27/** The truthy spellings of the `addAccount` URL flag. */
28export const isAddAccountRequested = (value: unknown): boolean =>
29 value === 1 || value === "1" || value === true;
30
13export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({31export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({
14 validateSearch: SearchParams,32 validateSearch: SearchParams,
15 component: () => {33 component: () => {
16 const { namespace } = Route.useParams();34 const { namespace } = Route.useParams();
17 const { tab } = Route.useSearch();35 const { tab, addAccount } = Route.useSearch();
18 return (36 return (
19 <IntegrationDetailPage37 <IntegrationDetailPage
20 namespace={namespace}38 namespace={namespace}
21 tab={tab as IntegrationDetailSearchTab | undefined}39 tab={tab as IntegrationDetailSearchTab | undefined}
40 addAccount={isAddAccountRequested(addAccount)}
22 />41 />
23 );42 );
24 },43 },
packages/react/src/routes/routeTree.gen.ts+43−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 10
11import { Route as rootRouteImport } from './__root'11import { Route as rootRouteImport } from './__root'
12import { Route as DotIndexRouteImport } from './index'12import { Route as DotIndexRouteImport } from './index'
13import { Route as DotUsersRouteImport } from './users'
13import { Route as DotToolsRouteImport } from './tools'14import { Route as DotToolsRouteImport } from './tools'
14import { Route as DotToolkitsRouteImport } from './toolkits'15import { Route as DotToolkitsRouteImport } from './toolkits'
15import { Route as DotSecretsRouteImport } from './secrets'16import { Route as DotSecretsRouteImport } from './secrets'
@@ -17,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 1 unmodified line
17import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'18import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
18import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'19import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
19import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'20import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
21import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'
20import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'22import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'
21import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey'23import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey'
22 24
@@ -25,6 +27,11 @@ const DotIndexRoute = DotIndexRouteImport.update({ · 2 unmodified lines
25 path: '/{-$orgSlug}/',27 path: '/{-$orgSlug}/',
26 getParentRoute: () => rootRouteImport,28 getParentRoute: () => rootRouteImport,
27} as any)29} as any)
30const DotUsersRoute = DotUsersRouteImport.update({
31 id: '/{-$orgSlug}/users',
32 path: '/{-$orgSlug}/users',
33 getParentRoute: () => rootRouteImport,
34} as any)
28const DotToolsRoute = DotToolsRouteImport.update({35const DotToolsRoute = DotToolsRouteImport.update({
29 id: '/{-$orgSlug}/tools',36 id: '/{-$orgSlug}/tools',
30 path: '/{-$orgSlug}/tools',37 path: '/{-$orgSlug}/tools',
@@ -62,6 +69,12 @@ const DotIntegrationsDotnamespaceRoute = · 31 unmodified lines
62 path: '/{-$orgSlug}/integrations/$namespace',69 path: '/{-$orgSlug}/integrations/$namespace',
63 getParentRoute: () => rootRouteImport,70 getParentRoute: () => rootRouteImport,
64 } as any)71 } as any)
72const DotConnectDotintegrationSlugRoute =
73 DotConnectDotintegrationSlugRouteImport.update({
74 id: '/{-$orgSlug}/connect/$integrationSlug',
75 path: '/{-$orgSlug}/connect/$integrationSlug',
76 getParentRoute: () => rootRouteImport,
77 } as any)
65const DotPluginsDotpluginIdDotsplatRoute =78const DotPluginsDotpluginIdDotsplatRoute =
66 DotPluginsDotpluginIdDotsplatRouteImport.update({79 DotPluginsDotpluginIdDotsplatRouteImport.update({
67 id: '/{-$orgSlug}/plugins/$pluginId/$',80 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -80,7 +93,9 @@ export interface FileRoutesByFullPath { · 12 unmodified lines
80 '/{-$orgSlug}/secrets': typeof DotSecretsRoute93 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
81 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren94 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
82 '/{-$orgSlug}/tools': typeof DotToolsRoute95 '/{-$orgSlug}/tools': typeof DotToolsRoute
96 '/{-$orgSlug}/users': typeof DotUsersRoute
83 '/{-$orgSlug}/': typeof DotIndexRoute97 '/{-$orgSlug}/': typeof DotIndexRoute
98 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
84 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute99 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
85 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute100 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
86 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute101 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -92,7 +107,9 @@ export interface FileRoutesByTo { · 5 unmodified lines
92 '/{-$orgSlug}/secrets': typeof DotSecretsRoute107 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
93 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren108 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
94 '/{-$orgSlug}/tools': typeof DotToolsRoute109 '/{-$orgSlug}/tools': typeof DotToolsRoute
110 '/{-$orgSlug}/users': typeof DotUsersRoute
95 '/{-$orgSlug}': typeof DotIndexRoute111 '/{-$orgSlug}': typeof DotIndexRoute
112 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
96 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute113 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
97 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute114 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
98 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute115 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -105,7 +122,9 @@ export interface FileRoutesById { · 6 unmodified lines
105 '/{-$orgSlug}/secrets': typeof DotSecretsRoute122 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
106 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren123 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
107 '/{-$orgSlug}/tools': typeof DotToolsRoute124 '/{-$orgSlug}/tools': typeof DotToolsRoute
125 '/{-$orgSlug}/users': typeof DotUsersRoute
108 '/{-$orgSlug}/': typeof DotIndexRoute126 '/{-$orgSlug}/': typeof DotIndexRoute
127 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
109 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute128 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
110 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute129 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
111 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute130 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -119,7 +138,9 @@ export interface FileRouteTypes { · 7 unmodified lines
119 | '/{-$orgSlug}/secrets'138 | '/{-$orgSlug}/secrets'
120 | '/{-$orgSlug}/toolkits'139 | '/{-$orgSlug}/toolkits'
121 | '/{-$orgSlug}/tools'140 | '/{-$orgSlug}/tools'
141 | '/{-$orgSlug}/users'
122 | '/{-$orgSlug}/'142 | '/{-$orgSlug}/'
143 | '/{-$orgSlug}/connect/$integrationSlug'
123 | '/{-$orgSlug}/integrations/$namespace'144 | '/{-$orgSlug}/integrations/$namespace'
124 | '/{-$orgSlug}/resume/$executionId'145 | '/{-$orgSlug}/resume/$executionId'
125 | '/{-$orgSlug}/toolkits/$toolkitSlug'146 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -131,7 +152,9 @@ export interface FileRouteTypes { · 5 unmodified lines
131 | '/{-$orgSlug}/secrets'152 | '/{-$orgSlug}/secrets'
132 | '/{-$orgSlug}/toolkits'153 | '/{-$orgSlug}/toolkits'
133 | '/{-$orgSlug}/tools'154 | '/{-$orgSlug}/tools'
155 | '/{-$orgSlug}/users'
134 | '/{-$orgSlug}'156 | '/{-$orgSlug}'
157 | '/{-$orgSlug}/connect/$integrationSlug'
135 | '/{-$orgSlug}/integrations/$namespace'158 | '/{-$orgSlug}/integrations/$namespace'
136 | '/{-$orgSlug}/resume/$executionId'159 | '/{-$orgSlug}/resume/$executionId'
137 | '/{-$orgSlug}/toolkits/$toolkitSlug'160 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -143,7 +166,9 @@ export interface FileRouteTypes { · 5 unmodified lines
143 | '/{-$orgSlug}/secrets'166 | '/{-$orgSlug}/secrets'
144 | '/{-$orgSlug}/toolkits'167 | '/{-$orgSlug}/toolkits'
145 | '/{-$orgSlug}/tools'168 | '/{-$orgSlug}/tools'
169 | '/{-$orgSlug}/users'
146 | '/{-$orgSlug}/'170 | '/{-$orgSlug}/'
171 | '/{-$orgSlug}/connect/$integrationSlug'
147 | '/{-$orgSlug}/integrations/$namespace'172 | '/{-$orgSlug}/integrations/$namespace'
148 | '/{-$orgSlug}/resume/$executionId'173 | '/{-$orgSlug}/resume/$executionId'
149 | '/{-$orgSlug}/toolkits/$toolkitSlug'174 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -156,7 +181,9 @@ export interface RootRouteChildren { · 6 unmodified lines
156 DotSecretsRoute: typeof DotSecretsRoute181 DotSecretsRoute: typeof DotSecretsRoute
157 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren182 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
158 DotToolsRoute: typeof DotToolsRoute183 DotToolsRoute: typeof DotToolsRoute
184 DotUsersRoute: typeof DotUsersRoute
159 DotIndexRoute: typeof DotIndexRoute185 DotIndexRoute: typeof DotIndexRoute
186 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
160 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute187 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
161 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute188 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute
162 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute189 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -172,6 +199,13 @@ declare module '@tanstack/react-router' { · 9 unmodified lines
172 preLoaderRoute: typeof DotIndexRouteImport199 preLoaderRoute: typeof DotIndexRouteImport
173 parentRoute: typeof rootRouteImport200 parentRoute: typeof rootRouteImport
174 }201 }
202 '/{-$orgSlug}/users': {
203 id: '/{-$orgSlug}/users'
204 path: '/{-$orgSlug}/users'
205 fullPath: '/{-$orgSlug}/users'
206 preLoaderRoute: typeof DotUsersRouteImport
207 parentRoute: typeof rootRouteImport
208 }
175 '/{-$orgSlug}/tools': {209 '/{-$orgSlug}/tools': {
176 id: '/{-$orgSlug}/tools'210 id: '/{-$orgSlug}/tools'
177 path: '/{-$orgSlug}/tools'211 path: '/{-$orgSlug}/tools'
@@ -221,6 +255,13 @@ declare module '@tanstack/react-router' { · 43 unmodified lines
221 preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport255 preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport
222 parentRoute: typeof rootRouteImport256 parentRoute: typeof rootRouteImport
223 }257 }
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 }
224 '/{-$orgSlug}/plugins/$pluginId/$': {265 '/{-$orgSlug}/plugins/$pluginId/$': {
225 id: '/{-$orgSlug}/plugins/$pluginId/$'266 id: '/{-$orgSlug}/plugins/$pluginId/$'
226 path: '/{-$orgSlug}/plugins/$pluginId/$'267 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -255,7 +296,9 @@ const rootRouteChildren: RootRouteChildren = { · 28 unmodified lines
255 DotSecretsRoute: DotSecretsRoute,296 DotSecretsRoute: DotSecretsRoute,
256 DotToolkitsRoute: DotToolkitsRouteWithChildren,297 DotToolkitsRoute: DotToolkitsRouteWithChildren,
257 DotToolsRoute: DotToolsRoute,298 DotToolsRoute: DotToolsRoute,
299 DotUsersRoute: DotUsersRoute,
258 DotIndexRoute: DotIndexRoute,300 DotIndexRoute: DotIndexRoute,
301 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
259 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,302 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
260 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,303 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,
261 DotIntegrationsDotaddDotpluginKeyRoute:304 DotIntegrationsDotaddDotpluginKeyRoute:
packages/react/src/routes/users.tsx+7−0
@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { AdminUsersPage } from "../pages/admin-users";
4
5export 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 */
27import { spawnSync } from "node:child_process";
28import { readFileSync, writeFileSync } from "node:fs";
29import { resolve } from "node:path";
30
31const 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 */
37const 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
45const 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 */
59const 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.
67const before = new Map<string, string>();
68for (const { tree } of TREES) {
69 before.set(tree, readFileSync(resolve(repoRoot, tree), "utf8"));
70}
71
72const 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. */
78const 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
93type Failure = { readonly tree: string; readonly detail: string };
94const failures: Failure[] = [];
95
96for (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
120restore();
121
122if (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
138console.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