* 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
Unified · Split
| @@ -136,6 +136,12 @@ jobs: · 135 unmodified lines | ||
| 136 | 136 | |
| 137 | 137 | - 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 | 145 | test: |
| 140 | 146 | name: Test |
| 141 | 147 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| @@ -0,0 +1,10 @@ | ||
| 1 | +CREATE TABLE "subject" ( | |
| 2 | + "external_id" varchar(255) NOT NULL, | |
| 3 | + "created_at" timestamp NOT NULL, | |
| 4 | + "last_seen_at" bigint, | |
| 5 | + "status" text, | |
| 6 | + "row_id" varchar(255) PRIMARY KEY NOT NULL, | |
| 7 | + "tenant" varchar(255) NOT NULL | |
| 8 | +); | |
| 9 | +--> statement-breakpoint | |
| 10 | +CREATE UNIQUE INDEX "subject_uidx" ON "subject" USING btree ("tenant","external_id"); | |
| @@ -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 | +} | |
| @@ -85,6 +85,13 @@ · 84 unmodified lines | ||
| 85 | 85 | "when": 1784581232732, |
| 86 | 86 | "tag": "0011_tense_caretaker", |
| 87 | 87 | "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 | } |
| @@ -0,0 +1,192 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Layer } from "effect"; | |
| 3 | + | |
| 4 | +import { AccountProvider } from "@executor-js/api/server"; | |
| 5 | +import { AccountError, AccountForbidden } from "@executor-js/api"; | |
| 6 | + | |
| 7 | +import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; | |
| 8 | +import { UserStoreService } from "../auth/context"; | |
| 9 | +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | |
| 10 | +import { AutumnService } from "../extensions/billing/service"; | |
| 11 | +import { AccountCaller, workosAccountProvider } from "./workos-account-service"; | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | +// `DELETE /account/org-api-keys/:apiKeyId` at the PROVIDER boundary. | |
| 15 | +// | |
| 16 | +// An org key reads every user and connection in the tenant through `/admin/*`, | |
| 17 | +// so it is the most privileged credential the product issues. Two properties | |
| 18 | +// have to hold together, and only this seam sees both: | |
| 19 | +// | |
| 20 | +// 1. Revoke is gated on org ADMIN membership — the same gate as the mint. A | |
| 21 | +// plain member calling it gets 403 and no delete goes out. | |
| 22 | +// 2. Revoke resolves ownership against the ORG's keys. `revokeApiKey` (the | |
| 23 | +// personal path) checks the caller's OWN key list, so it can never revoke | |
| 24 | +// an org key; conversely this path must never destroy a member's personal | |
| 25 | +// key. The scope-blind `deleteApiKey` would do exactly that if handed a | |
| 26 | +// user key id, so a wrong id has to fail cleanly instead. | |
| 27 | +// | |
| 28 | +// Ownership filtering itself is pinned per-shape in | |
| 29 | +// `auth/org-api-key-mint.node.test.ts`; here it is asserted end-to-end through | |
| 30 | +// the gate, on the provider the HTTP handler actually calls. | |
| 31 | +// --------------------------------------------------------------------------- | |
| 32 | + | |
| 33 | +const ORG = "org_123"; | |
| 34 | +const ADMIN = "user_admin"; | |
| 35 | +const MEMBER = "user_member"; | |
| 36 | +const ORG_KEY = "key_org_1"; | |
| 37 | +const USER_KEY = "key_user_1"; | |
| 38 | +const createdAt = new Date("2026-01-01T00:00:00.000Z"); | |
| 39 | + | |
| 40 | +const session = (accountId: string) => ({ | |
| 41 | + accountId, | |
| 42 | + email: `${accountId}@example.test`, | |
| 43 | + name: null, | |
| 44 | + avatarUrl: null, | |
| 45 | + organizationId: ORG, | |
| 46 | + sealedSession: "sealed", | |
| 47 | + refreshedSession: null, | |
| 48 | +}); | |
| 49 | + | |
| 50 | +/** Membership roles: only ADMIN carries the `admin` role slug. */ | |
| 51 | +const stubWorkOS = Layer.succeed( | |
| 52 | + WorkOSClient, | |
| 53 | + new Proxy({} as WorkOSClientService, { | |
| 54 | + get: (_target, prop) => { | |
| 55 | + if (prop === "listUserMemberships") { | |
| 56 | + return (userId: string) => | |
| 57 | + Effect.succeed({ | |
| 58 | + data: [{ userId, organizationId: ORG, status: "active" }], | |
| 59 | + }); | |
| 60 | + } | |
| 61 | + if (prop === "getUserOrgMembership") { | |
| 62 | + return (organizationId: string, userId: string) => | |
| 63 | + Effect.succeed( | |
| 64 | + organizationId === ORG | |
| 65 | + ? { | |
| 66 | + id: `om_${userId}`, | |
| 67 | + userId, | |
| 68 | + organizationId, | |
| 69 | + role: { slug: userId === ADMIN ? "admin" : "member" }, | |
| 70 | + } | |
| 71 | + : null, | |
| 72 | + ); | |
| 73 | + } | |
| 74 | + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | |
| 75 | + }, | |
| 76 | + }), | |
| 77 | +); | |
| 78 | + | |
| 79 | +const stubUsers = Layer.succeed(UserStoreService)({ | |
| 80 | + use: (fn) => | |
| 81 | + Effect.promise(() => | |
| 82 | + fn({ | |
| 83 | + ensureAccount: async (id: string) => ({ id, createdAt }), | |
| 84 | + getAccount: async (id: string) => ({ id, createdAt }), | |
| 85 | + upsertOrganization: async (org: { id: string; name: string }) => ({ | |
| 86 | + ...org, | |
| 87 | + slug: org.id, | |
| 88 | + createdAt, | |
| 89 | + }), | |
| 90 | + getOrganization: async (id: string) => ({ | |
| 91 | + id, | |
| 92 | + name: `Org ${id}`, | |
| 93 | + slug: id, | |
| 94 | + createdAt, | |
| 95 | + }), | |
| 96 | + getOrganizationBySlug: async (slug: string) => ({ | |
| 97 | + id: slug, | |
| 98 | + name: `Org ${slug}`, | |
| 99 | + slug, | |
| 100 | + createdAt, | |
| 101 | + }), | |
| 102 | + deleteOrganizationCascade: async () => {}, | |
| 103 | + }), | |
| 104 | + ), | |
| 105 | +}); | |
| 106 | + | |
| 107 | +const stubAutumn = Layer.succeed(AutumnService)({ | |
| 108 | + use: () => Effect.die("revoke does not touch billing"), | |
| 109 | + checkExecutionBalance: () => Effect.die("revoke does not touch billing"), | |
| 110 | + trackExecution: () => Effect.void, | |
| 111 | +}); | |
| 112 | + | |
| 113 | +/** | |
| 114 | + * The provider under test, over an `ApiKeyService` whose org listing holds a | |
| 115 | + * single org-owned key. Revoked ids land in `revoked` so "no delete happened" | |
| 116 | + * is assertable, not merely inferred from the error. | |
| 117 | + */ | |
| 118 | +const providerWith = (accountId: string) => { | |
| 119 | + const revoked: string[] = []; | |
| 120 | + const stubApiKeys = Layer.succeed(ApiKeyService)({ | |
| 121 | + validate: () => Effect.die("revoke test does not validate keys"), | |
| 122 | + listUserKeys: () => Effect.succeed([]), | |
| 123 | + createUserKey: () => Effect.die("revoke test does not create user keys"), | |
| 124 | + revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"), | |
| 125 | + listOrgKeys: () => Effect.die("revoke test does not list org keys"), | |
| 126 | + createOrgKey: () => Effect.die("revoke test does not create org keys"), | |
| 127 | + revokeOrgKey: ({ keyId }) => | |
| 128 | + keyId === ORG_KEY | |
| 129 | + ? Effect.sync(() => { | |
| 130 | + revoked.push(keyId); | |
| 131 | + }) | |
| 132 | + : Effect.fail(new OrgApiKeyNotFound({ keyId })), | |
| 133 | + }); | |
| 134 | + | |
| 135 | + const provider = AccountProvider.asEffect().pipe( | |
| 136 | + Effect.provide( | |
| 137 | + workosAccountProvider.pipe( | |
| 138 | + Layer.provide( | |
| 139 | + Layer.mergeAll( | |
| 140 | + stubWorkOS, | |
| 141 | + stubUsers, | |
| 142 | + stubApiKeys, | |
| 143 | + stubAutumn, | |
| 144 | + Layer.succeed(AccountCaller)({ session: session(accountId) }), | |
| 145 | + ), | |
| 146 | + ), | |
| 147 | + ), | |
| 148 | + ), | |
| 149 | + ); | |
| 150 | + | |
| 151 | + return { provider, revoked }; | |
| 152 | +}; | |
| 153 | + | |
| 154 | +describe("revokeOrgApiKey · provider boundary", () => { | |
| 155 | + it.effect("an org admin revokes an org-owned key", () => | |
| 156 | + Effect.gen(function* () { | |
| 157 | + const { provider, revoked } = providerWith(ADMIN); | |
| 158 | + const account = yield* provider; | |
| 159 | + | |
| 160 | + const result = yield* account.revokeOrgApiKey({}, ORG_KEY); | |
| 161 | + | |
| 162 | + expect(result).toEqual({ success: true }); | |
| 163 | + expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]); | |
| 164 | + }), | |
| 165 | + ); | |
| 166 | + | |
| 167 | + it.effect("a plain member is forbidden, and nothing is revoked", () => | |
| 168 | + Effect.gen(function* () { | |
| 169 | + const { provider, revoked } = providerWith(MEMBER); | |
| 170 | + const account = yield* provider; | |
| 171 | + | |
| 172 | + const error = yield* Effect.flip(account.revokeOrgApiKey({}, ORG_KEY)); | |
| 173 | + | |
| 174 | + expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden); | |
| 175 | + expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]); | |
| 176 | + }), | |
| 177 | + ); | |
| 178 | + | |
| 179 | + it.effect("a user-scoped key id fails cleanly through this path", () => | |
| 180 | + Effect.gen(function* () { | |
| 181 | + // Not found — not a 500, and emphatically not a silent success that would | |
| 182 | + // report a member's live personal credential as destroyed. | |
| 183 | + const { provider, revoked } = providerWith(ADMIN); | |
| 184 | + const account = yield* provider; | |
| 185 | + | |
| 186 | + const error = yield* Effect.flip(account.revokeOrgApiKey({}, USER_KEY)); | |
| 187 | + | |
| 188 | + expect(error).toBeInstanceOf(AccountError); | |
| 189 | + expect(revoked).toEqual([]); | |
| 190 | + }), | |
| 191 | + ); | |
| 192 | +}); | |
| @@ -246,6 +246,52 @@ export const workosAccountProvider: Layer.Layer< · 245 unmodified lines | ||
| 246 | 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 | 295 | listMembers: (headers) => |
| 250 | 296 | Effect.gen(function* () { |
| 251 | 297 | const { session, org } = yield* requireOrganization(headers); |
| @@ -0,0 +1,356 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers` | |
| 3 | +// backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`. | |
| 4 | +// | |
| 5 | +// TWO credentials reach this plane, and they are the two an operator actually | |
| 6 | +// has: | |
| 7 | +// 1. an ORG-SCOPED api key -> `PlatformAuth`. The key IS the authority: WorkOS | |
| 8 | +// validated it and reported which org owns it, and there is no member | |
| 9 | +// behind it to check membership for. This is the machine credential | |
| 10 | +// (a customer's backend calling us). | |
| 11 | +// 2. an admin SESSION member -> the console. Requires a live `getUserOrgMembership` | |
| 12 | +// whose role slug is `admin` AND whose status is `active`, matching the | |
| 13 | +// strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) — | |
| 14 | +// a pending admin invite is not an admin. | |
| 15 | +// A plain member session, or a USER-scoped api key, is refused: both name one | |
| 16 | +// acting member, and this plane deliberately serves the whole tenant. | |
| 17 | +// | |
| 18 | +// The executor is built by `makePlatformExecutor` — `{ tenant, subject: | |
| 19 | +// undefined, platformView: true }` — so the reads are tenant-wide and read-only | |
| 20 | +// by storage policy, and no `subject` row is minted for the caller. | |
| 21 | +// | |
| 22 | +// Cross-tenant isolation is structural, not a check in this file: the tenant is | |
| 23 | +// taken from the resolved credential (the key's own org, or the session's | |
| 24 | +// authorized org), never from client input, and `reach: "tenant"` still filters | |
| 25 | +// every query by that tenant. | |
| 26 | +// --------------------------------------------------------------------------- | |
| 27 | + | |
| 28 | +import { HttpRouter } from "effect/unstable/http"; | |
| 29 | +import { Context, Effect, Layer, Option } from "effect"; | |
| 30 | + | |
| 31 | +import { | |
| 32 | + AdminUsersProvider, | |
| 33 | + DbProvider, | |
| 34 | + HostConfig, | |
| 35 | + PluginsProvider, | |
| 36 | + getAdminUser, | |
| 37 | + listAdminUserConnections, | |
| 38 | + listAdminUsers, | |
| 39 | + listAdminUsersWithConnections, | |
| 40 | + makeAdminUsersApiLayer, | |
| 41 | + makePlatformExecutor, | |
| 42 | + normalizeAdminUserEmail, | |
| 43 | + platformViewOf, | |
| 44 | + requestScopedMiddleware, | |
| 45 | + type AdminEmailResolver, | |
| 46 | + type AdminIdentityDirectory, | |
| 47 | + type AdminUserDirectory, | |
| 48 | + type AdminUserIdentity, | |
| 49 | + type AdminUsersHeaders, | |
| 50 | +} from "@executor-js/api/server"; | |
| 51 | +import { | |
| 52 | + AdminUserNotFound, | |
| 53 | + AdminUsersError, | |
| 54 | + AdminUsersForbidden, | |
| 55 | + AdminUsersUnauthorized, | |
| 56 | +} from "@executor-js/api"; | |
| 57 | +import type { Executor } from "@executor-js/sdk"; | |
| 58 | + | |
| 59 | +import { ApiKeyService } from "../auth/api-keys"; | |
| 60 | +import { UserStoreService } from "../auth/context"; | |
| 61 | +import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider"; | |
| 62 | +import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization"; | |
| 63 | +import { WorkOSClient } from "../auth/workos"; | |
| 64 | +import { DbService } from "../db/db"; | |
| 65 | +import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; | |
| 66 | + | |
| 67 | +/** | |
| 68 | + * Resolve the tenant this request may read, or fail with the neutral 401/403. | |
| 69 | + * | |
| 70 | + * Returns only the organization id: nothing downstream needs to know WHICH of | |
| 71 | + * the two credentials got the caller here, and keeping the acting member out of | |
| 72 | + * the return value means no admin read can accidentally become subject-scoped. | |
| 73 | + */ | |
| 74 | +const authorizeTenant = ( | |
| 75 | + request: Request, | |
| 76 | +): Effect.Effect< | |
| 77 | + string, | |
| 78 | + AdminUsersUnauthorized | AdminUsersForbidden, | |
| 79 | + WorkOSClient | ApiKeyService | UserStoreService | |
| 80 | +> => | |
| 81 | + Effect.gen(function* () { | |
| 82 | + // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`, | |
| 83 | + // which rejects org keys for the product plane) is what distinguishes an | |
| 84 | + // org key from a user key. | |
| 85 | + const bearer = yield* resolveBearerAuth(request).pipe( | |
| 86 | + // Every rejected-credential and infra failure collapses to one refusal: | |
| 87 | + // this plane must not report whether a key exists, belongs to another | |
| 88 | + // org, or merely lacks privilege. | |
| 89 | + Effect.catchCause(() => Effect.succeed(null)), | |
| 90 | + ); | |
| 91 | + if (bearer !== null) { | |
| 92 | + if (isPlatformAuth(bearer)) return bearer.organizationId; | |
| 93 | + // A user-scoped key authenticated fine but names one member; the platform | |
| 94 | + // plane has no honest way to serve it. | |
| 95 | + return yield* new AdminUsersForbidden(); | |
| 96 | + } | |
| 97 | + | |
| 98 | + // (2) The session path: a live admin membership in the selected org. | |
| 99 | + const workos = yield* WorkOSClient; | |
| 100 | + const session = yield* workos | |
| 101 | + .authenticateRequest(request) | |
| 102 | + .pipe(Effect.catchCause(() => Effect.succeed(null))); | |
| 103 | + if (!session) return yield* new AdminUsersUnauthorized(); | |
| 104 | + | |
| 105 | + const selector = orgSelectorFromRequest(request) ?? session.organizationId; | |
| 106 | + if (!selector) return yield* new AdminUsersForbidden(); | |
| 107 | + // Re-checks live membership, so the org selector header can only ever name | |
| 108 | + // an org the caller already belongs to. | |
| 109 | + const org = yield* authorizeOrganizationSelector(session.userId, selector).pipe( | |
| 110 | + Effect.catchCause(() => Effect.succeed(null)), | |
| 111 | + ); | |
| 112 | + if (!org) return yield* new AdminUsersForbidden(); | |
| 113 | + | |
| 114 | + const membership = yield* workos | |
| 115 | + .getUserOrgMembership(org.id, session.userId) | |
| 116 | + .pipe(Effect.catchCause(() => Effect.succeed(null))); | |
| 117 | + // A pending admin invite is not an active admin — require both. | |
| 118 | + if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") { | |
| 119 | + return yield* new AdminUsersForbidden(); | |
| 120 | + } | |
| 121 | + return org.id; | |
| 122 | + }); | |
| 123 | + | |
| 124 | +/** | |
| 125 | + * How many user-detail reads run at once. Matches the account plane's own | |
| 126 | + * member listing (`workos-account-service.ts`), which fans out the same way for | |
| 127 | + * the same reason. | |
| 128 | + */ | |
| 129 | +const IDENTITY_CONCURRENCY = 5; | |
| 130 | + | |
| 131 | +/** | |
| 132 | + * Cloud's member directory: `externalId` → email/name. | |
| 133 | + * | |
| 134 | + * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that | |
| 135 | + * `workos-auth-provider.ts` binds as `accountId` on every credential path, and | |
| 136 | + * therefore what the subject table records in `external_id`. The membership's | |
| 137 | + * own `id` is an `om_...` row id and joins to nothing. | |
| 138 | + * | |
| 139 | + * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per | |
| 140 | + * request and is the authority on who belongs to the org, but WorkOS's | |
| 141 | + * `listOrganizationMemberships` carries no user detail and offers no | |
| 142 | + * include/expand — email and name only exist on the user resource. The SDK does | |
| 143 | + * expose a batched `listUsers({ organizationId })`, but the pinned | |
| 144 | + * `@executor-js/emulate` WorkOS emulator serves only `GET | |
| 145 | + * /user_management/users/:id`, so taking that path would leave every cloud e2e | |
| 146 | + * user unnamed. So: ONE membership read per request, then user detail fetched | |
| 147 | + * only for the ids ON THIS PAGE — never for the whole org, and never once per | |
| 148 | + * row of some larger list. An id that is not an active/pending member is not | |
| 149 | + * fetched at all and reports absent identity, which is the honest answer for a | |
| 150 | + * member who left while their connections remain. | |
| 151 | + */ | |
| 152 | +const identityDirectory = | |
| 153 | + (organizationId: string, context: Context.Context<WorkOSClient>): AdminIdentityDirectory => | |
| 154 | + (externalIds) => | |
| 155 | + Effect.gen(function* () { | |
| 156 | + const workos = yield* WorkOSClient; | |
| 157 | + const memberships = yield* workos.listOrgMembers(organizationId); | |
| 158 | + const wanted = new Set(externalIds); | |
| 159 | + const memberIds = memberships.data | |
| 160 | + .map((membership) => membership.userId) | |
| 161 | + .filter((userId) => wanted.has(userId)); | |
| 162 | + | |
| 163 | + const resolved = yield* Effect.all( | |
| 164 | + memberIds.map((userId) => | |
| 165 | + workos.getUser(userId).pipe( | |
| 166 | + Effect.map( | |
| 167 | + (user) => | |
| 168 | + [ | |
| 169 | + userId, | |
| 170 | + { | |
| 171 | + email: user.email, | |
| 172 | + displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, | |
| 173 | + }, | |
| 174 | + ] as const, | |
| 175 | + ), | |
| 176 | + // One unreadable user must not cost the whole page its names. | |
| 177 | + Effect.catchCause(() => Effect.succeed(null)), | |
| 178 | + ), | |
| 179 | + ), | |
| 180 | + { concurrency: IDENTITY_CONCURRENCY }, | |
| 181 | + ); | |
| 182 | + | |
| 183 | + const identities = new Map<string, AdminUserIdentity>(); | |
| 184 | + for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]); | |
| 185 | + return identities; | |
| 186 | + }).pipe(Effect.provideContext(context)); | |
| 187 | + | |
| 188 | +/** | |
| 189 | + * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id. | |
| 190 | + * | |
| 191 | + * WHY THE MEMBER LIST AND NOT A SERVER-SIDE EMAIL QUERY. WorkOS's SDK does | |
| 192 | + * expose `listUsers({ email })`, which would be one indexed call instead of a | |
| 193 | + * scan — but the pinned `@executor-js/emulate` WorkOS emulator serves no | |
| 194 | + * list-users route at all. Its route table was read directly from the shipped | |
| 195 | + * bundle: under `/user_management` it has only `users/:id`, | |
| 196 | + * `organization_memberships`, the auth/session routes, and invitations. Taking | |
| 197 | + * the `listUsers` path would therefore 404 in every cloud e2e run and could | |
| 198 | + * only be exercised in production — so the lookup is built on the SAME | |
| 199 | + * membership read the forward join already makes, which the emulator does | |
| 200 | + * serve. | |
| 201 | + * | |
| 202 | + * THE TRADEOFF is an in-memory scan of the org's members, bounded by org size | |
| 203 | + * and capped by the identity fan-out below. That is acceptable at the sizes | |
| 204 | + * this plane serves, and it has a real correctness advantage: the membership | |
| 205 | + * list is the authority on who belongs to THIS org, so a resolver built on it | |
| 206 | + * cannot return a user from another tenant, which a bare `listUsers({ email })` | |
| 207 | + * could. If org sizes ever make the scan hurt, the fix is a server-side query | |
| 208 | + * gated on emulator support, not a cache. | |
| 209 | + * | |
| 210 | + * CASING: WorkOS preserves whatever casing an email was created with (and the | |
| 211 | + * emulator compares byte-exact), so the directory value is normalized here | |
| 212 | + * before comparison, against an argument the seam already normalized. | |
| 213 | + */ | |
| 214 | +export const emailResolver = | |
| 215 | + (organizationId: string, context: Context.Context<WorkOSClient>): AdminEmailResolver => | |
| 216 | + (email) => | |
| 217 | + Effect.gen(function* () { | |
| 218 | + const workos = yield* WorkOSClient; | |
| 219 | + const memberships = yield* workos.listOrgMembers(organizationId); | |
| 220 | + const userIds = memberships.data.map((membership) => membership.userId); | |
| 221 | + | |
| 222 | + // Short-circuits on the first match: `Effect.findFirst` stops fetching | |
| 223 | + // once a user's email matches, so the common case costs far fewer reads | |
| 224 | + // than the member count. | |
| 225 | + const match = yield* Effect.findFirst(userIds, (userId) => | |
| 226 | + workos.getUser(userId).pipe( | |
| 227 | + Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email), | |
| 228 | + // One unreadable user must not fail the whole lookup — it simply | |
| 229 | + // cannot be the match. | |
| 230 | + Effect.catchCause(() => Effect.succeed(false)), | |
| 231 | + ), | |
| 232 | + ); | |
| 233 | + return Option.getOrNull(match); | |
| 234 | + }).pipe(Effect.provideContext(context)); | |
| 235 | + | |
| 236 | +/** Both directions of cloud's directory, built once per authorized request. */ | |
| 237 | +const userDirectory = ( | |
| 238 | + organizationId: string, | |
| 239 | + context: Context.Context<WorkOSClient>, | |
| 240 | +): AdminUserDirectory => ({ | |
| 241 | + identities: identityDirectory(organizationId, context), | |
| 242 | + resolveEmail: emailResolver(organizationId, context), | |
| 243 | +}); | |
| 244 | + | |
| 245 | +/** | |
| 246 | + * Authorize, then run `body` against the tenant's platform view. | |
| 247 | + * | |
| 248 | + * The executor is built per request and closed after, like every other cloud | |
| 249 | + * execution stack: it holds the per-request postgres socket, which Cloudflare's | |
| 250 | + * I/O isolation forbids sharing across requests. | |
| 251 | + */ | |
| 252 | +const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>( | |
| 253 | + headers: AdminUsersHeaders, | |
| 254 | + body: (executor: Executor, organizationId: string) => Effect.Effect<A, E>, | |
| 255 | +): Effect.Effect< | |
| 256 | + A, | |
| 257 | + // `AdminUsersError` unconditionally: opening the platform view can fail that | |
| 258 | + // way regardless of what `body` itself raises. | |
| 259 | + E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden, | |
| 260 | + WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | |
| 261 | +> => | |
| 262 | + Effect.gen(function* () { | |
| 263 | + const organizationId = yield* authorizeTenant( | |
| 264 | + new Request("https://admin.invalid", { headers }), | |
| 265 | + ); | |
| 266 | + const executor = yield* makePlatformExecutor(organizationId).pipe( | |
| 267 | + Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })), | |
| 268 | + ); | |
| 269 | + // The authorized tenant is handed to the body so an identity join reads the | |
| 270 | + // SAME org the reads are scoped to — never one named by client input. | |
| 271 | + return yield* Effect.ensuring( | |
| 272 | + body(executor, organizationId), | |
| 273 | + executor.close().pipe(Effect.ignore), | |
| 274 | + ); | |
| 275 | + }); | |
| 276 | + | |
| 277 | +/** | |
| 278 | + * Cloud's `AdminUsersProvider`, built per request so the platform executor | |
| 279 | + * closes over the per-request postgres socket. | |
| 280 | + */ | |
| 281 | +export const workosAdminUsersProvider: Layer.Layer< | |
| 282 | + AdminUsersProvider, | |
| 283 | + never, | |
| 284 | + WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | |
| 285 | +> = Layer.effect(AdminUsersProvider)( | |
| 286 | + Effect.gen(function* () { | |
| 287 | + const context = yield* Effect.context< | |
| 288 | + WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | |
| 289 | + >(); | |
| 290 | + return AdminUsersProvider.of({ | |
| 291 | + listUsers: (headers, options) => | |
| 292 | + withPlatformView(headers, (executor, organizationId) => | |
| 293 | + platformViewOf(executor).pipe( | |
| 294 | + Effect.flatMap((admin) => | |
| 295 | + listAdminUsers(admin, options, userDirectory(organizationId, context)), | |
| 296 | + ), | |
| 297 | + ), | |
| 298 | + ).pipe(Effect.provideContext(context)), | |
| 299 | + listUsersWithConnections: (headers, options) => | |
| 300 | + withPlatformView(headers, (executor, organizationId) => | |
| 301 | + platformViewOf(executor).pipe( | |
| 302 | + Effect.flatMap((admin) => | |
| 303 | + listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)), | |
| 304 | + ), | |
| 305 | + ), | |
| 306 | + ).pipe(Effect.provideContext(context)), | |
| 307 | + listUserConnections: (headers, externalId) => | |
| 308 | + withPlatformView(headers, (executor) => | |
| 309 | + platformViewOf(executor).pipe( | |
| 310 | + Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)), | |
| 311 | + ), | |
| 312 | + ).pipe(Effect.provideContext(context)), | |
| 313 | + getUser: (headers, identifier) => | |
| 314 | + withPlatformView(headers, (executor, organizationId) => | |
| 315 | + platformViewOf(executor).pipe( | |
| 316 | + Effect.flatMap((admin) => | |
| 317 | + getAdminUser(admin, identifier, userDirectory(organizationId, context)), | |
| 318 | + ), | |
| 319 | + ), | |
| 320 | + ).pipe(Effect.provideContext(context)), | |
| 321 | + }); | |
| 322 | + }), | |
| 323 | +); | |
| 324 | + | |
| 325 | +// Builds the provider per request, providing it to the handlers. Long-lived | |
| 326 | +// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the | |
| 327 | +// per-request `DbService`/`UserStoreService` (and the execution seams built | |
| 328 | +// over them) are supplied by the combined `requestScopedMiddleware`. | |
| 329 | +const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( | |
| 330 | + Effect.gen(function* () { | |
| 331 | + const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>(); | |
| 332 | + return (httpEffect) => | |
| 333 | + Effect.gen(function* () { | |
| 334 | + // Built inside the request body so the execution seams close over the | |
| 335 | + // per-request postgres socket. | |
| 336 | + const provider = yield* Effect.provide( | |
| 337 | + AdminUsersProvider.asEffect(), | |
| 338 | + workosAdminUsersProvider.pipe(Layer.provide(CloudExecutionSeamsLayer)), | |
| 339 | + ); | |
| 340 | + return yield* Effect.provideService(httpEffect, AdminUsersProvider, provider); | |
| 341 | + }).pipe(Effect.provideContext(longLived)); | |
| 342 | + }), | |
| 343 | +); | |
| 344 | + | |
| 345 | +/** | |
| 346 | + * The cloud admin-users route layer, mounted as an app extension under the same | |
| 347 | + * `/api` prefix as the rest of the cloud router. | |
| 348 | + */ | |
| 349 | +export const makeCloudAdminUsersRoutes = ( | |
| 350 | + rsLive: Layer.Layer<DbService | UserStoreService>, | |
| 351 | + options: Parameters<typeof makeAdminUsersApiLayer>[1] = {}, | |
| 352 | +) => | |
| 353 | + makeAdminUsersApiLayer( | |
| 354 | + AdminUsersProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer, | |
| 355 | + options, | |
| 356 | + ); | |
| @@ -0,0 +1,144 @@ | ||
| 1 | +import { expect, it } from "@effect/vitest"; | |
| 2 | +import { Data, Effect, Layer } from "effect"; | |
| 3 | + | |
| 4 | +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | |
| 5 | +import { emailResolver } from "./admin-users-api"; | |
| 6 | + | |
| 7 | +// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the | |
| 8 | +// subject table records in `external_id`. | |
| 9 | +// | |
| 10 | +// WHY THIS IS BUILT ON THE MEMBERSHIP LIST. WorkOS's SDK exposes | |
| 11 | +// `listUsers({ email })`, but the pinned `@executor-js/emulate` WorkOS emulator | |
| 12 | +// serves no list-users route at all — under `/user_management` it has only | |
| 13 | +// `users/:id`, `organization_memberships`, the auth/session routes, and | |
| 14 | +// invitations. A resolver built on `listUsers` would therefore 404 in every | |
| 15 | +// cloud e2e run. So the lookup reuses the SAME membership read the forward | |
| 16 | +// identity join already makes, which the emulator does serve, and scans it in | |
| 17 | +// memory (bounded by org size). | |
| 18 | +// | |
| 19 | +// The membership list is also the authority on who belongs to THIS org, so the | |
| 20 | +// resolver structurally cannot return a user from another tenant — something a | |
| 21 | +// bare `listUsers({ email })` could do. | |
| 22 | + | |
| 23 | +const ORG = "org_placeholder"; | |
| 24 | + | |
| 25 | +/** The failure a real WorkOS read raises for one user. Typed rather than a bare | |
| 26 | + * `Error`, so the resolver's skip-and-continue behaviour is exercised against | |
| 27 | + * the shape the client actually fails with. */ | |
| 28 | +class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{ | |
| 29 | + readonly userId: string; | |
| 30 | +}> {} | |
| 31 | + | |
| 32 | +/** Members of the org, with the casing WorkOS would have stored. WorkOS | |
| 33 | + * preserves whatever casing a user was created with (and the emulator | |
| 34 | + * compares byte-exact), so the mixed-case entry is the realistic case. */ | |
| 35 | +const DIRECTORY: Record<string, { readonly email: string | null }> = { | |
| 36 | + user_ada: { email: "Ada@Placeholder.test" }, | |
| 37 | + user_grace: { email: "grace@placeholder.test" }, | |
| 38 | + // A member the directory cannot name: `getUser` succeeds but carries no | |
| 39 | + // email. It must never match, and must not break the scan for others. | |
| 40 | + user_nameless: { email: null }, | |
| 41 | +}; | |
| 42 | + | |
| 43 | +const stubWorkOS = (calls: string[]) => | |
| 44 | + Layer.succeed( | |
| 45 | + WorkOSClient, | |
| 46 | + new Proxy({} as WorkOSClientService, { | |
| 47 | + get: (_target, prop) => { | |
| 48 | + if (prop === "listOrgMembers") { | |
| 49 | + return (organizationId: string) => { | |
| 50 | + calls.push(`listOrgMembers:${organizationId}`); | |
| 51 | + return Effect.succeed({ | |
| 52 | + data: Object.keys(DIRECTORY).map((userId) => ({ userId, organizationId })), | |
| 53 | + }); | |
| 54 | + }; | |
| 55 | + } | |
| 56 | + if (prop === "getUser") { | |
| 57 | + return (userId: string) => { | |
| 58 | + calls.push(`getUser:${userId}`); | |
| 59 | + const user = DIRECTORY[userId]; | |
| 60 | + if (!user) return Effect.die(`unexpected user ${userId}`); | |
| 61 | + return Effect.succeed(user); | |
| 62 | + }; | |
| 63 | + } | |
| 64 | + // A resolver that reaches for any other WorkOS call — notably a | |
| 65 | + // `listUsers` the emulator cannot serve — is the failure this catches. | |
| 66 | + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | |
| 67 | + }, | |
| 68 | + }), | |
| 69 | + ); | |
| 70 | + | |
| 71 | +const resolve = (email: string, calls: string[]) => | |
| 72 | + Effect.gen(function* () { | |
| 73 | + const context = yield* Effect.context<WorkOSClient>(); | |
| 74 | + return yield* emailResolver(ORG, context)(email); | |
| 75 | + }).pipe(Effect.provide(stubWorkOS(calls))); | |
| 76 | + | |
| 77 | +it.effect("resolves an email to the member's WorkOS user id", () => | |
| 78 | + Effect.gen(function* () { | |
| 79 | + const calls: string[] = []; | |
| 80 | + // The seam hands the resolver an already-normalized address; the STORED | |
| 81 | + // value is mixed-case, so this pins that the directory side is normalized | |
| 82 | + // too. Without that, `Ada@Placeholder.test` would never match. | |
| 83 | + expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada"); | |
| 84 | + expect(calls[0]).toBe(`listOrgMembers:${ORG}`); | |
| 85 | + }), | |
| 86 | +); | |
| 87 | + | |
| 88 | +it.effect("returns null for an email no member holds", () => | |
| 89 | + Effect.gen(function* () { | |
| 90 | + const calls: string[] = []; | |
| 91 | + expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull(); | |
| 92 | + // It looked at everyone before saying no, and a member with no email | |
| 93 | + // neither matched nor derailed the scan. | |
| 94 | + expect(calls).toContain("getUser:user_nameless"); | |
| 95 | + }), | |
| 96 | +); | |
| 97 | + | |
| 98 | +it.effect("stops fetching once it finds the match", () => | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const calls: string[] = []; | |
| 101 | + // `user_ada` is first in the member list, so a short-circuiting scan must | |
| 102 | + // never reach the members behind it. This is what keeps the in-memory scan | |
| 103 | + // acceptable at the sizes this plane serves. | |
| 104 | + expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada"); | |
| 105 | + expect(calls).not.toContain("getUser:user_grace"); | |
| 106 | + expect(calls).not.toContain("getUser:user_nameless"); | |
| 107 | + }), | |
| 108 | +); | |
| 109 | + | |
| 110 | +it.effect("does not let one unreadable member hide the real match", () => | |
| 111 | + Effect.gen(function* () { | |
| 112 | + const calls: string[] = []; | |
| 113 | + const failing = Layer.succeed( | |
| 114 | + WorkOSClient, | |
| 115 | + new Proxy({} as WorkOSClientService, { | |
| 116 | + get: (_target, prop) => { | |
| 117 | + if (prop === "listOrgMembers") { | |
| 118 | + return () => | |
| 119 | + Effect.succeed({ | |
| 120 | + data: [{ userId: "user_broken" }, { userId: "user_grace" }], | |
| 121 | + }); | |
| 122 | + } | |
| 123 | + if (prop === "getUser") { | |
| 124 | + return (userId: string) => { | |
| 125 | + calls.push(userId); | |
| 126 | + return userId === "user_broken" | |
| 127 | + ? Effect.fail(new WorkOSUnavailable({ userId })) | |
| 128 | + : Effect.succeed(DIRECTORY[userId]!); | |
| 129 | + }; | |
| 130 | + } | |
| 131 | + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | |
| 132 | + }, | |
| 133 | + }), | |
| 134 | + ); | |
| 135 | + | |
| 136 | + const resolved = yield* Effect.gen(function* () { | |
| 137 | + const context = yield* Effect.context<WorkOSClient>(); | |
| 138 | + return yield* emailResolver(ORG, context)("grace@placeholder.test"); | |
| 139 | + }).pipe(Effect.provide(failing)); | |
| 140 | + | |
| 141 | + expect(resolved, "an unreadable member is skipped, not fatal").toBe("user_grace"); | |
| 142 | + expect(calls).toEqual(["user_broken", "user_grace"]); | |
| 143 | + }), | |
| 144 | +); | |
| @@ -13,6 +13,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 12 unmodified lines | ||
| 13 | 13 | Effect.succeed( |
| 14 | 14 | value === "valid_user_key" |
| 15 | 15 | ? { |
| 16 | + scope: "user" as const, | |
| 16 | 17 | accountId: "user_123", |
| 17 | 18 | organizationId: "org_123", |
| 18 | 19 | keyId: "api_key_123", |
| @@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines | ||
| 22 | 23 | listUserKeys: () => Effect.succeed([]), |
| 23 | 24 | createUserKey: () => Effect.die("protected API auth test does not create API keys"), |
| 24 | 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 | |
| 27 | 31 | const stubWorkOS = Layer.succeed( |
| @@ -40,6 +40,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 39 unmodified lines | ||
| 40 | 40 | listUserKeys: () => Effect.succeed([]), |
| 41 | 41 | createUserKey: () => Effect.die("JWT auth test does not create API keys"), |
| 42 | 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 | |
| 45 | 48 | const stubWorkOS = Layer.succeed( |
| @@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { · 41 unmodified lines | ||
| 42 | 42 | }); |
| 43 | 43 | |
| 44 | 44 | expect(principal).toEqual({ |
| 45 | + scope: "user", | |
| 45 | 46 | accountId: "user_123", |
| 46 | 47 | organizationId: "org_123", |
| 47 | 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 | 71 | Effect.gen(function* () { |
| 71 | 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 | 73 | const orgLess = yield* validate({ |
| 79 | 74 | apiKey: { |
| 80 | 75 | id: "api_key_no_org", |
| @@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines | ||
| 83 | 78 | }); |
| 84 | 79 | |
| 85 | 80 | expect(missing).toBeNull(); |
| 86 | − expect(orgOwned).toBeNull(); | |
| 87 | 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 | 106 | it.effect("lists and creates user-owned keys", () => |
| 92 | 107 | Effect.gen(function* () { |
| 93 | 108 | const program = Effect.gen(function* () { |
| @@ -3,10 +3,24 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { ApiKeyManagementError } from "./errors"; |
| 4 | 4 | import { 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 | + */ | |
| 14 | +export type ApiKeyScope = "user" | "org"; | |
| 15 | + | |
| 6 | 16 | /** The owner an api key resolves to — NOT a full {@link Principal} (no email / |
| 7 | 17 | * name / roles), so it carries an honest, distinct name. */ |
| 8 | 18 | export type ApiKeyOwner = { |
| 9 | − readonly accountId: string; | |
| 19 | + readonly scope: ApiKeyScope; | |
| 20 | + /** The acting member for a `"user"` key; `null` for an `"org"` key, which | |
| 21 | + * has no member behind it. Nullable rather than a sentinel so nothing can | |
| 22 | + * accidentally bind an org key to a subject. */ | |
| 23 | + readonly accountId: string | null; | |
| 10 | 24 | readonly organizationId: string; |
| 11 | 25 | readonly keyId: string; |
| 12 | 26 | }; |
| @@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines | ||
| 28 | 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 | + */ | |
| 51 | +export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{ | |
| 52 | + readonly keyId: string; | |
| 53 | +}> {} | |
| 54 | + | |
| 55 | +// WorkOS keys are owned by either a user or an organization. The user shape | |
| 56 | +// carries the member id in `id` and the org in `organization_id`; the org shape | |
| 57 | +// carries the ORG in `id` and no member at all. Both decode here — the | |
| 58 | +// distinction is a branch (`ownerFromApiKey`), not a decode failure, so an | |
| 59 | +// org-owned key is no longer indistinguishable from an invalid one. | |
| 31 | 60 | const UserApiKeyOwner = Schema.Struct({ |
| 32 | 61 | type: Schema.Literal("user"), |
| 33 | 62 | id: Schema.String, |
| @@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line | ||
| 35 | 64 | organization_id: Schema.optional(Schema.String), |
| 36 | 65 | }); |
| 37 | 66 | |
| 67 | +const OrgApiKeyOwner = Schema.Struct({ | |
| 68 | + type: Schema.Literal("organization"), | |
| 69 | + id: Schema.String, | |
| 70 | +}); | |
| 71 | + | |
| 72 | +const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]); | |
| 73 | + | |
| 38 | 74 | const ApiKey = Schema.Struct({ |
| 39 | 75 | id: Schema.String, |
| 40 | 76 | owner: UserApiKeyOwner, |
| @@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines | ||
| 49 | 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. | |
| 92 | +const ValidatedApiKey = Schema.Struct({ | |
| 93 | + id: Schema.String, | |
| 94 | + owner: WorkOSApiKeyOwner, | |
| 95 | +}); | |
| 96 | + | |
| 52 | 97 | const ValidateApiKeyResponse = Schema.Struct({ |
| 53 | − apiKey: Schema.NullOr(ApiKey), | |
| 98 | + apiKey: Schema.NullOr(ValidatedApiKey), | |
| 54 | 99 | }); |
| 55 | 100 | |
| 56 | 101 | const RawCreatedApiKey = Schema.Struct({ |
| @@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines | ||
| 72 | 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. | |
| 125 | +const OrgApiKey = Schema.Struct({ | |
| 126 | + id: Schema.String, | |
| 127 | + owner: OrgApiKeyOwner, | |
| 128 | + name: Schema.optional(Schema.String), | |
| 129 | + obfuscatedValue: Schema.optional(Schema.String), | |
| 130 | + obfuscated_value: Schema.optional(Schema.String), | |
| 131 | + createdAt: Schema.optional(Schema.String), | |
| 132 | + created_at: Schema.optional(Schema.String), | |
| 133 | + updatedAt: Schema.optional(Schema.String), | |
| 134 | + updated_at: Schema.optional(Schema.String), | |
| 135 | + lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 136 | + last_used_at: Schema.optional(Schema.NullOr(Schema.String)), | |
| 137 | +}); | |
| 138 | + | |
| 139 | +const RawCreatedOrgApiKey = Schema.Struct({ | |
| 140 | + ...OrgApiKey.fields, | |
| 141 | + value: Schema.String, | |
| 142 | +}); | |
| 143 | + | |
| 144 | +// `data` is decoded element-by-element, NOT as `Schema.Array(OrgApiKey)`: WorkOS | |
| 145 | +// may return user-owned keys alongside org-owned ones, and a whole-array decode | |
| 146 | +// fails on the first non-org element — blanking the entire list rather than | |
| 147 | +// dropping the one row that doesn't belong. Same per-row tolerance the user-key | |
| 148 | +// path gets from `summaryFromApiKey` returning null. | |
| 149 | +const ListOrgApiKeysResponse = Schema.Struct({ | |
| 150 | + data: Schema.Array(Schema.Unknown), | |
| 151 | +}); | |
| 152 | + | |
| 153 | +const CreateOrgApiKeyResponse = Schema.Union([ | |
| 154 | + RawCreatedOrgApiKey, | |
| 155 | + Schema.Struct({ apiKey: RawCreatedOrgApiKey }), | |
| 156 | + Schema.Struct({ api_key: RawCreatedOrgApiKey }), | |
| 157 | +]); | |
| 158 | + | |
| 75 | 159 | const CreateApiKeyResponse = Schema.Union([ |
| 76 | 160 | RawCreatedApiKey, |
| 77 | 161 | Schema.Struct({ apiKey: RawCreatedApiKey }), |
| @@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines | ||
| 81 | 165 | const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse); |
| 82 | 166 | const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse); |
| 83 | 167 | const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse); |
| 168 | +const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse); | |
| 169 | +const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey); | |
| 170 | +const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse); | |
| 171 | + | |
| 172 | +const ownerFromApiKey = (apiKey: typeof ValidatedApiKey.Type): ApiKeyOwner | null => { | |
| 173 | + if (apiKey.owner.type === "organization") { | |
| 174 | + // An org-owned key IS the org: `owner.id` is the organization, and there | |
| 175 | + // is no member to act as. This resolves to the platform view. | |
| 176 | + return { | |
| 177 | + scope: "org", | |
| 178 | + accountId: null, | |
| 179 | + organizationId: apiKey.owner.id, | |
| 180 | + keyId: apiKey.id, | |
| 181 | + }; | |
| 182 | + } | |
| 183 | + const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; | |
| 184 | + if (!organizationId) return null; | |
| 185 | + return { | |
| 186 | + scope: "user", | |
| 187 | + accountId: apiKey.owner.id, | |
| 188 | + organizationId, | |
| 189 | + keyId: apiKey.id, | |
| 190 | + }; | |
| 191 | +}; | |
| 84 | 192 | |
| 85 | 193 | const ownerFromResponse = (value: unknown): ApiKeyOwner | null => |
| 86 | 194 | Option.match(decodeValidateApiKeyResponse(value), { |
| 87 | 195 | onNone: () => null, |
| 88 | − onSome: ({ apiKey }) => { | |
| 89 | − if (!apiKey) return null; | |
| 90 | − const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; | |
| 91 | − if (!organizationId) return null; | |
| 92 | − return { | |
| 93 | − accountId: apiKey.owner.id, | |
| 94 | − organizationId, | |
| 95 | − keyId: apiKey.id, | |
| 96 | − }; | |
| 97 | − }, | |
| 196 | + onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null), | |
| 98 | 197 | }); |
| 99 | 198 | |
| 100 | 199 | const 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. | |
| 226 | +const summaryFromOrgApiKey = ( | |
| 227 | + apiKey: typeof OrgApiKey.Type, | |
| 228 | + organizationId: string, | |
| 229 | +): ApiKeySummary | null => { | |
| 230 | + if (apiKey.owner.id !== organizationId) return null; | |
| 231 | + return { | |
| 232 | + id: apiKey.id, | |
| 233 | + name: apiKey.name ?? "API key", | |
| 234 | + obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "", | |
| 235 | + createdAt: apiKey.createdAt ?? apiKey.created_at ?? "", | |
| 236 | + updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "", | |
| 237 | + lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null, | |
| 238 | + }; | |
| 239 | +}; | |
| 240 | + | |
| 241 | +const orgListFromResponse = (value: unknown, organizationId: string): readonly ApiKeySummary[] => | |
| 242 | + Option.match(decodeListOrgApiKeysResponse(value), { | |
| 243 | + onNone: () => [], | |
| 244 | + onSome: ({ data }) => | |
| 245 | + data.flatMap((entry) => | |
| 246 | + Option.match(decodeOrgApiKey(entry), { | |
| 247 | + // Not an org-owned key (a user key sharing the listing, or a shape we | |
| 248 | + // don't recognize) — skip the row, keep the rest. | |
| 249 | + onNone: () => [], | |
| 250 | + onSome: (apiKey) => { | |
| 251 | + const summary = summaryFromOrgApiKey(apiKey, organizationId); | |
| 252 | + return summary ? [summary] : []; | |
| 253 | + }, | |
| 254 | + }), | |
| 255 | + ), | |
| 256 | + }); | |
| 257 | + | |
| 258 | +const orgCreatedFromResponse = (value: unknown, organizationId: string): CreatedApiKey | null => | |
| 259 | + Option.match(decodeCreateOrgApiKeyResponse(value), { | |
| 260 | + onNone: () => null, | |
| 261 | + onSome: (response) => { | |
| 262 | + const apiKey = | |
| 263 | + "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key; | |
| 264 | + const summary = summaryFromOrgApiKey(apiKey, organizationId); | |
| 265 | + return summary ? { ...summary, value: apiKey.value } : null; | |
| 266 | + }, | |
| 267 | + }); | |
| 268 | + | |
| 123 | 269 | const createdFromResponse = (value: unknown): CreatedApiKey | null => |
| 124 | 270 | Option.match(decodeCreateApiKeyResponse(value), { |
| 125 | 271 | onNone: () => null, |
| @@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines | ||
| 147 | 293 | readonly revokeUserKey: (input: { |
| 148 | 294 | readonly keyId: string; |
| 149 | 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 | 319 | >()("@executor-js/cloud/ApiKeyService") { |
| 152 | 320 | static WorkOS = Layer.effect(this)( |
| @@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines | ||
| 177 | 345 | workos |
| 178 | 346 | .deleteApiKey(keyId) |
| 179 | 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 | ); |
| @@ -0,0 +1,153 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Layer } from "effect"; | |
| 3 | + | |
| 4 | +import { ApiKeyService } from "./api-keys"; | |
| 5 | +import { UserStoreService } from "./context"; | |
| 6 | +import { WorkOSClient, type WorkOSClientService } from "./workos"; | |
| 7 | +import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider"; | |
| 8 | + | |
| 9 | +// Groundwork for the PRIVILEGED, org-level API key: it resolves to the platform | |
| 10 | +// view (tenant-wide, read-only, NO acting member) rather than to a member | |
| 11 | +// `Principal`. The product surfaces must reject it outright rather than | |
| 12 | +// inventing a subject for it — that is the property these tests pin. | |
| 13 | + | |
| 14 | +const createdAt = new Date("2026-01-01T00:00:00.000Z"); | |
| 15 | + | |
| 16 | +const stubApiKeys = Layer.succeed(ApiKeyService)({ | |
| 17 | + validate: (value: string) => { | |
| 18 | + if (value === "valid_org_key") { | |
| 19 | + return Effect.succeed({ | |
| 20 | + scope: "org" as const, | |
| 21 | + accountId: null, | |
| 22 | + organizationId: "org_123", | |
| 23 | + keyId: "api_key_org", | |
| 24 | + }); | |
| 25 | + } | |
| 26 | + if (value === "valid_user_key") { | |
| 27 | + return Effect.succeed({ | |
| 28 | + scope: "user" as const, | |
| 29 | + accountId: "user_123", | |
| 30 | + organizationId: "org_123", | |
| 31 | + keyId: "api_key_123", | |
| 32 | + }); | |
| 33 | + } | |
| 34 | + return Effect.succeed(null); | |
| 35 | + }, | |
| 36 | + listUserKeys: () => Effect.succeed([]), | |
| 37 | + createUserKey: () => Effect.die("org api key test does not create API keys"), | |
| 38 | + revokeUserKey: () => Effect.void, | |
| 39 | + listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | |
| 40 | + createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | |
| 41 | + revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | |
| 42 | +}); | |
| 43 | + | |
| 44 | +const stubWorkOS = Layer.succeed( | |
| 45 | + WorkOSClient, | |
| 46 | + new Proxy({} as WorkOSClientService, { | |
| 47 | + get: (_target, prop) => { | |
| 48 | + if (prop === "listUserMemberships") { | |
| 49 | + return (userId: string) => | |
| 50 | + Effect.succeed({ | |
| 51 | + data: | |
| 52 | + userId === "user_123" | |
| 53 | + ? [{ userId, organizationId: "org_123", status: "active" }] | |
| 54 | + : [], | |
| 55 | + }); | |
| 56 | + } | |
| 57 | + // An org key must NOT trigger a membership check — there is no user to | |
| 58 | + // check. Any such call dies here, which is the assertion. | |
| 59 | + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | |
| 60 | + }, | |
| 61 | + }), | |
| 62 | +); | |
| 63 | + | |
| 64 | +const stubUsers = Layer.succeed(UserStoreService)({ | |
| 65 | + use: (fn) => | |
| 66 | + Effect.promise(() => | |
| 67 | + fn({ | |
| 68 | + ensureAccount: async (id: string) => ({ id, createdAt }), | |
| 69 | + getAccount: async (id: string) => ({ id, createdAt }), | |
| 70 | + upsertOrganization: async (org: { id: string; name: string }) => ({ | |
| 71 | + ...org, | |
| 72 | + slug: `org-slug-${org.id}`, | |
| 73 | + createdAt, | |
| 74 | + }), | |
| 75 | + getOrganization: async (id: string) => ({ | |
| 76 | + id, | |
| 77 | + name: `Org ${id}`, | |
| 78 | + slug: `org-slug-${id}`, | |
| 79 | + createdAt, | |
| 80 | + }), | |
| 81 | + getOrganizationBySlug: async (slug: string) => ({ | |
| 82 | + id: "org_by_slug", | |
| 83 | + name: `Org ${slug}`, | |
| 84 | + slug, | |
| 85 | + createdAt, | |
| 86 | + }), | |
| 87 | + deleteOrganizationCascade: async () => {}, | |
| 88 | + }), | |
| 89 | + ), | |
| 90 | +}); | |
| 91 | + | |
| 92 | +const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers); | |
| 93 | + | |
| 94 | +const bearer = (token: string) => | |
| 95 | + new Request("https://executor.test/api/tools", { | |
| 96 | + headers: { authorization: `Bearer ${token}` }, | |
| 97 | + }); | |
| 98 | + | |
| 99 | +describe("org-level API keys", () => { | |
| 100 | + it.effect("resolve to the platform view with no acting member", () => | |
| 101 | + Effect.gen(function* () { | |
| 102 | + const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); | |
| 103 | + | |
| 104 | + expect(isPlatformAuth(auth)).toBe(true); | |
| 105 | + expect(auth).toEqual({ | |
| 106 | + kind: "platform", | |
| 107 | + organizationId: "org_123", | |
| 108 | + organizationName: "Org org_123", | |
| 109 | + organizationSlug: "org-slug-org_123", | |
| 110 | + keyId: "api_key_org", | |
| 111 | + }); | |
| 112 | + // No `accountId` anywhere in the shape: nothing downstream can bind a | |
| 113 | + // subject from it by accident. | |
| 114 | + expect(auth).not.toHaveProperty("accountId"); | |
| 115 | + }), | |
| 116 | + ); | |
| 117 | + | |
| 118 | + it.effect("user keys still resolve to a bound member principal", () => | |
| 119 | + Effect.gen(function* () { | |
| 120 | + const auth = yield* resolveBearerAuth(bearer("valid_user_key")).pipe(Effect.provide(layers)); | |
| 121 | + | |
| 122 | + expect(isPlatformAuth(auth)).toBe(false); | |
| 123 | + expect(auth).toMatchObject({ accountId: "user_123", organizationId: "org_123" }); | |
| 124 | + }), | |
| 125 | + ); | |
| 126 | + | |
| 127 | + it.effect("are REJECTED on the product surface rather than bound to a subject", () => | |
| 128 | + Effect.gen(function* () { | |
| 129 | + // THE security property of the api-key half: a product request carrying | |
| 130 | + // an org key must fail, not silently act as some user. | |
| 131 | + const error = yield* Effect.flip( | |
| 132 | + resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(Effect.provide(layers)), | |
| 133 | + ); | |
| 134 | + | |
| 135 | + expect(error).toMatchObject({ | |
| 136 | + _tag: "Unauthorized", | |
| 137 | + code: "invalid_api_key", | |
| 138 | + message: "Organization API keys cannot be used on this endpoint", | |
| 139 | + }); | |
| 140 | + }), | |
| 141 | + ); | |
| 142 | + | |
| 143 | + it.effect("do not trigger a user membership check", () => | |
| 144 | + Effect.gen(function* () { | |
| 145 | + // `authorizeOrganization` checks a USER's live membership; there is no | |
| 146 | + // user here. The WorkOS stub dies on any call other than the user path, | |
| 147 | + // so a clean resolution proves the org branch never took it. | |
| 148 | + const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); | |
| 149 | + | |
| 150 | + expect(isPlatformAuth(auth)).toBe(true); | |
| 151 | + }), | |
| 152 | + ); | |
| 153 | +}); | |
| @@ -0,0 +1,316 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Layer } from "effect"; | |
| 3 | + | |
| 4 | +import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys"; | |
| 5 | +import { ApiKeyManagementError } from "./errors"; | |
| 6 | +import { WorkOSClient, type WorkOSClientService } from "./workos"; | |
| 7 | + | |
| 8 | +// --------------------------------------------------------------------------- | |
| 9 | +// Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged | |
| 10 | +// credential that resolves to the read-only platform view (`/admin/*`). | |
| 11 | +// | |
| 12 | +// The decode boundary is what these pin. WorkOS returns an org-owned key with | |
| 13 | +// the ORG in `owner.id` and no member at all, which is a different shape from | |
| 14 | +// the user-owned keys the console's personal-key CRUD handles. The workspace | |
| 15 | +// api key is also workspace-WIDE, so a response naming a different org than the | |
| 16 | +// one asked for must not be surfaced as that org's key. | |
| 17 | +// | |
| 18 | +// NOTE (emulator gap): the WorkOS emulator's `serializeApiKey` hardcodes | |
| 19 | +// `owner.type: "user"` and exposes no org-key mint route, so this path cannot | |
| 20 | +// be exercised over the wire in e2e today. These stubs encode the REAL WorkOS | |
| 21 | +// API shape (`organizations.createOrganizationApiKey` / | |
| 22 | +// `listOrganizationApiKeys`, per the installed SDK's types). | |
| 23 | +// --------------------------------------------------------------------------- | |
| 24 | + | |
| 25 | +const ORG = "org_123"; | |
| 26 | + | |
| 27 | +const orgKeyResponse = (organizationId: string, id: string, name: string) => ({ | |
| 28 | + object: "api_key", | |
| 29 | + id, | |
| 30 | + owner: { type: "organization", id: organizationId }, | |
| 31 | + name, | |
| 32 | + obfuscatedValue: "sk_org…a1b2", | |
| 33 | + lastUsedAt: null, | |
| 34 | + permissions: [], | |
| 35 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 36 | + updatedAt: "2026-01-01T00:00:00.000Z", | |
| 37 | +}); | |
| 38 | + | |
| 39 | +const stubWorkOS = (overrides: Record<string, unknown> = {}) => | |
| 40 | + Layer.succeed( | |
| 41 | + WorkOSClient, | |
| 42 | + new Proxy({} as WorkOSClientService, { | |
| 43 | + get: (_target, prop) => { | |
| 44 | + const override = overrides[String(prop)]; | |
| 45 | + if (override) return override; | |
| 46 | + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | |
| 47 | + }, | |
| 48 | + }), | |
| 49 | + ); | |
| 50 | + | |
| 51 | +const serviceWith = (overrides: Record<string, unknown>) => | |
| 52 | + ApiKeyService.asEffect().pipe( | |
| 53 | + Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))), | |
| 54 | + ); | |
| 55 | + | |
| 56 | +describe("organization API keys", () => { | |
| 57 | + it.effect("mints an org-owned key and returns the one-time secret", () => | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const apiKeys = yield* serviceWith({ | |
| 60 | + createOrgApiKey: (params: { organizationId: string; name: string }) => | |
| 61 | + Effect.succeed({ | |
| 62 | + ...orgKeyResponse(params.organizationId, "key_org_1", params.name), | |
| 63 | + value: "sk_org_plaintext", | |
| 64 | + }), | |
| 65 | + }); | |
| 66 | + | |
| 67 | + const created = yield* apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }); | |
| 68 | + | |
| 69 | + expect(created.id).toBe("key_org_1"); | |
| 70 | + expect(created.name).toBe("acme-backend"); | |
| 71 | + expect(created.value, "create returns the one-time plaintext secret").toBe( | |
| 72 | + "sk_org_plaintext", | |
| 73 | + ); | |
| 74 | + expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe( | |
| 75 | + created.value, | |
| 76 | + ); | |
| 77 | + }), | |
| 78 | + ); | |
| 79 | + | |
| 80 | + it.effect("lists the organization's own keys, masked", () => | |
| 81 | + Effect.gen(function* () { | |
| 82 | + const apiKeys = yield* serviceWith({ | |
| 83 | + listOrgApiKeys: (organizationId: string) => | |
| 84 | + Effect.succeed({ | |
| 85 | + object: "list", | |
| 86 | + data: [ | |
| 87 | + orgKeyResponse(organizationId, "key_org_1", "acme-backend"), | |
| 88 | + orgKeyResponse(organizationId, "key_org_2", "ci"), | |
| 89 | + ], | |
| 90 | + listMetadata: { before: null, after: null }, | |
| 91 | + }), | |
| 92 | + }); | |
| 93 | + | |
| 94 | + const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | |
| 95 | + | |
| 96 | + expect(keys.map((key) => key.name)).toEqual(["acme-backend", "ci"]); | |
| 97 | + expect(JSON.stringify(keys), "the list never carries a plaintext secret").not.toContain( | |
| 98 | + "sk_org_plaintext", | |
| 99 | + ); | |
| 100 | + }), | |
| 101 | + ); | |
| 102 | + | |
| 103 | + it.effect("drops a key owned by a DIFFERENT organization", () => | |
| 104 | + Effect.gen(function* () { | |
| 105 | + // The WorkOS workspace api key is workspace-wide, so a response naming | |
| 106 | + // another org must never be surfaced as this org's key. | |
| 107 | + const apiKeys = yield* serviceWith({ | |
| 108 | + listOrgApiKeys: () => | |
| 109 | + Effect.succeed({ | |
| 110 | + object: "list", | |
| 111 | + data: [ | |
| 112 | + orgKeyResponse(ORG, "key_mine", "mine"), | |
| 113 | + orgKeyResponse("org_other", "key_theirs", "theirs"), | |
| 114 | + ], | |
| 115 | + listMetadata: { before: null, after: null }, | |
| 116 | + }), | |
| 117 | + }); | |
| 118 | + | |
| 119 | + const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | |
| 120 | + | |
| 121 | + expect( | |
| 122 | + keys.map((key) => key.id), | |
| 123 | + "only this org's key survives", | |
| 124 | + ).toEqual(["key_mine"]); | |
| 125 | + }), | |
| 126 | + ); | |
| 127 | + | |
| 128 | + it.effect("refuses a create response that names another organization", () => | |
| 129 | + Effect.gen(function* () { | |
| 130 | + const apiKeys = yield* serviceWith({ | |
| 131 | + createOrgApiKey: () => | |
| 132 | + Effect.succeed({ | |
| 133 | + ...orgKeyResponse("org_other", "key_theirs", "theirs"), | |
| 134 | + value: "sk_org_plaintext", | |
| 135 | + }), | |
| 136 | + }); | |
| 137 | + | |
| 138 | + const error = yield* Effect.flip( | |
| 139 | + apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }), | |
| 140 | + ); | |
| 141 | + | |
| 142 | + expect(error).toBeInstanceOf(ApiKeyManagementError); | |
| 143 | + }), | |
| 144 | + ); | |
| 145 | + | |
| 146 | + it.effect("ignores a user-owned key in an org-key listing", () => | |
| 147 | + Effect.gen(function* () { | |
| 148 | + // The org and user shapes are decoded separately on purpose; a user key | |
| 149 | + // appearing here would mean the caller could mistake a member's personal | |
| 150 | + // credential for a workspace one. | |
| 151 | + const apiKeys = yield* serviceWith({ | |
| 152 | + listOrgApiKeys: () => | |
| 153 | + Effect.succeed({ | |
| 154 | + object: "list", | |
| 155 | + data: [ | |
| 156 | + orgKeyResponse(ORG, "key_mine", "mine"), | |
| 157 | + { | |
| 158 | + object: "api_key", | |
| 159 | + id: "key_user", | |
| 160 | + owner: { type: "user", id: "user_1", organization_id: ORG }, | |
| 161 | + name: "personal", | |
| 162 | + obfuscatedValue: "sk_user…c3d4", | |
| 163 | + lastUsedAt: null, | |
| 164 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 165 | + updatedAt: "2026-01-01T00:00:00.000Z", | |
| 166 | + }, | |
| 167 | + ], | |
| 168 | + listMetadata: { before: null, after: null }, | |
| 169 | + }), | |
| 170 | + }); | |
| 171 | + | |
| 172 | + const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | |
| 173 | + | |
| 174 | + expect( | |
| 175 | + keys.map((key) => key.id), | |
| 176 | + "a user-owned key is not an org key", | |
| 177 | + ).toEqual(["key_mine"]); | |
| 178 | + }), | |
| 179 | + ); | |
| 180 | +}); | |
| 181 | + | |
| 182 | +// --------------------------------------------------------------------------- | |
| 183 | +// Revoking an org key. The mint is only half a credential lifecycle: an org key | |
| 184 | +// reads every user and connection in the tenant, so it has to be destroyable. | |
| 185 | +// | |
| 186 | +// Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves | |
| 187 | +// ownership against the CALLER's personal keys, so an org key id is never in | |
| 188 | +// its owned set. It also cannot just call `deleteApiKey` directly: that route is | |
| 189 | +// scope-blind and would happily destroy a member's personal key, or a key | |
| 190 | +// belonging to a sibling org in the same WorkOS workspace. So ownership is | |
| 191 | +// resolved against the ORG's decoded listing first, and that filter IS the | |
| 192 | +// authorization. These pin that filter. | |
| 193 | +// --------------------------------------------------------------------------- | |
| 194 | + | |
| 195 | +describe("revoking an organization API key", () => { | |
| 196 | + const listing = (...keys: ReadonlyArray<unknown>) => ({ | |
| 197 | + object: "list", | |
| 198 | + data: keys, | |
| 199 | + listMetadata: { before: null, after: null }, | |
| 200 | + }); | |
| 201 | + | |
| 202 | + it.effect("revokes a key the organization owns", () => | |
| 203 | + Effect.gen(function* () { | |
| 204 | + const deleted: string[] = []; | |
| 205 | + const apiKeys = yield* serviceWith({ | |
| 206 | + listOrgApiKeys: (organizationId: string) => | |
| 207 | + Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), | |
| 208 | + deleteApiKey: (id: string) => | |
| 209 | + Effect.sync(() => { | |
| 210 | + deleted.push(id); | |
| 211 | + }), | |
| 212 | + }); | |
| 213 | + | |
| 214 | + yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }); | |
| 215 | + | |
| 216 | + expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([ | |
| 217 | + "key_org_1", | |
| 218 | + ]); | |
| 219 | + }), | |
| 220 | + ); | |
| 221 | + | |
| 222 | + it.effect("refuses a USER-owned key id, without deleting anything", () => | |
| 223 | + Effect.gen(function* () { | |
| 224 | + // The regression this guards: a member's personal credential must not be | |
| 225 | + // destroyable through the org route just because an admin can call it. | |
| 226 | + const deleted: string[] = []; | |
| 227 | + const apiKeys = yield* serviceWith({ | |
| 228 | + listOrgApiKeys: () => | |
| 229 | + Effect.succeed( | |
| 230 | + listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), { | |
| 231 | + object: "api_key", | |
| 232 | + id: "key_user", | |
| 233 | + owner: { type: "user", id: "user_1", organization_id: ORG }, | |
| 234 | + name: "personal", | |
| 235 | + obfuscatedValue: "sk_user…c3d4", | |
| 236 | + lastUsedAt: null, | |
| 237 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 238 | + updatedAt: "2026-01-01T00:00:00.000Z", | |
| 239 | + }), | |
| 240 | + ), | |
| 241 | + deleteApiKey: (id: string) => | |
| 242 | + Effect.sync(() => { | |
| 243 | + deleted.push(id); | |
| 244 | + }), | |
| 245 | + }); | |
| 246 | + | |
| 247 | + const error = yield* Effect.flip( | |
| 248 | + apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }), | |
| 249 | + ); | |
| 250 | + | |
| 251 | + expect(error).toBeInstanceOf(OrgApiKeyNotFound); | |
| 252 | + expect(deleted, "no delete is issued for a key the org does not own").toEqual([]); | |
| 253 | + }), | |
| 254 | + ); | |
| 255 | + | |
| 256 | + it.effect("refuses a key belonging to ANOTHER organization", () => | |
| 257 | + Effect.gen(function* () { | |
| 258 | + // The WorkOS workspace key is workspace-wide, so an admin of one org must | |
| 259 | + // not be able to revoke a sibling org's credential by id. | |
| 260 | + const deleted: string[] = []; | |
| 261 | + const apiKeys = yield* serviceWith({ | |
| 262 | + listOrgApiKeys: () => | |
| 263 | + Effect.succeed( | |
| 264 | + listing( | |
| 265 | + orgKeyResponse(ORG, "key_mine", "mine"), | |
| 266 | + orgKeyResponse("org_other", "key_theirs", "theirs"), | |
| 267 | + ), | |
| 268 | + ), | |
| 269 | + deleteApiKey: (id: string) => | |
| 270 | + Effect.sync(() => { | |
| 271 | + deleted.push(id); | |
| 272 | + }), | |
| 273 | + }); | |
| 274 | + | |
| 275 | + const error = yield* Effect.flip( | |
| 276 | + apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }), | |
| 277 | + ); | |
| 278 | + | |
| 279 | + expect(error).toBeInstanceOf(OrgApiKeyNotFound); | |
| 280 | + expect(deleted).toEqual([]); | |
| 281 | + }), | |
| 282 | + ); | |
| 283 | + | |
| 284 | + it.effect("refuses an unknown key id", () => | |
| 285 | + Effect.gen(function* () { | |
| 286 | + const apiKeys = yield* serviceWith({ | |
| 287 | + listOrgApiKeys: () => Effect.succeed(listing()), | |
| 288 | + deleteApiKey: () => Effect.die("nothing should be deleted"), | |
| 289 | + }); | |
| 290 | + | |
| 291 | + const error = yield* Effect.flip( | |
| 292 | + apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }), | |
| 293 | + ); | |
| 294 | + | |
| 295 | + expect(error).toBeInstanceOf(OrgApiKeyNotFound); | |
| 296 | + }), | |
| 297 | + ); | |
| 298 | + | |
| 299 | + it.effect("surfaces an upstream delete failure as a management error", () => | |
| 300 | + Effect.gen(function* () { | |
| 301 | + // A failed delete must not read as a successful revoke — the console | |
| 302 | + // would tell an admin a live credential is gone. | |
| 303 | + const apiKeys = yield* serviceWith({ | |
| 304 | + listOrgApiKeys: (organizationId: string) => | |
| 305 | + Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), | |
| 306 | + deleteApiKey: () => Effect.fail("workos_unavailable"), | |
| 307 | + }); | |
| 308 | + | |
| 309 | + const error = yield* Effect.flip( | |
| 310 | + apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }), | |
| 311 | + ); | |
| 312 | + | |
| 313 | + expect(error).toBeInstanceOf(ApiKeyManagementError); | |
| 314 | + }), | |
| 315 | + ); | |
| 316 | +}); | |
| @@ -29,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 28 unmodified lines | ||
| 29 | 29 | listUserKeys: () => Effect.succeed([]), |
| 30 | 30 | createUserKey: () => Effect.die("not used"), |
| 31 | 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 | |
| 34 | 37 | const stubWorkOS = Layer.succeed( |
| @@ -47,6 +47,7 @@ import { · 46 unmodified lines | ||
| 47 | 47 | authorizeOrganization, |
| 48 | 48 | authorizeOrganizationSelector, |
| 49 | 49 | orgSelectorFromRequest, |
| 50 | + resolveOrganization, | |
| 50 | 51 | } from "./organization"; |
| 51 | 52 | import { UserStoreService } from "./context"; |
| 52 | 53 | import { sealedSessionDisplayName } from "./middleware"; |
| @@ -103,6 +104,13 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 50 unmodified lines | ||
| 103 | 104 | code: "no_organization", |
| 104 | 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. | |
| 110 | +const 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 | 115 | // A bearer value with three dot-separated segments is a JWT (a WorkOS access |
| 108 | 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 JWT | |
| 155 | − * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS | |
| 156 | − * API key. Returns `null` when there is no `Authorization` header, so the | |
| 157 | − * caller falls through to the sealed-session path. (Kept the historical name, | |
| 158 | − * the re-export and resolver tests reference it.) | |
| 162 | + * What an ORG-level API key resolves to: the platform view. Deliberately NOT a | |
| 163 | + * `Principal` — a `Principal` names an acting member (`accountId: string`), and | |
| 164 | + * an org key has none. Keeping it a separate shape means no product code path | |
| 165 | + * can accidentally treat it as a member, and the executor built from it binds | |
| 166 | + * `subject: null` + the read-only tenant reach rather than inventing a subject. | |
| 167 | + * | |
| 168 | + * The `/admin/*` mount turns this into an executor with `{ tenant: | |
| 169 | + * organizationId, subject: undefined, platformView: true }`. | |
| 170 | + */ | |
| 171 | +export interface PlatformAuth { | |
| 172 | + readonly kind: "platform"; | |
| 173 | + readonly organizationId: string; | |
| 174 | + readonly organizationName: string; | |
| 175 | + readonly organizationSlug?: string; | |
| 176 | + readonly keyId: string; | |
| 177 | +} | |
| 178 | + | |
| 179 | +/** A resolved bearer credential: an acting member, or the org-level platform | |
| 180 | + * view. `null` means no `Authorization` header at all. */ | |
| 181 | +export type BearerAuth = Principal | PlatformAuth | null; | |
| 182 | + | |
| 183 | +export const isPlatformAuth = (value: BearerAuth): value is PlatformAuth => | |
| 184 | + value !== null && "kind" in value && value.kind === "platform"; | |
| 185 | + | |
| 186 | +/** | |
| 187 | + * Resolve a `Bearer` credential into either a member `Principal` or the | |
| 188 | + * org-level {@link PlatformAuth}. Returns `null` when there is no | |
| 189 | + * `Authorization` header, so the caller falls through to the sealed-session | |
| 190 | + * path. | |
| 191 | + * | |
| 192 | + * The org branch does NOT call `authorizeOrganization`: that checks a USER's | |
| 193 | + * live membership, and there is no user here. The key itself is the authority — | |
| 194 | + * WorkOS validated it and reported which org owns it — so the org row is merely | |
| 195 | + * resolved (mirrored on first read) for its name and slug. | |
| 159 | 196 | */ |
| 160 | −export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) => | |
| 197 | +export const resolveBearerAuth = ( | |
| 198 | + request: Request, | |
| 199 | + jwt: JwtBearerConfig | null = null, | |
| 200 | +): Effect.Effect< | |
| 201 | + BearerAuth, | |
| 202 | + Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | |
| 203 | + WorkOSClient | ApiKeyService | UserStoreService | |
| 204 | +> => | |
| 161 | 205 | Effect.gen(function* () { |
| 162 | 206 | const authHeader = request.headers.get("authorization"); |
| 163 | 207 | if (!authHeader) return null; |
| @@ -172,7 +216,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines | ||
| 172 | 216 | if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); |
| 173 | 217 | |
| 174 | 218 | const apiKeys = yield* ApiKeyService; |
| 175 | − const principal = yield* apiKeys | |
| 219 | + const owner = yield* apiKeys | |
| 176 | 220 | .validate(value) |
| 177 | 221 | .pipe( |
| 178 | 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 | 245 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); |
| 187 | 246 | |
| 188 | 247 | return { |
| 189 | − accountId: principal.accountId, | |
| 248 | + accountId: owner.accountId, | |
| 190 | 249 | organizationId: org.id, |
| 191 | 250 | organizationName: org.name, |
| 192 | 251 | organizationSlug: org.slug, |
| @@ -197,6 +256,27 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines | ||
| 197 | 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 | + */ | |
| 266 | +export const resolveApiKeyPrincipal = ( | |
| 267 | + request: Request, | |
| 268 | + jwt: JwtBearerConfig | null = null, | |
| 269 | +): Effect.Effect< | |
| 270 | + Principal | null, | |
| 271 | + Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | |
| 272 | + WorkOSClient | ApiKeyService | UserStoreService | |
| 273 | +> => | |
| 274 | + Effect.gen(function* () { | |
| 275 | + const auth = yield* resolveBearerAuth(request, jwt); | |
| 276 | + if (isPlatformAuth(auth)) return yield* new Unauthorized(ORG_KEY_ON_PRODUCT_SURFACE); | |
| 277 | + return auth; | |
| 278 | + }); | |
| 279 | + | |
| 200 | 280 | export const resolveSessionPrincipal = (request: Request) => |
| 201 | 281 | Effect.gen(function* () { |
| 202 | 282 | const workos = yield* WorkOSClient; |
| @@ -544,6 +544,33 @@ const make = Effect.gen(function* () { · 543 unmodified lines | ||
| 544 | 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 | 574 | deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)), |
| 548 | 575 | |
| 549 | 576 | /** List organization memberships with user details. */ |
| @@ -33,6 +33,22 @@ export const integration = pgTable( · 32 unmodified lines | ||
| 33 | 33 | (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)], |
| 34 | 34 | ); |
| 35 | 35 | |
| 36 | +export const subject = pgTable( | |
| 37 | + "subject", | |
| 38 | + { | |
| 39 | + external_id: varchar("external_id", { length: 255 }).notNull(), | |
| 40 | + created_at: timestamp("created_at").notNull(), | |
| 41 | + last_seen_at: bigint("last_seen_at", { mode: "bigint" }), | |
| 42 | + status: text("status"), | |
| 43 | + row_id: varchar("row_id", { length: 255 }) | |
| 44 | + .primaryKey() | |
| 45 | + .notNull() | |
| 46 | + .$defaultFn(() => createId()), | |
| 47 | + tenant: varchar("tenant", { length: 255 }).notNull(), | |
| 48 | + }, | |
| 49 | + (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], | |
| 50 | +); | |
| 51 | + | |
| 36 | 52 | export const connection = pgTable( |
| 37 | 53 | "connection", |
| 38 | 54 | { |
| @@ -11,14 +11,21 @@ · 10 unmodified lines | ||
| 11 | 11 | // - a second org's data is completely untouched |
| 12 | 12 | // - the blob prefix match escapes LIKE wildcards (a `_` in the org id must |
| 13 | 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 | |
| 15 | 20 | import { describe, it, expect } from "@effect/vitest"; |
| 16 | 21 | import { Effect } from "effect"; |
| 17 | −import { eq, inArray } from "drizzle-orm"; | |
| 22 | +import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm"; | |
| 18 | 23 | |
| 19 | 24 | import { DbService } from "./db"; |
| 20 | 25 | import type { DrizzleDb } from "./db"; |
| 21 | 26 | import { makeUserStore } from "../auth/user-store"; |
| 27 | +import * as cloudSchema from "./schema"; | |
| 28 | +import * as executorSchema from "./executor-schema"; | |
| 22 | 29 | import { memberships, accounts } from "./schema"; |
| 23 | 30 | import { |
| 24 | 31 | blob, |
| @@ -28,6 +35,7 @@ import { · 3 unmodified lines | ||
| 28 | 35 | oauth_client, |
| 29 | 36 | oauth_session, |
| 30 | 37 | plugin_storage, |
| 38 | + subject, | |
| 31 | 39 | tool, |
| 32 | 40 | tool_policy, |
| 33 | 41 | } from "./executor-schema"; |
| @@ -131,6 +139,13 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { · 97 unmodified lines | ||
| 131 | 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 | 149 | const orgNs = `o:${tenant}/plugin`; |
| 135 | 150 | const userNs = `u:${tenant}:subject/plugin`; |
| 136 | 151 | await db.insert(blob).values({ |
| @@ -156,8 +171,25 @@ const TENANT_TABLES = [ · 19 unmodified lines | ||
| 156 | 171 | definition, |
| 157 | 172 | tool_policy, |
| 158 | 173 | plugin_storage, |
| 174 | + subject, | |
| 159 | 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. | |
| 180 | +const NOT_ORG_OWNED: Record<string, string> = { | |
| 181 | + // Purged, but by NAMESPACE PREFIX (`o:<org>/…` / `u:<org>:…`) rather than a | |
| 182 | + // tenant column, so it cannot be checked by the column rule. | |
| 183 | + blob: "org-scoped by namespace prefix, purged via the LIKE match", | |
| 184 | + // Instance-wide, not owned by any org. | |
| 185 | + private_executor_cloud_settings: "singleton instance settings, not org-scoped", | |
| 186 | + // Identity mirror. `organizations` is deleted directly and `memberships` | |
| 187 | + // cascades from its FK; `accounts` deliberately outlives the org. | |
| 188 | + organizations: "the identity row itself, deleted directly", | |
| 189 | + memberships: "cascades from the organizations FK", | |
| 190 | + accounts: "shared across orgs — deliberately survives", | |
| 191 | +}; | |
| 192 | + | |
| 161 | 193 | const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => { |
| 162 | 194 | let total = 0; |
| 163 | 195 | for (const table of TENANT_TABLES) { |
| @@ -249,3 +281,70 @@ describe("purgeOrganizationData", () => { · 85 unmodified lines | ||
| 249 | 281 | ); |
| 250 | 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. | |
| 298 | +describe("purge coverage", () => { | |
| 299 | + // Every drizzle table the cloud DB is built from (the same two modules | |
| 300 | + // `combinedSchema` spreads), by table name. | |
| 301 | + const allTables = Object.values({ ...cloudSchema, ...executorSchema }); | |
| 302 | + | |
| 303 | + const orgOwnedTables = allTables.filter((table) => "tenant" in getTableColumns(table)); | |
| 304 | + const orgOwnedNames = orgOwnedTables.map(getTableName).sort(); | |
| 305 | + const seededNames = TENANT_TABLES.map(getTableName).sort(); | |
| 306 | + | |
| 307 | + it("classifies every schema table as org-owned or explicitly exempt", () => { | |
| 308 | + const unclassified = allTables | |
| 309 | + .map(getTableName) | |
| 310 | + .filter((name) => !orgOwnedNames.includes(name) && !(name in NOT_ORG_OWNED)); | |
| 311 | + expect( | |
| 312 | + unclassified, | |
| 313 | + "a new table must either carry `tenant` (and be purged) or be listed in NOT_ORG_OWNED with a reason", | |
| 314 | + ).toEqual([]); | |
| 315 | + }); | |
| 316 | + | |
| 317 | + it("purges every org-owned table in the schema", () => { | |
| 318 | + // `TENANT_TABLES` is what this suite seeds and then asserts is empty after | |
| 319 | + // the purge, so keeping it equal to the schema-derived set is what makes | |
| 320 | + // the cascade test above cover the WHOLE schema rather than a snapshot of | |
| 321 | + // it. A tenant table added tomorrow fails here first — the fix is to seed | |
| 322 | + // it, which then fails the cascade test until `purgeOrganizationData` | |
| 323 | + // deletes it too. | |
| 324 | + expect(seededNames, "every org-owned table is seeded and checked by the cascade test").toEqual( | |
| 325 | + orgOwnedNames, | |
| 326 | + ); | |
| 327 | + }); | |
| 328 | + | |
| 329 | + it("seeds a row in every org-owned table, so the cascade assertions are not vacuous", async () => { | |
| 330 | + // Guards the other half: a table listed in TENANT_TABLES but never seeded | |
| 331 | + // would count zero rows before AND after the purge, passing while proving | |
| 332 | + // nothing. Assert the seed actually populates each one. | |
| 333 | + const tenant = `org_seed_${crypto.randomUUID().slice(0, 8)}`; | |
| 334 | + const empty = await program( | |
| 335 | + Effect.gen(function* () { | |
| 336 | + const { db } = yield* DbService; | |
| 337 | + return yield* Effect.promise(async () => { | |
| 338 | + await seedTenant(db, tenant, `seed${crypto.randomUUID().slice(0, 6)}`); | |
| 339 | + const missing: Array<string> = []; | |
| 340 | + for (const table of TENANT_TABLES) { | |
| 341 | + const rows = await db.select().from(table).where(eq(table.tenant, tenant)); | |
| 342 | + if (rows.length === 0) missing.push(getTableName(table)); | |
| 343 | + } | |
| 344 | + return missing; | |
| 345 | + }); | |
| 346 | + }), | |
| 347 | + ); | |
| 348 | + expect(empty, "seedTenant must insert a row into every table the cascade checks").toEqual([]); | |
| 349 | + }, 20_000); | |
| 350 | +}); | |
| @@ -23,6 +23,7 @@ import { · 22 unmodified lines | ||
| 23 | 23 | oauth_client, |
| 24 | 24 | oauth_session, |
| 25 | 25 | plugin_storage, |
| 26 | + subject, | |
| 26 | 27 | tool, |
| 27 | 28 | tool_policy, |
| 28 | 29 | } from "./executor-schema"; |
| @@ -48,6 +49,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr · 19 unmodified lines | ||
| 48 | 49 | await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); |
| 49 | 50 | await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); |
| 50 | 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 | 54 | // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by |
| 53 | 55 | // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>` |
| @@ -2,12 +2,18 @@ import { Effect, Layer } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; |
| 3 | 3 | import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; |
| 4 | 4 | |
| 5 | +import { AccountApi, AdminUsersApi } from "@executor-js/api"; | |
| 6 | + | |
| 5 | 7 | import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; |
| 6 | 8 | import { OrgApi } from "../org/api"; |
| 7 | 9 | |
| 8 | 10 | import { ProtectedCloudApi } from "../api/layers"; |
| 9 | 11 | |
| 10 | −export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi).add(CloudAuthApi).add(OrgApi); | |
| 12 | +export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi) | |
| 13 | + .add(CloudAuthApi) | |
| 14 | + .add(OrgApi) | |
| 15 | + .add(AccountApi) | |
| 16 | + .add(AdminUsersApi); | |
| 11 | 17 | |
| 12 | 18 | const spec = OpenApi.fromApi(CloudOpenApi); |
| 13 | 19 | |
| @@ -24,6 +24,7 @@ import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; · 23 unmodified lines | ||
| 24 | 24 | import { HttpApiBuilder } from "effect/unstable/httpapi"; |
| 25 | 25 | import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; |
| 26 | 26 | |
| 27 | +import { AccountApi, AdminUsersApi } from "@executor-js/api"; | |
| 27 | 28 | import { requestScopedMiddleware } from "@executor-js/api/server"; |
| 28 | 29 | |
| 29 | 30 | import { UserStoreService } from "../auth/context"; |
| @@ -34,6 +35,7 @@ import { · 4 unmodified lines | ||
| 34 | 35 | } from "../auth/handlers"; |
| 35 | 36 | import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; |
| 36 | 37 | import { SessionAuthLive } from "../auth/middleware-live"; |
| 38 | +import { makeCloudAdminUsersRoutes } from "../admin/admin-users-api"; | |
| 37 | 39 | import { OrgApi, OrgHttpApi } from "../org/api"; |
| 38 | 40 | import { orgAuthMiddleware } from "../org/auth-middleware"; |
| 39 | 41 | import { OrgHandlers } from "../org/handlers"; |
| @@ -55,6 +57,8 @@ const apiPrefixedRouter = Layer.effect(HttpRouter.HttpRouter)( · 15 unmodified lines | ||
| 55 | 57 | const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi) |
| 56 | 58 | .add(CloudAuthApi) |
| 57 | 59 | .add(OrgApi) |
| 60 | + .add(AccountApi) | |
| 61 | + .add(AdminUsersApi) | |
| 58 | 62 | .prefix("/api"); |
| 59 | 63 | |
| 60 | 64 | const spec = OpenApi.fromApi(CloudOpenApi); |
| @@ -100,5 +104,18 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 39 unmodified lines | ||
| 100 | 104 | |
| 101 | 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 | }; |
| @@ -248,6 +248,17 @@ export const McpAuthLive = Layer.effect( · 247 unmodified lines | ||
| 248 | 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 | 262 | yield* Effect.annotateCurrentSpan({ |
| 252 | 263 | "mcp.auth.outcome": "verified", |
| 253 | 264 | "mcp.auth.credential_type": "api_key", |
| @@ -13,6 +13,7 @@ import { Route as SetupMcpRouteImport } from './routes/bare/setup-mcp' · 12 unmodified lines | ||
| 13 | 13 | import { Route as LoginRouteImport } from './routes/bare/login' |
| 14 | 14 | import { Route as CreateOrgRouteImport } from './routes/bare/create-org' |
| 15 | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 16 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users' | |
| 16 | 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 17 | 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' |
| 18 | 19 | import { Route as SecretsRouteImport } from './routes/app/secrets' |
| @@ -23,6 +24,7 @@ import { Route as ApiKeysRouteImport } from './routes/app/api-keys' · 4 unmodified lines | ||
| 23 | 24 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 24 | 25 | import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' |
| 25 | 26 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 27 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | |
| 26 | 28 | import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' |
| 27 | 29 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' |
| 28 | 30 | |
| @@ -47,6 +49,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 18 unmodified lines | ||
| 47 | 49 | path: '/{-$orgSlug}/', |
| 48 | 50 | getParentRoute: () => rootRouteImport, |
| 49 | 51 | } as any) |
| 52 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute = | |
| 53 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({ | |
| 54 | + id: '/{-$orgSlug}/users', | |
| 55 | + path: '/{-$orgSlug}/users', | |
| 56 | + getParentRoute: () => rootRouteImport, | |
| 57 | + } as any) | |
| 50 | 58 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = |
| 51 | 59 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({ |
| 52 | 60 | id: '/{-$orgSlug}/tools', |
| @@ -107,6 +115,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 54 unmodified lines | ||
| 107 | 115 | getParentRoute: () => rootRouteImport, |
| 108 | 116 | } as any, |
| 109 | 117 | ) |
| 118 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = | |
| 119 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( | |
| 120 | + { | |
| 121 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 122 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 123 | + getParentRoute: () => rootRouteImport, | |
| 124 | + } as any, | |
| 125 | + ) | |
| 110 | 126 | const Billing_DotplansRoute = Billing_DotplansRouteImport.update({ |
| 111 | 127 | id: '/{-$orgSlug}/billing_/plans', |
| 112 | 128 | path: '/{-$orgSlug}/billing/plans', |
| @@ -132,8 +148,10 @@ export interface FileRoutesByFullPath { · 19 unmodified lines | ||
| 132 | 148 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 133 | 149 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 134 | 150 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 151 | + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | |
| 135 | 152 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 136 | 153 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 154 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 137 | 155 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 138 | 156 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 139 | 157 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -150,8 +168,10 @@ export interface FileRoutesByTo { · 10 unmodified lines | ||
| 150 | 168 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 151 | 169 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 152 | 170 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 171 | + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | |
| 153 | 172 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 154 | 173 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 174 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 155 | 175 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 156 | 176 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 157 | 177 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -169,8 +189,10 @@ export interface FileRoutesById { · 11 unmodified lines | ||
| 169 | 189 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 170 | 190 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 171 | 191 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 192 | + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | |
| 172 | 193 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 173 | 194 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute |
| 195 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 174 | 196 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 175 | 197 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 176 | 198 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -189,8 +211,10 @@ export interface FileRouteTypes { · 12 unmodified lines | ||
| 189 | 211 | | '/{-$orgSlug}/secrets' |
| 190 | 212 | | '/{-$orgSlug}/toolkits' |
| 191 | 213 | | '/{-$orgSlug}/tools' |
| 214 | + | '/{-$orgSlug}/users' | |
| 192 | 215 | | '/{-$orgSlug}/' |
| 193 | 216 | | '/{-$orgSlug}/billing/plans' |
| 217 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 194 | 218 | | '/{-$orgSlug}/integrations/$namespace' |
| 195 | 219 | | '/{-$orgSlug}/resume/$executionId' |
| 196 | 220 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -207,8 +231,10 @@ export interface FileRouteTypes { · 10 unmodified lines | ||
| 207 | 231 | | '/{-$orgSlug}/secrets' |
| 208 | 232 | | '/{-$orgSlug}/toolkits' |
| 209 | 233 | | '/{-$orgSlug}/tools' |
| 234 | + | '/{-$orgSlug}/users' | |
| 210 | 235 | | '/{-$orgSlug}' |
| 211 | 236 | | '/{-$orgSlug}/billing/plans' |
| 237 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 212 | 238 | | '/{-$orgSlug}/integrations/$namespace' |
| 213 | 239 | | '/{-$orgSlug}/resume/$executionId' |
| 214 | 240 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -225,8 +251,10 @@ export interface FileRouteTypes { · 10 unmodified lines | ||
| 225 | 251 | | '/{-$orgSlug}/secrets' |
| 226 | 252 | | '/{-$orgSlug}/toolkits' |
| 227 | 253 | | '/{-$orgSlug}/tools' |
| 254 | + | '/{-$orgSlug}/users' | |
| 228 | 255 | | '/{-$orgSlug}/' |
| 229 | 256 | | '/{-$orgSlug}/billing_/plans' |
| 257 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 230 | 258 | | '/{-$orgSlug}/integrations/$namespace' |
| 231 | 259 | | '/{-$orgSlug}/resume/$executionId' |
| 232 | 260 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -244,8 +272,10 @@ export interface RootRouteChildren { · 11 unmodified lines | ||
| 244 | 272 | SecretsRoute: typeof SecretsRoute |
| 245 | 273 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 246 | 274 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 275 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | |
| 247 | 276 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 248 | 277 | Billing_DotplansRoute: typeof Billing_DotplansRoute |
| 278 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 249 | 279 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 250 | 280 | ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute |
| 251 | 281 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -281,6 +311,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines | ||
| 281 | 311 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport |
| 282 | 312 | 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 | 321 | '/{-$orgSlug}/tools': { |
| 285 | 322 | id: '/{-$orgSlug}/tools' |
| 286 | 323 | path: '/{-$orgSlug}/tools' |
| @@ -351,6 +388,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines | ||
| 351 | 388 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport |
| 352 | 389 | 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 | 398 | '/{-$orgSlug}/billing_/plans': { |
| 355 | 399 | id: '/{-$orgSlug}/billing_/plans' |
| 356 | 400 | path: '/{-$orgSlug}/billing/plans' |
| @@ -397,9 +441,13 @@ const rootRouteChildren: RootRouteChildren = { · 40 unmodified lines | ||
| 397 | 441 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, |
| 398 | 442 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: |
| 399 | 443 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 444 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: | |
| 445 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute, | |
| 400 | 446 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| 401 | 447 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, |
| 402 | 448 | Billing_DotplansRoute: Billing_DotplansRoute, |
| 449 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: | |
| 450 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | |
| 403 | 451 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 404 | 452 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 405 | 453 | ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute, |
| @@ -411,11 +459,15 @@ export const routeTree = rootRouteImport · 5 unmodified lines | ||
| 411 | 459 | ._addFileTypes<FileRouteTypes>() |
| 412 | 460 | |
| 413 | 461 | import type { getRouter } from './router.tsx' |
| 462 | + | |
| 414 | 463 | import type { startInstance } from './start.ts' |
| 464 | + | |
| 415 | 465 | declare module '@tanstack/react-start' { |
| 416 | 466 | interface Register { |
| 417 | 467 | ssr: true |
| 468 | + | |
| 418 | 469 | router: Awaited<ReturnType<typeof getRouter>> |
| 470 | + | |
| 419 | 471 | config: Awaited<ReturnType<typeof startInstance.getOptions>> |
| 420 | 472 | } |
| 421 | 473 | } |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type React from "react"; |
| 2 | 2 | |
| 3 | 3 | import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; |
| 4 | +import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav"; | |
| 4 | 5 | import { trackEvent } from "@executor-js/react/api/analytics"; |
| 5 | 6 | import { AUTH_PATHS } from "../auth/api"; |
| 6 | 7 | import { OrgMenuSlot } from "./components/org-menu-slot"; |
| @@ -25,6 +26,12 @@ const navItems = [ · 18 unmodified lines | ||
| 25 | 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. | |
| 33 | +const adminNavItems = [{ to: "/users", label: "Users" }]; | |
| 34 | + | |
| 28 | 35 | // A top-level form POST, not fetch: the logout response 302s through the |
| 29 | 36 | // WorkOS logout endpoint (ending the hosted AuthKit session) before landing |
| 30 | 37 | // 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 | |
| 41 | 48 | export function Shell(props: { readonly content?: React.ReactNode }) { |
| 49 | + const items = useAdminNavItems(navItems, adminNavItems); | |
| 42 | 50 | return ( |
| 43 | 51 | <SharedShell |
| 44 | 52 | onSignOut={signOut} |
| 45 | − navItems={navItems} | |
| 53 | + navItems={items} | |
| 46 | 54 | orgMenuSlot={<OrgMenuSlot />} |
| 47 | 55 | supportSlot={<SupportSlot />} |
| 48 | 56 | content={props.content} |
| @@ -61,6 +61,11 @@ export const cloudflareAccountProvider = ( · 60 unmodified lines | ||
| 61 | 61 | listApiKeys: () => Effect.succeed({ apiKeys: [] }), |
| 62 | 62 | createApiKey: () => forbiddenWrite, |
| 63 | 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 | 69 | listMembers: () => Effect.succeed({ members: [] }), |
| 65 | 70 | listRoles: () => Effect.succeed({ roles: [] }), |
| 66 | 71 | inviteMember: () => forbiddenWrite, |
| @@ -10,6 +10,10 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; · 9 unmodified lines | ||
| 10 | 10 | // this app only owns its root (the Cloudflare-Access shell). To add |
| 11 | 11 | // app-specific routes, create web/routes/app and mount it inside the org |
| 12 | 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. | |
| 13 | 17 | export const routes = rootRoute("__root.tsx", [ |
| 14 | − ...consoleRoutes({ dir: "../../../../packages/react/src/routes" }), | |
| 18 | + ...consoleRoutes({ dir: "../../../../packages/react/src/routes", exclude: ["/users"] }), | |
| 15 | 19 | ]); |
| @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines | ||
| 17 | 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 18 | 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 19 | 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 20 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | |
| 20 | 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' |
| 21 | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' |
| 22 | 23 | |
| @@ -75,6 +76,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 52 unmodified lines | ||
| 75 | 76 | getParentRoute: () => rootRouteImport, |
| 76 | 77 | } as any, |
| 77 | 78 | ) |
| 79 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = | |
| 80 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( | |
| 81 | + { | |
| 82 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 83 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 84 | + getParentRoute: () => rootRouteImport, | |
| 85 | + } as any, | |
| 86 | + ) | |
| 78 | 87 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = |
| 79 | 88 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( |
| 80 | 89 | { |
| @@ -98,6 +107,7 @@ export interface FileRoutesByFullPath { · 17 unmodified lines | ||
| 98 | 107 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 99 | 108 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 100 | 109 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 110 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 101 | 111 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 102 | 112 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 103 | 113 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -110,6 +120,7 @@ export interface FileRoutesByTo { · 6 unmodified lines | ||
| 110 | 120 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 111 | 121 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 112 | 122 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 123 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 113 | 124 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 114 | 125 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 115 | 126 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -123,6 +134,7 @@ export interface FileRoutesById { · 7 unmodified lines | ||
| 123 | 134 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 124 | 135 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 125 | 136 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 137 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 126 | 138 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 127 | 139 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 128 | 140 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -137,6 +149,7 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 137 | 149 | | '/{-$orgSlug}/toolkits' |
| 138 | 150 | | '/{-$orgSlug}/tools' |
| 139 | 151 | | '/{-$orgSlug}/' |
| 152 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 140 | 153 | | '/{-$orgSlug}/integrations/$namespace' |
| 141 | 154 | | '/{-$orgSlug}/resume/$executionId' |
| 142 | 155 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -149,6 +162,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 149 | 162 | | '/{-$orgSlug}/toolkits' |
| 150 | 163 | | '/{-$orgSlug}/tools' |
| 151 | 164 | | '/{-$orgSlug}' |
| 165 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 152 | 166 | | '/{-$orgSlug}/integrations/$namespace' |
| 153 | 167 | | '/{-$orgSlug}/resume/$executionId' |
| 154 | 168 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -161,6 +175,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 161 | 175 | | '/{-$orgSlug}/toolkits' |
| 162 | 176 | | '/{-$orgSlug}/tools' |
| 163 | 177 | | '/{-$orgSlug}/' |
| 178 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 164 | 179 | | '/{-$orgSlug}/integrations/$namespace' |
| 165 | 180 | | '/{-$orgSlug}/resume/$executionId' |
| 166 | 181 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -174,6 +189,7 @@ export interface RootRouteChildren { · 7 unmodified lines | ||
| 174 | 189 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 175 | 190 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 176 | 191 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 192 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 177 | 193 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 178 | 194 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 179 | 195 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -238,6 +254,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines | ||
| 238 | 254 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport |
| 239 | 255 | 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 | 264 | '/{-$orgSlug}/plugins/$pluginId/$': { |
| 242 | 265 | id: '/{-$orgSlug}/plugins/$pluginId/$' |
| 243 | 266 | path: '/{-$orgSlug}/plugins/$pluginId/$' |
| @@ -281,6 +304,8 @@ const rootRouteChildren: RootRouteChildren = { · 37 unmodified lines | ||
| 281 | 304 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 282 | 305 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| 283 | 306 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, |
| 307 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: | |
| 308 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | |
| 284 | 309 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 285 | 310 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 286 | 311 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: |
| @@ -106,6 +106,33 @@ export const betterAuthAccountProvider: Layer.Layer<AccountProvider, never, Bett · 105 unmodified lines | ||
| 106 | 106 | auth.api.deleteApiKey({ body: { keyId: apiKeyId }, headers: toHeaders(headers) }), |
| 107 | 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 | 136 | listMembers: (headers) => |
| 110 | 137 | Effect.gen(function* () { |
| 111 | 138 | const resolved = yield* getSession(headers); |
| @@ -0,0 +1,184 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 5 | +import { afterAll, expect, test } from "@effect/vitest"; | |
| 6 | + | |
| 7 | +import { mintInviteCode } from "../testing/mint-invite"; | |
| 8 | + | |
| 9 | +// --------------------------------------------------------------------------- | |
| 10 | +// THE ESCALATION, performed rather than asserted about. | |
| 11 | +// | |
| 12 | +// `admin-users.node.test.ts` already asserts a plain member gets a 403, and it | |
| 13 | +// PASSED while the instance was fully bypassable — because it only ever asked | |
| 14 | +// the question one way. A member who simply calls the admin plane is refused; | |
| 15 | +// a member who first arranges to be an owner OF SOMETHING is a different | |
| 16 | +// caller, and the old gate could not tell the difference: it read the role from | |
| 17 | +// `session.activeOrganizationId`, which the caller controls, while the reads | |
| 18 | +// underneath stayed scoped to the instance's boot-seeded org. | |
| 19 | +// | |
| 20 | +// So this file does not test the label ("members are refused"). It runs the | |
| 21 | +// actual attack an invited member can run with nothing but their own session | |
| 22 | +// and the auth routes Better Auth mounts: | |
| 23 | +// | |
| 24 | +// 1. POST /api/auth/organization/create → become `owner` of a new org | |
| 25 | +// 2. POST /api/auth/organization/set-active → point the session at it | |
| 26 | +// 3. call the admin planes → must STILL be refused | |
| 27 | +// | |
| 28 | +// and asserts the refusal survives every step, on BOTH planes. The users plane | |
| 29 | +// leaks the instance's whole user directory and connection inventory; the | |
| 30 | +// invite plane is worse, because a `role: "admin"` invite code is durable | |
| 31 | +// escalation that outlives the session. Step 1 is now refused outright | |
| 32 | +// (`allowUserToCreateOrganization: false`), so the test asserts what it can at | |
| 33 | +// each step and never lets a blocked earlier step silently excuse a later one — | |
| 34 | +// steps 2 and 3 run regardless of whether step 1 succeeded. | |
| 35 | +// | |
| 36 | +// MUTATION-CHECKED, both directions: | |
| 37 | +// - gate reverted to a session-scoped role lookup + org creation re-opened: | |
| 38 | +// FAILS with "/api/admin/users refuses the escalated member: expected 200 | |
| 39 | +// to be 403". The escalation really is reachable, and this test really does | |
| 40 | +// catch it. | |
| 41 | +// - gate fixed, org creation re-opened: every admin-plane assertion above | |
| 42 | +// PASSES and only the final defense-in-depth line fails. That is the | |
| 43 | +// evidence that the gate — not the flag — is what closes the hole. | |
| 44 | +// --------------------------------------------------------------------------- | |
| 45 | + | |
| 46 | +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-escalation-")); | |
| 47 | +process.env.BETTER_AUTH_SECRET = "admin-escalation-secret-0123456789-abcdefg"; | |
| 48 | +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@escalation.test"; | |
| 49 | +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; | |
| 50 | + | |
| 51 | +const { makeSelfHostApiHandler } = await import("../app"); | |
| 52 | +const { handler, dispose } = await makeSelfHostApiHandler(); | |
| 53 | +afterAll(() => dispose()); | |
| 54 | + | |
| 55 | +const BASE = "http://localhost:4788"; | |
| 56 | + | |
| 57 | +const asMember = (token: string) => ({ authorization: `Bearer ${token}` }); | |
| 58 | + | |
| 59 | +const post = (path: string, token: string, body: unknown) => | |
| 60 | + handler( | |
| 61 | + new Request(`${BASE}${path}`, { | |
| 62 | + method: "POST", | |
| 63 | + headers: { "content-type": "application/json", ...asMember(token) }, | |
| 64 | + body: JSON.stringify(body), | |
| 65 | + }), | |
| 66 | + ); | |
| 67 | + | |
| 68 | +const get = (path: string, token: string) => | |
| 69 | + handler(new Request(`${BASE}${path}`, { headers: asMember(token) })); | |
| 70 | + | |
| 71 | +/** Join the instance for real: mint an invite as the bootstrap admin, redeem it. | |
| 72 | + * This is a legitimate, fully authenticated member of the instance org — the | |
| 73 | + * weakest principal that has any standing at all, which is precisely the | |
| 74 | + * attacker this gate has to hold against. */ | |
| 75 | +const joinAsMember = async (email: string): Promise<string> => { | |
| 76 | + const inviteCode = await mintInviteCode(handler); | |
| 77 | + const signUp = await handler( | |
| 78 | + new Request(`${BASE}/api/auth/sign-up/email`, { | |
| 79 | + method: "POST", | |
| 80 | + headers: { "content-type": "application/json" }, | |
| 81 | + body: JSON.stringify({ email, password: "password-12345678", name: "Member", inviteCode }), | |
| 82 | + }), | |
| 83 | + ); | |
| 84 | + expect(signUp.status, "the member joined through the real invite flow").toBe(200); | |
| 85 | + const token = signUp.headers.get("set-auth-token") ?? ""; | |
| 86 | + expect(token).not.toBe(""); | |
| 87 | + return token; | |
| 88 | +}; | |
| 89 | + | |
| 90 | +test("a member cannot escalate by owning an organization of their own", async () => { | |
| 91 | + const memberToken = await joinAsMember("climber@escalation.test"); | |
| 92 | + | |
| 93 | + // Baseline: the ordinary refusal the old test already covered. If this were | |
| 94 | + // ever a 200 the rest of the file would be meaningless, so it is asserted | |
| 95 | + // first rather than assumed. | |
| 96 | + expect((await get("/api/admin/users", memberToken)).status, "a plain member is refused").toBe( | |
| 97 | + 403, | |
| 98 | + ); | |
| 99 | + | |
| 100 | + // -- Step 1: become an owner. --------------------------------------------- | |
| 101 | + // With `allowUserToCreateOrganization: false` this is refused outright. Its | |
| 102 | + // status is RECORDED, not asserted here: the security property this test | |
| 103 | + // exists for is "the admin plane stays shut", and asserting the block at step | |
| 104 | + // 1 would abort the run before step 3 ever asserts it. So the attack proceeds | |
| 105 | + // as far as it can either way, the planes are checked, and only then is the | |
| 106 | + // defense-in-depth flag asserted at the bottom. | |
| 107 | + const created = await post("/api/auth/organization/create", memberToken, { | |
| 108 | + name: "Climber Org", | |
| 109 | + slug: "climber-org", | |
| 110 | + }); | |
| 111 | + const createdOrgId = created.ok ? ((await created.json()) as { id?: string }).id : undefined; | |
| 112 | + | |
| 113 | + // -- Step 2: point the session at an org the caller owns. ------------------ | |
| 114 | + // The cheaper repeat path. Attempted against the created org when step 1 | |
| 115 | + // somehow succeeded, and unconditionally against the slug either way. | |
| 116 | + if (createdOrgId) { | |
| 117 | + await post("/api/auth/organization/set-active", memberToken, { | |
| 118 | + organizationId: createdOrgId, | |
| 119 | + }); | |
| 120 | + } | |
| 121 | + await post("/api/auth/organization/set-active", memberToken, { | |
| 122 | + organizationSlug: "climber-org", | |
| 123 | + }); | |
| 124 | + | |
| 125 | + // -- Step 3: the planes must still refuse. --------------------------------- | |
| 126 | + // Every admin users route, not just the list: a gate applied at four call | |
| 127 | + // sites can be fixed at three. | |
| 128 | + for (const path of [ | |
| 129 | + "/api/admin/users", | |
| 130 | + "/api/admin/users/with-connections", | |
| 131 | + "/api/admin/users/user_anyone/connections", | |
| 132 | + "/api/admin/users/user_anyone", | |
| 133 | + `/api/admin/users/${encodeURIComponent("admin@escalation.test")}`, | |
| 134 | + ]) { | |
| 135 | + const response = await get(path, memberToken); | |
| 136 | + expect(response.status, `${path} refuses the escalated member`).toBe(403); | |
| 137 | + // A refusal that leaked the answer in its body would be no refusal at all. | |
| 138 | + expect(await response.text(), `${path} discloses nothing`).not.toContain( | |
| 139 | + "admin@escalation.test", | |
| 140 | + ); | |
| 141 | + } | |
| 142 | + | |
| 143 | + // The invite plane shares the gate, and a bypass there is DURABLE: a | |
| 144 | + // `role: "admin"` code redeems into a real admin membership that outlives | |
| 145 | + // this session. Read and write are both checked — listing invite codes is | |
| 146 | + // itself a disclosure (a live code is a credential). | |
| 147 | + expect((await get("/api/admin/invites", memberToken)).status, "invite list refused").toBe(403); | |
| 148 | + | |
| 149 | + const minted = await post("/api/admin/invites", memberToken, { role: "admin" }); | |
| 150 | + expect(minted.status, "an admin invite cannot be minted by a member").toBe(403); | |
| 151 | + expect(await minted.text(), "and no code came back").not.toContain("code"); | |
| 152 | + | |
| 153 | + const revoked = await handler( | |
| 154 | + new Request(`${BASE}/api/admin/invites/some-invite-id`, { | |
| 155 | + method: "DELETE", | |
| 156 | + headers: asMember(memberToken), | |
| 157 | + }), | |
| 158 | + ); | |
| 159 | + expect(revoked.status, "revoking someone else's invite is refused too").toBe(403); | |
| 160 | + | |
| 161 | + // The instance's real admin is unaffected — the gate closed the hole, it did | |
| 162 | + // not close the door. Without this, every assertion above would also pass if | |
| 163 | + // the admin plane were simply broken for everyone. | |
| 164 | + const adminList = await get("/api/admin/users", await signInAdmin()); | |
| 165 | + expect(adminList.status, "the real instance admin still gets in").toBe(200); | |
| 166 | + | |
| 167 | + // Defense in depth, asserted LAST so it can never mask the assertions above: | |
| 168 | + // step 1 of the attack is itself closed on a single-org instance. | |
| 169 | + expect(created.status, "org creation is closed on a single-org instance").not.toBe(200); | |
| 170 | +}); | |
| 171 | + | |
| 172 | +const signInAdmin = async (): Promise<string> => { | |
| 173 | + const response = await handler( | |
| 174 | + new Request(`${BASE}/api/auth/sign-in/email`, { | |
| 175 | + method: "POST", | |
| 176 | + headers: { "content-type": "application/json" }, | |
| 177 | + body: JSON.stringify({ | |
| 178 | + email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, | |
| 179 | + password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, | |
| 180 | + }), | |
| 181 | + }), | |
| 182 | + ); | |
| 183 | + return response.headers.get("set-auth-token") ?? ""; | |
| 184 | +}; | |
| @@ -0,0 +1,225 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Self-host admin users API — the shared, provider-neutral `AdminUsersHandlers` | |
| 3 | +// backed by a Better-Auth-authorized platform view, mounted at | |
| 4 | +// `/api/admin/users*` beside the existing invite-code admin routes. | |
| 5 | +// | |
| 6 | +// AUTH: an owner/admin member of the single org, resolved by the shared | |
| 7 | +// `requireInstanceAdmin` — the SAME gate the invite-code admin API uses | |
| 8 | +// (`admin/handlers.ts`), not a new permission concept. Membership is resolved | |
| 9 | +// against the INSTANCE's organization id, never the caller's | |
| 10 | +// `session.activeOrganizationId`: see require-admin.ts for the privilege | |
| 11 | +// escalation that distinction refuses. Self-host has no | |
| 12 | +// organization-OWNED api key (Better Auth keys always belong to the user who | |
| 13 | +// created one), so there is no machine-credential path here; the operator's own | |
| 14 | +// admin session is the credential. That asymmetry with cloud is deliberate and | |
| 15 | +// is why `AdminUsersProvider` is a seam rather than one shared implementation. | |
| 16 | +// | |
| 17 | +// The READ half is identical to cloud's: a subject-less, tenant-reach executor | |
| 18 | +// from `makePlatformExecutor`, projected by the shared `admin/reads`. Self-host | |
| 19 | +// is single-tenant, so the tenant is always the boot-seeded org. | |
| 20 | +// --------------------------------------------------------------------------- | |
| 21 | + | |
| 22 | +import { HttpRouter } from "effect/unstable/http"; | |
| 23 | +import { Effect, Layer } from "effect"; | |
| 24 | + | |
| 25 | +import { | |
| 26 | + AdminUsersProvider, | |
| 27 | + DbProvider, | |
| 28 | + HostConfig, | |
| 29 | + PluginsProvider, | |
| 30 | + getAdminUser, | |
| 31 | + listAdminUserConnections, | |
| 32 | + listAdminUsers, | |
| 33 | + listAdminUsersWithConnections, | |
| 34 | + makeAdminUsersApiLayer, | |
| 35 | + makePlatformExecutor, | |
| 36 | + normalizeAdminUserEmail, | |
| 37 | + platformViewOf, | |
| 38 | + requestScopedMiddleware, | |
| 39 | + type AdminUserDirectory, | |
| 40 | + type AdminUserIdentity, | |
| 41 | + type AdminUsersHeaders, | |
| 42 | +} from "@executor-js/api/server"; | |
| 43 | +import { | |
| 44 | + AdminUserNotFound, | |
| 45 | + AdminUsersError, | |
| 46 | + AdminUsersForbidden, | |
| 47 | + AdminUsersUnauthorized, | |
| 48 | +} from "@executor-js/api"; | |
| 49 | +import type { Executor } from "@executor-js/sdk"; | |
| 50 | + | |
| 51 | +import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; | |
| 52 | +import { requireInstanceAdmin } from "./require-admin"; | |
| 53 | +import { SelfHostDb, SelfHostDbProvider, type SelfHostDbHandle } from "../db/self-host-db"; | |
| 54 | +import { SelfHostHostConfig, SelfHostPluginsProvider } from "../execution"; | |
| 55 | + | |
| 56 | +/** | |
| 57 | + * The SAME gate the invite-code admin API applies — literally the same | |
| 58 | + * function, not a second copy of the same idea, which is how the two planes | |
| 59 | + * came to share a bypass. A plain member session is refused, and so is an owner | |
| 60 | + * of some OTHER organization: this plane reports every user in the instance, so | |
| 61 | + * the org it authorizes against is the instance's own (see require-admin.ts). | |
| 62 | + */ | |
| 63 | +const requireAdmin = (headers: AdminUsersHeaders) => | |
| 64 | + requireInstanceAdmin(new Headers(headers)).pipe( | |
| 65 | + Effect.mapError((denial) => | |
| 66 | + denial === "unauthorized" ? new AdminUsersUnauthorized() : new AdminUsersForbidden(), | |
| 67 | + ), | |
| 68 | + ); | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * Self-host's member directory: `externalId` → email/name. | |
| 72 | + * | |
| 73 | + * THE JOIN KEY is `member.userId`, the Better Auth `user.id` — precisely what | |
| 74 | + * `auth/identity.ts` binds as `accountId` and therefore what the subject table | |
| 75 | + * records in `external_id`. `member.id` is the organization `member` ROW id and | |
| 76 | + * joins to nothing; the two look alike, so the choice is pinned here and in the | |
| 77 | + * node test rather than left to a reader. | |
| 78 | + * | |
| 79 | + * One `listMembers` call per request: Better Auth's organization plugin already | |
| 80 | + * attaches the `user` row to each member, so email and name arrive with the | |
| 81 | + * membership and no per-user lookup is needed. The requested ids are not passed | |
| 82 | + * to the call — the plugin offers no id filter, and a single-instance member | |
| 83 | + * list is small — but the caller only reads the ids it asked for. | |
| 84 | + * | |
| 85 | + * Runs as the CALLER, using their own admin headers, so this reads exactly the | |
| 86 | + * directory that session is already entitled to on `/account/members`. | |
| 87 | + */ | |
| 88 | +const listMembers = (auth: BetterAuthHandle["auth"], headers: AdminUsersHeaders) => | |
| 89 | + Effect.tryPromise(() => auth.api.listMembers({ headers: new Headers(headers) })); | |
| 90 | + | |
| 91 | +/** | |
| 92 | + * Both directions of self-host's directory, over the SAME single `listMembers` | |
| 93 | + * read. | |
| 94 | + * | |
| 95 | + * The reverse (email → `user.id`) needs no extra call and no new permission: | |
| 96 | + * the organization plugin already attaches the `user` row to each member, so | |
| 97 | + * the email is sitting beside the id the forward join uses. Better Auth | |
| 98 | + * lower-cases every email it writes, but the directory value is normalized | |
| 99 | + * anyway so this host cannot answer differently from cloud if that ever | |
| 100 | + * changes. | |
| 101 | + * | |
| 102 | + * A member with no `user.email` cannot match — `null` is not an address, and | |
| 103 | + * coercing it to "" would let an empty `?email=` select an arbitrary row. | |
| 104 | + */ | |
| 105 | +const userDirectory = ( | |
| 106 | + auth: BetterAuthHandle["auth"], | |
| 107 | + headers: AdminUsersHeaders, | |
| 108 | +): AdminUserDirectory => ({ | |
| 109 | + identities: () => | |
| 110 | + listMembers(auth, headers).pipe( | |
| 111 | + Effect.map((result) => { | |
| 112 | + const identities = new Map<string, AdminUserIdentity>(); | |
| 113 | + for (const member of result.members) { | |
| 114 | + identities.set(member.userId, { | |
| 115 | + email: member.user?.email ?? null, | |
| 116 | + displayName: member.user?.name ?? null, | |
| 117 | + }); | |
| 118 | + } | |
| 119 | + return identities; | |
| 120 | + }), | |
| 121 | + ), | |
| 122 | + resolveEmail: (email) => | |
| 123 | + listMembers(auth, headers).pipe( | |
| 124 | + Effect.map( | |
| 125 | + (result) => | |
| 126 | + result.members.find((member) => { | |
| 127 | + const stored = member.user?.email; | |
| 128 | + return stored != null && normalizeAdminUserEmail(stored) === email; | |
| 129 | + })?.userId ?? null, | |
| 130 | + ), | |
| 131 | + ), | |
| 132 | +}); | |
| 133 | + | |
| 134 | +const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>( | |
| 135 | + headers: AdminUsersHeaders, | |
| 136 | + organizationId: string, | |
| 137 | + body: (executor: Executor) => Effect.Effect<A, E>, | |
| 138 | +): Effect.Effect< | |
| 139 | + A, | |
| 140 | + // `AdminUsersError` unconditionally: opening the platform view can fail that | |
| 141 | + // way regardless of what `body` itself raises. | |
| 142 | + E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden, | |
| 143 | + BetterAuth | DbProvider | PluginsProvider | HostConfig | |
| 144 | +> => | |
| 145 | + Effect.gen(function* () { | |
| 146 | + yield* requireAdmin(headers); | |
| 147 | + const executor = yield* makePlatformExecutor(organizationId).pipe( | |
| 148 | + Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })), | |
| 149 | + ); | |
| 150 | + return yield* Effect.ensuring(body(executor), executor.close().pipe(Effect.ignore)); | |
| 151 | + }); | |
| 152 | + | |
| 153 | +export const betterAuthAdminUsersProvider: Layer.Layer< | |
| 154 | + AdminUsersProvider, | |
| 155 | + never, | |
| 156 | + BetterAuth | DbProvider | PluginsProvider | HostConfig | |
| 157 | +> = Layer.effect(AdminUsersProvider)( | |
| 158 | + Effect.gen(function* () { | |
| 159 | + const context = yield* Effect.context<BetterAuth | DbProvider | PluginsProvider | HostConfig>(); | |
| 160 | + const { auth, organizationId } = yield* BetterAuth; | |
| 161 | + return AdminUsersProvider.of({ | |
| 162 | + listUsers: (headers, options) => | |
| 163 | + withPlatformView(headers, organizationId, (executor) => | |
| 164 | + platformViewOf(executor).pipe( | |
| 165 | + Effect.flatMap((admin) => listAdminUsers(admin, options, userDirectory(auth, headers))), | |
| 166 | + ), | |
| 167 | + ).pipe(Effect.provideContext(context)), | |
| 168 | + listUsersWithConnections: (headers, options) => | |
| 169 | + withPlatformView(headers, organizationId, (executor) => | |
| 170 | + platformViewOf(executor).pipe( | |
| 171 | + Effect.flatMap((admin) => | |
| 172 | + listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)), | |
| 173 | + ), | |
| 174 | + ), | |
| 175 | + ).pipe(Effect.provideContext(context)), | |
| 176 | + listUserConnections: (headers, externalId) => | |
| 177 | + withPlatformView(headers, organizationId, (executor) => | |
| 178 | + platformViewOf(executor).pipe( | |
| 179 | + Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)), | |
| 180 | + ), | |
| 181 | + ).pipe(Effect.provideContext(context)), | |
| 182 | + getUser: (headers, identifier) => | |
| 183 | + withPlatformView(headers, organizationId, (executor) => | |
| 184 | + platformViewOf(executor).pipe( | |
| 185 | + Effect.flatMap((admin) => | |
| 186 | + getAdminUser(admin, identifier, userDirectory(auth, headers)), | |
| 187 | + ), | |
| 188 | + ), | |
| 189 | + ).pipe(Effect.provideContext(context)), | |
| 190 | + }); | |
| 191 | + }), | |
| 192 | +); | |
| 193 | + | |
| 194 | +export interface SelfHostAdminUsersApiDeps { | |
| 195 | + readonly betterAuth: BetterAuthHandle; | |
| 196 | + readonly db: SelfHostDbHandle; | |
| 197 | + readonly mountPrefix: `/${string}`; | |
| 198 | +} | |
| 199 | + | |
| 200 | +/** | |
| 201 | + * The mountable extension route layer for `/api/admin/users*`. Self-host's DB | |
| 202 | + * handle and Better Auth are app singletons, so the provider is self-contained | |
| 203 | + * (no per-request socket to close over, unlike cloud) — but it still goes | |
| 204 | + * through `requestScopedMiddleware`, because an HttpApi handler's service | |
| 205 | + * requirement is not erased by a plain `Layer.provide` on the builder layer. | |
| 206 | + */ | |
| 207 | +export const makeSelfHostAdminUsersApiLayer = ({ | |
| 208 | + betterAuth, | |
| 209 | + db, | |
| 210 | + mountPrefix, | |
| 211 | +}: SelfHostAdminUsersApiDeps) => { | |
| 212 | + const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( | |
| 213 | + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), | |
| 214 | + ); | |
| 215 | + const provider = betterAuthAdminUsersProvider.pipe( | |
| 216 | + Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), | |
| 217 | + Layer.provide(SelfHostDbProvider), | |
| 218 | + Layer.provide(SelfHostPluginsProvider), | |
| 219 | + Layer.provide(SelfHostHostConfig), | |
| 220 | + Layer.provide(Layer.succeed(SelfHostDb)(db)), | |
| 221 | + ); | |
| 222 | + return makeAdminUsersApiLayer(requestScopedMiddleware(provider).layer, { | |
| 223 | + router: prefixedRouter, | |
| 224 | + }); | |
| 225 | +}; | |
| @@ -0,0 +1,234 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 5 | +import { afterAll, expect, test } from "@effect/vitest"; | |
| 6 | + | |
| 7 | +import { mintInviteCode } from "../testing/mint-invite"; | |
| 8 | + | |
| 9 | +// The self-host admin users plane (`/api/admin/users*`), over the REAL booted | |
| 10 | +// app: Better Auth, the libSQL store, and the mounted route layer. | |
| 11 | +// | |
| 12 | +// Self-host has no organization-owned api key (Better Auth keys always belong | |
| 13 | +// to the user who made one), so the credential here is the operator's own | |
| 14 | +// owner/admin session — the same gate the invite-code admin API uses. What this | |
| 15 | +// pins is that the mount is live, that the owner/admin gate actually refuses a | |
| 16 | +// plain member, and that a member who has used the instance shows up in the | |
| 17 | +// owner's view without their credential coming with them. | |
| 18 | + | |
| 19 | +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-users-")); | |
| 20 | +process.env.BETTER_AUTH_SECRET = "admin-users-test-secret-0123456789-abcdefghij"; | |
| 21 | +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@admin-users.test"; | |
| 22 | +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; | |
| 23 | + | |
| 24 | +const { makeSelfHostApiHandler } = await import("../app"); | |
| 25 | +const { handler, dispose } = await makeSelfHostApiHandler(); | |
| 26 | +afterAll(() => dispose()); | |
| 27 | + | |
| 28 | +const BASE = "http://localhost:4788"; | |
| 29 | + | |
| 30 | +const signIn = async (email: string, password: string): Promise<string> => { | |
| 31 | + const response = await handler( | |
| 32 | + new Request(`${BASE}/api/auth/sign-in/email`, { | |
| 33 | + method: "POST", | |
| 34 | + headers: { "content-type": "application/json" }, | |
| 35 | + body: JSON.stringify({ email, password }), | |
| 36 | + }), | |
| 37 | + ); | |
| 38 | + return response.headers.get("set-auth-token") ?? ""; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const adminUsers = (token?: string, path = "/api/admin/users") => | |
| 42 | + handler( | |
| 43 | + new Request(`${BASE}${path}`, { | |
| 44 | + headers: token ? { authorization: `Bearer ${token}` } : {}, | |
| 45 | + }), | |
| 46 | + ); | |
| 47 | + | |
| 48 | +test("the owner sees who uses the instance; a plain member cannot look", async () => { | |
| 49 | + const adminToken = await signIn( | |
| 50 | + process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL!, | |
| 51 | + process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD!, | |
| 52 | + ); | |
| 53 | + expect(adminToken).not.toBe(""); | |
| 54 | + | |
| 55 | + // A second person joins through the real invite flow and uses the instance. | |
| 56 | + const inviteCode = await mintInviteCode(handler); | |
| 57 | + const signUp = await handler( | |
| 58 | + new Request(`${BASE}/api/auth/sign-up/email`, { | |
| 59 | + method: "POST", | |
| 60 | + headers: { "content-type": "application/json" }, | |
| 61 | + body: JSON.stringify({ | |
| 62 | + email: "member@admin-users.test", | |
| 63 | + password: "password-12345678", | |
| 64 | + name: "Member", | |
| 65 | + inviteCode, | |
| 66 | + }), | |
| 67 | + }), | |
| 68 | + ); | |
| 69 | + expect(signUp.status).toBe(200); | |
| 70 | + const memberToken = signUp.headers.get("set-auth-token") ?? ""; | |
| 71 | + expect(memberToken).not.toBe(""); | |
| 72 | + | |
| 73 | + // A user earns their row the first time they touch the EXECUTOR plane, so | |
| 74 | + // have both make one product read before looking at the admin view. | |
| 75 | + for (const token of [adminToken, memberToken]) { | |
| 76 | + const integrations = await handler( | |
| 77 | + new Request(`${BASE}/api/integrations`, { headers: { authorization: `Bearer ${token}` } }), | |
| 78 | + ); | |
| 79 | + expect(integrations.status).toBe(200); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // The owner's view reports both people. | |
| 83 | + const response = await adminUsers(adminToken); | |
| 84 | + expect(response.status).toBe(200); | |
| 85 | + const body = (await response.json()) as { | |
| 86 | + users: ReadonlyArray<{ | |
| 87 | + externalId: string; | |
| 88 | + lastSeenAt: number | null; | |
| 89 | + email: string | null; | |
| 90 | + displayName: string | null; | |
| 91 | + }>; | |
| 92 | + }; | |
| 93 | + expect(body.users.length, "both the owner and the invited member are reported").toBe(2); | |
| 94 | + expect(typeof body.users[0]?.lastSeenAt, "last-seen crosses the wire as a number").toBe("number"); | |
| 95 | + | |
| 96 | + // THE JOIN KEY, proven against real Better Auth rather than assumed. | |
| 97 | + // | |
| 98 | + // `auth/identity.ts` binds `session.user.id` as the accountId, so that is what | |
| 99 | + // the subject table records in `external_id` — and it is the member list's | |
| 100 | + // `userId`, NOT its `id` (the organization `member` ROW id). The two look | |
| 101 | + // alike and only one joins, so this asserts the actual emails land on the | |
| 102 | + // actual rows: a mismatch would leave every user unnamed while the counts | |
| 103 | + // above still passed. | |
| 104 | + const byEmail = new Map(body.users.map((user) => [user.email, user.externalId])); | |
| 105 | + expect( | |
| 106 | + [...byEmail.keys()].sort(), | |
| 107 | + "each user carries the email Better Auth holds for them", | |
| 108 | + ).toEqual(["admin@admin-users.test", "member@admin-users.test"]); | |
| 109 | + expect( | |
| 110 | + body.users.every((user) => user.externalId.length > 0), | |
| 111 | + "and the id space still lines up — nobody was named by matching nothing", | |
| 112 | + ).toBe(true); | |
| 113 | + expect( | |
| 114 | + body.users.find((user) => user.email === "member@admin-users.test")?.displayName, | |
| 115 | + "the sign-up name comes through as the display name", | |
| 116 | + ).toBe("Member"); | |
| 117 | + | |
| 118 | + // The joined view is mounted too, and carries the same identity. | |
| 119 | + const joined = await adminUsers(adminToken, "/api/admin/users/with-connections"); | |
| 120 | + expect(joined.status).toBe(200); | |
| 121 | + const joinedBody = (await joined.json()) as { | |
| 122 | + users: ReadonlyArray<{ | |
| 123 | + externalId: string; | |
| 124 | + email: string | null; | |
| 125 | + connections: readonly unknown[]; | |
| 126 | + }>; | |
| 127 | + }; | |
| 128 | + expect(joinedBody.users.length).toBe(2); | |
| 129 | + expect( | |
| 130 | + joinedBody.users.every((user) => Array.isArray(user.connections)), | |
| 131 | + "every user carries a connections array", | |
| 132 | + ).toBe(true); | |
| 133 | + expect( | |
| 134 | + joinedBody.users.map((user) => [user.externalId, user.email]).sort(), | |
| 135 | + "the joined view reports the same identities, keyed the same way", | |
| 136 | + ).toEqual(body.users.map((user) => [user.externalId, user.email]).sort()); | |
| 137 | + | |
| 138 | + // ------------------------------------------------------------------------- | |
| 139 | + // The single-user read, by opaque id and by email. | |
| 140 | + // ------------------------------------------------------------------------- | |
| 141 | + | |
| 142 | + const memberId = byEmail.get("member@admin-users.test")!; | |
| 143 | + | |
| 144 | + type UserBody = { | |
| 145 | + user: { | |
| 146 | + externalId: string; | |
| 147 | + email: string | null; | |
| 148 | + displayName: string | null; | |
| 149 | + connections: readonly unknown[]; | |
| 150 | + }; | |
| 151 | + }; | |
| 152 | + | |
| 153 | + const single = await adminUsers(adminToken, `/api/admin/users/${memberId}`); | |
| 154 | + expect(single.status).toBe(200); | |
| 155 | + const singleBody = (await single.json()) as UserBody; | |
| 156 | + expect(singleBody.user.externalId).toBe(memberId); | |
| 157 | + expect(singleBody.user.email).toBe("member@admin-users.test"); | |
| 158 | + expect(singleBody.user.displayName).toBe("Member"); | |
| 159 | + expect( | |
| 160 | + Array.isArray(singleBody.user.connections), | |
| 161 | + "identity and connections arrive in ONE call", | |
| 162 | + ).toBe(true); | |
| 163 | + | |
| 164 | + // The same person by email, in three casings. Better Auth stores emails | |
| 165 | + // lower-cased, so this proves the QUERY side is normalized against a real | |
| 166 | + // directory rather than against a fixture that happens to agree. | |
| 167 | + for (const spelling of [ | |
| 168 | + "member@admin-users.test", | |
| 169 | + "Member@Admin-Users.Test", | |
| 170 | + "MEMBER@ADMIN-USERS.TEST", | |
| 171 | + ]) { | |
| 172 | + const byEmailResponse = await adminUsers( | |
| 173 | + adminToken, | |
| 174 | + `/api/admin/users/${encodeURIComponent(spelling)}`, | |
| 175 | + ); | |
| 176 | + expect(byEmailResponse.status, `${spelling} resolves`).toBe(200); | |
| 177 | + expect(((await byEmailResponse.json()) as UserBody).user.externalId).toBe(memberId); | |
| 178 | + } | |
| 179 | + | |
| 180 | + // An email nobody in the directory holds. | |
| 181 | + const unknownEmail = await adminUsers( | |
| 182 | + adminToken, | |
| 183 | + `/api/admin/users/${encodeURIComponent("nobody@admin-users.test")}`, | |
| 184 | + ); | |
| 185 | + expect(unknownEmail.status, "unknown email → 404").toBe(404); | |
| 186 | + // Asserted on the encoded body — the distinct error is what lets a caller | |
| 187 | + // tell "no footprint" from "bad credential" without parsing a message. | |
| 188 | + expect(await unknownEmail.text()).toContain("AdminUserNotFound"); | |
| 189 | + | |
| 190 | + // An id that names no subject row. | |
| 191 | + const unknownId = await adminUsers(adminToken, "/api/admin/users/user_does_not_exist"); | |
| 192 | + expect(unknownId.status, "unknown id → 404").toBe(404); | |
| 193 | + | |
| 194 | + // The bulk `?email=` filter, over the same real directory. | |
| 195 | + const filtered = await adminUsers( | |
| 196 | + adminToken, | |
| 197 | + `/api/admin/users?email=${encodeURIComponent("MEMBER@admin-users.test")}`, | |
| 198 | + ); | |
| 199 | + expect(filtered.status).toBe(200); | |
| 200 | + const filteredBody = (await filtered.json()) as { users: ReadonlyArray<{ externalId: string }> }; | |
| 201 | + expect( | |
| 202 | + filteredBody.users.map((user) => user.externalId), | |
| 203 | + "the filter selects exactly the one member, case-insensitively", | |
| 204 | + ).toEqual([memberId]); | |
| 205 | + | |
| 206 | + const filteredUnknown = await adminUsers( | |
| 207 | + adminToken, | |
| 208 | + `/api/admin/users?email=${encodeURIComponent("nobody@admin-users.test")}`, | |
| 209 | + ); | |
| 210 | + expect( | |
| 211 | + ((await filteredUnknown.json()) as { users: readonly unknown[] }).users, | |
| 212 | + "an unknown email is an EMPTY page, never the unfiltered tenant", | |
| 213 | + ).toEqual([]); | |
| 214 | + | |
| 215 | + // The router-ordering invariant against the real mount: the static segment | |
| 216 | + // must still beat `/:identifier`. | |
| 217 | + const stillTheList = await adminUsers(adminToken, "/api/admin/users/with-connections"); | |
| 218 | + expect(stillTheList.status).toBe(200); | |
| 219 | + expect(((await stillTheList.json()) as { users: readonly unknown[] }).users.length).toBe(2); | |
| 220 | + | |
| 221 | + // The gate: a plain member may not read the instance-wide view. | |
| 222 | + const asMember = await adminUsers(memberToken); | |
| 223 | + expect(asMember.status, "a member is refused").toBe(403); | |
| 224 | + | |
| 225 | + // And an anonymous caller has no session at all. | |
| 226 | + const anonymous = await adminUsers(); | |
| 227 | + expect(anonymous.status, "no session → unauthorized").toBe(401); | |
| 228 | + | |
| 229 | + // The single-user read refuses on the same terms — and refuses BEFORE | |
| 230 | + // looking, so a refused caller cannot probe which users exist. | |
| 231 | + expect((await adminUsers(memberToken, `/api/admin/users/${memberId}`)).status).toBe(403); | |
| 232 | + expect((await adminUsers(memberToken, "/api/admin/users/user_nobody")).status).toBe(403); | |
| 233 | + expect((await adminUsers(undefined, `/api/admin/users/${memberId}`)).status).toBe(401); | |
| 234 | +}); | |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | type InviteCode as InviteCodeSchema, |
| 11 | 11 | } from "./api"; |
| 12 | 12 | import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; |
| 13 | +import { requireInstanceAdmin } from "./require-admin"; | |
| 13 | 14 | import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; |
| 14 | 15 | import { |
| 15 | 16 | createInviteCode, |
| @@ -22,9 +23,11 @@ import { · 6 unmodified lines | ||
| 22 | 23 | // --------------------------------------------------------------------------- |
| 23 | 24 | // Handlers for the self-host admin (invite-code) API. Every Promise-returning |
| 24 | 25 | // boundary (Better Auth, the libSQL store) is wrapped in Effect.tryPromise with |
| 25 | −// a typed failure — no raw try/catch, no Promise.catch. Each route is gated: | |
| 26 | −// the caller must be an owner/admin member of the one org (resolved through the | |
| 27 | −// org primitive's getActiveMember). | |
| 26 | +// a typed failure — no raw try/catch, no Promise.catch. Each route is gated by | |
| 27 | +// the SHARED `requireInstanceAdmin`: the caller must be an owner/admin member | |
| 28 | +// of THIS INSTANCE'S organization, named explicitly rather than taken from the | |
| 29 | +// caller's session (see require-admin.ts for the escalation that rule refuses — | |
| 30 | +// this plane mints invite codes, so a bypass here is durable admin). | |
| 28 | 31 | // --------------------------------------------------------------------------- |
| 29 | 32 | |
| 30 | 33 | const requestHeaders = Effect.map( |
| @@ -32,18 +35,15 @@ const requestHeaders = Effect.map( · 1 unmodified line | ||
| 32 | 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). | |
| 36 | 41 | const requireAdmin = (headers: Headers) => |
| 37 | − Effect.gen(function* () { | |
| 38 | − const { auth } = yield* BetterAuth; | |
| 39 | − const member = yield* Effect.tryPromise({ | |
| 40 | − try: () => auth.api.getActiveMember({ headers }), | |
| 41 | − catch: () => new AdminError({ message: "Failed to resolve session" }), | |
| 42 | − }).pipe(Effect.orElseSucceed(() => null)); | |
| 43 | − if (!member) return yield* new AdminUnauthorized(); | |
| 44 | − if (member.role !== "owner" && member.role !== "admin") return yield* new AdminForbidden(); | |
| 45 | − return member; | |
| 46 | − }); | |
| 42 | + requireInstanceAdmin(headers).pipe( | |
| 43 | + Effect.mapError((denial) => | |
| 44 | + denial === "unauthorized" ? new AdminUnauthorized() : new AdminForbidden(), | |
| 45 | + ), | |
| 46 | + ); | |
| 47 | 47 | |
| 48 | 48 | const narrowRole = (role: string | undefined): InviteRole => |
| 49 | 49 | role === "admin" ? "admin" : "member"; |
| @@ -0,0 +1,126 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// THE self-host admin gate. One implementation, shared by every admin plane: | |
| 3 | +// the invite-code API (`handlers.ts`) and the admin users API | |
| 4 | +// (`admin-users-api.ts`). Both planes report on, or grant power over, the whole | |
| 5 | +// instance, so they must agree on exactly who counts as an admin — and there is | |
| 6 | +// only one way to be wrong here, so there is only one place to be right. | |
| 7 | +// | |
| 8 | +// WHY THIS IS NOT `getActiveMember`. | |
| 9 | +// | |
| 10 | +// Both planes previously authorized with `auth.api.getActiveMember({ headers | |
| 11 | +// })`. That endpoint resolves the caller's membership in | |
| 12 | +// `session.activeOrganizationId` — a field the CALLER controls. Better Auth's | |
| 13 | +// organization plugin exposes `POST /organization/create` (mounted bare, | |
| 14 | +// `allowUserToCreateOrganization` defaults to true) and `POST | |
| 15 | +// /organization/set-active`, so any authenticated member could create an | |
| 16 | +// organization of their own, become its `owner` (the creator role), have that | |
| 17 | +// org set active, and then present a session whose active org is one they own. | |
| 18 | +// `getActiveMember` would answer `role: "owner"` — for THEIR org — and the gate | |
| 19 | +// would open. Meanwhile the reads underneath are scoped to the instance's | |
| 20 | +// boot-seeded org, so the escalated caller read the real instance: every user's | |
| 21 | +// externalId/createdAt/lastSeenAt and their whole connection inventory on one | |
| 22 | +// plane, and a live `role: "admin"` invite code (durable escalation) on the | |
| 23 | +// other. | |
| 24 | +// | |
| 25 | +// The bug is that the gate and the data disagreed about WHICH organization the | |
| 26 | +// request was about. This gate removes the disagreement by naming the | |
| 27 | +// instance's organization explicitly, so the authorization decision is made | |
| 28 | +// against the same org the reads are scoped to and nothing the caller sends can | |
| 29 | +// redirect it. | |
| 30 | +// | |
| 31 | +// HOW. `getActiveMemberRole` accepts an explicit `organizationId` query, and | |
| 32 | +// when given one it ignores `session.activeOrganizationId` entirely, looks up | |
| 33 | +// the caller's membership in THAT org, and refuses with FORBIDDEN when there | |
| 34 | +// isn't one (better-auth 1.6.12, `plugins/organization/routes/crud-members.mjs` | |
| 35 | +// — read against the installed build, not the docs). So a caller who owns ten | |
| 36 | +// organizations of their own still resolves to "not a member of the instance | |
| 37 | +// org" unless they were actually invited to it. There is no | |
| 38 | +// `member.organizationId !== organizationId` check to write, because the query | |
| 39 | +// makes the mismatch unrepresentable rather than detectable. | |
| 40 | +// | |
| 41 | +// Defense in depth lives in `auth/better-auth.ts`, which now mounts | |
| 42 | +// `organization({ allowUserToCreateOrganization: false })` so the first step of | |
| 43 | +// the escalation is refused too. Either fix alone closes the hole; the gate is | |
| 44 | +// the load-bearing one, because it is what makes the authorization decision | |
| 45 | +// correct rather than merely making one route to it harder. | |
| 46 | +// --------------------------------------------------------------------------- | |
| 47 | + | |
| 48 | +import { Effect } from "effect"; | |
| 49 | + | |
| 50 | +import { BetterAuth } from "../auth/better-auth"; | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * Why the caller was refused, in the vocabulary both planes share. | |
| 54 | + * | |
| 55 | + * The two planes render refusals through their OWN error classes | |
| 56 | + * (`AdminUnauthorized` / `AdminUsersUnauthorized`, and so on), because those | |
| 57 | + * are what their respective HttpApi contracts declare. So the gate decides, and | |
| 58 | + * each caller translates — rather than the gate importing one plane's errors | |
| 59 | + * and the other plane mapping between two error vocabularies. | |
| 60 | + */ | |
| 61 | +export type AdminGateDenial = "unauthorized" | "forbidden"; | |
| 62 | + | |
| 63 | +/** What a caller who passed the gate is. `userId` is the Better Auth `user.id` | |
| 64 | + * — the same id `auth/identity.ts` binds as the accountId, which is what the | |
| 65 | + * invite plane records as `createdBy`. */ | |
| 66 | +export interface InstanceAdmin { | |
| 67 | + readonly userId: string; | |
| 68 | + readonly role: string; | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** | |
| 72 | + * Better Auth writes a member's roles as a comma-separated list (its own | |
| 73 | + * `leaveOrganization` reads them with `role.split(",")`), so a single-role | |
| 74 | + * string is the common case rather than the contract. Membership in the | |
| 75 | + * privileged set is therefore tested per role, not by equality on the whole | |
| 76 | + * field — an `"owner,admin"` value must not read as neither. | |
| 77 | + */ | |
| 78 | +const isPrivileged = (role: string): boolean => | |
| 79 | + role | |
| 80 | + .split(",") | |
| 81 | + .map((part) => part.trim()) | |
| 82 | + .some((part) => part === "owner" || part === "admin"); | |
| 83 | + | |
| 84 | +/** | |
| 85 | + * Authorize the caller as an owner/admin OF THE INSTANCE'S ORGANIZATION. | |
| 86 | + * | |
| 87 | + * Fails with `"unauthorized"` when there is no session at all, and | |
| 88 | + * `"forbidden"` when there is a session that is not an owner/admin member of | |
| 89 | + * the instance org — including a session belonging to an owner of some OTHER | |
| 90 | + * organization, which is the escalation this gate exists to refuse. | |
| 91 | + * | |
| 92 | + * Both Better Auth calls FAIL CLOSED, and closed means the less-informative | |
| 93 | + * answer of the two: a `getSession` that throws is treated as "no session" | |
| 94 | + * (401) and a `getActiveMemberRole` that throws is treated as "not entitled" | |
| 95 | + * (403), because the endpoint's own way of saying "you are not a member of this | |
| 96 | + * organization" IS a thrown FORBIDDEN. An infrastructure fault therefore | |
| 97 | + * refuses the request rather than surfacing as a 500, which is the correct | |
| 98 | + * trade on a plane where the wrong answer is a disclosure. | |
| 99 | + * | |
| 100 | + * TWO CALLS, NOT ONE. The session read is what distinguishes 401 from 403 — the | |
| 101 | + * role endpoint cannot, since it refuses the anonymous and the unentitled with | |
| 102 | + * the same status — and it is also where `userId` comes from, since the role | |
| 103 | + * endpoint answers with a role and nothing else. | |
| 104 | + */ | |
| 105 | +export const requireInstanceAdmin = ( | |
| 106 | + headers: Headers, | |
| 107 | +): Effect.Effect<InstanceAdmin, AdminGateDenial, BetterAuth> => | |
| 108 | + Effect.gen(function* () { | |
| 109 | + const { auth, organizationId } = yield* BetterAuth; | |
| 110 | + | |
| 111 | + const session = yield* Effect.tryPromise(() => auth.api.getSession({ headers })).pipe( | |
| 112 | + Effect.orElseSucceed(() => null), | |
| 113 | + ); | |
| 114 | + if (!session) return yield* Effect.fail<AdminGateDenial>("unauthorized"); | |
| 115 | + | |
| 116 | + // The whole fix in one argument: the org is named by the INSTANCE, never | |
| 117 | + // read from the caller's session. | |
| 118 | + const resolved = yield* Effect.tryPromise(() => | |
| 119 | + auth.api.getActiveMemberRole({ headers, query: { organizationId } }), | |
| 120 | + ).pipe(Effect.orElseSucceed(() => null)); | |
| 121 | + if (!resolved || !isPrivileged(resolved.role)) { | |
| 122 | + return yield* Effect.fail<AdminGateDenial>("forbidden"); | |
| 123 | + } | |
| 124 | + | |
| 125 | + return { userId: session.user.id, role: resolved.role }; | |
| 126 | + }); | |
| @@ -9,6 +9,7 @@ import { runSqliteDataMigrations } from "@executor-js/sdk"; · 8 unmodified lines | ||
| 9 | 9 | import { resolveAuthProviders } from "./auth"; |
| 10 | 10 | import { selfHostDataMigrations } from "./db/data-migrations"; |
| 11 | 11 | import { makeSelfHostAdminApiLayer } from "./admin/handlers"; |
| 12 | +import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api"; | |
| 12 | 13 | import { makeSelfHostSystemApiLayer } from "./system/handlers"; |
| 13 | 14 | import { selfHostAccountMiddleware } from "./account"; |
| 14 | 15 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config"; |
| @@ -117,6 +118,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 102 unmodified lines | ||
| 117 | 118 | HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), |
| 118 | 119 | // App-local admin (invite-code) API, served under /api/admin/*. |
| 119 | 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 | 125 | // Public system API: /api/health + /api/setup-status (unauthenticated). |
| 121 | 126 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 122 | 127 | // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). |
| @@ -110,7 +110,26 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 109 unmodified lines | ||
| 110 | 110 | // are re-emitted by the shared envelope (MCP clients probe the origin root, |
| 111 | 111 | // not the /api/auth basePath). |
| 112 | 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 | 133 | admin(), |
| 115 | 134 | apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), |
| 116 | 135 | bearer(), |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to"; | |
| 3 | +import { | |
| 4 | + isSafeReturnTo, | |
| 5 | + loginPath, | |
| 6 | + mcpAuthorizeResumeTarget, | |
| 7 | + postLoginTarget, | |
| 8 | + safeReturnTo, | |
| 9 | +} from "./return-to"; | |
| 4 | 10 | |
| 5 | 11 | describe("isSafeReturnTo", () => { |
| 6 | 12 | const safe = [ |
| @@ -73,6 +79,44 @@ describe("mcpAuthorizeResumeTarget", () => { · 66 unmodified lines | ||
| 73 | 79 | }); |
| 74 | 80 | }); |
| 75 | 81 | |
| 82 | +describe("postLoginTarget", () => { | |
| 83 | + // Self-host renders the login page IN PLACE of the requested route without | |
| 84 | + // navigating, so the live location is the only record of where the person was | |
| 85 | + // headed. A connect deep link must survive sign-in on that basis alone. | |
| 86 | + it("returns to the deep link the person actually opened", () => { | |
| 87 | + expect(postLoginTarget({ pathname: "/connect/linear", search: "" })).toBe("/connect/linear"); | |
| 88 | + expect(postLoginTarget({ pathname: "/integrations/sentry", search: "?addAccount=1" })).toBe( | |
| 89 | + "/integrations/sentry?addAccount=1", | |
| 90 | + ); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("lands on the dashboard when signing in from the bare login page", () => { | |
| 94 | + // No deep link to resume, and echoing /login back would re-render the form. | |
| 95 | + expect(postLoginTarget({ pathname: "/login", search: "" })).toBe("/"); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + it("prefers an explicit returnTo over the current location", () => { | |
| 99 | + expect(postLoginTarget({ pathname: "/login", search: "?returnTo=%2Fconnect%2Flinear" })).toBe( | |
| 100 | + "/connect/linear", | |
| 101 | + ); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it("prefers an MCP authorize resume over everything else", () => { | |
| 105 | + const target = postLoginTarget({ | |
| 106 | + pathname: "/login", | |
| 107 | + search: | |
| 108 | + "?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback", | |
| 109 | + }); | |
| 110 | + expect(target.startsWith("/api/auth/mcp/authorize?")).toBe(true); | |
| 111 | + }); | |
| 112 | + | |
| 113 | + it("never honors an unsafe location", () => { | |
| 114 | + // `safeReturnTo` vets the echoed path too — an app-plane path is not a | |
| 115 | + // place to land a freshly signed-in browser. | |
| 116 | + expect(postLoginTarget({ pathname: "/api/auth/logout", search: "" })).toBe("/"); | |
| 117 | + }); | |
| 118 | +}); | |
| 119 | + | |
| 76 | 120 | describe("loginPath", () => { |
| 77 | 121 | it("omits returnTo for the root", () => { |
| 78 | 122 | expect(loginPath("/")).toBe("/login"); |
| @@ -32,3 +32,32 @@ export const mcpAuthorizeResumeTarget = (search: string): string | null => { · 31 unmodified lines | ||
| 32 | 32 | if (!params.get("client_id") || !params.get("redirect_uri")) return null; |
| 33 | 33 | return `${MCP_AUTHORIZE_PATH}?${params.toString()}`; |
| 34 | 34 | }; |
| 35 | + | |
| 36 | +const LOGIN_PATH = "/login"; | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * Where the self-host login page sends someone after a successful sign-in, in | |
| 40 | + * priority order: | |
| 41 | + * | |
| 42 | + * 1. an interrupted MCP OAuth authorize (the params ARE the request), | |
| 43 | + * 2. an explicit safe `returnTo` (e.g. the integration OAuth callback), | |
| 44 | + * 3. the URL they actually opened, and | |
| 45 | + * 4. the dashboard. | |
| 46 | + * | |
| 47 | + * Step 3 is what makes a deep link like `/connect/linear` survive sign-in here. | |
| 48 | + * Unlike cloud — which redirects to `/login?returnTo=…` — self-host's gate | |
| 49 | + * swaps the login page in WITHOUT navigating, so the address bar still holds | |
| 50 | + * the requested URL and no `returnTo` was ever written. `/login` itself is | |
| 51 | + * excluded so signing in from the bare login page lands on the dashboard rather | |
| 52 | + * than looping back to the form. | |
| 53 | + */ | |
| 54 | +export const postLoginTarget = (location: { | |
| 55 | + readonly pathname: string; | |
| 56 | + readonly search: string; | |
| 57 | +}): string => | |
| 58 | + mcpAuthorizeResumeTarget(location.search) ?? | |
| 59 | + safeReturnTo(new URLSearchParams(location.search).get("returnTo")) ?? | |
| 60 | + (location.pathname === LOGIN_PATH | |
| 61 | + ? null | |
| 62 | + : safeReturnTo(`${location.pathname}${location.search}`)) ?? | |
| 63 | + "/"; | |
| @@ -0,0 +1,82 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 5 | +import { Effect, Layer } from "effect"; | |
| 6 | +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; | |
| 7 | +import { withQueryContext } from "@executor-js/fumadb/query"; | |
| 8 | + | |
| 9 | +import { makeScopedExecutor } from "@executor-js/api/server"; | |
| 10 | + | |
| 11 | +import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; | |
| 12 | +import { SelfHostScopedExecutorSeams } from "./execution"; | |
| 13 | +import type { SelfHostPlugins } from "./plugins"; | |
| 14 | + | |
| 15 | +// The `subject` table is populated at the request seam: `makeScopedExecutor` is | |
| 16 | +// what every HTTP request and MCP session on every host passes through, so | |
| 17 | +// building a scoped executor IS a sighting. Exercised here against a real host's | |
| 18 | +// seams (long-lived SQLite DbProvider + real plugins) rather than a stub, since | |
| 19 | +// the point is that the production wiring reaches the writer at all. | |
| 20 | + | |
| 21 | +const createScopedExecutor = (accountId: string, organizationId: string) => | |
| 22 | + makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe( | |
| 23 | + Effect.provide(SelfHostScopedExecutorSeams), | |
| 24 | + ); | |
| 25 | + | |
| 26 | +const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-")); | |
| 27 | +process.env.EXECUTOR_DATA_DIR = dataDir; | |
| 28 | + | |
| 29 | +let dbLayer!: Layer.Layer<SelfHostDb>; | |
| 30 | +let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined; | |
| 31 | + | |
| 32 | +beforeAll(async () => { | |
| 33 | + dbHandle = await createSelfHostDb({ | |
| 34 | + path: join(dataDir, "data.db"), | |
| 35 | + namespace: "executor_selfhost", | |
| 36 | + version: "1.0.0", | |
| 37 | + }); | |
| 38 | + dbLayer = Layer.succeed(SelfHostDb)(dbHandle); | |
| 39 | +}); | |
| 40 | +afterAll(async () => { | |
| 41 | + await dbHandle?.close(); | |
| 42 | +}); | |
| 43 | + | |
| 44 | +const subjectsOf = async (tenant: string): Promise<readonly string[]> => { | |
| 45 | + const rows = await withQueryContext(dbHandle!.db, { | |
| 46 | + tenant, | |
| 47 | + subject: null, | |
| 48 | + }).findMany("subject", { | |
| 49 | + orderBy: ["external_id", "asc"], | |
| 50 | + }); | |
| 51 | + return rows.map((row) => String(row.external_id)); | |
| 52 | +}; | |
| 53 | + | |
| 54 | +test("building a scoped executor records the acting principal", async () => { | |
| 55 | + const before = Date.now(); | |
| 56 | + await Effect.runPromise( | |
| 57 | + createScopedExecutor("alice", "sightings-org").pipe(Effect.provide(dbLayer), Effect.asVoid), | |
| 58 | + ); | |
| 59 | + | |
| 60 | + expect(await subjectsOf("sightings-org")).toEqual(["alice"]); | |
| 61 | + | |
| 62 | + const [row] = await withQueryContext(dbHandle!.db, { | |
| 63 | + tenant: "sightings-org", | |
| 64 | + subject: null, | |
| 65 | + }).findMany("subject", {}); | |
| 66 | + expect(Number(row?.last_seen_at)).toBeGreaterThanOrEqual(before); | |
| 67 | +}); | |
| 68 | + | |
| 69 | +test("repeat requests from several members converge on one row each", async () => { | |
| 70 | + const org = "sightings-org-2"; | |
| 71 | + await Effect.runPromise( | |
| 72 | + Effect.forEach( | |
| 73 | + ["alice", "alice", "bob", "alice"], | |
| 74 | + (accountId) => createScopedExecutor(accountId, org).pipe(Effect.asVoid), | |
| 75 | + { discard: true }, | |
| 76 | + ).pipe(Effect.provide(dbLayer)), | |
| 77 | + ); | |
| 78 | + | |
| 79 | + // Four requests, two principals, two rows: the sighting is an upsert, and | |
| 80 | + // the repeat sightings are absorbed by the throttle rather than duplicating. | |
| 81 | + expect(await subjectsOf(org)).toEqual(["alice", "bob"]); | |
| 82 | +}); | |
| @@ -6,7 +6,7 @@ import { Label } from "@executor-js/react/components/label"; · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | import { authClient } from "./auth-client"; |
| 8 | 8 | import { AuthLayout } from "./auth-layout"; |
| 9 | −import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to"; | |
| 9 | +import { postLoginTarget } from "../src/auth/return-to"; | |
| 10 | 10 | |
| 11 | 11 | // Self-host login: email + password sign-in via Better Auth. On success we |
| 12 | 12 | // 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 | 17 | // redeeming an invite — either the full /join/<code> link, or by entering the |
| 18 | 18 | // code here ("Have an invite code?"), which forwards to the same join page. |
| 19 | 19 | export const LoginPage = () => { |
| 20 | − const search = window.location.search; | |
| 21 | − // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we | |
| 22 | − // arrived from one (Better Auth redirects it here with the OAuth params), | |
| 23 | − // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else | |
| 24 | − // land on the dashboard. | |
| 25 | − const postLogin = | |
| 26 | − mcpAuthorizeResumeTarget(search) ?? | |
| 27 | − safeReturnTo(new URLSearchParams(search).get("returnTo")) ?? | |
| 28 | − "/"; | |
| 20 | + // Where to go after sign-in. The gate renders this page IN PLACE of the | |
| 21 | + // requested route without navigating, so the live location is what carries a | |
| 22 | + // deep link (`/connect/linear`) across sign-in — see `postLoginTarget`. | |
| 23 | + const postLogin = postLoginTarget(window.location); | |
| 29 | 24 | const [mode, setMode] = useState<"signin" | "code">("signin"); |
| 30 | 25 | const [email, setEmail] = useState(""); |
| 31 | 26 | const [password, setPassword] = useState(""); |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 13 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users' | |
| 13 | 14 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 14 | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' |
| 15 | 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' |
| @@ -20,6 +21,7 @@ import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' · 4 unmodified lines | ||
| 20 | 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 21 | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 22 | 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 24 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | |
| 23 | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' |
| 24 | 26 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' |
| 25 | 27 | |
| @@ -29,6 +31,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 3 unmodified lines | ||
| 29 | 31 | path: '/{-$orgSlug}/', |
| 30 | 32 | getParentRoute: () => rootRouteImport, |
| 31 | 33 | } as any) |
| 34 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute = | |
| 35 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({ | |
| 36 | + id: '/{-$orgSlug}/users', | |
| 37 | + path: '/{-$orgSlug}/users', | |
| 38 | + getParentRoute: () => rootRouteImport, | |
| 39 | + } as any) | |
| 32 | 40 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = |
| 33 | 41 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({ |
| 34 | 42 | id: '/{-$orgSlug}/tools', |
| @@ -93,6 +101,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 58 unmodified lines | ||
| 93 | 101 | getParentRoute: () => rootRouteImport, |
| 94 | 102 | } as any, |
| 95 | 103 | ) |
| 104 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = | |
| 105 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( | |
| 106 | + { | |
| 107 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 108 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 109 | + getParentRoute: () => rootRouteImport, | |
| 110 | + } as any, | |
| 111 | + ) | |
| 96 | 112 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = |
| 97 | 113 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( |
| 98 | 114 | { |
| @@ -118,7 +134,9 @@ export interface FileRoutesByFullPath { · 19 unmodified lines | ||
| 118 | 134 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 119 | 135 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 120 | 136 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 137 | + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | |
| 121 | 138 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 139 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 122 | 140 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 123 | 141 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 124 | 142 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -133,7 +151,9 @@ export interface FileRoutesByTo { · 8 unmodified lines | ||
| 133 | 151 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 134 | 152 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 135 | 153 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 154 | + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | |
| 136 | 155 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 156 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 137 | 157 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 138 | 158 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 139 | 159 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -149,7 +169,9 @@ export interface FileRoutesById { · 9 unmodified lines | ||
| 149 | 169 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 150 | 170 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 151 | 171 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 172 | + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | |
| 152 | 173 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 174 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 153 | 175 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 154 | 176 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 155 | 177 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -166,7 +188,9 @@ export interface FileRouteTypes { · 10 unmodified lines | ||
| 166 | 188 | | '/{-$orgSlug}/secrets' |
| 167 | 189 | | '/{-$orgSlug}/toolkits' |
| 168 | 190 | | '/{-$orgSlug}/tools' |
| 191 | + | '/{-$orgSlug}/users' | |
| 169 | 192 | | '/{-$orgSlug}/' |
| 193 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 170 | 194 | | '/{-$orgSlug}/integrations/$namespace' |
| 171 | 195 | | '/{-$orgSlug}/resume/$executionId' |
| 172 | 196 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -181,7 +205,9 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 181 | 205 | | '/{-$orgSlug}/secrets' |
| 182 | 206 | | '/{-$orgSlug}/toolkits' |
| 183 | 207 | | '/{-$orgSlug}/tools' |
| 208 | + | '/{-$orgSlug}/users' | |
| 184 | 209 | | '/{-$orgSlug}' |
| 210 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 185 | 211 | | '/{-$orgSlug}/integrations/$namespace' |
| 186 | 212 | | '/{-$orgSlug}/resume/$executionId' |
| 187 | 213 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -196,7 +222,9 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 196 | 222 | | '/{-$orgSlug}/secrets' |
| 197 | 223 | | '/{-$orgSlug}/toolkits' |
| 198 | 224 | | '/{-$orgSlug}/tools' |
| 225 | + | '/{-$orgSlug}/users' | |
| 199 | 226 | | '/{-$orgSlug}/' |
| 227 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 200 | 228 | | '/{-$orgSlug}/integrations/$namespace' |
| 201 | 229 | | '/{-$orgSlug}/resume/$executionId' |
| 202 | 230 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -212,7 +240,9 @@ export interface RootRouteChildren { · 9 unmodified lines | ||
| 212 | 240 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 213 | 241 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 214 | 242 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 243 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | |
| 215 | 244 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 245 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 216 | 246 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 217 | 247 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 218 | 248 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -228,6 +258,13 @@ declare module '@tanstack/react-router' { · 9 unmodified lines | ||
| 228 | 258 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport |
| 229 | 259 | 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 | 268 | '/{-$orgSlug}/tools': { |
| 232 | 269 | id: '/{-$orgSlug}/tools' |
| 233 | 270 | path: '/{-$orgSlug}/tools' |
| @@ -298,6 +335,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines | ||
| 298 | 335 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport |
| 299 | 336 | 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 | 345 | '/{-$orgSlug}/plugins/$pluginId/$': { |
| 302 | 346 | id: '/{-$orgSlug}/plugins/$pluginId/$' |
| 303 | 347 | path: '/{-$orgSlug}/plugins/$pluginId/$' |
| @@ -342,8 +386,12 @@ const rootRouteChildren: RootRouteChildren = { · 38 unmodified lines | ||
| 342 | 386 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, |
| 343 | 387 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: |
| 344 | 388 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 389 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: | |
| 390 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute, | |
| 345 | 391 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| 346 | 392 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, |
| 393 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: | |
| 394 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | |
| 347 | 395 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 348 | 396 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 349 | 397 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | } from "@executor-js/react/components/card"; |
| 17 | 17 | import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context"; |
| 18 | 18 | import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; |
| 19 | +import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav"; | |
| 19 | 20 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; |
| 20 | 21 | |
| 21 | 22 | import { authClient } from "../auth-client"; |
| @@ -46,6 +47,13 @@ const selfHostNavItems = [ · 24 unmodified lines | ||
| 46 | 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.) | |
| 55 | +const selfHostAdminNavItems = [{ to: "/users", label: "Users" }]; | |
| 56 | + | |
| 49 | 57 | const signOut = async () => { |
| 50 | 58 | await authClient.signOut(); |
| 51 | 59 | window.location.href = "/"; |
| @@ -150,12 +158,13 @@ function AuthGate({ children }: { children: ReactNode }) { · 98 unmodified lines | ||
| 150 | 158 | function AuthenticatedApp() { |
| 151 | 159 | const auth = useAuth(); |
| 152 | 160 | const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null; |
| 161 | + const navItems = useAdminNavItems(selfHostNavItems, selfHostAdminNavItems); | |
| 153 | 162 | |
| 154 | 163 | // Single-org instance: a bare URL canonicalizes onto the instance org's |
| 155 | 164 | // slug. There's only ever one org, so no other slug is reachable. |
| 156 | 165 | const gated = ( |
| 157 | 166 | <> |
| 158 | − <Shell onSignOut={signOut} navItems={selfHostNavItems} /> | |
| 167 | + <Shell onSignOut={signOut} navItems={navItems} /> | |
| 159 | 168 | <Toaster /> |
| 160 | 169 | </> |
| 161 | 170 | ); |
| @@ -0,0 +1,389 @@ | ||
| 1 | +// Cloud-only: the admin Users PAGE — the console surface over `/api/admin/users*`. | |
| 2 | +// | |
| 3 | +// The sibling `admin-users.test.ts` pins the API's guarantees. This one pins | |
| 4 | +// what an operator actually sees, which the API scenario cannot: that the page | |
| 5 | +// renders every member of the workspace, that each row's connection summary | |
| 6 | +// distinguishes connected from merely available, that opening a user shows that | |
| 7 | +// user's own connections with their health, and — the failure path that matters | |
| 8 | +// most here — that a plain member who reaches the URL is told they don't have | |
| 9 | +// access rather than shown an empty workspace. | |
| 10 | +// | |
| 11 | +// Two members are built through the REAL flows (login → create-organization → | |
| 12 | +// invite → accept-invitation, in `./support/session`) and each connects their | |
| 13 | +// own credential, so the two rows differ in what they've connected and the | |
| 14 | +// summary has something to be right about. | |
| 15 | +import { randomBytes } from "node:crypto"; | |
| 16 | + | |
| 17 | +import { expect } from "@effect/vitest"; | |
| 18 | +import { Effect } from "effect"; | |
| 19 | +import type { HttpApiClient } from "effect/unstable/httpapi"; | |
| 20 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 21 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 22 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 23 | + | |
| 24 | +import { scenario } from "../src/scenario"; | |
| 25 | +import { Api, Browser, Target } from "../src/services"; | |
| 26 | +import { accountIdOf, forBrowser, joinOrg } from "./support/session"; | |
| 27 | + | |
| 28 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 29 | +type Client = HttpApiClient.ForApi<typeof api>; | |
| 30 | + | |
| 31 | +declare global { | |
| 32 | + interface Window { | |
| 33 | + // The copy assertions stash what the page handed to the clipboard here, so | |
| 34 | + // it can be read back out of the browser context without needing a | |
| 35 | + // clipboard-read permission a headless context won't grant. | |
| 36 | + __e2eCopied?: Array<string>; | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); | |
| 41 | + | |
| 42 | +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ | |
| 43 | +const pingSpec = JSON.stringify({ | |
| 44 | + openapi: "3.0.3", | |
| 45 | + info: { title: "Ping API", version: "1.0.0" }, | |
| 46 | + paths: { | |
| 47 | + "/ping": { | |
| 48 | + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, | |
| 49 | + }, | |
| 50 | + }, | |
| 51 | +}); | |
| 52 | + | |
| 53 | +/** Registers a fresh apiKey-authenticated integration for connections to bind to. */ | |
| 54 | +const registerIntegration = (client: Client, label: string) => | |
| 55 | + Effect.gen(function* () { | |
| 56 | + const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`); | |
| 57 | + yield* client.openapi.addSpec({ | |
| 58 | + payload: { | |
| 59 | + spec: { kind: "blob", value: pingSpec }, | |
| 60 | + slug, | |
| 61 | + baseUrl: "http://127.0.0.1:59999", // never contacted during registration | |
| 62 | + authenticationTemplate: [ | |
| 63 | + { | |
| 64 | + slug: "apiKey", | |
| 65 | + type: "apiKey", | |
| 66 | + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, | |
| 67 | + }, | |
| 68 | + ], | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | + return slug; | |
| 72 | + }); | |
| 73 | + | |
| 74 | +const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); | |
| 75 | + | |
| 76 | +scenario( | |
| 77 | + "Admin · the Users page shows every member and what each has connected, and refuses a plain member", | |
| 78 | + { timeout: 180_000 }, | |
| 79 | + Effect.gen(function* () { | |
| 80 | + const target = yield* Target; | |
| 81 | + const browser = yield* Browser; | |
| 82 | + const { client: apiClient } = yield* Api; | |
| 83 | + | |
| 84 | + const admin = yield* target.newIdentity(); | |
| 85 | + const invitee = yield* target.newIdentity({ org: false }); | |
| 86 | + const member = yield* joinOrg(target, admin, invitee); | |
| 87 | + | |
| 88 | + const adminClient = yield* apiClient(api, admin); | |
| 89 | + const memberClient = yield* apiClient(api, member); | |
| 90 | + const adminId = yield* accountIdOf(target, admin); | |
| 91 | + const memberId = yield* accountIdOf(target, member); | |
| 92 | + | |
| 93 | + // Two integrations so the summary has a real available-vs-connected split: | |
| 94 | + // each member connects one, so each row shows one connected and one not. | |
| 95 | + const connectedIntegration = yield* registerIntegration(adminClient, "admin-ui-conn"); | |
| 96 | + const availableIntegration = yield* registerIntegration(adminClient, "admin-ui-avail"); | |
| 97 | + const adminConnection = freshConnectionName(); | |
| 98 | + const memberConnection = freshConnectionName(); | |
| 99 | + | |
| 100 | + yield* Effect.ensuring( | |
| 101 | + Effect.gen(function* () { | |
| 102 | + // Each member stores their OWN credential. Neither can see the other's | |
| 103 | + // through the product plane — the admin page is the only surface that | |
| 104 | + // reports both. | |
| 105 | + yield* adminClient.connections.create({ | |
| 106 | + payload: { | |
| 107 | + owner: "user", | |
| 108 | + name: adminConnection, | |
| 109 | + integration: connectedIntegration, | |
| 110 | + template: TEMPLATE_API_KEY, | |
| 111 | + value: "admin-personal-token", | |
| 112 | + }, | |
| 113 | + }); | |
| 114 | + yield* memberClient.connections.create({ | |
| 115 | + payload: { | |
| 116 | + owner: "user", | |
| 117 | + name: memberConnection, | |
| 118 | + integration: connectedIntegration, | |
| 119 | + template: TEMPLATE_API_KEY, | |
| 120 | + value: "member-personal-token", | |
| 121 | + }, | |
| 122 | + }); | |
| 123 | + | |
| 124 | + // ── The admin's view ──────────────────────────────────────────────── | |
| 125 | + yield* browser.session(forBrowser(admin), async ({ page, step }) => { | |
| 126 | + let slug = ""; | |
| 127 | + | |
| 128 | + await step("Land in the workspace and canonicalize onto the org slug", async () => { | |
| 129 | + await page.goto("/", { waitUntil: "networkidle" }); | |
| 130 | + await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | |
| 131 | + timeout: 30_000, | |
| 132 | + }); | |
| 133 | + slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; | |
| 134 | + expect(slug, "the URL settled on an org slug").not.toBe(""); | |
| 135 | + }); | |
| 136 | + | |
| 137 | + await step("Open Users from the sidebar", async () => { | |
| 138 | + // The section is admin-only, so an admin must actually see the link | |
| 139 | + // — reaching the page by URL would not prove the nav gate opened. | |
| 140 | + await page.getByRole("link", { name: "Users" }).click(); | |
| 141 | + await page.waitForURL((url) => url.pathname === `/${slug}/users`, { timeout: 30_000 }); | |
| 142 | + await page.locator("[data-slot='admin-users-table']").waitFor({ timeout: 30_000 }); | |
| 143 | + }); | |
| 144 | + | |
| 145 | + await step("Both members of the workspace are listed", async () => { | |
| 146 | + const rows = page.locator("[data-slot='admin-user-row']"); | |
| 147 | + await rows.first().waitFor({ timeout: 30_000 }); | |
| 148 | + // The id is opaque and rendered shortened, so the row's full value | |
| 149 | + // lives in the title the operator hovers for. | |
| 150 | + const ids = await rows | |
| 151 | + .locator("[data-slot='admin-user-id']") | |
| 152 | + .evaluateAll((elements) => | |
| 153 | + elements.map((element) => element.getAttribute("title") ?? ""), | |
| 154 | + ); | |
| 155 | + expect(ids, "the admin's own row is listed").toContain(adminId); | |
| 156 | + expect( | |
| 157 | + ids, | |
| 158 | + "the invited member is listed too, though the admin's product view cannot see them", | |
| 159 | + ).toContain(memberId); | |
| 160 | + | |
| 161 | + // The host joins identity server-side, so an operator reads people | |
| 162 | + // by name rather than by opaque id. The emails are the ones these | |
| 163 | + // identities were actually minted with — asserting the seeded values | |
| 164 | + // is what proves the join key lines up, since a wrong key would | |
| 165 | + // leave every row unnamed while the id assertions above still pass. | |
| 166 | + const names = await rows | |
| 167 | + .locator("[data-slot='admin-user-name']") | |
| 168 | + .evaluateAll((elements) => elements.map((element) => element.textContent ?? "")); | |
| 169 | + expect(names, "the admin's row leads with their email").toContain( | |
| 170 | + admin.credentials?.email, | |
| 171 | + ); | |
| 172 | + expect(names, "and the invited member's with theirs").toContain( | |
| 173 | + member.credentials?.email, | |
| 174 | + ); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + await step("Each row summarizes connected vs available integrations", async () => { | |
| 178 | + const memberRow = page | |
| 179 | + .locator("[data-slot='admin-user-row']") | |
| 180 | + .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) }); | |
| 181 | + await memberRow.waitFor({ state: "visible", timeout: 30_000 }); | |
| 182 | + // A fresh cloud org's catalog is exactly the two integrations this | |
| 183 | + // scenario registered: the built-in Executor integration is not | |
| 184 | + // connectable, so it is neither a slot nor a denominator. The | |
| 185 | + // denominator is therefore pinned, not read — a third slot here | |
| 186 | + // would mean the built-in leaked back into the view. | |
| 187 | + const summary = memberRow.locator("[data-slot='admin-user-connection-count']"); | |
| 188 | + await summary.waitFor({ state: "visible", timeout: 30_000 }); | |
| 189 | + expect( | |
| 190 | + await summary.textContent(), | |
| 191 | + "one of the two connectable integrations, with the built-in out of both numbers", | |
| 192 | + ).toBe("1/2"); | |
| 193 | + expect( | |
| 194 | + await memberRow.locator("[data-integration='executor']").count(), | |
| 195 | + "the built-in integration has no connect flow, so it gets no slot", | |
| 196 | + ).toBe(0); | |
| 197 | + expect( | |
| 198 | + await memberRow | |
| 199 | + .locator(`[data-integration='${connectedIntegration}'][data-connected='true']`) | |
| 200 | + .count(), | |
| 201 | + "the integration this member connected is lit in their summary", | |
| 202 | + ).toBe(1); | |
| 203 | + expect( | |
| 204 | + await memberRow | |
| 205 | + .locator(`[data-integration='${availableIntegration}'][data-connected='false']`) | |
| 206 | + .count(), | |
| 207 | + "the one they haven't connected still has a slot — that absence is the view", | |
| 208 | + ).toBe(1); | |
| 209 | + }); | |
| 210 | + | |
| 211 | + await step("Open the member's detail and read their connections", async () => { | |
| 212 | + await page | |
| 213 | + .locator("[data-slot='admin-user-row']") | |
| 214 | + .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) }) | |
| 215 | + .click(); | |
| 216 | + const detail = page.getByRole("dialog"); | |
| 217 | + await detail.waitFor({ state: "visible", timeout: 30_000 }); | |
| 218 | + | |
| 219 | + // Their own connection, by name, with the shared health vocabulary. | |
| 220 | + await detail | |
| 221 | + .getByText(memberConnection, { exact: true }) | |
| 222 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 223 | + // Never probed, so the honest verdict is Unchecked — not Healthy. | |
| 224 | + expect( | |
| 225 | + await detail.getByLabel("Status: Unchecked").count(), | |
| 226 | + "a never-probed connection reads as unchecked, not healthy", | |
| 227 | + ).toBe(1); | |
| 228 | + // The other member's credential is not this member's business. | |
| 229 | + expect( | |
| 230 | + await detail.getByText(adminConnection, { exact: true }).count(), | |
| 231 | + "one user's detail never shows another user's connection", | |
| 232 | + ).toBe(0); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + await step("The detail header copies the member's email and their id", async () => { | |
| 236 | + const detail = page.getByRole("dialog"); | |
| 237 | + // Record what reaches the clipboard rather than reading it back: | |
| 238 | + // a headless context grants no clipboard-read permission, and the | |
| 239 | + // assertion is about what the button hands over, not about the | |
| 240 | + // browser's store. | |
| 241 | + await page.evaluate(() => { | |
| 242 | + const copied: Array<string> = []; | |
| 243 | + window.__e2eCopied = copied; | |
| 244 | + Object.defineProperty(navigator, "clipboard", { | |
| 245 | + configurable: true, | |
| 246 | + get: () => ({ | |
| 247 | + writeText: (text: string) => { | |
| 248 | + copied.push(text); | |
| 249 | + return Promise.resolve(); | |
| 250 | + }, | |
| 251 | + }), | |
| 252 | + }); | |
| 253 | + }); | |
| 254 | + | |
| 255 | + // Two buttons, not one: the email is what an operator pastes into a | |
| 256 | + // mail client, the id is what they paste into a log search. | |
| 257 | + await detail.getByRole("button", { name: "Copy email" }).click(); | |
| 258 | + await detail.getByRole("button", { name: "Copy ID" }).click(); | |
| 259 | + | |
| 260 | + const copied = await page.evaluate(() => window.__e2eCopied ?? []); | |
| 261 | + expect( | |
| 262 | + copied, | |
| 263 | + "the email button copies the address the member signed up with", | |
| 264 | + ).toContain(member.credentials?.email); | |
| 265 | + expect(copied, "and the id button copies the opaque principal id").toContain(memberId); | |
| 266 | + }); | |
| 267 | + | |
| 268 | + await step("The not-connected integration offers a copyable connect link", async () => { | |
| 269 | + const detail = page.getByRole("dialog"); | |
| 270 | + const origin = new URL(target.baseUrl).origin; | |
| 271 | + // The link carries THIS admin's org slug. A recipient who belongs | |
| 272 | + // to several orgs would otherwise resolve the bare form against | |
| 273 | + // their default org and connect in the wrong workspace. That | |
| 274 | + // multi-org round trip is its own scenario | |
| 275 | + // (`connect-link-multi-org.test.ts`), which needs a second org to | |
| 276 | + // land in by mistake; here the concern is what this page RENDERS, | |
| 277 | + // plus that the rendered link actually resolves (below). | |
| 278 | + await detail | |
| 279 | + .getByText(`${origin}/${slug}/connect/${availableIntegration}`, { exact: true }) | |
| 280 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 281 | + expect( | |
| 282 | + await detail | |
| 283 | + .getByText(`${origin}/connect/${availableIntegration}`, { exact: true }) | |
| 284 | + .count(), | |
| 285 | + "the org-free form is never rendered on a host that has orgs", | |
| 286 | + ).toBe(0); | |
| 287 | + // Exactly one: the member connected one of the two connectable | |
| 288 | + // integrations, and the built-in offers no link at all. | |
| 289 | + expect( | |
| 290 | + await detail.getByRole("button", { name: "Copy link" }).count(), | |
| 291 | + "one link per not-connected connectable integration, and none for the built-in", | |
| 292 | + ).toBe(1); | |
| 293 | + expect( | |
| 294 | + await detail.getByText("/connect/executor", { exact: false }).count(), | |
| 295 | + "the built-in integration is never offered as a connect link", | |
| 296 | + ).toBe(0); | |
| 297 | + }); | |
| 298 | + | |
| 299 | + await step("The rendered connect link actually resolves", async () => { | |
| 300 | + // Following the link the page just rendered, rather than only | |
| 301 | + // reading it back. A rendered string proves the page composed a URL; | |
| 302 | + // it cannot tell a working link from a 404 — which is the failure an | |
| 303 | + // operator would discover only after sending it to someone. | |
| 304 | + const origin = new URL(target.baseUrl).origin; | |
| 305 | + await page.goto(`${origin}/${slug}/connect/${availableIntegration}`, { | |
| 306 | + waitUntil: "domcontentloaded", | |
| 307 | + }); | |
| 308 | + // It forwards into that integration's own detail route with the | |
| 309 | + // add-account handoff, still inside this admin's org. | |
| 310 | + await page.waitForURL( | |
| 311 | + (url) => url.pathname === `/${slug}/integrations/${availableIntegration}`, | |
| 312 | + { timeout: 30_000 }, | |
| 313 | + ); | |
| 314 | + expect( | |
| 315 | + new URL(page.url()).searchParams.get("addAccount"), | |
| 316 | + "the link lands in the connect flow, not just on the page", | |
| 317 | + ).toBe("1"); | |
| 318 | + }); | |
| 319 | + }); | |
| 320 | + | |
| 321 | + // ── The plain member's view ───────────────────────────────────────── | |
| 322 | + yield* browser.session(forBrowser(member), async ({ page, step }) => { | |
| 323 | + let slug = ""; | |
| 324 | + | |
| 325 | + await step("Sign in as a plain member of the same workspace", async () => { | |
| 326 | + await page.goto("/", { waitUntil: "networkidle" }); | |
| 327 | + await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | |
| 328 | + timeout: 30_000, | |
| 329 | + }); | |
| 330 | + slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; | |
| 331 | + expect(slug, "the member landed in the workspace").not.toBe(""); | |
| 332 | + }); | |
| 333 | + | |
| 334 | + await step("The Users section is not offered to them", async () => { | |
| 335 | + await page | |
| 336 | + .getByRole("link", { name: "Integrations" }) | |
| 337 | + .first() | |
| 338 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 339 | + expect( | |
| 340 | + await page.getByRole("link", { name: "Users" }).count(), | |
| 341 | + "a plain member is not shown a section that would only refuse them", | |
| 342 | + ).toBe(0); | |
| 343 | + }); | |
| 344 | + | |
| 345 | + await step( | |
| 346 | + "Reaching the URL directly says no access, not an empty workspace", | |
| 347 | + async () => { | |
| 348 | + await page.goto(`/${slug}/users`, { waitUntil: "networkidle" }); | |
| 349 | + // The denial is the assertion: an empty table here would misreport | |
| 350 | + // a populated workspace as having no users. | |
| 351 | + await page | |
| 352 | + .getByText("You don't have access to this workspace's users") | |
| 353 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 354 | + expect( | |
| 355 | + await page.locator("[data-slot='admin-users-table']").count(), | |
| 356 | + "the refusal replaces the table rather than rendering it empty", | |
| 357 | + ).toBe(0); | |
| 358 | + expect( | |
| 359 | + await page.getByText(adminId, { exact: true }).count(), | |
| 360 | + "no other member's identity leaks to a refused viewer", | |
| 361 | + ).toBe(0); | |
| 362 | + }, | |
| 363 | + ); | |
| 364 | + }); | |
| 365 | + }), | |
| 366 | + Effect.all( | |
| 367 | + [ | |
| 368 | + adminClient.connections | |
| 369 | + .remove({ | |
| 370 | + params: { owner: "user", integration: connectedIntegration, name: adminConnection }, | |
| 371 | + }) | |
| 372 | + .pipe(Effect.ignore), | |
| 373 | + memberClient.connections | |
| 374 | + .remove({ | |
| 375 | + params: { owner: "user", integration: connectedIntegration, name: memberConnection }, | |
| 376 | + }) | |
| 377 | + .pipe(Effect.ignore), | |
| 378 | + adminClient.openapi | |
| 379 | + .removeSpec({ params: { slug: connectedIntegration } }) | |
| 380 | + .pipe(Effect.ignore), | |
| 381 | + adminClient.openapi | |
| 382 | + .removeSpec({ params: { slug: availableIntegration } }) | |
| 383 | + .pipe(Effect.ignore), | |
| 384 | + ], | |
| 385 | + { discard: true }, | |
| 386 | + ), | |
| 387 | + ); | |
| 388 | + }), | |
| 389 | +); | |
| @@ -0,0 +1,322 @@ | ||
| 1 | +// Cloud-only: the OWNER's view of a workspace — `/api/admin/users*`. The | |
| 2 | +// product plane answers "what can I, this member, reach"; this plane answers | |
| 3 | +// "who uses my workspace, and what have they connected", reading across every | |
| 4 | +// member of the tenant instead of binding to one. | |
| 5 | +// | |
| 6 | +// Two members are built through the REAL flows (login → create-organization → | |
| 7 | +// invite → accept-invitation), each connects their own credential, and the | |
| 8 | +// admin then reads the joined view — the exact shape a customer dashboard's | |
| 9 | +// icon grid consumes. The guarantees pinned here: | |
| 10 | +// | |
| 11 | +// 1. the joined view reports BOTH members and each one's own connections, | |
| 12 | +// even though no single product-view caller can see another member's; | |
| 13 | +// 2. it never carries credential material; | |
| 14 | +// 3. a plain member is refused (403) and an anonymous caller too (401); | |
| 15 | +// 4. another tenant's admin sees none of it. | |
| 16 | +// | |
| 17 | +// EMULATOR GAP: the WorkOS emulator hardcodes `owner.type: "user"` in its api-key | |
| 18 | +// serializer and exposes no organization-key mint route, so an org-scoped API | |
| 19 | +// key — the machine credential for this plane — cannot exist end-to-end here. | |
| 20 | +// This scenario therefore drives the ADMIN SESSION path, which is the other | |
| 21 | +// credential the plane accepts. The org-key half is covered at unit level in | |
| 22 | +// `apps/cloud/src/auth/org-api-key-mint.node.test.ts` (against the real WorkOS | |
| 23 | +// response shape) and `org-api-key-auth.node.test.ts` (PlatformAuth resolution). | |
| 24 | +import { randomBytes } from "node:crypto"; | |
| 25 | + | |
| 26 | +import { expect } from "@effect/vitest"; | |
| 27 | +import { Effect } from "effect"; | |
| 28 | +import type { HttpApiClient } from "effect/unstable/httpapi"; | |
| 29 | +import { AdminUsersHttpApi } from "@executor-js/api"; | |
| 30 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 31 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 32 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 33 | + | |
| 34 | +import { scenario } from "../src/scenario"; | |
| 35 | +import { Api, Target } from "../src/services"; | |
| 36 | +import { accountIdOf, cookieOf, joinOrg } from "./support/session"; | |
| 37 | + | |
| 38 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 39 | +type Client = HttpApiClient.ForApi<typeof api>; | |
| 40 | + | |
| 41 | +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); | |
| 42 | + | |
| 43 | +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ | |
| 44 | +const pingSpec = JSON.stringify({ | |
| 45 | + openapi: "3.0.3", | |
| 46 | + info: { title: "Ping API", version: "1.0.0" }, | |
| 47 | + paths: { | |
| 48 | + "/ping": { | |
| 49 | + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, | |
| 50 | + }, | |
| 51 | + }, | |
| 52 | +}); | |
| 53 | + | |
| 54 | +/** Registers a fresh apiKey-authenticated integration for connections to bind to. */ | |
| 55 | +const registerIntegration = (client: Client) => | |
| 56 | + Effect.gen(function* () { | |
| 57 | + const slug = IntegrationSlug.make(`admin-scn-${randomBytes(4).toString("hex")}`); | |
| 58 | + yield* client.openapi.addSpec({ | |
| 59 | + payload: { | |
| 60 | + spec: { kind: "blob", value: pingSpec }, | |
| 61 | + slug, | |
| 62 | + baseUrl: "http://127.0.0.1:59999", // never contacted during registration | |
| 63 | + authenticationTemplate: [ | |
| 64 | + { | |
| 65 | + slug: "apiKey", | |
| 66 | + type: "apiKey", | |
| 67 | + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, | |
| 68 | + }, | |
| 69 | + ], | |
| 70 | + }, | |
| 71 | + }); | |
| 72 | + return slug; | |
| 73 | + }); | |
| 74 | + | |
| 75 | +const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); | |
| 76 | + | |
| 77 | +scenario( | |
| 78 | + "Admin · the owner sees every member of the workspace and what each has connected", | |
| 79 | + {}, | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const target = yield* Target; | |
| 82 | + const { client: apiClient } = yield* Api; | |
| 83 | + | |
| 84 | + const admin = yield* target.newIdentity(); | |
| 85 | + const invitee = yield* target.newIdentity({ org: false }); | |
| 86 | + const member = yield* joinOrg(target, admin, invitee); | |
| 87 | + | |
| 88 | + const adminClient = yield* apiClient(api, admin); | |
| 89 | + const memberClient = yield* apiClient(api, member); | |
| 90 | + const adminId = yield* accountIdOf(target, admin); | |
| 91 | + const memberId = yield* accountIdOf(target, member); | |
| 92 | + | |
| 93 | + const integration = yield* registerIntegration(adminClient); | |
| 94 | + const adminConnection = freshConnectionName(); | |
| 95 | + const memberConnection = freshConnectionName(); | |
| 96 | + | |
| 97 | + yield* Effect.ensuring( | |
| 98 | + Effect.gen(function* () { | |
| 99 | + // Each member stores their OWN credential. Neither can see the other's | |
| 100 | + // through the product plane — that is the whole point of the admin one. | |
| 101 | + yield* adminClient.connections.create({ | |
| 102 | + payload: { | |
| 103 | + owner: "user", | |
| 104 | + name: adminConnection, | |
| 105 | + integration, | |
| 106 | + template: TEMPLATE_API_KEY, | |
| 107 | + value: "admin-personal-token", | |
| 108 | + }, | |
| 109 | + }); | |
| 110 | + yield* memberClient.connections.create({ | |
| 111 | + payload: { | |
| 112 | + owner: "user", | |
| 113 | + name: memberConnection, | |
| 114 | + integration, | |
| 115 | + template: TEMPLATE_API_KEY, | |
| 116 | + value: "member-personal-token", | |
| 117 | + }, | |
| 118 | + }); | |
| 119 | + | |
| 120 | + const client = yield* apiClient(AdminUsersHttpApi, admin); | |
| 121 | + | |
| 122 | + // (1) The joined view: both members, each with their own connection. | |
| 123 | + const joined = yield* client.adminUsers.listUsersWithConnections({ query: {} }); | |
| 124 | + const byId = new Map(joined.users.map((user) => [user.externalId, user])); | |
| 125 | + | |
| 126 | + expect([...byId.keys()].sort(), "both members of the workspace are reported").toEqual( | |
| 127 | + [adminId, memberId].sort(), | |
| 128 | + ); | |
| 129 | + expect( | |
| 130 | + byId.get(adminId)?.connections.map((connection) => connection.name), | |
| 131 | + "the admin's own connection is attributed to them", | |
| 132 | + ).toContain(adminConnection); | |
| 133 | + expect( | |
| 134 | + byId.get(memberId)?.connections.map((connection) => connection.name), | |
| 135 | + "the member's connection is visible to the owner, though not to the admin's product view", | |
| 136 | + ).toContain(memberConnection); | |
| 137 | + | |
| 138 | + // The host identity join: the WorkOS email lands on the right row. | |
| 139 | + // | |
| 140 | + // The join key is the membership's `userId` — the same WorkOS `user_...` | |
| 141 | + // the subject table records — so this proves the id spaces line up. A | |
| 142 | + // mismatch would leave every user unnamed while the id assertions above | |
| 143 | + // still passed, which is exactly the failure this asserts against. | |
| 144 | + expect( | |
| 145 | + byId.get(adminId)?.email, | |
| 146 | + "the admin's row carries the email they signed in with", | |
| 147 | + ).toBe(admin.credentials?.email); | |
| 148 | + expect(byId.get(memberId)?.email, "and the invited member's row carries theirs").toBe( | |
| 149 | + member.credentials?.email, | |
| 150 | + ); | |
| 151 | + | |
| 152 | + // The flat list agrees with the joined one. | |
| 153 | + const flat = yield* client.adminUsers.listUsers({ query: {} }); | |
| 154 | + expect( | |
| 155 | + flat.users.map((user) => user.externalId).sort(), | |
| 156 | + "the flat list reports the same members", | |
| 157 | + ).toEqual([adminId, memberId].sort()); | |
| 158 | + expect( | |
| 159 | + flat.users.map((user) => user.email).sort(), | |
| 160 | + "with the same identities joined onto it", | |
| 161 | + ).toEqual([admin.credentials?.email, member.credentials?.email].sort()); | |
| 162 | + | |
| 163 | + // Per-user connections resolve the same rows. | |
| 164 | + const memberConnections = yield* client.adminUsers.listUserConnections({ | |
| 165 | + params: { externalId: memberId }, | |
| 166 | + }); | |
| 167 | + expect( | |
| 168 | + memberConnections.connections.map((connection) => connection.name), | |
| 169 | + "the member's connections are readable by external id", | |
| 170 | + ).toContain(memberConnection); | |
| 171 | + | |
| 172 | + // The single-user read, addressed by EMAIL, against real WorkOS-shaped | |
| 173 | + // identity: the reverse directory lookup (email → user id) has to agree | |
| 174 | + // with the forward join above, and the answer must carry identity AND | |
| 175 | + // connections in the one call a per-user check makes. | |
| 176 | + const memberEmail = member.credentials?.email ?? ""; | |
| 177 | + const single = yield* client.adminUsers.getUser({ | |
| 178 | + params: { identifier: memberEmail }, | |
| 179 | + }); | |
| 180 | + expect(single.user.externalId, "the email resolves to the same member").toBe(memberId); | |
| 181 | + expect(single.user.email, "and carries the identity the list reported").toBe(memberEmail); | |
| 182 | + expect( | |
| 183 | + single.user.connections.map((connection) => connection.name), | |
| 184 | + "with their connections joined in the same response", | |
| 185 | + ).toContain(memberConnection); | |
| 186 | + | |
| 187 | + // The same read by opaque id agrees, so neither identifier is a | |
| 188 | + // different code path with a different answer. | |
| 189 | + const byOpaqueId = yield* client.adminUsers.getUser({ | |
| 190 | + params: { identifier: memberId }, | |
| 191 | + }); | |
| 192 | + expect(byOpaqueId.user.email).toBe(memberEmail); | |
| 193 | + expect(byOpaqueId.user.connections.map((connection) => connection.name)).toEqual( | |
| 194 | + single.user.connections.map((connection) => connection.name), | |
| 195 | + ); | |
| 196 | + | |
| 197 | + // The bulk filter selects the same member out of the workspace. | |
| 198 | + const filtered = yield* client.adminUsers.listUsers({ | |
| 199 | + query: { email: memberEmail }, | |
| 200 | + }); | |
| 201 | + expect( | |
| 202 | + filtered.users.map((user) => user.externalId), | |
| 203 | + "?email= narrows the list to exactly that member", | |
| 204 | + ).toEqual([memberId]); | |
| 205 | + | |
| 206 | + // (2) No credential material anywhere in the joined view. | |
| 207 | + expect( | |
| 208 | + JSON.stringify(joined), | |
| 209 | + "the admin view never carries a stored credential", | |
| 210 | + ).not.toContain("member-personal-token"); | |
| 211 | + expect(JSON.stringify(joined)).not.toContain("admin-personal-token"); | |
| 212 | + | |
| 213 | + // (3) A plain member is refused: this plane reports on everyone. | |
| 214 | + const asMember = yield* Effect.promise(() => | |
| 215 | + fetch(new URL("/api/admin/users", target.baseUrl), { | |
| 216 | + headers: { cookie: cookieOf(member) }, | |
| 217 | + }), | |
| 218 | + ); | |
| 219 | + expect(asMember.status, "a non-admin member cannot read the workspace view").toBe(403); | |
| 220 | + | |
| 221 | + // And an anonymous caller has no credential at all. | |
| 222 | + const anonymous = yield* Effect.promise(() => | |
| 223 | + fetch(new URL("/api/admin/users", target.baseUrl)), | |
| 224 | + ); | |
| 225 | + expect(anonymous.status, "no session → unauthorized").toBe(401); | |
| 226 | + }), | |
| 227 | + Effect.all( | |
| 228 | + [ | |
| 229 | + adminClient.connections | |
| 230 | + .remove({ params: { owner: "user", integration, name: adminConnection } }) | |
| 231 | + .pipe(Effect.ignore), | |
| 232 | + memberClient.connections | |
| 233 | + .remove({ params: { owner: "user", integration, name: memberConnection } }) | |
| 234 | + .pipe(Effect.ignore), | |
| 235 | + adminClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore), | |
| 236 | + ], | |
| 237 | + { discard: true }, | |
| 238 | + ), | |
| 239 | + ); | |
| 240 | + }), | |
| 241 | +); | |
| 242 | + | |
| 243 | +scenario( | |
| 244 | + "Admin · one workspace's owner sees nothing of another workspace", | |
| 245 | + {}, | |
| 246 | + Effect.gen(function* () { | |
| 247 | + const target = yield* Target; | |
| 248 | + const { client: apiClient } = yield* Api; | |
| 249 | + | |
| 250 | + const mine = yield* target.newIdentity(); | |
| 251 | + const theirs = yield* target.newIdentity(); | |
| 252 | + const myId = yield* accountIdOf(target, mine); | |
| 253 | + const theirId = yield* accountIdOf(target, theirs); | |
| 254 | + | |
| 255 | + const myClient = yield* apiClient(api, mine); | |
| 256 | + // A user earns their row in the workspace the first time they touch the | |
| 257 | + // EXECUTOR plane (the account plane doesn't bind an executor), so the other | |
| 258 | + // owner makes one product read — otherwise their own workspace is empty and | |
| 259 | + // the isolation assertion below would pass vacuously. | |
| 260 | + yield* (yield* apiClient(api, theirs)).connections.list({ query: {} }); | |
| 261 | + const integration = yield* registerIntegration(myClient); | |
| 262 | + const connection = freshConnectionName(); | |
| 263 | + | |
| 264 | + yield* Effect.ensuring( | |
| 265 | + Effect.gen(function* () { | |
| 266 | + yield* myClient.connections.create({ | |
| 267 | + payload: { | |
| 268 | + owner: "user", | |
| 269 | + name: connection, | |
| 270 | + integration, | |
| 271 | + template: TEMPLATE_API_KEY, | |
| 272 | + value: "my-private-token", | |
| 273 | + }, | |
| 274 | + }); | |
| 275 | + | |
| 276 | + const theirAdmin = yield* apiClient(AdminUsersHttpApi, theirs); | |
| 277 | + const theirView = yield* theirAdmin.adminUsers.listUsersWithConnections({ query: {} }); | |
| 278 | + | |
| 279 | + expect( | |
| 280 | + theirView.users.map((user) => user.externalId), | |
| 281 | + "another workspace's owner never sees my members", | |
| 282 | + ).not.toContain(myId); | |
| 283 | + expect( | |
| 284 | + theirView.users.map((user) => user.externalId), | |
| 285 | + "they do see their own", | |
| 286 | + ).toContain(theirId); | |
| 287 | + expect( | |
| 288 | + JSON.stringify(theirView), | |
| 289 | + "and none of my connections leak across the tenant boundary", | |
| 290 | + ).not.toContain(connection); | |
| 291 | + | |
| 292 | + // Naming my account id directly returns nothing rather than my rows. | |
| 293 | + const probe = yield* theirAdmin.adminUsers.listUserConnections({ | |
| 294 | + params: { externalId: myId }, | |
| 295 | + }); | |
| 296 | + expect(probe.connections, "a cross-tenant id resolves to nothing").toEqual([]); | |
| 297 | + | |
| 298 | + // And the single-user read refuses to confirm I exist at all — by my | |
| 299 | + // id or by my email, both of which are real, just not theirs. | |
| 300 | + for (const identifier of [myId, mine.credentials?.email ?? ""]) { | |
| 301 | + const response = yield* Effect.promise(() => | |
| 302 | + fetch(new URL(`/api/admin/users/${encodeURIComponent(identifier)}`, target.baseUrl), { | |
| 303 | + headers: { cookie: cookieOf(theirs) }, | |
| 304 | + }), | |
| 305 | + ); | |
| 306 | + expect(response.status, "a cross-tenant lookup is a plain 404, revealing nothing").toBe( | |
| 307 | + 404, | |
| 308 | + ); | |
| 309 | + } | |
| 310 | + }), | |
| 311 | + Effect.all( | |
| 312 | + [ | |
| 313 | + myClient.connections | |
| 314 | + .remove({ params: { owner: "user", integration, name: connection } }) | |
| 315 | + .pipe(Effect.ignore), | |
| 316 | + myClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore), | |
| 317 | + ], | |
| 318 | + { discard: true }, | |
| 319 | + ), | |
| 320 | + ); | |
| 321 | + }), | |
| 322 | +); | |
| @@ -0,0 +1,232 @@ | ||
| 1 | +// Cloud-only: the connect deep link's REASON FOR CARRYING AN ORG SLUG. | |
| 2 | +// | |
| 3 | +// An operator on the admin Users page copies a connect link for a member who | |
| 4 | +// hasn't connected something yet and sends it to them. `connectLinkUrl` builds | |
| 5 | +// that link org-PREFIXED (`/<org>/connect/<slug>`) rather than bare, and the | |
| 6 | +// failure that choice prevents is specific and silent: a recipient who belongs | |
| 7 | +// to several workspaces has ONE session-default org, and a bare `/connect/x` | |
| 8 | +// resolves against THAT one. They would land in the wrong workspace, connect a | |
| 9 | +// real credential there, and nothing anywhere would report a problem — the | |
| 10 | +// connection succeeds, it is just in the wrong tenant. The sender then keeps | |
| 11 | +// seeing an unconnected user. | |
| 12 | +// | |
| 13 | +// The existing coverage cannot catch this. `scenarios/connect-deep-link.test.ts` | |
| 14 | +// deliberately accepts EITHER URL form (it runs on hosts with and without an | |
| 15 | +// org segment) and its user belongs to one org, so both forms resolve to the | |
| 16 | +// same place. `packages/react/src/routes/connect-deep-link.test.ts` only proves | |
| 17 | +// the router parses the param. Neither has a second org to land in by mistake. | |
| 18 | +// | |
| 19 | +// So: a recipient who is a member of TWO orgs, whose session defaults to org B, | |
| 20 | +// follows an org-A-scoped link, and the credential must end up in org A. | |
| 21 | +// | |
| 22 | +// Both orgs register an integration under the SAME slug — that is what makes | |
| 23 | +// this a real test. With distinct slugs, org B's catalog would simply not | |
| 24 | +// contain the link's slug and the page would show its not-found state, so the | |
| 25 | +// scenario would pass for the wrong reason (a lookup miss rather than correct | |
| 26 | +// scoping). Sharing the slug means BOTH orgs can satisfy the link, and only the | |
| 27 | +// org prefix decides which one does. | |
| 28 | +import { randomBytes } from "node:crypto"; | |
| 29 | + | |
| 30 | +import { expect } from "@effect/vitest"; | |
| 31 | +import { Effect } from "effect"; | |
| 32 | +import type { HttpApiClient } from "effect/unstable/httpapi"; | |
| 33 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 34 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 35 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 36 | + | |
| 37 | +import { scenario } from "../src/scenario"; | |
| 38 | +import { Api, Browser, Target } from "../src/services"; | |
| 39 | +import { activeOrg, forBrowser, joinOrg, organizationsOf } from "./support/session"; | |
| 40 | + | |
| 41 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 42 | +type Client = HttpApiClient.ForApi<typeof api>; | |
| 43 | + | |
| 44 | +/** The spec's title, which the console uses to name a saved connection | |
| 45 | + * ("Personal Ping API"). */ | |
| 46 | +const INTEGRATION_TITLE = "Ping API"; | |
| 47 | + | |
| 48 | +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ | |
| 49 | +const pingSpec = JSON.stringify({ | |
| 50 | + openapi: "3.0.3", | |
| 51 | + info: { title: INTEGRATION_TITLE, version: "1.0.0" }, | |
| 52 | + paths: { | |
| 53 | + "/ping": { | |
| 54 | + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, | |
| 55 | + }, | |
| 56 | + }, | |
| 57 | +}); | |
| 58 | + | |
| 59 | +/** Register an apiKey-authenticated integration under an EXACT slug, so both | |
| 60 | + * orgs can hold the same catalog identity. */ | |
| 61 | +const registerIntegration = (client: Client, slug: IntegrationSlug) => | |
| 62 | + client.openapi.addSpec({ | |
| 63 | + payload: { | |
| 64 | + spec: { kind: "blob", value: pingSpec }, | |
| 65 | + slug, | |
| 66 | + baseUrl: "http://127.0.0.1:59999", // never contacted during registration | |
| 67 | + authenticationTemplate: [ | |
| 68 | + { | |
| 69 | + slug: "apiKey", | |
| 70 | + type: "apiKey", | |
| 71 | + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, | |
| 72 | + }, | |
| 73 | + ], | |
| 74 | + }, | |
| 75 | + }); | |
| 76 | + | |
| 77 | +scenario( | |
| 78 | + "Connect · an org-scoped connect link lands a multi-org recipient in the SENDING org", | |
| 79 | + { timeout: 180_000 }, | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const target = yield* Target; | |
| 82 | + const browser = yield* Browser; | |
| 83 | + const { client: apiClient } = yield* Api; | |
| 84 | + | |
| 85 | + // Two workspaces with two different owners, and one person invited into | |
| 86 | + // both. Built through the real invite flow, so the memberships are the ones | |
| 87 | + // WorkOS actually issues. | |
| 88 | + const ownerA = yield* target.newIdentity(); | |
| 89 | + const ownerB = yield* target.newIdentity(); | |
| 90 | + const recipientSeed = yield* target.newIdentity({ org: false }); | |
| 91 | + | |
| 92 | + const joinedA = yield* joinOrg(target, ownerA, recipientSeed); | |
| 93 | + // Accepting B's invitation SECOND re-seals the session onto org B, which is | |
| 94 | + // exactly the hazard: the recipient's default is now the wrong workspace | |
| 95 | + // for the link they are about to follow. | |
| 96 | + const inOrgB = yield* joinOrg(target, ownerB, joinedA); | |
| 97 | + | |
| 98 | + // `joinedA`'s cookie was re-sealed by the SECOND accept, so it is stale and | |
| 99 | + // must not be used to read anything — the recipient's live session is | |
| 100 | + // `inOrgB`, pointed at whichever org a given read is about. | |
| 101 | + const orgA = yield* activeOrg(target, ownerA); | |
| 102 | + const orgB = yield* activeOrg(target, inOrgB); | |
| 103 | + expect(orgA.id, "the two workspaces are genuinely different").not.toBe(orgB.id); | |
| 104 | + | |
| 105 | + const memberships = yield* organizationsOf(target, inOrgB); | |
| 106 | + expect( | |
| 107 | + memberships.organizations.map((org) => org.id).sort(), | |
| 108 | + "the recipient really is a member of BOTH workspaces", | |
| 109 | + ).toEqual([orgA.id, orgB.id].sort()); | |
| 110 | + expect( | |
| 111 | + memberships.activeOrganizationId, | |
| 112 | + "and their session defaults to org B — the org the link does NOT point at", | |
| 113 | + ).toBe(orgB.id); | |
| 114 | + | |
| 115 | + const ownerAClient = yield* apiClient(api, ownerA); | |
| 116 | + const ownerBClient = yield* apiClient(api, ownerB); | |
| 117 | + | |
| 118 | + // ONE slug, registered in BOTH catalogs (see the header note). | |
| 119 | + const slug = IntegrationSlug.make(`multiorg-${randomBytes(4).toString("hex")}`); | |
| 120 | + | |
| 121 | + yield* Effect.ensuring( | |
| 122 | + Effect.gen(function* () { | |
| 123 | + yield* registerIntegration(ownerAClient, slug); | |
| 124 | + yield* registerIntegration(ownerBClient, slug); | |
| 125 | + | |
| 126 | + // The link an operator copies off org A's Users page. Built here the | |
| 127 | + // same way the page builds it (origin + org prefix + slug); the page's | |
| 128 | + // own rendering of it is asserted in `admin-users-console.test.ts`. | |
| 129 | + const origin = new URL(target.baseUrl).origin; | |
| 130 | + const connectLink = `${origin}/${orgA.slug}/connect/${slug}`; | |
| 131 | + | |
| 132 | + // The recipient follows the link with their org-B-default session. | |
| 133 | + yield* browser.session(forBrowser(inOrgB), async ({ page, step }) => { | |
| 134 | + await step("Follow the org-A connect link with an org-B session", async () => { | |
| 135 | + // `domcontentloaded`, not `networkidle`: the console holds live | |
| 136 | + // connections open, so idle is not a state this page reaches. The | |
| 137 | + // real wait is the redirect assertion below. | |
| 138 | + await page.goto(connectLink, { waitUntil: "domcontentloaded" }); | |
| 139 | + // The deep link forwards into the integration detail route with the | |
| 140 | + // add-account handoff — and it must keep ORG A's prefix through the | |
| 141 | + // redirect. Landing on `/${orgB.slug}/...` here IS the bug. | |
| 142 | + await page.waitForURL((url) => url.pathname === `/${orgA.slug}/integrations/${slug}`, { | |
| 143 | + timeout: 30_000, | |
| 144 | + }); | |
| 145 | + expect( | |
| 146 | + new URL(page.url()).pathname.split("/").filter(Boolean)[0], | |
| 147 | + "the connect flow stayed in the SENDING org, not the session default", | |
| 148 | + ).toBe(orgA.slug); | |
| 149 | + expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); | |
| 150 | + }); | |
| 151 | + | |
| 152 | + await step("Complete the connection from that page", async () => { | |
| 153 | + const dialog = page.getByRole("dialog"); | |
| 154 | + await dialog.getByRole("heading", { name: /Add connection/ }).waitFor({ | |
| 155 | + timeout: 30_000, | |
| 156 | + }); | |
| 157 | + // The credential field is labelled by the method's PLACEMENT (the | |
| 158 | + // `authorization` header this spec declares), not by the variable. | |
| 159 | + // Waited for explicitly: the field renders only once the modal has | |
| 160 | + // loaded the integration's auth methods, which is a second fetch | |
| 161 | + // after the heading appears. | |
| 162 | + const credential = dialog.getByRole("textbox", { name: "authorization" }); | |
| 163 | + await credential.waitFor({ state: "visible", timeout: 90_000 }); | |
| 164 | + await credential.fill("recipient-personal-token"); | |
| 165 | + // The offered health check is opt-in (it runs only on "Check"), and | |
| 166 | + // this spec's base URL is never served — so the credential is saved | |
| 167 | + // unprobed, which is what this scenario is about: WHERE it lands, | |
| 168 | + // not whether it works. | |
| 169 | + await dialog.getByRole("button", { name: "Continue" }).click(); | |
| 170 | + await dialog.getByRole("button", { name: "Add connection" }).click(); | |
| 171 | + // The saved credential appears as a row in the accounts list of the | |
| 172 | + // page it was saved from — and that page is ORG A's (its URL was | |
| 173 | + // pinned to `orgA.slug` in the step above). So this row IS the | |
| 174 | + // "landed in the sending workspace" half of the guarantee, read | |
| 175 | + // from the surface the recipient is actually looking at. | |
| 176 | + // | |
| 177 | + // Waited on rather than the success toast (which auto-dismisses | |
| 178 | + // below the fold) or the dialog's disappearance (which races the | |
| 179 | + // close animation). | |
| 180 | + await page | |
| 181 | + .getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }) | |
| 182 | + .first() | |
| 183 | + .waitFor({ state: "visible", timeout: 90_000 }); | |
| 184 | + expect( | |
| 185 | + new URL(page.url()).pathname.split("/").filter(Boolean)[0], | |
| 186 | + "the credential was saved from a page scoped to the SENDING org", | |
| 187 | + ).toBe(orgA.slug); | |
| 188 | + }); | |
| 189 | + | |
| 190 | + // ── The other half: it is NOT in the session's default org ───────── | |
| 191 | + // | |
| 192 | + // Same person, same session, same integration slug — the only thing | |
| 193 | + // that differs is the org in the URL. Org B registered the SAME slug, | |
| 194 | + // so this page exists and renders; it simply must hold no connection. | |
| 195 | + // Had the link resolved against the session default, THIS is the page | |
| 196 | + // the credential would be on. | |
| 197 | + await step("Org B — the session's own default — has none", async () => { | |
| 198 | + await page.goto(`${origin}/${orgB.slug}/integrations/${slug}?tab=accounts`, { | |
| 199 | + waitUntil: "domcontentloaded", | |
| 200 | + }); | |
| 201 | + // Wait for the accounts panel to finish loading before asserting an | |
| 202 | + // absence, so "not rendered yet" cannot pass as "not there". The | |
| 203 | + // empty state is the positive signal that the list resolved AND is | |
| 204 | + // empty — checking only for the missing row would also pass while | |
| 205 | + // the list was still loading. | |
| 206 | + await page | |
| 207 | + .getByRole("button", { name: "Add connection" }) | |
| 208 | + .first() | |
| 209 | + .waitFor({ state: "visible", timeout: 90_000 }); | |
| 210 | + await page | |
| 211 | + .getByText("No connections", { exact: false }) | |
| 212 | + .first() | |
| 213 | + .waitFor({ state: "visible", timeout: 90_000 }); | |
| 214 | + expect( | |
| 215 | + await page.getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }).count(), | |
| 216 | + "nothing landed in the org the recipient's session happened to default to", | |
| 217 | + ).toBe(0); | |
| 218 | + }); | |
| 219 | + }); | |
| 220 | + }), | |
| 221 | + // Removing each org's spec takes its connections with it, so the | |
| 222 | + // UI-created credential (whose name the console chose) needs no lookup. | |
| 223 | + Effect.all( | |
| 224 | + [ | |
| 225 | + ownerAClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), | |
| 226 | + ownerBClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), | |
| 227 | + ], | |
| 228 | + { discard: true }, | |
| 229 | + ), | |
| 230 | + ); | |
| 231 | + }), | |
| 232 | +); | |
| @@ -0,0 +1,205 @@ | ||
| 1 | +// Cloud session + membership helpers shared by the scenarios that need MORE | |
| 2 | +// than one identity in an org. | |
| 3 | +// | |
| 4 | +// Every one of these is a thin wrapper over a REAL product endpoint — there is | |
| 5 | +// no back door here. They were copy-pasted verbatim into four cloud scenarios | |
| 6 | +// before living here, and the copies drifted in the two ways that matter, both | |
| 7 | +// now folded in: | |
| 8 | +// | |
| 9 | +// - only one carried `forBrowser`, the fix for the fact that `joinOrg` | |
| 10 | +// refreshes `headers.cookie` (the sealed session is re-issued on the new | |
| 11 | +// org) but leaves `cookies` on the PRE-join value — and the browser context | |
| 12 | +// is seeded from `cookies`. A scenario on a stale copy browses as the | |
| 13 | +// person's old org while its API calls use the new one. | |
| 14 | +// - only one forwarded the ORG SELECTOR header. Org-scoped endpoints fail | |
| 15 | +// closed without it (`AccountNoOrganization`), so a cookie-only copy cannot | |
| 16 | +// even build the fixture: `/api/account/members/invite` 403s. | |
| 17 | +// | |
| 18 | +// `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module | |
| 19 | +// is never collected as a suite. | |
| 20 | +import { Effect } from "effect"; | |
| 21 | + | |
| 22 | +import type { Identity, Target as TargetShape } from "../../src/target"; | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * The per-request org selector. `/api/auth/switch-organization` was removed | |
| 26 | + * (#1000): the URL prefix or this header is the scope authority now, and the | |
| 27 | + * session's own org is only a default. | |
| 28 | + * | |
| 29 | + * This is the RIGHT way to read one account's data in a specific org: a session | |
| 30 | + * cookie captured before the account joined another org is stale the moment the | |
| 31 | + * later `accept-invitation` re-seals it, so holding onto the old cookie reads as | |
| 32 | + * an unauthenticated caller. Take the CURRENT identity and point it at the org | |
| 33 | + * you mean. | |
| 34 | + * | |
| 35 | + * Mirrors `ORG_SELECTOR_HEADER` in `apps/cloud/src/auth/organization.ts` | |
| 36 | + * (`EXECUTOR_ORG_SELECTOR_HEADER` in the SDK's shared exports); duplicated as a | |
| 37 | + * literal so the e2e suite stays a black-box client of the deployed product. | |
| 38 | + */ | |
| 39 | +const ORG_SELECTOR_HEADER = "x-executor-organization"; | |
| 40 | + | |
| 41 | +/** The session cookie header this identity authenticates API calls with. */ | |
| 42 | +export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * The identity re-bound so the BROWSER carries the same session the API calls | |
| 46 | + * do. `joinOrg` / `createOrg` refresh `headers.cookie` (the org is baked into | |
| 47 | + * the sealed session) but leave `cookies` on the pre-change value, and the | |
| 48 | + * browser context is seeded from `cookies`. | |
| 49 | + */ | |
| 50 | +export const forBrowser = (identity: Identity): Identity => { | |
| 51 | + const cookie = cookieOf(identity); | |
| 52 | + const separator = cookie.indexOf("="); | |
| 53 | + if (separator < 0) throw new Error("identity carries no session cookie"); | |
| 54 | + return { | |
| 55 | + ...identity, | |
| 56 | + cookies: [{ name: cookie.slice(0, separator), value: cookie.slice(separator + 1) }], | |
| 57 | + }; | |
| 58 | +}; | |
| 59 | + | |
| 60 | +/** POST as `identity`, failing loudly on a non-2xx (a silent skip here would | |
| 61 | + * leave the scenario asserting against a half-built fixture). | |
| 62 | + * | |
| 63 | + * Sends the identity's OWN headers, not just its cookie: org-scoped endpoints | |
| 64 | + * (`/api/account/members/invite` among them) fail closed with | |
| 65 | + * `AccountNoOrganization` when the org selector header is absent, because the | |
| 66 | + * session's own org is only a default and is never fallen back to. */ | |
| 67 | +export const postJson = ( | |
| 68 | + target: TargetShape, | |
| 69 | + path: string, | |
| 70 | + identity: Identity, | |
| 71 | + body: unknown, | |
| 72 | +): Effect.Effect<Response> => | |
| 73 | + Effect.promise(async () => { | |
| 74 | + const response = await fetch(new URL(path, target.baseUrl), { | |
| 75 | + method: "POST", | |
| 76 | + headers: { | |
| 77 | + ...(identity.headers ?? {}), | |
| 78 | + "content-type": "application/json", | |
| 79 | + origin: new URL(target.baseUrl).origin, | |
| 80 | + }, | |
| 81 | + body: JSON.stringify(body), | |
| 82 | + }); | |
| 83 | + if (!response.ok) { | |
| 84 | + throw new Error(`${path} failed (${response.status}): ${await response.text()}`); | |
| 85 | + } | |
| 86 | + return response; | |
| 87 | + }); | |
| 88 | + | |
| 89 | +/** The identity re-bound to the refreshed session cookie a response set, and | |
| 90 | + * scoped to `orgSelector` — carrying the cookie alone would leave every | |
| 91 | + * org-scoped read the identity goes on to make failing closed. */ | |
| 92 | +export const withRefreshedSession = ( | |
| 93 | + identity: Identity, | |
| 94 | + response: Response, | |
| 95 | + orgSelector: string, | |
| 96 | +): Identity => { | |
| 97 | + const refreshed = (response.headers.getSetCookie?.() ?? []) | |
| 98 | + .find((header) => header.startsWith("wos-session=")) | |
| 99 | + ?.split(";")[0]; | |
| 100 | + if (!refreshed) throw new Error("response did not refresh the session cookie"); | |
| 101 | + return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } }; | |
| 102 | +}; | |
| 103 | + | |
| 104 | +/** The org selector this identity's requests carry — the same header the web | |
| 105 | + * client derives from the console URL's slug. */ | |
| 106 | +export const orgSelectorOf = (identity: Identity): string => { | |
| 107 | + const selector = identity.headers?.[ORG_SELECTOR_HEADER]; | |
| 108 | + if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`); | |
| 109 | + return selector; | |
| 110 | +}; | |
| 111 | + | |
| 112 | +/** | |
| 113 | + * Invite `member` into `admin`'s org and accept — the real invite flow. The | |
| 114 | + * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor | |
| 115 | + * an access-denial assertion needs); pass `roleSlug` for anything else. | |
| 116 | + * | |
| 117 | + * The returned identity is bound to the newly-joined org — the re-sealed | |
| 118 | + * session from accept-invitation, scoped by the selector to the org they were | |
| 119 | + * invited INTO (`admin`'s). Calling this twice for one person is how a scenario | |
| 120 | + * builds a multi-org member: the WorkOS membership in the earlier org persists, | |
| 121 | + * and both the session and the selector point at the later one. | |
| 122 | + */ | |
| 123 | +export const joinOrg = ( | |
| 124 | + target: TargetShape, | |
| 125 | + admin: Identity, | |
| 126 | + member: Identity, | |
| 127 | + options: { readonly roleSlug?: string } = {}, | |
| 128 | +): Effect.Effect<Identity> => | |
| 129 | + Effect.gen(function* () { | |
| 130 | + const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { | |
| 131 | + email: member.credentials?.email, | |
| 132 | + ...(options.roleSlug === undefined ? {} : { roleSlug: options.roleSlug }), | |
| 133 | + }); | |
| 134 | + const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; | |
| 135 | + const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { | |
| 136 | + invitationId: invitation.id, | |
| 137 | + }); | |
| 138 | + return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin)); | |
| 139 | + }); | |
| 140 | + | |
| 141 | +/** Create another org for this account; returns the identity bound to it. */ | |
| 142 | +export const createOrg = ( | |
| 143 | + target: TargetShape, | |
| 144 | + identity: Identity, | |
| 145 | + name: string, | |
| 146 | +): Effect.Effect<Identity> => | |
| 147 | + Effect.gen(function* () { | |
| 148 | + const response = yield* postJson(target, "/api/auth/create-organization", identity, { name }); | |
| 149 | + const created = (yield* Effect.promise(() => response.clone().json())) as { id: string }; | |
| 150 | + return withRefreshedSession(identity, response, created.id); | |
| 151 | + }); | |
| 152 | + | |
| 153 | +/** This identity, scoped to `organizationId` for the requests it makes. */ | |
| 154 | +export const inOrg = (identity: Identity, organizationId: string): Identity => ({ | |
| 155 | + ...identity, | |
| 156 | + headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId }, | |
| 157 | +}); | |
| 158 | + | |
| 159 | +/** The caller's own account id — the `externalId` the admin plane reports and | |
| 160 | + * the users page renders in mono. */ | |
| 161 | +export const accountIdOf = (target: TargetShape, identity: Identity): Effect.Effect<string> => | |
| 162 | + Effect.promise(async () => { | |
| 163 | + const response = await fetch(new URL("/api/account/me", target.baseUrl), { | |
| 164 | + headers: { cookie: cookieOf(identity) }, | |
| 165 | + }); | |
| 166 | + if (!response.ok) throw new Error(`/api/account/me failed (${response.status})`); | |
| 167 | + const body = (await response.json()) as { user: { id: string } }; | |
| 168 | + return body.user.id; | |
| 169 | + }); | |
| 170 | + | |
| 171 | +/** The org this identity's session is currently bound to: id and URL slug. */ | |
| 172 | +export const activeOrg = ( | |
| 173 | + target: TargetShape, | |
| 174 | + identity: Identity, | |
| 175 | +): Effect.Effect<{ readonly id: string; readonly slug: string }> => | |
| 176 | + Effect.promise(async () => { | |
| 177 | + const response = await fetch(new URL("/api/auth/me", target.baseUrl), { | |
| 178 | + headers: { cookie: cookieOf(identity) }, | |
| 179 | + }); | |
| 180 | + if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`); | |
| 181 | + const body = (await response.json()) as { | |
| 182 | + organization: { id: string; slug: string } | null; | |
| 183 | + }; | |
| 184 | + if (!body.organization) throw new Error("identity has no active organization"); | |
| 185 | + return { id: body.organization.id, slug: body.organization.slug }; | |
| 186 | + }); | |
| 187 | + | |
| 188 | +/** Every org this account is a member of, plus the one the session is on. */ | |
| 189 | +export const organizationsOf = ( | |
| 190 | + target: TargetShape, | |
| 191 | + identity: Identity, | |
| 192 | +): Effect.Effect<{ | |
| 193 | + readonly organizations: ReadonlyArray<{ readonly id: string; readonly slug: string }>; | |
| 194 | + readonly activeOrganizationId: string | null; | |
| 195 | +}> => | |
| 196 | + Effect.promise(async () => { | |
| 197 | + const response = await fetch(new URL("/api/auth/organizations", target.baseUrl), { | |
| 198 | + headers: { cookie: cookieOf(identity) }, | |
| 199 | + }); | |
| 200 | + if (!response.ok) throw new Error(`/api/auth/organizations failed (${response.status})`); | |
| 201 | + return (await response.json()) as { | |
| 202 | + organizations: ReadonlyArray<{ id: string; slug: string }>; | |
| 203 | + activeOrganizationId: string | null; | |
| 204 | + }; | |
| 205 | + }); | |
| @@ -35,6 +35,27 @@ scenario( · 34 unmodified lines | ||
| 35 | 35 | expect(paths, "path-param routes are documented").toContain( |
| 36 | 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 | |
| @@ -0,0 +1,168 @@ | ||
| 1 | +// The connect deep link: a customer sends an end user a direct link for ONE | |
| 2 | +// integration ("connect your Linear") instead of pointing them at the dashboard | |
| 3 | +// to hunt for it. `/connect/<integration slug>` resolves the slug against the | |
| 4 | +// tenant's catalog and lands the person in the SAME connect flow the | |
| 5 | +// integration detail page's own button opens. | |
| 6 | +// | |
| 7 | +// The slug — not a plugin id — is the stable identity here: it is what a | |
| 8 | +// customer can write down and template into an email. | |
| 9 | +// | |
| 10 | +// Covered: | |
| 11 | +// 1. Signed in, known slug → the Add connection flow is open on the right | |
| 12 | +// integration, with the auth methods the spec declares | |
| 13 | +// 2. Unknown slug → a clear not-found state, not a blank page or a | |
| 14 | +// crash (an end user WILL mistype these, and links outlive integrations) | |
| 15 | +// 3. Already connected → the deep link shows the existing connection and | |
| 16 | +// still offers to add another account | |
| 17 | +import { randomBytes, randomUUID } from "node:crypto"; | |
| 18 | + | |
| 19 | +import { expect } from "@effect/vitest"; | |
| 20 | +import { Effect } from "effect"; | |
| 21 | +import { AccountHttpApi } from "@executor-js/api"; | |
| 22 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 23 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 24 | +import { | |
| 25 | + AuthTemplateSlug, | |
| 26 | + ConnectionName, | |
| 27 | + IntegrationSlug, | |
| 28 | + ProviderItemId, | |
| 29 | +} from "@executor-js/sdk/shared"; | |
| 30 | + | |
| 31 | +import { scenario } from "../src/scenario"; | |
| 32 | +import { Api, Browser, Target } from "../src/services"; | |
| 33 | + | |
| 34 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 35 | + | |
| 36 | +/** OpenAPI 3 spec with a single operation — enough to be a real catalog entry. */ | |
| 37 | +const greetSpec = (baseUrl: string): string => | |
| 38 | + JSON.stringify({ | |
| 39 | + openapi: "3.0.3", | |
| 40 | + info: { title: "Deep Link API", version: "1.0.0" }, | |
| 41 | + servers: [{ url: baseUrl }], | |
| 42 | + paths: { | |
| 43 | + "/greet": { | |
| 44 | + get: { | |
| 45 | + operationId: "getGreeting", | |
| 46 | + summary: "Return a greeting message", | |
| 47 | + responses: { "200": { description: "A greeting" } }, | |
| 48 | + }, | |
| 49 | + }, | |
| 50 | + }, | |
| 51 | + }); | |
| 52 | + | |
| 53 | +scenario( | |
| 54 | + "Connect · /connect/<slug> lands an end user in that integration's connect flow", | |
| 55 | + { timeout: 180_000 }, | |
| 56 | + Effect.gen(function* () { | |
| 57 | + const target = yield* Target; | |
| 58 | + const browser = yield* Browser; | |
| 59 | + const { client } = yield* Api; | |
| 60 | + | |
| 61 | + const identity = yield* target.newIdentity(); | |
| 62 | + const apiClient = yield* client(api, identity); | |
| 63 | + | |
| 64 | + // Selfhost shares one bootstrap-admin identity, so a unique slug keeps | |
| 65 | + // repeated and parallel runs out of each other's catalogs. | |
| 66 | + const slug = `connect-dl-${randomBytes(4).toString("hex")}`; | |
| 67 | + const specBaseUrl = "http://127.0.0.1:59999"; // never contacted here | |
| 68 | + | |
| 69 | + // Org-scoped hosts canonicalize console URLs onto the active org's slug, so | |
| 70 | + // the deep link may legitimately arrive at either `/connect/x` or | |
| 71 | + // `/<org>/connect/x`. Read the slug the shell will canonicalize onto. | |
| 72 | + const accountClient = yield* client(AccountHttpApi, identity); | |
| 73 | + const me = yield* accountClient.account.me(); | |
| 74 | + const orgSlug = me.organization?.slug ?? null; | |
| 75 | + const detailPath = orgSlug ? `/${orgSlug}/integrations/${slug}` : `/integrations/${slug}`; | |
| 76 | + | |
| 77 | + yield* Effect.ensuring( | |
| 78 | + Effect.gen(function* () { | |
| 79 | + const added = yield* apiClient.openapi.addSpec({ | |
| 80 | + payload: { | |
| 81 | + spec: { kind: "blob", value: greetSpec(specBaseUrl) }, | |
| 82 | + slug, | |
| 83 | + name: "Deep Link API", | |
| 84 | + baseUrl: specBaseUrl, | |
| 85 | + authenticationTemplate: [ | |
| 86 | + { | |
| 87 | + slug: "apiKey", | |
| 88 | + type: "apiKey", | |
| 89 | + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, | |
| 90 | + }, | |
| 91 | + ], | |
| 92 | + }, | |
| 93 | + }); | |
| 94 | + expect(added.slug, "the integration keeps the requested slug").toBe(slug); | |
| 95 | + | |
| 96 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 97 | + await step("Open the connect deep link for the seeded integration", async () => { | |
| 98 | + await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" }); | |
| 99 | + // It forwards into the integration's OWN detail route rather than | |
| 100 | + // rendering a parallel connect UI, carrying the add-account handoff. | |
| 101 | + await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); | |
| 102 | + expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + await step("The Add connection flow is open for that integration", async () => { | |
| 106 | + await page | |
| 107 | + .getByRole("heading", { name: /Add connection/ }) | |
| 108 | + .waitFor({ timeout: 20_000 }); | |
| 109 | + // The right integration, and the declared auth method is offered — | |
| 110 | + // proof it resolved the slug rather than opening a generic dialog. | |
| 111 | + await page | |
| 112 | + .getByRole("dialog") | |
| 113 | + .getByRole("textbox", { name: "x-api-key" }) | |
| 114 | + .waitFor({ timeout: 20_000 }); | |
| 115 | + }); | |
| 116 | + | |
| 117 | + await step("An unknown slug is a clear not-found, not a blank page", async () => { | |
| 118 | + await page.goto("/connect/zz-no-such-integration", { waitUntil: "networkidle" }); | |
| 119 | + await page | |
| 120 | + .getByText("Unknown integration: zz-no-such-integration") | |
| 121 | + .waitFor({ timeout: 30_000 }); | |
| 122 | + await page.getByRole("link", { name: "Back to integrations" }).waitFor(); | |
| 123 | + }); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + // Already connected: the deep link must show the existing connection | |
| 127 | + // and still let the person add another account. | |
| 128 | + const providers = yield* apiClient.providers.list(); | |
| 129 | + expect(providers.length, "a credential provider is available").toBeGreaterThan(0); | |
| 130 | + yield* apiClient.connections.create({ | |
| 131 | + payload: { | |
| 132 | + owner: "org", | |
| 133 | + name: ConnectionName.make("existing"), | |
| 134 | + integration: IntegrationSlug.make(slug), | |
| 135 | + template: AuthTemplateSlug.make("apiKey"), | |
| 136 | + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, | |
| 137 | + }, | |
| 138 | + }); | |
| 139 | + | |
| 140 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 141 | + await step("The deep link surfaces the existing connection", async () => { | |
| 142 | + await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" }); | |
| 143 | + await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); | |
| 144 | + // The connect flow still opens (this link means "connect me"), and | |
| 145 | + // the already-saved account is visible behind it. | |
| 146 | + await page | |
| 147 | + .getByRole("heading", { name: /Add connection/ }) | |
| 148 | + .waitFor({ timeout: 20_000 }); | |
| 149 | + await page.keyboard.press("Escape"); | |
| 150 | + await page.getByText("existing").first().waitFor({ timeout: 20_000 }); | |
| 151 | + }); | |
| 152 | + }); | |
| 153 | + }), | |
| 154 | + Effect.gen(function* () { | |
| 155 | + yield* apiClient.connections | |
| 156 | + .remove({ | |
| 157 | + params: { | |
| 158 | + owner: "org", | |
| 159 | + integration: IntegrationSlug.make(slug), | |
| 160 | + name: ConnectionName.make("existing"), | |
| 161 | + }, | |
| 162 | + }) | |
| 163 | + .pipe(Effect.ignore); | |
| 164 | + yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 165 | + }).pipe(Effect.ignore), | |
| 166 | + ); | |
| 167 | + }), | |
| 168 | +); | |
| @@ -0,0 +1,330 @@ | ||
| 1 | +// Selfhost-only (browser): the admin Users page against the OTHER auth model. | |
| 2 | +// | |
| 3 | +// Cloud gates this plane on a WorkOS `admin` membership; self-host gates it on | |
| 4 | +// a Better Auth `owner`/`admin` member. The console is shared, so the same page | |
| 5 | +// must open for the instance owner and refuse an invited plain member — that | |
| 6 | +// second role vocabulary is exactly what the cloud scenario cannot cover. | |
| 7 | +// | |
| 8 | +// Selfhost identities are shared across scenarios (one bootstrap admin, one | |
| 9 | +// org), so this asserts "contains mine" rather than global counts, and every | |
| 10 | +// resource it creates is prefixed. | |
| 11 | +import { randomBytes } from "node:crypto"; | |
| 12 | + | |
| 13 | +import { expect } from "@effect/vitest"; | |
| 14 | +import { Effect } from "effect"; | |
| 15 | +import type { HttpApiClient } from "effect/unstable/httpapi"; | |
| 16 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 17 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 18 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 19 | + | |
| 20 | +import { scenario } from "../src/scenario"; | |
| 21 | +import { createInvitedIdentity } from "../targets/selfhost"; | |
| 22 | +import { Api, Browser, Target } from "../src/services"; | |
| 23 | + | |
| 24 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 25 | +type Client = HttpApiClient.ForApi<typeof api>; | |
| 26 | + | |
| 27 | +declare global { | |
| 28 | + interface Window { | |
| 29 | + // What the page handed to the clipboard through the non-secure-origin | |
| 30 | + // execCommand fallback, read back out of the browser context. | |
| 31 | + __e2eCopied?: Array<string>; | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); | |
| 36 | + | |
| 37 | +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ | |
| 38 | +const pingSpec = JSON.stringify({ | |
| 39 | + openapi: "3.0.3", | |
| 40 | + info: { title: "Ping API", version: "1.0.0" }, | |
| 41 | + paths: { | |
| 42 | + "/ping": { | |
| 43 | + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, | |
| 44 | + }, | |
| 45 | + }, | |
| 46 | +}); | |
| 47 | + | |
| 48 | +const registerIntegration = (client: Client, label = "admin-ui-sh") => | |
| 49 | + Effect.gen(function* () { | |
| 50 | + const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`); | |
| 51 | + yield* client.openapi.addSpec({ | |
| 52 | + payload: { | |
| 53 | + spec: { kind: "blob", value: pingSpec }, | |
| 54 | + slug, | |
| 55 | + baseUrl: "http://127.0.0.1:59999", // never contacted during registration | |
| 56 | + authenticationTemplate: [ | |
| 57 | + { | |
| 58 | + slug: "apiKey", | |
| 59 | + type: "apiKey", | |
| 60 | + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, | |
| 61 | + }, | |
| 62 | + ], | |
| 63 | + }, | |
| 64 | + }); | |
| 65 | + return slug; | |
| 66 | + }); | |
| 67 | + | |
| 68 | +scenario( | |
| 69 | + "Admin · a self-hosted owner sees the workspace's users; an invited member is refused", | |
| 70 | + { timeout: 180_000 }, | |
| 71 | + Effect.gen(function* () { | |
| 72 | + const target = yield* Target; | |
| 73 | + const browser = yield* Browser; | |
| 74 | + const { client: apiClient } = yield* Api; | |
| 75 | + | |
| 76 | + // The bootstrap identity owns the instance; the invited one is a plain | |
| 77 | + // member, which is the Better Auth role the admin plane refuses. | |
| 78 | + const owner = yield* target.newIdentity(); | |
| 79 | + const member = yield* Effect.promise(() => | |
| 80 | + createInvitedIdentity(target.baseUrl, owner, { | |
| 81 | + role: "member", | |
| 82 | + emailPrefix: "admin-ui-member", | |
| 83 | + }), | |
| 84 | + ); | |
| 85 | + | |
| 86 | + const ownerClient = yield* apiClient(api, owner); | |
| 87 | + const memberClient = yield* apiClient(api, member); | |
| 88 | + const integration = yield* registerIntegration(ownerClient); | |
| 89 | + // A second, deliberately unconnected integration, so the member's detail is | |
| 90 | + // guaranteed to render at least one connect link to assert the shape of. | |
| 91 | + const availableIntegration = yield* registerIntegration(ownerClient, "admin-ui-sh-avail"); | |
| 92 | + const memberConnection = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); | |
| 93 | + | |
| 94 | + yield* Effect.ensuring( | |
| 95 | + Effect.gen(function* () { | |
| 96 | + // The member stores their own credential, so the owner's view has | |
| 97 | + // something to report that the owner's product view cannot see. | |
| 98 | + yield* memberClient.connections.create({ | |
| 99 | + payload: { | |
| 100 | + owner: "user", | |
| 101 | + name: memberConnection, | |
| 102 | + integration, | |
| 103 | + template: TEMPLATE_API_KEY, | |
| 104 | + value: "member-personal-token", | |
| 105 | + }, | |
| 106 | + }); | |
| 107 | + | |
| 108 | + yield* browser.session(owner, async ({ page, step }) => { | |
| 109 | + await step("Open Users from the sidebar as the instance owner", async () => { | |
| 110 | + await page.goto("/", { waitUntil: "networkidle" }); | |
| 111 | + await page | |
| 112 | + .locator("nav") | |
| 113 | + .getByRole("link", { name: "Users" }) | |
| 114 | + .first() | |
| 115 | + .click({ timeout: 30_000 }); | |
| 116 | + await page | |
| 117 | + .locator("[data-slot='admin-users-table']") | |
| 118 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 119 | + }); | |
| 120 | + | |
| 121 | + await step("The invited member's connection is attributed to them", async () => { | |
| 122 | + // Selfhost shares one org across scenarios, so this asserts the | |
| 123 | + // member's own row exists — never a count of the whole instance. | |
| 124 | + const row = page | |
| 125 | + .locator("[data-slot='admin-user-row']") | |
| 126 | + .filter({ | |
| 127 | + has: page.locator(`[data-integration='${integration}'][data-connected='true']`), | |
| 128 | + }) | |
| 129 | + .first(); | |
| 130 | + await row.waitFor({ state: "visible", timeout: 30_000 }); | |
| 131 | + | |
| 132 | + // Better Auth's identity, joined server-side. The join key is the | |
| 133 | + // member list's `userId` (the Better Auth user id the subject table | |
| 134 | + // records), NOT the organization `member` row id — so asserting the | |
| 135 | + // email this identity was actually minted with is what proves the | |
| 136 | + // id spaces line up on THIS host's auth model. | |
| 137 | + expect( | |
| 138 | + await row.locator("[data-slot='admin-user-name']").textContent(), | |
| 139 | + "the member's row leads with the email they signed up with", | |
| 140 | + ).toBe(member.credentials?.email); | |
| 141 | + | |
| 142 | + await row.click(); | |
| 143 | + | |
| 144 | + const detail = page.getByRole("dialog"); | |
| 145 | + await detail.waitFor({ state: "visible", timeout: 30_000 }); | |
| 146 | + await detail | |
| 147 | + .getByText(memberConnection, { exact: true }) | |
| 148 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + await step("The detail header copies the member's email and their id", async () => { | |
| 152 | + const detail = page.getByRole("dialog"); | |
| 153 | + // The member's opaque id, from the supporting line the header shows | |
| 154 | + // beneath the email. | |
| 155 | + const externalId = await detail | |
| 156 | + .locator("[data-slot='admin-user-detail-id']") | |
| 157 | + .getAttribute("title"); | |
| 158 | + expect(externalId, "the header carries the opaque principal id").not.toBe(""); | |
| 159 | + | |
| 160 | + // Self-host is served over plain HTTP — a NON-secure origin, where | |
| 161 | + // `navigator.clipboard` does not exist and the copy must go through | |
| 162 | + // the execCommand fallback (see @executor-js/react `lib/clipboard`). | |
| 163 | + // Record what that path selects, exactly as the api-keys copy test | |
| 164 | + // does, so this asserts the real self-host copy path rather than a | |
| 165 | + // secure-context one the deployment never takes. | |
| 166 | + await page.evaluate(() => { | |
| 167 | + Object.defineProperty(navigator, "clipboard", { | |
| 168 | + configurable: true, | |
| 169 | + get: () => undefined, | |
| 170 | + }); | |
| 171 | + const copied: Array<string> = []; | |
| 172 | + window.__e2eCopied = copied; | |
| 173 | + const exec = document.execCommand?.bind(document); | |
| 174 | + document.execCommand = (command, ...rest) => { | |
| 175 | + if (String(command).toLowerCase() === "copy") { | |
| 176 | + copied.push(window.getSelection ? String(window.getSelection()) : ""); | |
| 177 | + } | |
| 178 | + return exec ? exec(command, ...rest) : false; | |
| 179 | + }; | |
| 180 | + }); | |
| 181 | + | |
| 182 | + await detail.getByRole("button", { name: "Copy email" }).click(); | |
| 183 | + await detail.getByRole("button", { name: "Copy ID" }).click(); | |
| 184 | + | |
| 185 | + const copied = await page.evaluate(() => window.__e2eCopied ?? []); | |
| 186 | + expect(copied, "the email button copies the member's address").toContain( | |
| 187 | + member.credentials?.email, | |
| 188 | + ); | |
| 189 | + expect(copied, "and the id button copies their opaque id").toContain(externalId); | |
| 190 | + }); | |
| 191 | + | |
| 192 | + await step( | |
| 193 | + "The built-in integration is never offered as something to connect", | |
| 194 | + async () => { | |
| 195 | + // It is a static, credential-free source: there is no connect flow | |
| 196 | + // to send anyone to, so it is neither a slot nor a link. This is the | |
| 197 | + // reported bug — it used to appear under "not connected" with a | |
| 198 | + // copyable link to a route that would do nothing. | |
| 199 | + const detail = page.getByRole("dialog"); | |
| 200 | + expect( | |
| 201 | + await detail.locator("[data-integration='executor']").count(), | |
| 202 | + "the built-in gets no slot in the detail sheet", | |
| 203 | + ).toBe(0); | |
| 204 | + expect( | |
| 205 | + await detail.getByText("/connect/executor", { exact: false }).count(), | |
| 206 | + "and no connect link is rendered for it", | |
| 207 | + ).toBe(0); | |
| 208 | + }, | |
| 209 | + ); | |
| 210 | + | |
| 211 | + await step("A connect link is scoped to the org the admin is viewing", async () => { | |
| 212 | + // The invariant is a round trip, not a fixed shape: whatever org the | |
| 213 | + // console is currently scoped to is the org the link carries, so a | |
| 214 | + // recipient cannot be sent into a different workspace than the one | |
| 215 | + // the admin was looking at. | |
| 216 | + // | |
| 217 | + // Self-host DOES have an org segment — it canonicalizes onto a | |
| 218 | + // `default` org — so this host exercises the prefixed form just as | |
| 219 | + // cloud does. The org-free form is what a host that renders no | |
| 220 | + // segment at all would produce, and it is covered by the display | |
| 221 | + // unit tests rather than pinned to a host here. | |
| 222 | + // | |
| 223 | + // The SHAPE of every rendered link is what this step pins; that the | |
| 224 | + // link resolves is asserted by following one in the next step, and | |
| 225 | + // the multi-org "lands in the sending workspace" round trip is | |
| 226 | + // cloud's `connect-link-multi-org.test.ts` (it needs a second org, | |
| 227 | + // which self-host's single turnkey org cannot provide). | |
| 228 | + const detail = page.getByRole("dialog"); | |
| 229 | + const origin = new URL(target.baseUrl).origin; | |
| 230 | + // The console's own org prefix, read from the URL the page settled | |
| 231 | + // on — "" when this host renders no segment. | |
| 232 | + const consolePath = new URL(page.url()).pathname; | |
| 233 | + const orgPrefix = consolePath.endsWith("/users") | |
| 234 | + ? consolePath.slice(0, -"/users".length) | |
| 235 | + : ""; | |
| 236 | + | |
| 237 | + // The integration nobody connected is the one whose link must be here. | |
| 238 | + await detail | |
| 239 | + .getByText(`${origin}${orgPrefix}/connect/${availableIntegration}`, { exact: true }) | |
| 240 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 241 | + | |
| 242 | + // And EVERY rendered link agrees with the console's own scope — one | |
| 243 | + // link pointing at another org would silently misroute a recipient. | |
| 244 | + const links = await detail | |
| 245 | + .locator("[data-slot='admin-user-connect-link']") | |
| 246 | + .allTextContents(); | |
| 247 | + expect(links.length, "the owner sees at least one link to send").toBeGreaterThanOrEqual( | |
| 248 | + 1, | |
| 249 | + ); | |
| 250 | + for (const link of links) { | |
| 251 | + const parsed = new URL(link); | |
| 252 | + expect(parsed.origin, `absolute for this deployment: ${link}`).toBe(origin); | |
| 253 | + expect( | |
| 254 | + parsed.pathname.startsWith(`${orgPrefix}/connect/`), | |
| 255 | + `scoped to the console's own org (${orgPrefix || "<none>"}): ${link}`, | |
| 256 | + ).toBe(true); | |
| 257 | + expect( | |
| 258 | + parsed.pathname.slice(`${orgPrefix}/connect/`.length).split("/").length, | |
| 259 | + `exactly one slug after /connect/: ${link}`, | |
| 260 | + ).toBe(1); | |
| 261 | + } | |
| 262 | + }); | |
| 263 | + | |
| 264 | + await step("The rendered connect link actually resolves", async () => { | |
| 265 | + // Following the link the page just rendered. A rendered string | |
| 266 | + // proves the page composed a URL; it cannot distinguish a working | |
| 267 | + // link from a 404 — which an operator would only discover after | |
| 268 | + // sending it to someone. | |
| 269 | + const origin = new URL(target.baseUrl).origin; | |
| 270 | + const consolePath = new URL(page.url()).pathname; | |
| 271 | + const orgPrefix = consolePath.endsWith("/users") | |
| 272 | + ? consolePath.slice(0, -"/users".length) | |
| 273 | + : ""; | |
| 274 | + await page.goto(`${origin}${orgPrefix}/connect/${availableIntegration}`, { | |
| 275 | + waitUntil: "domcontentloaded", | |
| 276 | + }); | |
| 277 | + // It forwards into that integration's own detail route with the | |
| 278 | + // add-account handoff, still inside the console's own org. | |
| 279 | + await page.waitForURL( | |
| 280 | + (url) => url.pathname === `${orgPrefix}/integrations/${availableIntegration}`, | |
| 281 | + { timeout: 30_000 }, | |
| 282 | + ); | |
| 283 | + expect( | |
| 284 | + new URL(page.url()).searchParams.get("addAccount"), | |
| 285 | + "the link lands in the connect flow, not just on the page", | |
| 286 | + ).toBe("1"); | |
| 287 | + }); | |
| 288 | + }); | |
| 289 | + | |
| 290 | + yield* browser.session(member, async ({ page, step }) => { | |
| 291 | + await step("A plain member is not offered the section", async () => { | |
| 292 | + await page.goto("/", { waitUntil: "networkidle" }); | |
| 293 | + await page | |
| 294 | + .locator("nav") | |
| 295 | + .getByRole("link", { name: "Integrations" }) | |
| 296 | + .first() | |
| 297 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 298 | + expect( | |
| 299 | + await page.locator("nav").getByRole("link", { name: "Users" }).count(), | |
| 300 | + "a plain member is not shown a section that would only refuse them", | |
| 301 | + ).toBe(0); | |
| 302 | + }); | |
| 303 | + | |
| 304 | + await step("And reaching the URL directly is refused, not silently empty", async () => { | |
| 305 | + await page.goto("/users", { waitUntil: "networkidle" }); | |
| 306 | + await page | |
| 307 | + .getByText("You don't have access to this workspace's users") | |
| 308 | + .waitFor({ state: "visible", timeout: 30_000 }); | |
| 309 | + expect( | |
| 310 | + await page.locator("[data-slot='admin-users-table']").count(), | |
| 311 | + "the refusal replaces the table rather than rendering it empty", | |
| 312 | + ).toBe(0); | |
| 313 | + }); | |
| 314 | + }); | |
| 315 | + }), | |
| 316 | + Effect.all( | |
| 317 | + [ | |
| 318 | + memberClient.connections | |
| 319 | + .remove({ params: { owner: "user", integration, name: memberConnection } }) | |
| 320 | + .pipe(Effect.ignore), | |
| 321 | + ownerClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore), | |
| 322 | + ownerClient.openapi | |
| 323 | + .removeSpec({ params: { slug: availableIntegration } }) | |
| 324 | + .pipe(Effect.ignore), | |
| 325 | + ], | |
| 326 | + { discard: true }, | |
| 327 | + ), | |
| 328 | + ); | |
| 329 | + }), | |
| 330 | +); | |
| @@ -0,0 +1,167 @@ | ||
| 1 | +// Selfhost-only: the admin Users PAGER, which no other scenario reaches. | |
| 2 | +// | |
| 3 | +// The page shows 25 users at a time. To know whether a "Next" exists without a | |
| 4 | +// count endpoint, the atom over-fetches by one (`limit: 25 + 1`) and `splitPage` | |
| 5 | +// drops the extra row to derive `hasNext`. That +1/-1 pair is an off-by-one | |
| 6 | +// waiting to happen in the most damaging place: the page BOUNDARY. Drop one row | |
| 7 | +// too many and user #25 is invisible on every page — present in the workspace, | |
| 8 | +// absent from the operator's view, with no error anywhere. Drop one too few and | |
| 9 | +// the same person appears twice. | |
| 10 | +// | |
| 11 | +// Both existing admin console scenarios seed two members, so the pager never | |
| 12 | +// renders at all and none of that logic is exercised. This one seeds past the | |
| 13 | +// page size and walks the boundary. | |
| 14 | +// | |
| 15 | +// Self-host rather than cloud because cloud's free plan caps a workspace at 3 | |
| 16 | +// member seats — 26 members cannot exist there. Self-host has one turnkey org, | |
| 17 | +// no seat gate, and an admin invite plane that mints codes freely. | |
| 18 | +// | |
| 19 | +// A user earns their row in the workspace the first time they touch the | |
| 20 | +// EXECUTOR plane (`touchSubject` at the request seam) — signing up alone is not | |
| 21 | +// enough, so each seeded identity makes one product read. | |
| 22 | +// | |
| 23 | +// The org is SHARED with every other selfhost scenario in the run, so this | |
| 24 | +// asserts "contains mine, split correctly across pages" and never a global | |
| 25 | +// count or a fixed row index. Rows are matched by the emails this scenario | |
| 26 | +// minted, under a per-run prefix. | |
| 27 | +import { randomBytes } from "node:crypto"; | |
| 28 | + | |
| 29 | +import { expect } from "@effect/vitest"; | |
| 30 | +import { Effect } from "effect"; | |
| 31 | +import type { Page } from "playwright"; | |
| 32 | + | |
| 33 | +import { scenario } from "../src/scenario"; | |
| 34 | +import { Browser, Target } from "../src/services"; | |
| 35 | +import { createInvitedIdentity } from "../targets/selfhost"; | |
| 36 | +import type { Identity } from "../src/target"; | |
| 37 | + | |
| 38 | +/** The page size the atom and the page component share | |
| 39 | + * (`ADMIN_USERS_PAGE_SIZE`). Restated rather than imported: this is the | |
| 40 | + * contract the UI promises an operator, and a scenario that imported it would | |
| 41 | + * silently follow the product if it changed. */ | |
| 42 | +const PAGE_SIZE = 25; | |
| 43 | + | |
| 44 | +/** Enough seeded users that a second page must exist even in a workspace that | |
| 45 | + * starts empty — one more than a full page. */ | |
| 46 | +const SEEDED_USERS = PAGE_SIZE + 1; | |
| 47 | + | |
| 48 | +/** The `[data-slot='admin-user-name']` values on the page right now. Rows are | |
| 49 | + * named by email on this host, which is what this scenario matches on. */ | |
| 50 | +const rowNames = (page: Page): Promise<Array<string>> => | |
| 51 | + page | |
| 52 | + .locator("[data-slot='admin-user-row'] [data-slot='admin-user-name']") | |
| 53 | + .evaluateAll((elements) => elements.map((element) => element.textContent ?? "")); | |
| 54 | + | |
| 55 | +scenario( | |
| 56 | + "Admin · the Users pager walks a workspace larger than one page without dropping or repeating anyone", | |
| 57 | + { timeout: 180_000 }, | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const target = yield* Target; | |
| 60 | + const browser = yield* Browser; | |
| 61 | + | |
| 62 | + const owner = yield* target.newIdentity(); | |
| 63 | + | |
| 64 | + // A per-run prefix so this scenario's rows are identifiable among the users | |
| 65 | + // other selfhost scenarios left in the shared org. | |
| 66 | + const prefix = `pager-${randomBytes(4).toString("hex")}`; | |
| 67 | + | |
| 68 | + // Seed past the page size. Each identity then makes ONE executor-plane read, | |
| 69 | + // which is what mints its `subject` row — without it the account exists in | |
| 70 | + // the auth store but never appears on this page. | |
| 71 | + const seeded = yield* Effect.forEach( | |
| 72 | + Array.from({ length: SEEDED_USERS }, (_, index) => index), | |
| 73 | + (index) => | |
| 74 | + Effect.promise(async (): Promise<Identity> => { | |
| 75 | + const identity = await createInvitedIdentity(target.baseUrl, owner, { | |
| 76 | + role: "member", | |
| 77 | + emailPrefix: `${prefix}-${String(index).padStart(2, "0")}`, | |
| 78 | + }); | |
| 79 | + const response = await fetch(new URL("/api/integrations", target.baseUrl), { | |
| 80 | + headers: { cookie: identity.headers?.cookie ?? "" }, | |
| 81 | + }); | |
| 82 | + if (!response.ok) { | |
| 83 | + throw new Error(`seeding read failed for ${identity.label} (${response.status})`); | |
| 84 | + } | |
| 85 | + return identity; | |
| 86 | + }), | |
| 87 | + // Bounded: one dev server, one database connection, and a password hash | |
| 88 | + // per signup. Unbounded here would starve the server rather than go | |
| 89 | + // faster. | |
| 90 | + { concurrency: 4 }, | |
| 91 | + ); | |
| 92 | + | |
| 93 | + const seededEmails = new Set(seeded.map((identity) => identity.credentials?.email ?? "")); | |
| 94 | + expect(seededEmails.size, "every seeded member has a distinct email").toBe(SEEDED_USERS); | |
| 95 | + | |
| 96 | + yield* browser.session(owner, async ({ page, step }) => { | |
| 97 | + let firstPage: ReadonlyArray<string> = []; | |
| 98 | + let secondPage: ReadonlyArray<string> = []; | |
| 99 | + | |
| 100 | + await step("Open the Users page", async () => { | |
| 101 | + await page.goto("/", { waitUntil: "domcontentloaded" }); | |
| 102 | + await page.getByRole("link", { name: "Users" }).click(); | |
| 103 | + await page.waitForURL((url) => url.pathname.endsWith("/users"), { timeout: 30_000 }); | |
| 104 | + await page.locator("[data-slot='admin-users-table']").waitFor({ timeout: 30_000 }); | |
| 105 | + }); | |
| 106 | + | |
| 107 | + await step("The first page is full and offers a next page", async () => { | |
| 108 | + await page.locator("[data-slot='admin-user-row']").first().waitFor({ timeout: 30_000 }); | |
| 109 | + firstPage = await rowNames(page); | |
| 110 | + // Exactly the page size — this is the `limit + 1` over-fetch being | |
| 111 | + // trimmed back. A page of 26 would mean `splitPage` failed to drop the | |
| 112 | + // probe row and the operator sees a row that belongs to page 2. | |
| 113 | + expect(firstPage.length, "a full page shows exactly the page size").toBe(PAGE_SIZE); | |
| 114 | + await page.getByText("Page 1", { exact: false }).waitFor({ timeout: 30_000 }); | |
| 115 | + // The over-fetch exists precisely to answer this without a count. | |
| 116 | + await expectEnabled(page, "Next", true); | |
| 117 | + await expectEnabled(page, "Previous", false); | |
| 118 | + }); | |
| 119 | + | |
| 120 | + await step("Next advances to a second page of different people", async () => { | |
| 121 | + await page.getByRole("button", { name: "Next" }).click(); | |
| 122 | + await page.getByText("Page 2", { exact: false }).waitFor({ timeout: 30_000 }); | |
| 123 | + await page.locator("[data-slot='admin-user-row']").first().waitFor({ timeout: 30_000 }); | |
| 124 | + secondPage = await rowNames(page); | |
| 125 | + expect(secondPage.length, "the second page has rows").toBeGreaterThan(0); | |
| 126 | + | |
| 127 | + // THE boundary invariant, and the whole reason this scenario exists: | |
| 128 | + // the +1 over-fetch must not leak a row onto both pages, and the -1 | |
| 129 | + // trim must not swallow one between them. | |
| 130 | + const overlap = secondPage.filter((name) => firstPage.includes(name)); | |
| 131 | + expect( | |
| 132 | + overlap, | |
| 133 | + "no one appears on both pages — the +1 probe row is not shown twice", | |
| 134 | + ).toEqual([]); | |
| 135 | + | |
| 136 | + // And nobody fell into the gap: every user this scenario seeded is on | |
| 137 | + // one of the two pages. Asserted over MY rows only, since the shared | |
| 138 | + // org also holds users from other scenarios. | |
| 139 | + const seen = new Set([...firstPage, ...secondPage]); | |
| 140 | + const missing = [...seededEmails].filter((email) => !seen.has(email)); | |
| 141 | + expect(missing, "every seeded member is on one of the two pages").toEqual([]); | |
| 142 | + | |
| 143 | + await expectEnabled(page, "Previous", true); | |
| 144 | + }); | |
| 145 | + | |
| 146 | + await step("Previous returns to the identical first page", async () => { | |
| 147 | + await page.getByRole("button", { name: "Previous" }).click(); | |
| 148 | + await page.getByText("Page 1", { exact: false }).waitFor({ timeout: 30_000 }); | |
| 149 | + await page.locator("[data-slot='admin-user-row']").first().waitFor({ timeout: 30_000 }); | |
| 150 | + // Same rows, same order: paging back is navigation, not a reshuffle. | |
| 151 | + // `listSubjects` orders by (created_at, external_id), a total order, so | |
| 152 | + // this is a guarantee rather than a coincidence. | |
| 153 | + expect(await rowNames(page), "going back shows the same first page").toEqual(firstPage); | |
| 154 | + await expectEnabled(page, "Previous", false); | |
| 155 | + }); | |
| 156 | + }); | |
| 157 | + }), | |
| 158 | +); | |
| 159 | + | |
| 160 | +/** Assert a pager button's enabled state — the affordance an operator reads to | |
| 161 | + * know whether a page exists in that direction. */ | |
| 162 | +const expectEnabled = async (page: Page, name: string, enabled: boolean): Promise<void> => { | |
| 163 | + expect( | |
| 164 | + await page.getByRole("button", { name }).isEnabled(), | |
| 165 | + `${name} is ${enabled ? "offered" : "not offered"}`, | |
| 166 | + ).toBe(enabled); | |
| 167 | +}; | |
| @@ -45,6 +45,7 @@ · 44 unmodified lines | ||
| 45 | 45 | "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:changelog-stubs", |
| 46 | 46 | "lint:changelog-stubs": "bun run scripts/check-changelog-stubs.ts", |
| 47 | 47 | "check:patches": "bun run scripts/check-patched-deps.ts", |
| 48 | + "check:routes": "bun run scripts/check-route-trees.ts", | |
| 48 | 49 | "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .", |
| 49 | 50 | "docs:snippets": "bun run scripts/generate-doc-snippets.ts", |
| 50 | 51 | "docs:smoke:install": "bun run scripts/smoke-docs-install.ts", |
| @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines | ||
| 17 | 17 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' |
| 18 | 18 | import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' |
| 19 | 19 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' |
| 20 | +import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' | |
| 20 | 21 | import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' |
| 21 | 22 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey' |
| 22 | 23 | |
| @@ -67,6 +68,12 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = · 44 unmodified lines | ||
| 67 | 68 | path: '/{-$orgSlug}/integrations/$namespace', |
| 68 | 69 | getParentRoute: () => rootRouteImport, |
| 69 | 70 | } as any) |
| 71 | +const DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute = | |
| 72 | + DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport.update({ | |
| 73 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 74 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 75 | + getParentRoute: () => rootRouteImport, | |
| 76 | + } as any) | |
| 70 | 77 | const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute = |
| 71 | 78 | DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({ |
| 72 | 79 | id: '/{-$orgSlug}/plugins/$pluginId/$', |
| @@ -88,6 +95,7 @@ export interface FileRoutesByFullPath { · 15 unmodified lines | ||
| 88 | 95 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren |
| 89 | 96 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 90 | 97 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 98 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | |
| 91 | 99 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 92 | 100 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 93 | 101 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -100,6 +108,7 @@ export interface FileRoutesByTo { · 6 unmodified lines | ||
| 100 | 108 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren |
| 101 | 109 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 102 | 110 | '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 111 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | |
| 103 | 112 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 104 | 113 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 105 | 114 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -113,6 +122,7 @@ export interface FileRoutesById { · 7 unmodified lines | ||
| 113 | 122 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren |
| 114 | 123 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 115 | 124 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 125 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | |
| 116 | 126 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 117 | 127 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 118 | 128 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -127,6 +137,7 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 127 | 137 | | '/{-$orgSlug}/toolkits' |
| 128 | 138 | | '/{-$orgSlug}/tools' |
| 129 | 139 | | '/{-$orgSlug}/' |
| 140 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 130 | 141 | | '/{-$orgSlug}/integrations/$namespace' |
| 131 | 142 | | '/{-$orgSlug}/resume/$executionId' |
| 132 | 143 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -139,6 +150,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 139 | 150 | | '/{-$orgSlug}/toolkits' |
| 140 | 151 | | '/{-$orgSlug}/tools' |
| 141 | 152 | | '/{-$orgSlug}' |
| 153 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 142 | 154 | | '/{-$orgSlug}/integrations/$namespace' |
| 143 | 155 | | '/{-$orgSlug}/resume/$executionId' |
| 144 | 156 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -151,6 +163,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 151 | 163 | | '/{-$orgSlug}/toolkits' |
| 152 | 164 | | '/{-$orgSlug}/tools' |
| 153 | 165 | | '/{-$orgSlug}/' |
| 166 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 154 | 167 | | '/{-$orgSlug}/integrations/$namespace' |
| 155 | 168 | | '/{-$orgSlug}/resume/$executionId' |
| 156 | 169 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -164,6 +177,7 @@ export interface RootRouteChildren { · 7 unmodified lines | ||
| 164 | 177 | DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren |
| 165 | 178 | DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 166 | 179 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 180 | + DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | |
| 167 | 181 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 168 | 182 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 169 | 183 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -228,6 +242,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines | ||
| 228 | 242 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport |
| 229 | 243 | 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 | 252 | '/{-$orgSlug}/plugins/$pluginId/$': { |
| 232 | 253 | id: '/{-$orgSlug}/plugins/$pluginId/$' |
| 233 | 254 | path: '/{-$orgSlug}/plugins/$pluginId/$' |
| @@ -270,6 +291,8 @@ const rootRouteChildren: RootRouteChildren = { · 36 unmodified lines | ||
| 270 | 291 | DotDotDotDotDotDotReactSrcRoutesToolsRoute, |
| 271 | 292 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: |
| 272 | 293 | DotDotDotDotDotDotReactSrcRoutesIndexRoute, |
| 294 | + DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: | |
| 295 | + DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute, | |
| 273 | 296 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 274 | 297 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 275 | 298 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: |
| @@ -7,13 +7,15 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; · 6 unmodified lines | ||
| 7 | 7 | // Shared console routes come from @executor-js/react (see its |
| 8 | 8 | // console-routes.ts); this app owns its root and the local-specific routes |
| 9 | 9 | // under src/routes/app. /secrets is excluded: the local app's variant shows |
| 10 | −// credential-provider info. App routes mount INSIDE the shared `{-$orgSlug}` | |
| 11 | −// scope (local never sets the param, so URLs stay bare) so the override masks | |
| 12 | −// the shared route id. | |
| 10 | +// credential-provider info. /users is excluded because local serves no account | |
| 11 | +// or admin API at all (see apps/local/src/app.ts) — there is no tenant to | |
| 12 | +// operate and no endpoint behind the page. App routes mount INSIDE the shared | |
| 13 | +// `{-$orgSlug}` scope (local never sets the param, so URLs stay bare) so the | |
| 14 | +// override masks the shared route id. | |
| 13 | 15 | export const routes = rootRoute("__root.tsx", [ |
| 14 | 16 | ...consoleRoutes({ |
| 15 | 17 | dir: "../../../react/src/routes", |
| 16 | − exclude: ["/secrets"], | |
| 18 | + exclude: ["/secrets", "/users"], | |
| 17 | 19 | orgScoped: [physical("", "app")], |
| 18 | 20 | }), |
| 19 | 21 | ]); |
| @@ -97,6 +97,18 @@ export const CreatedApiKeyResponse = Schema.Struct({ · 96 unmodified lines | ||
| 97 | 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 | + */ | |
| 108 | +export const OrgApiKeysResponse = Schema.Struct({ | |
| 109 | + apiKeys: Schema.Array(ApiKeySummary), | |
| 110 | +}); | |
| 111 | + | |
| 100 | 112 | export const OrgMember = Schema.Struct({ |
| 101 | 113 | id: Schema.String, |
| 102 | 114 | userId: Schema.String, |
| @@ -194,6 +206,31 @@ export const AccountApi = HttpApiGroup.make("account") · 91 unmodified lines | ||
| 194 | 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 | 234 | .add( |
| 198 | 235 | HttpApiEndpoint.get("listMembers", "/account/members", { |
| 199 | 236 | success: OrgMembersResponse, |
| @@ -44,6 +44,24 @@ export const AccountHandlers = HttpApiBuilder.group(AccountHttpApi, "account", ( · 43 unmodified lines | ||
| 44 | 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 | 65 | .handle("listMembers", () => |
| 48 | 66 | Effect.gen(function* () { |
| 49 | 67 | const headers = yield* requestHeaders; |
| @@ -57,7 +57,34 @@ describe("isValidOrgSlug", () => { · 56 unmodified lines | ||
| 57 | 57 | }); |
| 58 | 58 | |
| 59 | 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 | 88 | expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true); |
| 62 | 89 | } |
| 63 | 90 | }); |
| @@ -17,8 +17,9 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; · 16 unmodified lines | ||
| 17 | 17 | * predictably will): |
| 18 | 18 | * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost |
| 19 | 19 | * envelope, cloudflare `run_worker_first`) |
| 20 | − * - Console routes: integrations, policies, secrets, tools, resume, | |
| 21 | − * plugins (the shared contract), plus host extras: | |
| 20 | + * - Console routes: connect, integrations, policies, secrets, tools, users, | |
| 21 | + * toolkits, resume, plugins (the shared contract, i.e. | |
| 22 | + * every entry in `CONSOLE_ROUTE_PATHS`), plus host extras: | |
| 22 | 23 | * api-keys, org, billing, create-org, setup-mcp (cloud), |
| 23 | 24 | * admin, join, docs (selfhost), login (Better Auth |
| 24 | 25 | * `mcp({ loginPage })` + cloud/selfhost login UX) |
| @@ -41,10 +42,12 @@ export const RESERVED_ORG_SLUGS: ReadonlySet<string> = new Set([ · 16 unmodified lines | ||
| 41 | 42 | "mcp", |
| 42 | 43 | ".well-known", |
| 43 | 44 | // console + host routes |
| 45 | + "connect", | |
| 44 | 46 | "integrations", |
| 45 | 47 | "policies", |
| 46 | 48 | "secrets", |
| 47 | 49 | "tools", |
| 50 | + "toolkits", | |
| 48 | 51 | "resume", |
| 49 | 52 | "plugins", |
| 50 | 53 | "api-keys", |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type AccountUnauthorized, |
| 8 | 8 | AccountMeResponse, |
| 9 | 9 | ApiKeysResponse, |
| 10 | + OrgApiKeysResponse, | |
| 10 | 11 | CreatedApiKeyResponse, |
| 11 | 12 | OrgMembersResponse, |
| 12 | 13 | OrgRolesResponse, |
| @@ -33,6 +34,7 @@ export type AccountHeaders = Record<string, string>; · 20 unmodified lines | ||
| 33 | 34 | |
| 34 | 35 | type Me = typeof AccountMeResponse.Type; |
| 35 | 36 | type ApiKeys = typeof ApiKeysResponse.Type; |
| 37 | +type OrgApiKeys = typeof OrgApiKeysResponse.Type; | |
| 36 | 38 | type CreatedApiKey = typeof CreatedApiKeyResponse.Type; |
| 37 | 39 | type Members = typeof OrgMembersResponse.Type; |
| 38 | 40 | type Roles = typeof OrgRolesResponse.Type; |
| @@ -49,6 +51,16 @@ export interface AccountProviderShape { · 10 unmodified lines | ||
| 49 | 51 | readonly listApiKeys: (headers: AccountHeaders) => OrgScoped<ApiKeys>; |
| 50 | 52 | readonly createApiKey: (headers: AccountHeaders, name: string) => OrgScoped<CreatedApiKey>; |
| 51 | 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 | 64 | readonly listMembers: (headers: AccountHeaders) => OrgScoped<Members>; |
| 53 | 65 | readonly listRoles: (headers: AccountHeaders) => OrgScoped<Roles>; |
| 54 | 66 | readonly inviteMember: ( |
| @@ -0,0 +1,1070 @@ | ||
| 1 | +import { HttpApiBuilder } from "effect/unstable/httpapi"; | |
| 2 | +import { HttpRouter, HttpServer } from "effect/unstable/http"; | |
| 3 | +import { describe, expect, it } from "@effect/vitest"; | |
| 4 | +import { Context, Data, Effect, Layer, type Scope } from "effect"; | |
| 5 | + | |
| 6 | +import { Subject, Tenant, collectTables, createExecutor, type Executor } from "@executor-js/sdk"; | |
| 7 | +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; | |
| 8 | +import { resetSubjectTouchCache, touchSubject } from "@executor-js/sdk/host-internal"; | |
| 9 | + | |
| 10 | +import { AdminUsersHttpApi, AdminUsersForbidden, AdminUsersUnauthorized } from "./api"; | |
| 11 | +import { AdminUsersHandlers } from "./handlers"; | |
| 12 | +import { AdminUsersProvider, type AdminUsersHeaders } from "./service"; | |
| 13 | +import { | |
| 14 | + getUser, | |
| 15 | + listUserConnections, | |
| 16 | + listUsers, | |
| 17 | + listUsersWithConnections, | |
| 18 | + platformViewOf, | |
| 19 | + type AdminIdentityDirectory, | |
| 20 | + type AdminUserDirectory, | |
| 21 | +} from "./reads"; | |
| 22 | + | |
| 23 | +// --------------------------------------------------------------------------- | |
| 24 | +// The admin users HTTP surface, over a real SQLite-backed platform view. | |
| 25 | +// | |
| 26 | +// The reads themselves (tenant reach, the bigint `last_seen_at` round-trip, the | |
| 27 | +// field allowlist) are proven in the SDK's `platform-view.test.ts`. What this | |
| 28 | +// file proves is the HTTP EDGE on top of them: | |
| 29 | +// - the public vocabulary + wire shapes the contract promises, | |
| 30 | +// - the authorization outcomes (401 vs 403) a host's provider produces, | |
| 31 | +// - that a tenant's admin plane cannot see another tenant's rows, | |
| 32 | +// - that no credential-bearing field survives the projection. | |
| 33 | +// | |
| 34 | +// The provider seam is stubbed the way each host's real one behaves, so the | |
| 35 | +// authz assertions are about the CONTRACT (which status a refusal renders), | |
| 36 | +// not about WorkOS or Better Auth. | |
| 37 | +// --------------------------------------------------------------------------- | |
| 38 | + | |
| 39 | +const TENANT_A = "org_a"; | |
| 40 | +const TENANT_B = "org_b"; | |
| 41 | +const USER_A1 = "user_a1"; | |
| 42 | +const USER_A2 = "user_a2"; | |
| 43 | +const USER_B1 = "user_b1"; | |
| 44 | + | |
| 45 | +/** Every field that could resolve, or help resolve, a credential. NONE may | |
| 46 | + * appear anywhere in an admin HTTP response — enumerated so a future column | |
| 47 | + * addition has to be argued with this list. Mirrors the SDK's own list. */ | |
| 48 | +const FORBIDDEN_FIELDS = [ | |
| 49 | + "item_ids", | |
| 50 | + "itemIds", | |
| 51 | + "refresh_item_id", | |
| 52 | + "refreshItemId", | |
| 53 | + "provider", | |
| 54 | + "provider_state", | |
| 55 | + "providerState", | |
| 56 | + "oauth_token_url", | |
| 57 | + "oauthTokenUrl", | |
| 58 | + "template", | |
| 59 | + "client_secret", | |
| 60 | + "clientSecret", | |
| 61 | + "secret", | |
| 62 | + "token", | |
| 63 | + // The internal partition keys: the public vocabulary is users/owners, so | |
| 64 | + // neither the raw column name nor the tenant may leak onto the wire. | |
| 65 | + "subject", | |
| 66 | + "tenant", | |
| 67 | + // The upstream-derived halves of a stored health verdict. These are not | |
| 68 | + // CREDENTIALS — they are the provider's own response content: the account | |
| 69 | + // identity extracted from `identityField` (another user's email at the | |
| 70 | + // provider), a raw diagnostic that can quote an upstream error body, and a | |
| 71 | + // sample of the real response body. `lastHealth` is an allowed COLUMN, which | |
| 72 | + // is exactly why these need naming: the per-column allowlist passed while the | |
| 73 | + // whole nested object rode along inside it. `AdminConnectionHealth` narrows | |
| 74 | + // it to status + checkedAt; this list is what keeps it narrow. | |
| 75 | + "identity", | |
| 76 | + "detail", | |
| 77 | + "responseSample", | |
| 78 | + "httpStatus", | |
| 79 | +] as const; | |
| 80 | + | |
| 81 | +// The bodies below acquire a scoped web handler, so `R` carries `Scope` rather | |
| 82 | +// than being erased; `Effect.scoped` closes it when the body finishes. | |
| 83 | +const withDb = <A, E>( | |
| 84 | + body: (db: SqliteTestFumaDb) => Effect.Effect<A, E, Scope.Scope>, | |
| 85 | +): Effect.Effect<A, E> => | |
| 86 | + Effect.scoped( | |
| 87 | + Effect.acquireUseRelease( | |
| 88 | + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), | |
| 89 | + body, | |
| 90 | + (db) => Effect.promise(() => db.close()), | |
| 91 | + ), | |
| 92 | + ); | |
| 93 | + | |
| 94 | +const insertConnection = ( | |
| 95 | + db: SqliteTestFumaDb, | |
| 96 | + row: { | |
| 97 | + readonly rowId: string; | |
| 98 | + readonly tenant: string; | |
| 99 | + readonly owner: string; | |
| 100 | + readonly subject: string; | |
| 101 | + readonly integration: string; | |
| 102 | + readonly name: string; | |
| 103 | + readonly oauthScope?: string | null; | |
| 104 | + }, | |
| 105 | +): Effect.Effect<void> => | |
| 106 | + Effect.promise(async () => { | |
| 107 | + await db.client.execute({ | |
| 108 | + sql: `INSERT INTO connection ( | |
| 109 | + row_id, tenant, owner, subject, integration, name, template, provider, | |
| 110 | + item_ids, refresh_item_id, oauth_scope, last_health, created_at, updated_at | |
| 111 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 112 | + args: [ | |
| 113 | + row.rowId, | |
| 114 | + row.tenant, | |
| 115 | + row.owner, | |
| 116 | + row.subject, | |
| 117 | + row.integration, | |
| 118 | + row.name, | |
| 119 | + "oauth2", | |
| 120 | + "memory", | |
| 121 | + // Deliberately secret-bearing: the assertions below prove these never | |
| 122 | + // reach an HTTP response. | |
| 123 | + JSON.stringify({ token: `SECRET-item-${row.rowId}` }), | |
| 124 | + `SECRET-refresh-${row.rowId}`, | |
| 125 | + row.oauthScope ?? null, | |
| 126 | + // A REAL stored health verdict, including the parts that come from the | |
| 127 | + // upstream provider's own response. `identity` is the value pulled from | |
| 128 | + // the configured `identityField` (typically the account's email AT the | |
| 129 | + // provider), `detail` is a raw diagnostic that can quote an upstream | |
| 130 | + // body, and `responseSample` is a bounded sample of the actual response | |
| 131 | + // body's scalar leaves. All three are legitimate on the product plane, | |
| 132 | + // where the caller owns the credential — and none may cross onto the | |
| 133 | + // ADMIN plane, which reports on other people's connections. Seeded here | |
| 134 | + // so the assertions below have ground truth to catch rather than | |
| 135 | + // asserting the absence of something that was never present. | |
| 136 | + JSON.stringify({ | |
| 137 | + status: "healthy", | |
| 138 | + checkedAt: 1_700_000_000_000, | |
| 139 | + httpStatus: 200, | |
| 140 | + identity: `UPSTREAM-identity-${row.rowId}@provider.test`, | |
| 141 | + detail: `UPSTREAM-detail-${row.rowId}`, | |
| 142 | + responseSample: [ | |
| 143 | + { path: "user.email", value: `UPSTREAM-sample-${row.rowId}@provider.test` }, | |
| 144 | + { path: "user.login", value: `UPSTREAM-login-${row.rowId}` }, | |
| 145 | + ], | |
| 146 | + }), | |
| 147 | + Date.now(), | |
| 148 | + Date.now(), | |
| 149 | + ], | |
| 150 | + }); | |
| 151 | + }); | |
| 152 | + | |
| 153 | +/** Two users with connections under tenant A, plus a whole separate tenant B | |
| 154 | + * that A's admin plane must never see. */ | |
| 155 | +const seed = (db: SqliteTestFumaDb): Effect.Effect<void> => | |
| 156 | + Effect.gen(function* () { | |
| 157 | + // `touchSubject` keeps a process-local memory of principals it already | |
| 158 | + // filed, so a second seed against a FRESH database would be skipped as a | |
| 159 | + // repeat sighting and leave the table empty. Each seed is a new world. | |
| 160 | + resetSubjectTouchCache(); | |
| 161 | + | |
| 162 | + yield* touchSubject(db.db, { tenant: TENANT_A, externalId: USER_A1 }); | |
| 163 | + yield* touchSubject(db.db, { tenant: TENANT_A, externalId: USER_A2 }); | |
| 164 | + yield* touchSubject(db.db, { tenant: TENANT_B, externalId: USER_B1 }); | |
| 165 | + | |
| 166 | + yield* insertConnection(db, { | |
| 167 | + rowId: "c-a1-gh", | |
| 168 | + tenant: TENANT_A, | |
| 169 | + owner: "user", | |
| 170 | + subject: USER_A1, | |
| 171 | + integration: "github", | |
| 172 | + name: "personal", | |
| 173 | + oauthScope: "repo read:user", | |
| 174 | + }); | |
| 175 | + yield* insertConnection(db, { | |
| 176 | + rowId: "c-a2-linear", | |
| 177 | + tenant: TENANT_A, | |
| 178 | + owner: "user", | |
| 179 | + subject: USER_A2, | |
| 180 | + integration: "linear", | |
| 181 | + name: "work", | |
| 182 | + }); | |
| 183 | + yield* insertConnection(db, { | |
| 184 | + rowId: "c-b1-gh", | |
| 185 | + tenant: TENANT_B, | |
| 186 | + owner: "user", | |
| 187 | + subject: USER_B1, | |
| 188 | + integration: "github", | |
| 189 | + name: "b-secret", | |
| 190 | + oauthScope: "admin:org", | |
| 191 | + }); | |
| 192 | + }); | |
| 193 | + | |
| 194 | +const platformExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effect<Executor> => | |
| 195 | + createExecutor({ | |
| 196 | + tenant: Tenant.make(tenant), | |
| 197 | + db: db.db, | |
| 198 | + onElicitation: "accept-all", | |
| 199 | + platformView: true, | |
| 200 | + }).pipe(Effect.orDie); | |
| 201 | + | |
| 202 | +/** A product-view executor: bound to one subject, no platform view at all. */ | |
| 203 | +const productExecutorFor = (db: SqliteTestFumaDb, tenant: string): Effect.Effect<Executor> => | |
| 204 | + createExecutor({ | |
| 205 | + tenant: Tenant.make(tenant), | |
| 206 | + subject: Subject.make(USER_A1), | |
| 207 | + db: db.db, | |
| 208 | + onElicitation: "accept-all", | |
| 209 | + }).pipe(Effect.orDie); | |
| 210 | + | |
| 211 | +/** | |
| 212 | + * A stub `AdminUsersProvider` shaped like a host's real one: it authorizes off | |
| 213 | + * the request headers, then delegates to the shared reads. `authorize` returns | |
| 214 | + * the tenant an authorized caller may read, or a refusal — exactly the decision | |
| 215 | + * cloud makes from an org key / admin session and self-host from a Better Auth | |
| 216 | + * member. | |
| 217 | + */ | |
| 218 | +const stubProvider = ( | |
| 219 | + executorFor: (tenant: string) => Effect.Effect<Executor>, | |
| 220 | + authorize: ( | |
| 221 | + headers: AdminUsersHeaders, | |
| 222 | + ) => Effect.Effect<string, AdminUsersUnauthorized | AdminUsersForbidden>, | |
| 223 | + directory?: AdminIdentityDirectory | AdminUserDirectory, | |
| 224 | +) => | |
| 225 | + Layer.succeed(AdminUsersProvider)({ | |
| 226 | + listUsers: (headers, options) => | |
| 227 | + authorize(headers).pipe( | |
| 228 | + Effect.flatMap(executorFor), | |
| 229 | + Effect.flatMap((executor) => | |
| 230 | + platformViewOf(executor).pipe( | |
| 231 | + Effect.flatMap((admin) => listUsers(admin, options, directory)), | |
| 232 | + ), | |
| 233 | + ), | |
| 234 | + ), | |
| 235 | + listUsersWithConnections: (headers, options) => | |
| 236 | + authorize(headers).pipe( | |
| 237 | + Effect.flatMap(executorFor), | |
| 238 | + Effect.flatMap((executor) => | |
| 239 | + platformViewOf(executor).pipe( | |
| 240 | + Effect.flatMap((admin) => listUsersWithConnections(admin, options, directory)), | |
| 241 | + ), | |
| 242 | + ), | |
| 243 | + ), | |
| 244 | + listUserConnections: (headers, externalId) => | |
| 245 | + authorize(headers).pipe( | |
| 246 | + Effect.flatMap(executorFor), | |
| 247 | + Effect.flatMap((executor) => | |
| 248 | + platformViewOf(executor).pipe( | |
| 249 | + Effect.flatMap((admin) => listUserConnections(admin, externalId)), | |
| 250 | + ), | |
| 251 | + ), | |
| 252 | + ), | |
| 253 | + getUser: (headers, identifier) => | |
| 254 | + authorize(headers).pipe( | |
| 255 | + Effect.flatMap(executorFor), | |
| 256 | + Effect.flatMap((executor) => | |
| 257 | + platformViewOf(executor).pipe( | |
| 258 | + Effect.flatMap((admin) => getUser(admin, identifier, directory)), | |
| 259 | + ), | |
| 260 | + ), | |
| 261 | + ), | |
| 262 | + }); | |
| 263 | + | |
| 264 | +/** | |
| 265 | + * The authorization the hosts implement, reduced to its decision table: | |
| 266 | + * an org-scoped key (or admin session) names its tenant; a plain member or a | |
| 267 | + * user-scoped key is forbidden; no credential at all is unauthorized. | |
| 268 | + */ | |
| 269 | +const headerAuthorize = (headers: AdminUsersHeaders) => { | |
| 270 | + const credential = headers["authorization"]; | |
| 271 | + if (!credential) return Effect.fail(new AdminUsersUnauthorized()); | |
| 272 | + if (credential === "Bearer org_a_key") return Effect.succeed(TENANT_A); | |
| 273 | + if (credential === "Bearer org_b_key") return Effect.succeed(TENANT_B); | |
| 274 | + // A valid user-scoped key / plain member session: authentic, but this plane | |
| 275 | + // serves the whole tenant and they name one member. | |
| 276 | + return Effect.fail(new AdminUsersForbidden()); | |
| 277 | +}; | |
| 278 | + | |
| 279 | +// `HttpRouter.provideRequest` (not a plain `Layer.provide`) is what clears the | |
| 280 | +// handlers' per-request `AdminUsersProvider` marker — the same reason the real | |
| 281 | +// hosts mount the provider through a router middleware. | |
| 282 | +const webHandlerFor = (provider: Layer.Layer<AdminUsersProvider>) => | |
| 283 | + Effect.acquireRelease( | |
| 284 | + Effect.sync(() => | |
| 285 | + HttpRouter.toWebHandler( | |
| 286 | + HttpApiBuilder.layer(AdminUsersHttpApi).pipe( | |
| 287 | + Layer.provide(AdminUsersHandlers), | |
| 288 | + HttpRouter.provideRequest(provider), | |
| 289 | + Layer.provideMerge(HttpServer.layerServices), | |
| 290 | + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), | |
| 291 | + ), | |
| 292 | + { disableLogger: true }, | |
| 293 | + ), | |
| 294 | + ), | |
| 295 | + (web) => Effect.promise(() => web.dispose()), | |
| 296 | + ); | |
| 297 | + | |
| 298 | +const get = ( | |
| 299 | + web: { | |
| 300 | + readonly handler: (request: Request, context: Context.Context<never>) => Promise<Response>; | |
| 301 | + }, | |
| 302 | + path: string, | |
| 303 | + credential?: string, | |
| 304 | +) => | |
| 305 | + Effect.promise(() => | |
| 306 | + web.handler( | |
| 307 | + new Request(`http://localhost${path}`, { | |
| 308 | + headers: credential ? { authorization: credential } : {}, | |
| 309 | + }), | |
| 310 | + Context.empty(), | |
| 311 | + ), | |
| 312 | + ); | |
| 313 | + | |
| 314 | +const jsonOf = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>); | |
| 315 | + | |
| 316 | +type UsersBody = { | |
| 317 | + readonly users: ReadonlyArray<{ | |
| 318 | + readonly externalId: string; | |
| 319 | + readonly email: string | null; | |
| 320 | + readonly displayName: string | null; | |
| 321 | + }>; | |
| 322 | +}; | |
| 323 | +type ConnectionsBody = { | |
| 324 | + readonly connections: ReadonlyArray<{ readonly integration: string; readonly name: string }>; | |
| 325 | +}; | |
| 326 | +type UserBody = { | |
| 327 | + readonly user: { | |
| 328 | + readonly externalId: string; | |
| 329 | + readonly email: string | null; | |
| 330 | + readonly displayName: string | null; | |
| 331 | + readonly lastSeenAt: number | null; | |
| 332 | + readonly status: string | null; | |
| 333 | + readonly connections: ReadonlyArray<{ | |
| 334 | + readonly integration: string; | |
| 335 | + readonly oauthScope: string | null; | |
| 336 | + readonly lastHealth: { readonly status: string } | null; | |
| 337 | + }>; | |
| 338 | + }; | |
| 339 | +}; | |
| 340 | +type UsersWithConnectionsBody = { | |
| 341 | + readonly users: ReadonlyArray<{ | |
| 342 | + readonly externalId: string; | |
| 343 | + readonly email: string | null; | |
| 344 | + readonly displayName: string | null; | |
| 345 | + readonly lastSeenAt: number | null; | |
| 346 | + readonly status: string | null; | |
| 347 | + readonly connections: ReadonlyArray<{ | |
| 348 | + readonly integration: string; | |
| 349 | + readonly oauthScope: string | null; | |
| 350 | + readonly lastHealth: { readonly status: string } | null; | |
| 351 | + }>; | |
| 352 | + }>; | |
| 353 | +}; | |
| 354 | + | |
| 355 | +const ORG_A = "Bearer org_a_key"; | |
| 356 | + | |
| 357 | +/** | |
| 358 | + * A host member directory that knows USER_A1 only. | |
| 359 | + * | |
| 360 | + * USER_A2 is deliberately absent — that is the real case of a member who left | |
| 361 | + * the org while their connections remain, and the one an id-space mismatch would | |
| 362 | + * also look like. `seen` records the ids the directory was asked for, so a test | |
| 363 | + * can prove the join is ONE batched read of the returned page rather than a | |
| 364 | + * lookup per user. | |
| 365 | + */ | |
| 366 | +const stubDirectory = | |
| 367 | + (seen: string[][]): AdminIdentityDirectory => | |
| 368 | + (externalIds) => { | |
| 369 | + seen.push([...externalIds]); | |
| 370 | + return Effect.succeed(new Map([[USER_A1, { email: "a1@users.test", displayName: "User A1" }]])); | |
| 371 | + }; | |
| 372 | + | |
| 373 | +/** The email the stub directory knows USER_A1 by. Stored MIXED-CASE on | |
| 374 | + * purpose: WorkOS preserves whatever casing a user was created with, so the | |
| 375 | + * case-insensitivity rule has to hold against a non-normalized directory | |
| 376 | + * value, not just a non-normalized query. */ | |
| 377 | +const A1_EMAIL_STORED = "A1@Users.Test"; | |
| 378 | +const A1_EMAIL = "a1@users.test"; | |
| 379 | + | |
| 380 | +/** | |
| 381 | + * The two-way directory: the same USER_A1-only knowledge as `stubDirectory`, | |
| 382 | + * plus the reverse resolver. | |
| 383 | + * | |
| 384 | + * USER_A2 stays absent in BOTH directions, so it doubles as the "member the | |
| 385 | + * directory cannot name" case. `resolved` records every email the resolver was | |
| 386 | + * asked for, so a test can prove the lookup normalizes before comparing. | |
| 387 | + */ | |
| 388 | +const stubUserDirectory = (options: { | |
| 389 | + readonly seen?: string[][]; | |
| 390 | + readonly resolved?: string[]; | |
| 391 | +}): AdminUserDirectory => ({ | |
| 392 | + identities: (externalIds) => { | |
| 393 | + options.seen?.push([...externalIds]); | |
| 394 | + return Effect.succeed(new Map([[USER_A1, { email: A1_EMAIL_STORED, displayName: "User A1" }]])); | |
| 395 | + }, | |
| 396 | + resolveEmail: (email) => { | |
| 397 | + options.resolved?.push(email); | |
| 398 | + // Compares a NORMALIZED stored value, the rule both real hosts follow. | |
| 399 | + return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null); | |
| 400 | + }, | |
| 401 | +}); | |
| 402 | + | |
| 403 | +/** The failure a host's directory raises — WorkOS or Better Auth being | |
| 404 | + * unreachable. Typed rather than a bare `Error`, since the directory seam's | |
| 405 | + * `unknown` error channel exists to carry each host's OWN error type. */ | |
| 406 | +class DirectoryUnavailable extends Data.TaggedError("DirectoryUnavailable")<{ | |
| 407 | + readonly message: string; | |
| 408 | +}> {} | |
| 409 | + | |
| 410 | +/** A directory whose read fails, standing in for a WorkOS/Better Auth outage. */ | |
| 411 | +const failingDirectory: AdminIdentityDirectory = () => | |
| 412 | + Effect.fail(new DirectoryUnavailable({ message: "member directory unavailable" })); | |
| 413 | + | |
| 414 | +describe("admin users API", () => { | |
| 415 | + it.effect("lists every user of the tenant for an authorized org caller", () => | |
| 416 | + withDb((db) => | |
| 417 | + Effect.gen(function* () { | |
| 418 | + yield* seed(db); | |
| 419 | + const web = yield* webHandlerFor( | |
| 420 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 421 | + ); | |
| 422 | + | |
| 423 | + const response = yield* get(web, "/admin/users", ORG_A); | |
| 424 | + expect(response.status).toBe(200); | |
| 425 | + const body = yield* jsonOf<UsersBody>(response); | |
| 426 | + | |
| 427 | + expect( | |
| 428 | + body.users.map((user) => user.externalId), | |
| 429 | + "both of the tenant's users are reported", | |
| 430 | + ).toEqual([USER_A1, USER_A2]); | |
| 431 | + }), | |
| 432 | + ), | |
| 433 | + ); | |
| 434 | + | |
| 435 | + it.effect("reports a user's connections with access summary but no credential", () => | |
| 436 | + withDb((db) => | |
| 437 | + Effect.gen(function* () { | |
| 438 | + yield* seed(db); | |
| 439 | + const web = yield* webHandlerFor( | |
| 440 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 441 | + ); | |
| 442 | + | |
| 443 | + const response = yield* get(web, `/admin/users/${USER_A1}/connections`, ORG_A); | |
| 444 | + expect(response.status).toBe(200); | |
| 445 | + const body = yield* jsonOf<ConnectionsBody>(response); | |
| 446 | + | |
| 447 | + expect( | |
| 448 | + body.connections.map((connection) => connection.integration), | |
| 449 | + "the user's own connection is reported", | |
| 450 | + ).toEqual(["github"]); | |
| 451 | + expect(body.connections[0]?.name).toBe("personal"); | |
| 452 | + }), | |
| 453 | + ), | |
| 454 | + ); | |
| 455 | + | |
| 456 | + it.effect("joins users with their connections for the dashboard view", () => | |
| 457 | + withDb((db) => | |
| 458 | + Effect.gen(function* () { | |
| 459 | + yield* seed(db); | |
| 460 | + const web = yield* webHandlerFor( | |
| 461 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 462 | + ); | |
| 463 | + | |
| 464 | + const response = yield* get(web, "/admin/users/with-connections", ORG_A); | |
| 465 | + expect(response.status).toBe(200); | |
| 466 | + const body = yield* jsonOf<UsersWithConnectionsBody>(response); | |
| 467 | + | |
| 468 | + expect( | |
| 469 | + body.users.map((user) => [user.externalId, user.connections.map((c) => c.integration)]), | |
| 470 | + "each user carries their own connections", | |
| 471 | + ).toEqual([ | |
| 472 | + [USER_A1, ["github"]], | |
| 473 | + [USER_A2, ["linear"]], | |
| 474 | + ]); | |
| 475 | + // The access summary and health travel; `lastSeenAt` is a number the | |
| 476 | + // dashboard can render (a raw SQL read would hand back a blob). | |
| 477 | + expect(body.users[0]?.connections[0]?.oauthScope).toBe("repo read:user"); | |
| 478 | + expect(body.users[0]?.connections[0]?.lastHealth?.status).toBe("healthy"); | |
| 479 | + expect(typeof body.users[0]?.lastSeenAt).toBe("number"); | |
| 480 | + expect(body.users[0]?.status, "no lifecycle state is recorded yet").toBeNull(); | |
| 481 | + }), | |
| 482 | + ), | |
| 483 | + ); | |
| 484 | + | |
| 485 | + it.effect("never serves credential material on any admin response", () => | |
| 486 | + withDb((db) => | |
| 487 | + Effect.gen(function* () { | |
| 488 | + yield* seed(db); | |
| 489 | + const web = yield* webHandlerFor( | |
| 490 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 491 | + ); | |
| 492 | + | |
| 493 | + for (const path of [ | |
| 494 | + "/admin/users", | |
| 495 | + "/admin/users/with-connections", | |
| 496 | + `/admin/users/${USER_A1}/connections`, | |
| 497 | + `/admin/users/${USER_A1}`, | |
| 498 | + ]) { | |
| 499 | + const response = yield* get(web, path, ORG_A); | |
| 500 | + const raw = yield* Effect.promise(() => response.text()); | |
| 501 | + | |
| 502 | + for (const field of FORBIDDEN_FIELDS) { | |
| 503 | + expect(raw, `${path} must not expose "${field}"`).not.toContain(`"${field}"`); | |
| 504 | + } | |
| 505 | + // The seeded secrets are the ground truth: nothing derived from a | |
| 506 | + // credential may appear, whatever it is called. | |
| 507 | + expect(raw, `${path} must not leak a stored secret`).not.toContain("SECRET-"); | |
| 508 | + // And the same test for upstream ACCOUNT data, which is a different | |
| 509 | + // kind of leak from a credential and was reaching the wire inside | |
| 510 | + // `lastHealth`. Matching on the seeded marker rather than on field | |
| 511 | + // names catches a rename, a re-nesting, or a stringified blob. | |
| 512 | + expect(raw, `${path} must not leak upstream account data`).not.toContain("UPSTREAM-"); | |
| 513 | + } | |
| 514 | + }), | |
| 515 | + ), | |
| 516 | + ); | |
| 517 | + | |
| 518 | + it.effect("isolates tenants: an org caller sees nothing of another tenant", () => | |
| 519 | + withDb((db) => | |
| 520 | + Effect.gen(function* () { | |
| 521 | + yield* seed(db); | |
| 522 | + const web = yield* webHandlerFor( | |
| 523 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 524 | + ); | |
| 525 | + | |
| 526 | + const users = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users", ORG_A)); | |
| 527 | + expect( | |
| 528 | + users.users.map((user) => user.externalId), | |
| 529 | + "tenant B's user is invisible to tenant A", | |
| 530 | + ).not.toContain(USER_B1); | |
| 531 | + | |
| 532 | + // Naming another tenant's user directly returns nothing rather than | |
| 533 | + // that user's connections — the tenant clause is never relaxed. | |
| 534 | + const crossTenant = yield* jsonOf<ConnectionsBody>( | |
| 535 | + yield* get(web, `/admin/users/${USER_B1}/connections`, ORG_A), | |
| 536 | + ); | |
| 537 | + expect(crossTenant.connections, "tenant B's connections stay in tenant B").toEqual([]); | |
| 538 | + | |
| 539 | + // And tenant B's own caller does see them — proving the emptiness above | |
| 540 | + // is isolation, not a broken fixture. | |
| 541 | + const ownView = yield* jsonOf<ConnectionsBody>( | |
| 542 | + yield* get(web, `/admin/users/${USER_B1}/connections`, "Bearer org_b_key"), | |
| 543 | + ); | |
| 544 | + expect(ownView.connections.map((c) => c.name)).toEqual(["b-secret"]); | |
| 545 | + }), | |
| 546 | + ), | |
| 547 | + ); | |
| 548 | + | |
| 549 | + it.effect("refuses a caller with no credential and a caller who is only a member", () => | |
| 550 | + withDb((db) => | |
| 551 | + Effect.gen(function* () { | |
| 552 | + yield* seed(db); | |
| 553 | + const web = yield* webHandlerFor( | |
| 554 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 555 | + ); | |
| 556 | + | |
| 557 | + const anonymous = yield* get(web, "/admin/users"); | |
| 558 | + expect(anonymous.status, "no credential → unauthorized").toBe(401); | |
| 559 | + | |
| 560 | + // A user-scoped API key or plain member session: authentic, but this | |
| 561 | + // plane serves the whole tenant. | |
| 562 | + const member = yield* get(web, "/admin/users", "Bearer user_scoped_key"); | |
| 563 | + expect(member.status, "a non-admin caller → forbidden").toBe(403); | |
| 564 | + | |
| 565 | + const memberJoined = yield* get(web, "/admin/users/with-connections", "Bearer user_key"); | |
| 566 | + expect(memberJoined.status).toBe(403); | |
| 567 | + const memberConnections = yield* get( | |
| 568 | + web, | |
| 569 | + `/admin/users/${USER_A1}/connections`, | |
| 570 | + "Bearer user_key", | |
| 571 | + ); | |
| 572 | + expect(memberConnections.status).toBe(403); | |
| 573 | + | |
| 574 | + // The single-user read refuses on the same terms, and crucially it | |
| 575 | + // refuses BEFORE looking: an unauthorized caller must not be able to | |
| 576 | + // tell a real user from an absent one by the status they get back. | |
| 577 | + expect((yield* get(web, `/admin/users/${USER_A1}`)).status).toBe(401); | |
| 578 | + expect((yield* get(web, `/admin/users/${USER_A1}`, "Bearer user_key")).status).toBe(403); | |
| 579 | + expect((yield* get(web, "/admin/users/nobody", "Bearer user_key")).status).toBe(403); | |
| 580 | + }), | |
| 581 | + ), | |
| 582 | + ); | |
| 583 | + | |
| 584 | + // The response schema strips unknown fields on encode, so the HTTP assertions | |
| 585 | + // above would still pass if the projection leaked one. Assert the projection | |
| 586 | + // directly too: it is the layer that is supposed to be an allowlist, and both | |
| 587 | + // layers should hold on their own. | |
| 588 | + it.effect("projects only allowlisted fields, before the schema ever encodes", () => | |
| 589 | + withDb((db) => | |
| 590 | + Effect.gen(function* () { | |
| 591 | + yield* seed(db); | |
| 592 | + const executor = yield* platformExecutorFor(db, TENANT_A); | |
| 593 | + const admin = yield* platformViewOf(executor); | |
| 594 | + | |
| 595 | + const { connections } = yield* listUserConnections(admin, USER_A1); | |
| 596 | + expect( | |
| 597 | + Object.keys(connections[0] ?? {}).sort(), | |
| 598 | + "the connection projection is exactly the allowlist", | |
| 599 | + ).toEqual(["integration", "lastHealth", "name", "oauthScope", "owner"]); | |
| 600 | + | |
| 601 | + // `lastHealth` is an allowed column holding a nested object, so the | |
| 602 | + // allowlist has to reach INSIDE it — a whole-object forward is what let | |
| 603 | + // the provider's `identity` / `detail` / `responseSample` onto the | |
| 604 | + // wire. The stored verdict here carries all of them (see the seed), so | |
| 605 | + // this is a projection assertion, not a vacuous one. | |
| 606 | + expect( | |
| 607 | + Object.keys(connections[0]?.lastHealth ?? {}).sort(), | |
| 608 | + "the health projection is exactly status + checkedAt", | |
| 609 | + ).toEqual(["checkedAt", "status"]); | |
| 610 | + expect(connections[0]?.lastHealth?.status, "and the verdict itself survives").toBe( | |
| 611 | + "healthy", | |
| 612 | + ); | |
| 613 | + | |
| 614 | + const { users } = yield* listUsers(admin, {}); | |
| 615 | + expect( | |
| 616 | + Object.keys(users[0] ?? {}).sort(), | |
| 617 | + "the user projection is exactly the allowlist", | |
| 618 | + ).toEqual(["createdAt", "displayName", "email", "externalId", "lastSeenAt", "status"]); | |
| 619 | + }), | |
| 620 | + ), | |
| 621 | + ); | |
| 622 | + | |
| 623 | + it.effect("fails loudly when wired to a product-view executor", () => | |
| 624 | + withDb((db) => | |
| 625 | + Effect.gen(function* () { | |
| 626 | + yield* seed(db); | |
| 627 | + // A host that forgot `platformView: true` must not silently report an | |
| 628 | + // empty tenant — that would read as "this owner has no users". | |
| 629 | + const web = yield* webHandlerFor( | |
| 630 | + stubProvider((tenant) => productExecutorFor(db, tenant), headerAuthorize), | |
| 631 | + ); | |
| 632 | + | |
| 633 | + const response = yield* get(web, "/admin/users", ORG_A); | |
| 634 | + expect(response.status, "a missing platform view is a server error").toBe(500); | |
| 635 | + }), | |
| 636 | + ), | |
| 637 | + ); | |
| 638 | + | |
| 639 | + // ── Host identity join ──────────────────────────────────────────────────── | |
| 640 | + | |
| 641 | + it.effect("joins the host's email and name onto users the directory resolves", () => | |
| 642 | + withDb((db) => | |
| 643 | + Effect.gen(function* () { | |
| 644 | + yield* seed(db); | |
| 645 | + const seen: string[][] = []; | |
| 646 | + const web = yield* webHandlerFor( | |
| 647 | + stubProvider( | |
| 648 | + (tenant) => platformExecutorFor(db, tenant), | |
| 649 | + headerAuthorize, | |
| 650 | + stubDirectory(seen), | |
| 651 | + ), | |
| 652 | + ); | |
| 653 | + | |
| 654 | + const body = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users", ORG_A)); | |
| 655 | + expect( | |
| 656 | + body.users.map((user) => [user.externalId, user.email, user.displayName]), | |
| 657 | + "the resolvable member is named; the unresolvable one reports absent identity", | |
| 658 | + ).toEqual([ | |
| 659 | + [USER_A1, "a1@users.test", "User A1"], | |
| 660 | + [USER_A2, null, null], | |
| 661 | + ]); | |
| 662 | + | |
| 663 | + // The join is a BATCH: one directory read for the whole page, carrying | |
| 664 | + // every id at once — never one read per user. | |
| 665 | + expect(seen, "one directory read, for exactly the page's ids").toEqual([ | |
| 666 | + [USER_A1, USER_A2], | |
| 667 | + ]); | |
| 668 | + }), | |
| 669 | + ), | |
| 670 | + ); | |
| 671 | + | |
| 672 | + it.effect("joins identity onto the connections view too, over the same one read", () => | |
| 673 | + withDb((db) => | |
| 674 | + Effect.gen(function* () { | |
| 675 | + yield* seed(db); | |
| 676 | + const seen: string[][] = []; | |
| 677 | + const web = yield* webHandlerFor( | |
| 678 | + stubProvider( | |
| 679 | + (tenant) => platformExecutorFor(db, tenant), | |
| 680 | + headerAuthorize, | |
| 681 | + stubDirectory(seen), | |
| 682 | + ), | |
| 683 | + ); | |
| 684 | + | |
| 685 | + const body = yield* jsonOf<UsersWithConnectionsBody>( | |
| 686 | + yield* get(web, "/admin/users/with-connections", ORG_A), | |
| 687 | + ); | |
| 688 | + expect(body.users.map((user) => [user.externalId, user.email])).toEqual([ | |
| 689 | + [USER_A1, "a1@users.test"], | |
| 690 | + [USER_A2, null], | |
| 691 | + ]); | |
| 692 | + expect(seen.length, "still one directory read for the page").toBe(1); | |
| 693 | + }), | |
| 694 | + ), | |
| 695 | + ); | |
| 696 | + | |
| 697 | + // Identity is decoration on an operator view, not the answer: a directory | |
| 698 | + // outage must cost the rows their names, never cost the operator the read. | |
| 699 | + it.effect("still serves users when the host's identity lookup fails", () => | |
| 700 | + withDb((db) => | |
| 701 | + Effect.gen(function* () { | |
| 702 | + yield* seed(db); | |
| 703 | + const web = yield* webHandlerFor( | |
| 704 | + stubProvider( | |
| 705 | + (tenant) => platformExecutorFor(db, tenant), | |
| 706 | + headerAuthorize, | |
| 707 | + failingDirectory, | |
| 708 | + ), | |
| 709 | + ); | |
| 710 | + | |
| 711 | + const response = yield* get(web, "/admin/users", ORG_A); | |
| 712 | + expect(response.status, "a directory outage is not a failed request").toBe(200); | |
| 713 | + const body = yield* jsonOf<UsersBody>(response); | |
| 714 | + expect( | |
| 715 | + body.users.map((user) => [user.externalId, user.email, user.displayName]), | |
| 716 | + "every user is still reported, simply unnamed", | |
| 717 | + ).toEqual([ | |
| 718 | + [USER_A1, null, null], | |
| 719 | + [USER_A2, null, null], | |
| 720 | + ]); | |
| 721 | + }), | |
| 722 | + ), | |
| 723 | + ); | |
| 724 | + | |
| 725 | + // A host that cannot map its id space to a directory at all passes none. The | |
| 726 | + // rows must still be complete — absent identity, not a missing field. | |
| 727 | + it.effect("reports absent identity when the host wires no directory", () => | |
| 728 | + withDb((db) => | |
| 729 | + Effect.gen(function* () { | |
| 730 | + yield* seed(db); | |
| 731 | + const web = yield* webHandlerFor( | |
| 732 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 733 | + ); | |
| 734 | + | |
| 735 | + const body = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users", ORG_A)); | |
| 736 | + expect(body.users.map((user) => [user.email, user.displayName])).toEqual([ | |
| 737 | + [null, null], | |
| 738 | + [null, null], | |
| 739 | + ]); | |
| 740 | + }), | |
| 741 | + ), | |
| 742 | + ); | |
| 743 | + | |
| 744 | + // Email and name are the ONLY fields this join is allowed to add. The | |
| 745 | + // forbidden-field assertions above run without a directory, so re-run the | |
| 746 | + // credential check with one wired: a directory is a new data source reaching | |
| 747 | + // the response, and it must not become a hole in the allowlist. | |
| 748 | + it.effect("adds only email and displayName to the allowlist", () => | |
| 749 | + withDb((db) => | |
| 750 | + Effect.gen(function* () { | |
| 751 | + yield* seed(db); | |
| 752 | + const seen: string[][] = []; | |
| 753 | + const web = yield* webHandlerFor( | |
| 754 | + stubProvider( | |
| 755 | + (tenant) => platformExecutorFor(db, tenant), | |
| 756 | + headerAuthorize, | |
| 757 | + stubUserDirectory({ seen }), | |
| 758 | + ), | |
| 759 | + ); | |
| 760 | + | |
| 761 | + for (const path of [ | |
| 762 | + "/admin/users", | |
| 763 | + "/admin/users/with-connections", | |
| 764 | + // The single-user read joins identity through the same seam, so it | |
| 765 | + // gets the same sweep — including the email-addressed form, whose | |
| 766 | + // response is built on a directory value. | |
| 767 | + `/admin/users/${USER_A1}`, | |
| 768 | + `/admin/users/${encodeURIComponent(A1_EMAIL)}`, | |
| 769 | + `/admin/users?email=${encodeURIComponent(A1_EMAIL)}`, | |
| 770 | + ]) { | |
| 771 | + const response = yield* get(web, path, ORG_A); | |
| 772 | + const raw = yield* Effect.promise(() => response.text()); | |
| 773 | + for (const field of FORBIDDEN_FIELDS) { | |
| 774 | + expect(raw, `${path} must not expose "${field}"`).not.toContain(`"${field}"`); | |
| 775 | + } | |
| 776 | + expect(raw, `${path} must not leak a stored secret`).not.toContain("SECRET-"); | |
| 777 | + } | |
| 778 | + }), | |
| 779 | + ), | |
| 780 | + ); | |
| 781 | + | |
| 782 | + it.effect("pages the user list", () => | |
| 783 | + withDb((db) => | |
| 784 | + Effect.gen(function* () { | |
| 785 | + yield* seed(db); | |
| 786 | + const web = yield* webHandlerFor( | |
| 787 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 788 | + ); | |
| 789 | + | |
| 790 | + const first = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users?limit=1", ORG_A)); | |
| 791 | + expect(first.users.map((user) => user.externalId)).toEqual([USER_A1]); | |
| 792 | + | |
| 793 | + const second = yield* jsonOf<UsersBody>( | |
| 794 | + yield* get(web, "/admin/users?limit=1&offset=1", ORG_A), | |
| 795 | + ); | |
| 796 | + expect(second.users.map((user) => user.externalId)).toEqual([USER_A2]); | |
| 797 | + }), | |
| 798 | + ), | |
| 799 | + ); | |
| 800 | + | |
| 801 | + // `offset` and `limit` are independently optional in the contract, so | |
| 802 | + // `?offset=` alone is a request a consumer will make. Drizzle's SQLite | |
| 803 | + // dialect emits OFFSET only alongside LIMIT and SQLite rejects a bare OFFSET, | |
| 804 | + // so this used to 500 on every SQLite host — local, self-host and D1 alike. | |
| 805 | + // The SDK's default page size is what makes it a legal query. | |
| 806 | + it.effect("serves an offset with no limit rather than 500ing", () => | |
| 807 | + withDb((db) => | |
| 808 | + Effect.gen(function* () { | |
| 809 | + yield* seed(db); | |
| 810 | + const web = yield* webHandlerFor( | |
| 811 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 812 | + ); | |
| 813 | + | |
| 814 | + for (const path of ["/admin/users?offset=1", "/admin/users/with-connections?offset=1"]) { | |
| 815 | + const response = yield* get(web, path, ORG_A); | |
| 816 | + expect(response.status, path).toBe(200); | |
| 817 | + expect((yield* jsonOf<UsersBody>(response)).users.map((user) => user.externalId)).toEqual( | |
| 818 | + [USER_A2], | |
| 819 | + ); | |
| 820 | + } | |
| 821 | + | |
| 822 | + // Well past the end is an empty page, still a 200. | |
| 823 | + const far = yield* get(web, "/admin/users?offset=25", ORG_A); | |
| 824 | + expect(far.status).toBe(200); | |
| 825 | + expect((yield* jsonOf<UsersBody>(far)).users).toEqual([]); | |
| 826 | + }), | |
| 827 | + ), | |
| 828 | + ); | |
| 829 | + | |
| 830 | + // A fraction decoded cleanly through `FiniteFromString` and reached the | |
| 831 | + // driver as a `datatype mismatch` 500. A row count is an integer by nature, | |
| 832 | + // so the contract rejects it up front — the same 400 a non-numeric value gets, | |
| 833 | + // which is what the schema's comment always claimed. | |
| 834 | + it.effect("400s a fractional limit or offset instead of failing in the driver", () => | |
| 835 | + withDb((db) => | |
| 836 | + Effect.gen(function* () { | |
| 837 | + yield* seed(db); | |
| 838 | + const web = yield* webHandlerFor( | |
| 839 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize), | |
| 840 | + ); | |
| 841 | + | |
| 842 | + for (const path of [ | |
| 843 | + "/admin/users?limit=1.5", | |
| 844 | + "/admin/users?offset=0.5", | |
| 845 | + "/admin/users/with-connections?limit=1.5", | |
| 846 | + // Still 400s for the reasons that already held. | |
| 847 | + "/admin/users?limit=abc", | |
| 848 | + "/admin/users?limit=0", | |
| 849 | + "/admin/users?limit=501", | |
| 850 | + ]) { | |
| 851 | + expect((yield* get(web, path, ORG_A)).status, path).toBe(400); | |
| 852 | + } | |
| 853 | + | |
| 854 | + // The integral neighbours are unaffected. | |
| 855 | + expect((yield* get(web, "/admin/users?limit=1&offset=0", ORG_A)).status).toBe(200); | |
| 856 | + }), | |
| 857 | + ), | |
| 858 | + ); | |
| 859 | + | |
| 860 | + // ------------------------------------------------------------------------- | |
| 861 | + // Single-user reads, by opaque id and by email. | |
| 862 | + // ------------------------------------------------------------------------- | |
| 863 | + | |
| 864 | + const singleUserWeb = (db: SqliteTestFumaDb, resolved?: string[]) => | |
| 865 | + webHandlerFor( | |
| 866 | + stubProvider( | |
| 867 | + (tenant) => platformExecutorFor(db, tenant), | |
| 868 | + headerAuthorize, | |
| 869 | + stubUserDirectory(resolved ? { resolved } : {}), | |
| 870 | + ), | |
| 871 | + ); | |
| 872 | + | |
| 873 | + it.effect("returns one user with identity and connections in a single call", () => | |
| 874 | + withDb((db) => | |
| 875 | + Effect.gen(function* () { | |
| 876 | + yield* seed(db); | |
| 877 | + const web = yield* singleUserWeb(db); | |
| 878 | + | |
| 879 | + const response = yield* get(web, `/admin/users/${USER_A1}`, ORG_A); | |
| 880 | + expect(response.status).toBe(200); | |
| 881 | + const body = yield* jsonOf<UserBody>(response); | |
| 882 | + | |
| 883 | + // The whole point of the endpoint: identity AND connections together, | |
| 884 | + // so a per-user check needs exactly one request. | |
| 885 | + expect(body.user.externalId).toBe(USER_A1); | |
| 886 | + expect(body.user.email).toBe(A1_EMAIL_STORED); | |
| 887 | + expect(body.user.displayName).toBe("User A1"); | |
| 888 | + expect(typeof body.user.lastSeenAt).toBe("number"); | |
| 889 | + expect(body.user.connections.map((connection) => connection.integration)).toEqual([ | |
| 890 | + "github", | |
| 891 | + ]); | |
| 892 | + expect(body.user.connections[0]?.oauthScope).toBe("repo read:user"); | |
| 893 | + expect(body.user.connections[0]?.lastHealth?.status).toBe("healthy"); | |
| 894 | + }), | |
| 895 | + ), | |
| 896 | + ); | |
| 897 | + | |
| 898 | + it.effect("finds the same user by email, case-insensitively", () => | |
| 899 | + withDb((db) => | |
| 900 | + Effect.gen(function* () { | |
| 901 | + yield* seed(db); | |
| 902 | + const resolved: string[] = []; | |
| 903 | + const web = yield* singleUserWeb(db, resolved); | |
| 904 | + | |
| 905 | + // Three spellings of one address: as stored, all-lower, and shouted. | |
| 906 | + for (const spelling of [A1_EMAIL_STORED, A1_EMAIL, A1_EMAIL.toUpperCase()]) { | |
| 907 | + const response = yield* get(web, `/admin/users/${encodeURIComponent(spelling)}`, ORG_A); | |
| 908 | + expect(response.status, `${spelling} should resolve`).toBe(200); | |
| 909 | + const body = yield* jsonOf<UserBody>(response); | |
| 910 | + expect(body.user.externalId).toBe(USER_A1); | |
| 911 | + expect(body.user.connections.map((connection) => connection.integration)).toEqual([ | |
| 912 | + "github", | |
| 913 | + ]); | |
| 914 | + } | |
| 915 | + | |
| 916 | + // The seam normalizes before the resolver ever sees the value, so a | |
| 917 | + // host implementation never has to repeat the rule for the QUERY side. | |
| 918 | + expect(resolved).toEqual([A1_EMAIL, A1_EMAIL, A1_EMAIL]); | |
| 919 | + }), | |
| 920 | + ), | |
| 921 | + ); | |
| 922 | + | |
| 923 | + it.effect("404s an unknown id and an unknown email, distinctly from 401 and 403", () => | |
| 924 | + withDb((db) => | |
| 925 | + Effect.gen(function* () { | |
| 926 | + yield* seed(db); | |
| 927 | + const web = yield* singleUserWeb(db); | |
| 928 | + | |
| 929 | + const unknownId = yield* get(web, "/admin/users/user_nobody", ORG_A); | |
| 930 | + expect(unknownId.status).toBe(404); | |
| 931 | + const unknownEmail = yield* get( | |
| 932 | + web, | |
| 933 | + `/admin/users/${encodeURIComponent("nobody@users.test")}`, | |
| 934 | + ORG_A, | |
| 935 | + ); | |
| 936 | + expect(unknownEmail.status).toBe(404); | |
| 937 | + | |
| 938 | + // The distinct error is what lets a caller tell "no footprint" from | |
| 939 | + // "bad credential" without parsing a message. Asserted on the encoded | |
| 940 | + // body, which is what a consumer actually receives. | |
| 941 | + expect(yield* Effect.promise(() => unknownId.text())).toContain("AdminUserNotFound"); | |
| 942 | + | |
| 943 | + // And 404 must not echo the identifier back. | |
| 944 | + const raw = yield* Effect.promise(() => unknownEmail.text()); | |
| 945 | + expect(raw).not.toContain("nobody@users.test"); | |
| 946 | + }), | |
| 947 | + ), | |
| 948 | + ); | |
| 949 | + | |
| 950 | + it.effect("404s a user of ANOTHER tenant rather than revealing them", () => | |
| 951 | + withDb((db) => | |
| 952 | + Effect.gen(function* () { | |
| 953 | + yield* seed(db); | |
| 954 | + const web = yield* singleUserWeb(db); | |
| 955 | + | |
| 956 | + // B's user exists, but not for A — the same answer as a nonexistent id, | |
| 957 | + // which is what keeps the admin plane tenant-isolated. | |
| 958 | + expect((yield* get(web, `/admin/users/${USER_B1}`, ORG_A)).status).toBe(404); | |
| 959 | + expect((yield* get(web, `/admin/users/${USER_B1}`, "Bearer org_b_key")).status).toBe(200); | |
| 960 | + }), | |
| 961 | + ), | |
| 962 | + ); | |
| 963 | + | |
| 964 | + // The decision documented on the contract: the admin plane reports FOOTPRINT, | |
| 965 | + // not org membership. A directory member who never touched executor is a 404, | |
| 966 | + // not identity-with-empty-connections — otherwise a caller gating on this | |
| 967 | + // endpoint would treat a never-onboarded invitee as onboarded. | |
| 968 | + it.effect("404s an email the directory knows but who has no subject row", () => | |
| 969 | + withDb((db) => | |
| 970 | + Effect.gen(function* () { | |
| 971 | + // Seed connections/tenant B but NOT a subject row for the directory's | |
| 972 | + // only known member, so the directory resolves and storage does not. | |
| 973 | + yield* touchSubject(db.db, { tenant: TENANT_B, externalId: USER_B1 }); | |
| 974 | + const web = yield* singleUserWeb(db); | |
| 975 | + | |
| 976 | + const response = yield* get(web, `/admin/users/${encodeURIComponent(A1_EMAIL)}`, ORG_A); | |
| 977 | + expect(response.status, "known to the directory, unknown to this tenant").toBe(404); | |
| 978 | + expect(yield* Effect.promise(() => response.text())).toContain("AdminUserNotFound"); | |
| 979 | + }), | |
| 980 | + ), | |
| 981 | + ); | |
| 982 | + | |
| 983 | + it.effect("filters the bulk lists by email, and returns an empty list for an unknown one", () => | |
| 984 | + withDb((db) => | |
| 985 | + Effect.gen(function* () { | |
| 986 | + yield* seed(db); | |
| 987 | + const web = yield* singleUserWeb(db); | |
| 988 | + | |
| 989 | + const filtered = yield* jsonOf<UsersBody>( | |
| 990 | + yield* get( | |
| 991 | + web, | |
| 992 | + `/admin/users?email=${encodeURIComponent(A1_EMAIL.toUpperCase())}`, | |
| 993 | + ORG_A, | |
| 994 | + ), | |
| 995 | + ); | |
| 996 | + expect(filtered.users.map((user) => user.externalId)).toEqual([USER_A1]); | |
| 997 | + | |
| 998 | + const joined = yield* jsonOf<UsersWithConnectionsBody>( | |
| 999 | + yield* get( | |
| 1000 | + web, | |
| 1001 | + `/admin/users/with-connections?email=${encodeURIComponent(A1_EMAIL)}`, | |
| 1002 | + ORG_A, | |
| 1003 | + ), | |
| 1004 | + ); | |
| 1005 | + expect(joined.users.map((user) => user.externalId)).toEqual([USER_A1]); | |
| 1006 | + expect(joined.users[0]?.connections.map((c) => c.integration)).toEqual(["github"]); | |
| 1007 | + | |
| 1008 | + // An unknown email is an empty page, never an unfiltered one — the | |
| 1009 | + // failure mode that would quietly hand back the whole tenant. | |
| 1010 | + const unknown = yield* jsonOf<UsersBody>( | |
| 1011 | + yield* get(web, "/admin/users?email=nobody%40users.test", ORG_A), | |
| 1012 | + ); | |
| 1013 | + expect(unknown.users).toEqual([]); | |
| 1014 | + }), | |
| 1015 | + ), | |
| 1016 | + ); | |
| 1017 | + | |
| 1018 | + // A resolver OUTAGE must not read as "no such user": that is a wrong answer an | |
| 1019 | + // operator would act on. Contrast with the identity join, which degrades to | |
| 1020 | + // unnamed rows precisely because it is decoration. | |
| 1021 | + it.effect("500s when the email resolver fails, rather than reporting no match", () => | |
| 1022 | + withDb((db) => | |
| 1023 | + Effect.gen(function* () { | |
| 1024 | + yield* seed(db); | |
| 1025 | + const web = yield* webHandlerFor( | |
| 1026 | + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, { | |
| 1027 | + resolveEmail: () => Effect.fail(new DirectoryUnavailable({ message: "down" })), | |
| 1028 | + }), | |
| 1029 | + ); | |
| 1030 | + | |
| 1031 | + expect( | |
| 1032 | + (yield* get(web, `/admin/users/${encodeURIComponent(A1_EMAIL)}`, ORG_A)).status, | |
| 1033 | + ).toBe(500); | |
| 1034 | + expect((yield* get(web, `/admin/users?email=${A1_EMAIL}`, ORG_A)).status).toBe(500); | |
| 1035 | + }), | |
| 1036 | + ), | |
| 1037 | + ); | |
| 1038 | + | |
| 1039 | + // `externalId` is opaque by contract, so the "@" test is a routing hint and | |
| 1040 | + // not a parse: an id that happens to contain "@" must still be findable. | |
| 1041 | + it.effect("falls back to a literal id lookup for an id containing @", () => | |
| 1042 | + withDb((db) => | |
| 1043 | + Effect.gen(function* () { | |
| 1044 | + const oddId = "user@odd"; | |
| 1045 | + yield* touchSubject(db.db, { tenant: TENANT_A, externalId: oddId }); | |
| 1046 | + const web = yield* singleUserWeb(db); | |
| 1047 | + | |
| 1048 | + const response = yield* get(web, `/admin/users/${encodeURIComponent(oddId)}`, ORG_A); | |
| 1049 | + expect(response.status).toBe(200); | |
| 1050 | + expect((yield* jsonOf<UserBody>(response)).user.externalId).toBe(oddId); | |
| 1051 | + }), | |
| 1052 | + ), | |
| 1053 | + ); | |
| 1054 | + | |
| 1055 | + it.effect("still resolves /with-connections as the list, not as a user id", () => | |
| 1056 | + withDb((db) => | |
| 1057 | + Effect.gen(function* () { | |
| 1058 | + yield* seed(db); | |
| 1059 | + const web = yield* singleUserWeb(db); | |
| 1060 | + | |
| 1061 | + // The router-ordering invariant, asserted rather than trusted: the | |
| 1062 | + // static segment must keep winning now that `/:identifier` exists. | |
| 1063 | + const body = yield* jsonOf<UsersWithConnectionsBody>( | |
| 1064 | + yield* get(web, "/admin/users/with-connections", ORG_A), | |
| 1065 | + ); | |
| 1066 | + expect(body.users.map((user) => user.externalId)).toEqual([USER_A1, USER_A2]); | |
| 1067 | + }), | |
| 1068 | + ), | |
| 1069 | + ); | |
| 1070 | +}); | |
| @@ -0,0 +1,355 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Admin Users HTTP API — the public, tenant-wide operator surface. | |
| 3 | +// | |
| 4 | +// The product plane (`/api/*`) answers "what can I, this member, reach". This | |
| 5 | +// one answers the OWNER's question: "who are my users, and what have they | |
| 6 | +// connected". It reads the SDK's platform view (`executor.admin`, opt-in via | |
| 7 | +// `platformView: true`), which is read-only by construction — the owner policy | |
| 8 | +// rejects writes at `reach: "tenant"` — so every endpoint here is a GET. | |
| 9 | +// | |
| 10 | +// VOCABULARY: this is the translation seam. Internal code says subject / tenant | |
| 11 | +// / reach; everything public-facing says users / owners. `AdminSubject | |
| 12 | +// .externalId` becomes `externalId` on a `users` collection, and nothing below | |
| 13 | +// leaks the word "subject". | |
| 14 | +// | |
| 15 | +// FIELD DISCIPLINE: the response shapes are 1:1 with the SDK's hand-picked | |
| 16 | +// admin allowlist (`AdminSubject` / `AdminConnection`), NOT a projection of the | |
| 17 | +// underlying rows. No credential material of any kind may appear — no item ids, | |
| 18 | +// no refresh ids, no oauth client secrets, no tokens. `oauthScope` is a summary | |
| 19 | +// of ACCESS (which scopes were granted), never a token. Adding a field here is | |
| 20 | +// a deliberate act that has to be argued against `platform-view.test.ts`'s | |
| 21 | +// forbidden-field list. | |
| 22 | +// | |
| 23 | +// The allowlist is applied per FIELD, not per column: a column may be allowed | |
| 24 | +// while the object inside it is not. `lastHealth` is the live case — the SDK's | |
| 25 | +// `HealthCheckResult` carries upstream-derived content (`identity`, `detail`, | |
| 26 | +// `responseSample`), so this contract narrows it to `AdminConnectionHealth` | |
| 27 | +// rather than forwarding the nested object. Any future nested field arrives | |
| 28 | +// under the same rule: project it, don't re-export it. | |
| 29 | +// | |
| 30 | +// Auth is applied by each host's own admin middleware (cloud: an org-scoped API | |
| 31 | +// key or an admin session member; self-host: a Better Auth owner/admin member), | |
| 32 | +// so this contract carries no provider-specific auth scheme — the same shape | |
| 33 | +// the Account API uses. | |
| 34 | +// --------------------------------------------------------------------------- | |
| 35 | + | |
| 36 | +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | |
| 37 | +import { Schema } from "effect"; | |
| 38 | + | |
| 39 | +import { ConnectionName, HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared"; | |
| 40 | + | |
| 41 | +// --------------------------------------------------------------------------- | |
| 42 | +// Errors | |
| 43 | +// --------------------------------------------------------------------------- | |
| 44 | + | |
| 45 | +export class AdminUsersError extends Schema.TaggedErrorClass<AdminUsersError>()( | |
| 46 | + "AdminUsersError", | |
| 47 | + { message: Schema.String }, | |
| 48 | + { httpApiStatus: 500 }, | |
| 49 | +) {} | |
| 50 | + | |
| 51 | +export class AdminUsersUnauthorized extends Schema.TaggedErrorClass<AdminUsersUnauthorized>()( | |
| 52 | + "AdminUsersUnauthorized", | |
| 53 | + {}, | |
| 54 | + { httpApiStatus: 401 }, | |
| 55 | +) {} | |
| 56 | + | |
| 57 | +export class AdminUsersForbidden extends Schema.TaggedErrorClass<AdminUsersForbidden>()( | |
| 58 | + "AdminUsersForbidden", | |
| 59 | + {}, | |
| 60 | + { httpApiStatus: 403 }, | |
| 61 | +) {} | |
| 62 | + | |
| 63 | +/** | |
| 64 | + * No user of THIS tenant matches the identifier. Distinct from 401/403 on | |
| 65 | + * purpose: an operator polling one user needs "this person has no footprint | |
| 66 | + * here" to be a different, non-alarming answer from "your credential is | |
| 67 | + * wrong". Carries no echo of the identifier — an admin credential is already | |
| 68 | + * scoped to one tenant, but reflecting input into an error body is a habit | |
| 69 | + * worth not having. | |
| 70 | + * | |
| 71 | + * Deliberately does NOT distinguish "never existed" from "exists in the host | |
| 72 | + * directory but never touched executor": see the `getUser` docs below. | |
| 73 | + */ | |
| 74 | +export class AdminUserNotFound extends Schema.TaggedErrorClass<AdminUserNotFound>()( | |
| 75 | + "AdminUserNotFound", | |
| 76 | + {}, | |
| 77 | + { httpApiStatus: 404 }, | |
| 78 | +) {} | |
| 79 | + | |
| 80 | +// --------------------------------------------------------------------------- | |
| 81 | +// Response schemas — the public projection of the SDK's admin shapes. | |
| 82 | +// --------------------------------------------------------------------------- | |
| 83 | + | |
| 84 | +/** | |
| 85 | + * One user of the tenant. Mirrors `AdminSubject` with the public vocabulary. | |
| 86 | + * | |
| 87 | + * `lastSeenAt` is epoch ms of the last sighting on the request path, and is | |
| 88 | + * deliberately COARSE: the writer (`touchSubject`) only rewrites it past a | |
| 89 | + * throttle interval (1 hour by default), so treat it as "active around then", | |
| 90 | + * never as a precise last-activity timestamp. `null` means never seen on a | |
| 91 | + * request — a user can earn a row at connection-create before any sighting. | |
| 92 | + */ | |
| 93 | +export const AdminUser = Schema.Struct({ | |
| 94 | + /** The host-auth principal id (cloud: the WorkOS user id). Opaque — it also | |
| 95 | + * carries host sentinels like "local", so nothing may parse it. */ | |
| 96 | + externalId: Schema.String, | |
| 97 | + /** Epoch ms the user was first recorded under this tenant. */ | |
| 98 | + createdAt: Schema.Number, | |
| 99 | + lastSeenAt: Schema.NullOr(Schema.Number), | |
| 100 | + /** User lifecycle. Unconstrained until the lifecycle values are defined; | |
| 101 | + * `null` means no state recorded (which is every row today). */ | |
| 102 | + status: Schema.NullOr(Schema.String), | |
| 103 | + /** | |
| 104 | + * The host's email for this principal, joined server-side from the host's own | |
| 105 | + * member directory (cloud: WorkOS; self-host: Better Auth). `null` whenever | |
| 106 | + * the host cannot resolve the id — the member left the org while their | |
| 107 | + * connections remain, the directory read failed, or the id is a host sentinel | |
| 108 | + * like "local" that names no member at all. | |
| 109 | + * | |
| 110 | + * NOT credential material and not part of the storage projection: it is a | |
| 111 | + * directory lookup keyed by `externalId`, which is why it can be absent on a | |
| 112 | + * row whose other fields are complete. Nothing may treat its presence as an | |
| 113 | + * authorization signal. | |
| 114 | + */ | |
| 115 | + email: Schema.NullOr(Schema.String), | |
| 116 | + /** The host's human name for this principal (cloud: given + family name; | |
| 117 | + * self-host: the Better Auth `name`). `null` on the same terms as `email`, | |
| 118 | + * and independently — a directory can hold one without the other. */ | |
| 119 | + displayName: Schema.NullOr(Schema.String), | |
| 120 | +}); | |
| 121 | + | |
| 122 | +/** | |
| 123 | + * The admin plane's view of a stored health verdict — a deliberate PROJECTION | |
| 124 | + * of the SDK's `HealthCheckResult`, not that type re-exported. | |
| 125 | + * | |
| 126 | + * WHY THIS IS ITS OWN SCHEMA. `HealthCheckResult` is the OWNER's view of their | |
| 127 | + * own probe, and it carries content lifted from the upstream provider's actual | |
| 128 | + * response: `identity` (the display value extracted from `identityField` — | |
| 129 | + * typically the account's email AT THE PROVIDER), `detail` (a raw diagnostic | |
| 130 | + * message, which for an HTTP failure can quote the upstream body), and | |
| 131 | + * `responseSample` (up to 25 scalar leaves of the real response body, ~120 | |
| 132 | + * chars each, via `extractResponseFields`). That is fine on the product plane, | |
| 133 | + * where the caller IS the credential's owner. It is not fine here: this plane | |
| 134 | + * reports on OTHER people's connections, so forwarding the whole nested object | |
| 135 | + * would hand an operator another user's upstream account identity and sampled | |
| 136 | + * response content. The per-column allowlist in `admin/reads.ts` cannot catch | |
| 137 | + * that on its own — it allowlists the COLUMN and then forwards everything | |
| 138 | + * inside it — so the narrowing is expressed here, in the contract. | |
| 139 | + * | |
| 140 | + * WHAT SURVIVES. `status` answers the only question this plane asks ("is it | |
| 141 | + * healthy"), and `checkedAt` says how stale that answer is. Both are derived | |
| 142 | + * verdicts about the connection, never content from the provider's response. | |
| 143 | + * `httpStatus` is deliberately dropped as well: it is a diagnostic for the | |
| 144 | + * credential's owner, and adding fields here has to be argued against the | |
| 145 | + * forbidden-field lists in `platform-view.test.ts` and `admin-users.test.ts`. | |
| 146 | + */ | |
| 147 | +export const AdminConnectionHealth = Schema.Struct({ | |
| 148 | + /** The stored verdict: healthy / expired / degraded / unknown. */ | |
| 149 | + status: HealthStatus, | |
| 150 | + /** Epoch ms the check ran, so an operator can tell a fresh verdict from a | |
| 151 | + * stale one. */ | |
| 152 | + checkedAt: Schema.Number, | |
| 153 | +}); | |
| 154 | + | |
| 155 | +/** | |
| 156 | + * A connection as the platform view sees it: enough to answer "what has this | |
| 157 | + * user connected, and is it healthy", and nothing that could resolve a | |
| 158 | + * credential — or name the upstream account. Mirrors `AdminConnection`, except | |
| 159 | + * that `lastHealth` is narrowed to `AdminConnectionHealth` (see above). | |
| 160 | + */ | |
| 161 | +export const AdminUserConnection = Schema.Struct({ | |
| 162 | + owner: Owner, | |
| 163 | + integration: IntegrationSlug, | |
| 164 | + name: ConnectionName, | |
| 165 | + /** The scope set the provider actually granted, space-delimited as recorded | |
| 166 | + * at connect/refresh. Null for static credentials. A summary of ACCESS — | |
| 167 | + * never a token. */ | |
| 168 | + oauthScope: Schema.NullOr(Schema.String), | |
| 169 | + /** The stored health verdict, PROJECTED to status + checkedAt. Never the | |
| 170 | + * SDK's full `HealthCheckResult`: see `AdminConnectionHealth`. */ | |
| 171 | + lastHealth: Schema.NullOr(AdminConnectionHealth), | |
| 172 | +}); | |
| 173 | + | |
| 174 | +/** A user joined with every connection they own in the tenant. */ | |
| 175 | +export const AdminUserWithConnections = Schema.Struct({ | |
| 176 | + ...AdminUser.fields, | |
| 177 | + connections: Schema.Array(AdminUserConnection), | |
| 178 | +}); | |
| 179 | + | |
| 180 | +export const AdminUsersResponse = Schema.Struct({ | |
| 181 | + users: Schema.Array(AdminUser), | |
| 182 | +}); | |
| 183 | + | |
| 184 | +export const AdminUserConnectionsResponse = Schema.Struct({ | |
| 185 | + connections: Schema.Array(AdminUserConnection), | |
| 186 | +}); | |
| 187 | + | |
| 188 | +export const AdminUsersWithConnectionsResponse = Schema.Struct({ | |
| 189 | + users: Schema.Array(AdminUserWithConnections), | |
| 190 | +}); | |
| 191 | + | |
| 192 | +/** One user, identity and connections together. Wrapped in `{ user }` rather | |
| 193 | + * than returned bare so the response can grow a sibling field without a | |
| 194 | + * breaking change — the same reason every list here is wrapped. */ | |
| 195 | +export const AdminUserResponse = Schema.Struct({ | |
| 196 | + user: AdminUserWithConnections, | |
| 197 | +}); | |
| 198 | + | |
| 199 | +// --------------------------------------------------------------------------- | |
| 200 | +// Params / query | |
| 201 | +// --------------------------------------------------------------------------- | |
| 202 | + | |
| 203 | +const AdminUserParams = { externalId: Schema.String }; | |
| 204 | + | |
| 205 | +/** | |
| 206 | + * The single-user path parameter, which names a user by EITHER identifier the | |
| 207 | + * operator has: the opaque `externalId`, or their email. | |
| 208 | + * | |
| 209 | + * WHY ONE PARAM AND NOT A `?email=` VARIANT. The alternative — `GET | |
| 210 | + * /admin/users?email=` returning the matched user — would make one endpoint | |
| 211 | + * answer with two different shapes (a list, or one user), which is exactly the | |
| 212 | + * thing the group comment below rules out: this repo's query params FILTER a | |
| 213 | + * fixed shape, they never switch it. Two identifiers for one resource is what | |
| 214 | + * a path parameter is for, and it keeps "fetch the user I am asking about" as | |
| 215 | + * one route with one response schema and one 404. | |
| 216 | + * | |
| 217 | + * DISAMBIGUATION is by shape — an identifier containing "@" is tried as an | |
| 218 | + * email — but that is a ROUTING HINT, not a parse of the id. `externalId` is | |
| 219 | + * opaque by contract, so the resolver falls back to a literal id lookup when | |
| 220 | + * the email interpretation matches nothing. No host mints an id containing | |
| 221 | + * "@" today (cloud: `user_...`; self-host: a Better Auth id; sentinels like | |
| 222 | + * "local"), so the fallback is a safety net rather than a live path — but it | |
| 223 | + * means the opacity rule is never actually violated. | |
| 224 | + * | |
| 225 | + * Note the value arrives URL-DECODED, so a client sends `alice%40example.com` | |
| 226 | + * and the handler sees `alice@example.com`. | |
| 227 | + */ | |
| 228 | +const AdminUserIdentifierParams = { identifier: Schema.String }; | |
| 229 | + | |
| 230 | +// Paging, mirroring `AdminListSubjectsOptions`. Query params arrive as strings, | |
| 231 | +// so decode them here rather than making every handler interpret raw strings — | |
| 232 | +// a non-numeric, FRACTIONAL, or out-of-range value is a 400 from the contract, | |
| 233 | +// not a silently-ignored filter and not a value handed to the driver. | |
| 234 | +// | |
| 235 | +// `isInt` is load-bearing, not decoration: `FiniteFromString` alone accepts | |
| 236 | +// "1.5", and a fractional limit reached the driver as a `datatype mismatch` | |
| 237 | +// 500. A row count is an integer by nature, so rejecting the fraction is the | |
| 238 | +// honest answer rather than rounding a caller's request into something they | |
| 239 | +// did not ask for. | |
| 240 | +// | |
| 241 | +// OMITTING these is not the same as passing 0. The SDK applies its own default | |
| 242 | +// page size (`ADMIN_DEFAULT_PAGE_SIZE`, 100) and clamps to | |
| 243 | +// `ADMIN_MAX_PAGE_SIZE` (500, the `maximum` below), so every response is a | |
| 244 | +// bounded page — including `?offset=` with no `limit=`, which is a valid | |
| 245 | +// request that pages through the default-sized window. | |
| 246 | +// | |
| 247 | +// `email` is an exact-match FILTER on the fixed list shape (the response is | |
| 248 | +// still a `users` array, empty when nothing matches), which is how every other | |
| 249 | +// list here uses query params. It selects a specific principal rather than | |
| 250 | +// narrowing a scan, so paging over a filtered result is not meaningful — it is | |
| 251 | +// still honored so the endpoint has one set of semantics, not two. | |
| 252 | +// | |
| 253 | +// Carried as a plain string: the trim + lower-case normalization lives at the | |
| 254 | +// handler seam (`normalizeEmail`), which is also where the single-user path | |
| 255 | +// parameter is normalized, so both entry points share ONE rule rather than a | |
| 256 | +// schema transform on one and hand-rolled code on the other. | |
| 257 | +const AdminListQuery = Schema.Struct({ | |
| 258 | + limit: Schema.optional( | |
| 259 | + Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })), | |
| 260 | + ), | |
| 261 | + offset: Schema.optional( | |
| 262 | + Schema.FiniteFromString.check( | |
| 263 | + Schema.isInt(), | |
| 264 | + Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), | |
| 265 | + ), | |
| 266 | + ), | |
| 267 | + email: Schema.optional(Schema.String), | |
| 268 | +}); | |
| 269 | + | |
| 270 | +// --------------------------------------------------------------------------- | |
| 271 | +// Group | |
| 272 | +// --------------------------------------------------------------------------- | |
| 273 | + | |
| 274 | +/** | |
| 275 | + * The admin users group, mounted at `/admin/*` by each host. | |
| 276 | + * | |
| 277 | + * The joined view is its OWN path (`/admin/users/with-connections`) rather than | |
| 278 | + * an `?include=connections` flag on `/admin/users`, because the two return | |
| 279 | + * different row shapes. A query flag that changes the response schema can't be | |
| 280 | + * expressed as one typed endpoint — the repo's list endpoints use query params | |
| 281 | + * only to FILTER a fixed shape (`/tools`, `/connections`), never to switch it. | |
| 282 | + * It sorts before `/admin/users/:externalId/...` in the router either way, and | |
| 283 | + * "with-connections" is not a valid opaque principal id. | |
| 284 | + * | |
| 285 | + * ROUTER ORDERING, re-verified now that `/admin/users/:identifier` exists and | |
| 286 | + * collides with `/admin/users/with-connections` at the same path depth. The | |
| 287 | + * router is find-my-way (`HttpRouter` builds on effect's vendored copy), a | |
| 288 | + * radix tree that ranks a STATIC segment above a parametric one at the same | |
| 289 | + * position regardless of registration order. Probed directly against the | |
| 290 | + * pinned build with the parametric route registered FIRST — the worst case — | |
| 291 | + * and `/admin/users/with-connections` still resolves to the list endpoint, | |
| 292 | + * while `/admin/users/user_123` and `/admin/users/alice%40example.test` both | |
| 293 | + * resolve to the single-user endpoint. The original comment's claim therefore | |
| 294 | + * still holds, and the endpoint order below is documentation rather than the | |
| 295 | + * thing that makes it work. Registering `:identifier` here and `:externalId` | |
| 296 | + * on the nested route is likewise fine: the names differ but they sit at the | |
| 297 | + * same position, and the tree matches on position, not on name. | |
| 298 | + */ | |
| 299 | +export const AdminUsersApi = HttpApiGroup.make("adminUsers") | |
| 300 | + .add( | |
| 301 | + HttpApiEndpoint.get("listUsers", "/admin/users", { | |
| 302 | + query: AdminListQuery, | |
| 303 | + success: AdminUsersResponse, | |
| 304 | + error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden], | |
| 305 | + }), | |
| 306 | + ) | |
| 307 | + .add( | |
| 308 | + HttpApiEndpoint.get("listUsersWithConnections", "/admin/users/with-connections", { | |
| 309 | + query: AdminListQuery, | |
| 310 | + success: AdminUsersWithConnectionsResponse, | |
| 311 | + error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden], | |
| 312 | + }), | |
| 313 | + ) | |
| 314 | + .add( | |
| 315 | + HttpApiEndpoint.get("listUserConnections", "/admin/users/:externalId/connections", { | |
| 316 | + params: AdminUserParams, | |
| 317 | + success: AdminUserConnectionsResponse, | |
| 318 | + error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden], | |
| 319 | + }), | |
| 320 | + ) | |
| 321 | + /** | |
| 322 | + * One user by `externalId` OR email, identity and connections in a single | |
| 323 | + * call — the read a per-user check makes, which operators run far more often | |
| 324 | + * than the bulk list. | |
| 325 | + * | |
| 326 | + * NOT-FOUND SEMANTICS. `AdminUserNotFound` (404) means "no subject row under | |
| 327 | + * this tenant matches", and that is the answer even when the host directory | |
| 328 | + * DOES know the email. A member who was invited but never touched executor | |
| 329 | + * has no footprint on this plane, and the admin plane exists to report | |
| 330 | + * footprint: "who are my users and what have they connected". Returning | |
| 331 | + * directory identity with an empty `connections` array would answer a | |
| 332 | + * question this API was not asked — it would make "is a member of the org" | |
| 333 | + * and "is a user of executor" the same answer, and a caller gating on that | |
| 334 | + * would treat a never-onboarded invitee as onboarded. 404 keeps the two | |
| 335 | + * distinct, and the org's membership roster is already the account plane's | |
| 336 | + * job (`/account/members`). The bulk `?email=` filter agrees: it returns an | |
| 337 | + * empty list, never a synthesized row. | |
| 338 | + */ | |
| 339 | + .add( | |
| 340 | + HttpApiEndpoint.get("getUser", "/admin/users/:identifier", { | |
| 341 | + params: AdminUserIdentifierParams, | |
| 342 | + success: AdminUserResponse, | |
| 343 | + error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden, AdminUserNotFound], | |
| 344 | + }), | |
| 345 | + ); | |
| 346 | + | |
| 347 | +/** | |
| 348 | + * Standalone HttpApi wrapping just the admin users group. Hosts mount THIS as | |
| 349 | + * an extension route (like the account API) rather than adding the group to | |
| 350 | + * `CoreExecutorApi`: the core API is served behind the execution-stack | |
| 351 | + * middleware, which binds a product-view executor to one acting subject. The | |
| 352 | + * admin plane needs the opposite (no subject, tenant reach), so it gets its own | |
| 353 | + * mount and its own per-host auth. | |
| 354 | + */ | |
| 355 | +export const AdminUsersHttpApi = HttpApi.make("executor-admin-users").add(AdminUsersApi); | |
| @@ -0,0 +1,69 @@ | ||
| 1 | +import { HttpApiBuilder } from "effect/unstable/httpapi"; | |
| 2 | +import { HttpServerRequest } from "effect/unstable/http"; | |
| 3 | +import { Effect } from "effect"; | |
| 4 | + | |
| 5 | +import { AdminUsersHttpApi } from "./api"; | |
| 6 | +import { normalizeEmail } from "./reads"; | |
| 7 | +import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service"; | |
| 8 | + | |
| 9 | +// --------------------------------------------------------------------------- | |
| 10 | +// Shared, provider-neutral handlers for the Admin Users API. They do nothing | |
| 11 | +// but read the request headers + paging and delegate to the injected | |
| 12 | +// `AdminUsersProvider`, so cloud and self-host serve identical routes — only | |
| 13 | +// the authorization impl differs. The neutral errors map to their HTTP statuses | |
| 14 | +// (401/403/500) via the contract annotations. | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | + | |
| 17 | +const requestHeaders = Effect.map( | |
| 18 | + HttpServerRequest.HttpServerRequest.asEffect(), | |
| 19 | + (request): AdminUsersHeaders => ({ ...request.headers }), | |
| 20 | +); | |
| 21 | + | |
| 22 | +// The contract decodes `limit`/`offset` to numbers, but leaves them optional. | |
| 23 | +// Spread only the keys that are present so the SDK's own defaults apply rather | |
| 24 | +// than an explicit `undefined` overriding them. | |
| 25 | +// `email` is normalized here rather than in the contract schema, so the filter | |
| 26 | +// and the single-user path parameter share ONE rule (`normalizeEmail`). | |
| 27 | +const listOptions = (query: { | |
| 28 | + readonly limit?: number | undefined; | |
| 29 | + readonly offset?: number | undefined; | |
| 30 | + readonly email?: string | undefined; | |
| 31 | +}): AdminUsersListOptions => ({ | |
| 32 | + ...(query.limit === undefined ? {} : { limit: query.limit }), | |
| 33 | + ...(query.offset === undefined ? {} : { offset: query.offset }), | |
| 34 | + ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), | |
| 35 | +}); | |
| 36 | + | |
| 37 | +export const AdminUsersHandlers = HttpApiBuilder.group( | |
| 38 | + AdminUsersHttpApi, | |
| 39 | + "adminUsers", | |
| 40 | + (handlers) => | |
| 41 | + handlers | |
| 42 | + .handle("listUsers", ({ query }) => | |
| 43 | + Effect.gen(function* () { | |
| 44 | + const headers = yield* requestHeaders; | |
| 45 | + return yield* (yield* AdminUsersProvider).listUsers(headers, listOptions(query)); | |
| 46 | + }), | |
| 47 | + ) | |
| 48 | + .handle("listUsersWithConnections", ({ query }) => | |
| 49 | + Effect.gen(function* () { | |
| 50 | + const headers = yield* requestHeaders; | |
| 51 | + return yield* (yield* AdminUsersProvider).listUsersWithConnections( | |
| 52 | + headers, | |
| 53 | + listOptions(query), | |
| 54 | + ); | |
| 55 | + }), | |
| 56 | + ) | |
| 57 | + .handle("listUserConnections", ({ params }) => | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const headers = yield* requestHeaders; | |
| 60 | + return yield* (yield* AdminUsersProvider).listUserConnections(headers, params.externalId); | |
| 61 | + }), | |
| 62 | + ) | |
| 63 | + .handle("getUser", ({ params }) => | |
| 64 | + Effect.gen(function* () { | |
| 65 | + const headers = yield* requestHeaders; | |
| 66 | + return yield* (yield* AdminUsersProvider).getUser(headers, params.identifier); | |
| 67 | + }), | |
| 68 | + ), | |
| 69 | +); | |
| @@ -0,0 +1,348 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// The READ half of the admin users surface, shared by every host. | |
| 3 | +// | |
| 4 | +// A host's `AdminUsersProvider` owns AUTHORIZATION (who may look) and delegates | |
| 5 | +// the actual reads here, so the projection from the SDK's platform view onto | |
| 6 | +// the wire shapes exists exactly ONCE. That matters because the projection IS | |
| 7 | +// the field-discipline boundary: if each host mapped rows itself, a credential | |
| 8 | +// field could leak into one host's responses and not the other's. | |
| 9 | +// | |
| 10 | +// Every mapping below is explicit rather than a spread. A spread would silently | |
| 11 | +// forward any field a future SDK change adds to `AdminSubject`/`AdminConnection` | |
| 12 | +// — the opposite of an allowlist. | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | + | |
| 15 | +import { Effect } from "effect"; | |
| 16 | + | |
| 17 | +import type { | |
| 18 | + AdminConnection, | |
| 19 | + AdminSubject, | |
| 20 | + AdminSubjectWithConnections, | |
| 21 | + Executor, | |
| 22 | + ExecutorAdmin, | |
| 23 | +} from "@executor-js/sdk"; | |
| 24 | + | |
| 25 | +import { | |
| 26 | + AdminUserNotFound, | |
| 27 | + AdminUsersError, | |
| 28 | + type AdminUserConnectionsResponse, | |
| 29 | + type AdminUserResponse, | |
| 30 | + type AdminUsersResponse, | |
| 31 | + type AdminUsersWithConnectionsResponse, | |
| 32 | +} from "./api"; | |
| 33 | +import type { AdminUsersListOptions } from "./service"; | |
| 34 | + | |
| 35 | +/** | |
| 36 | + * Narrow an executor to its platform view. `admin` is present only when the | |
| 37 | + * executor was built with `platformView: true`; a host that reaches these reads | |
| 38 | + * with a product-view executor is a wiring bug, so it fails loudly as a 500 | |
| 39 | + * rather than silently returning an empty tenant (which would read as "this | |
| 40 | + * owner has no users"). | |
| 41 | + */ | |
| 42 | +export const platformViewOf = (executor: Executor): Effect.Effect<ExecutorAdmin, AdminUsersError> => | |
| 43 | + executor.admin | |
| 44 | + ? Effect.succeed(executor.admin) | |
| 45 | + : Effect.fail( | |
| 46 | + new AdminUsersError({ | |
| 47 | + message: "Admin reads require an executor built with the platform view enabled", | |
| 48 | + }), | |
| 49 | + ); | |
| 50 | + | |
| 51 | +// Storage faults are the only failure these reads can raise. They surface as a | |
| 52 | +// 500 with a flat message — the cause carries the detail into the host's error | |
| 53 | +// capture, and is deliberately not echoed to an API consumer. | |
| 54 | +const readFailed = (what: string) => () => | |
| 55 | + new AdminUsersError({ message: `Failed to list ${what}` }); | |
| 56 | + | |
| 57 | +// --------------------------------------------------------------------------- | |
| 58 | +// Host identity join | |
| 59 | +// --------------------------------------------------------------------------- | |
| 60 | + | |
| 61 | +/** What a host's member directory can say about one principal. Both halves are | |
| 62 | + * independently nullable: a directory can hold an email with no name. */ | |
| 63 | +export interface AdminUserIdentity { | |
| 64 | + readonly email: string | null; | |
| 65 | + readonly displayName: string | null; | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** | |
| 69 | + * The ONE email normalization rule: shared by the `?email=` filter, the | |
| 70 | + * single-user path parameter, and every host's own resolver (which must apply | |
| 71 | + * it to the DIRECTORY value before comparing). | |
| 72 | + * | |
| 73 | + * Case-insensitive because the two hosts disagree about what they store: | |
| 74 | + * Better Auth lower-cases every email it writes, while WorkOS preserves the | |
| 75 | + * casing it was given. Lower-casing BOTH sides is the only rule that answers | |
| 76 | + * the same way on both. The local part of an address is technically | |
| 77 | + * case-sensitive per RFC 5321, but no directory this reads treats it that way, | |
| 78 | + * and an operator typing "Alice@..." means the same person. | |
| 79 | + */ | |
| 80 | +export const normalizeEmail = (email: string): string => email.trim().toLowerCase(); | |
| 81 | + | |
| 82 | +/** | |
| 83 | + * A host's member directory, keyed by the SAME id space the subject table | |
| 84 | + * records in `external_id`. | |
| 85 | + * | |
| 86 | + * That id is the host-auth principal id — cloud binds the WorkOS `user_...`, | |
| 87 | + * self-host binds the Better Auth `user.id` — so the join key on both hosts is | |
| 88 | + * the member list's `userId`, NEVER its `id` (a membership-row id: `om_...` on | |
| 89 | + * cloud, the `member` row on self-host, both of which join to nothing). | |
| 90 | + * | |
| 91 | + * Called ONCE per request with the whole page of ids, so a host implements it | |
| 92 | + * as one directory read joined in memory rather than a lookup per user. A host | |
| 93 | + * that cannot resolve identities at all simply passes no directory, and every | |
| 94 | + * row reports absent identity. | |
| 95 | + */ | |
| 96 | +export type AdminIdentityDirectory = ( | |
| 97 | + externalIds: readonly string[], | |
| 98 | +) => Effect.Effect<ReadonlyMap<string, AdminUserIdentity>, unknown>; | |
| 99 | + | |
| 100 | +/** | |
| 101 | + * The REVERSE of the join above: email → the host-auth principal id, so an | |
| 102 | + * operator can name a user by the identifier they actually have. | |
| 103 | + * | |
| 104 | + * The email arrives already trimmed and lower-cased by the contract, and an | |
| 105 | + * implementation must compare against a lower-cased directory value — neither | |
| 106 | + * host guarantees the casing it stores, so case-insensitivity is the seam's | |
| 107 | + * rule and not a per-host accident. | |
| 108 | + * | |
| 109 | + * `null` means the directory knows no such member. That is NOT the same as | |
| 110 | + * "not a user of this tenant": a resolved id may still have no subject row, | |
| 111 | + * which the reads below treat as absent (see `getUser`). Failures are the | |
| 112 | + * caller's to interpret — unlike the decorative identity join, a lookup that | |
| 113 | + * cannot run must not silently become "no such user". | |
| 114 | + */ | |
| 115 | +export type AdminEmailResolver = (email: string) => Effect.Effect<string | null, unknown>; | |
| 116 | + | |
| 117 | +/** Both directions of a host's member directory. Optional as a whole (a host | |
| 118 | + * with no directory reports unnamed rows and cannot resolve emails), and | |
| 119 | + * optional per direction. */ | |
| 120 | +export interface AdminUserDirectory { | |
| 121 | + readonly identities?: AdminIdentityDirectory; | |
| 122 | + readonly resolveEmail?: AdminEmailResolver; | |
| 123 | +} | |
| 124 | + | |
| 125 | +/** Identity is decoration on an operator view, not part of the answer: a | |
| 126 | + * directory outage must degrade to unnamed rows, never fail the read the | |
| 127 | + * operator actually asked for. Logged once per failed page — the cause carries | |
| 128 | + * the detail, and the caller learns nothing from it. */ | |
| 129 | +const resolveIdentities = ( | |
| 130 | + directory: AdminIdentityDirectory | undefined, | |
| 131 | + externalIds: readonly string[], | |
| 132 | +): Effect.Effect<ReadonlyMap<string, AdminUserIdentity>> => { | |
| 133 | + if (!directory || externalIds.length === 0) return Effect.succeed(new Map()); | |
| 134 | + return directory(externalIds).pipe( | |
| 135 | + Effect.catchCause((cause) => | |
| 136 | + Effect.as( | |
| 137 | + Effect.logWarning("admin users: host identity lookup failed; reporting users unnamed", { | |
| 138 | + cause, | |
| 139 | + }), | |
| 140 | + new Map<string, AdminUserIdentity>(), | |
| 141 | + ), | |
| 142 | + ), | |
| 143 | + ); | |
| 144 | +}; | |
| 145 | + | |
| 146 | +const ABSENT_IDENTITY: AdminUserIdentity = { email: null, displayName: null }; | |
| 147 | + | |
| 148 | +/** | |
| 149 | + * `AdminSubject` → the public `AdminUser` shape. | |
| 150 | + * | |
| 151 | + * `createdAt` crosses the wire as epoch ms (the SDK hands back a `Date`), which | |
| 152 | + * matches how every other "when did this happen" field on the API is carried | |
| 153 | + * and keeps the whole response JSON-native. | |
| 154 | + * | |
| 155 | + * `email`/`displayName` come from the host directory rather than storage, so an | |
| 156 | + * id the directory doesn't know — a member who left the org while their | |
| 157 | + * connections remain, or a host sentinel like "local" that names no member — | |
| 158 | + * reports absent identity beside otherwise complete row data. | |
| 159 | + */ | |
| 160 | +const toUser = (subject: AdminSubject, identities: ReadonlyMap<string, AdminUserIdentity>) => { | |
| 161 | + const identity = identities.get(subject.externalId) ?? ABSENT_IDENTITY; | |
| 162 | + return { | |
| 163 | + externalId: subject.externalId, | |
| 164 | + createdAt: subject.createdAt.getTime(), | |
| 165 | + lastSeenAt: subject.lastSeenAt, | |
| 166 | + status: subject.status, | |
| 167 | + email: identity.email, | |
| 168 | + displayName: identity.displayName, | |
| 169 | + }; | |
| 170 | +}; | |
| 171 | + | |
| 172 | +/** | |
| 173 | + * The stored health verdict → the admin plane's `AdminConnectionHealth`. | |
| 174 | + * | |
| 175 | + * NESTED OBJECTS GET THE SAME TREATMENT AS COLUMNS. `HealthCheckResult` is the | |
| 176 | + * owner's view of their own probe and carries content taken from the UPSTREAM | |
| 177 | + * provider's response: `identity` (the value at the configured `identityField` | |
| 178 | + * — usually the account's email at the provider), `detail` (a raw diagnostic | |
| 179 | + * that can quote an upstream error body), and `responseSample` (a bounded | |
| 180 | + * sample of the real response body's scalar leaves). This plane reports on | |
| 181 | + * OTHER people's connections, so none of that may travel; forwarding | |
| 182 | + * `connection.lastHealth` whole is exactly the leak this function exists to | |
| 183 | + * prevent — the column allowlist below would still have "passed" while every | |
| 184 | + * upstream field rode along inside it. | |
| 185 | + * | |
| 186 | + * Two fields survive, both of them verdicts ABOUT the connection rather than | |
| 187 | + * content FROM it: `status` and `checkedAt`. Written as an explicit | |
| 188 | + * construction, never a spread, for the same reason the row mappings are. | |
| 189 | + */ | |
| 190 | +const toHealth = (health: AdminConnection["lastHealth"]) => | |
| 191 | + health === null ? null : { status: health.status, checkedAt: health.checkedAt }; | |
| 192 | + | |
| 193 | +/** | |
| 194 | + * `AdminConnection` → the public `AdminUserConnection` shape. | |
| 195 | + * | |
| 196 | + * `subject` is dropped: on `/admin/users/:externalId/connections` it is the | |
| 197 | + * path parameter, and on the joined view it is the enclosing user's own id, so | |
| 198 | + * carrying it would be redundant on both. Everything kept here is either an | |
| 199 | + * identifier or a health/access summary — never credential material, and never | |
| 200 | + * upstream account content (see `toHealth`). | |
| 201 | + */ | |
| 202 | +const toConnection = (connection: AdminConnection) => ({ | |
| 203 | + owner: connection.owner, | |
| 204 | + integration: connection.integration, | |
| 205 | + name: connection.name, | |
| 206 | + oauthScope: connection.oauthScope, | |
| 207 | + lastHealth: toHealth(connection.lastHealth), | |
| 208 | +}); | |
| 209 | + | |
| 210 | +const toUserWithConnections = ( | |
| 211 | + subject: AdminSubjectWithConnections, | |
| 212 | + identities: ReadonlyMap<string, AdminUserIdentity>, | |
| 213 | +) => ({ | |
| 214 | + ...toUser(subject, identities), | |
| 215 | + connections: subject.connections.map(toConnection), | |
| 216 | +}); | |
| 217 | + | |
| 218 | +/** | |
| 219 | + * Accept the legacy one-way directory or the two-way one, so a host that only | |
| 220 | + * joins identities keeps working unchanged. | |
| 221 | + */ | |
| 222 | +const asDirectory = ( | |
| 223 | + directory: AdminIdentityDirectory | AdminUserDirectory | undefined, | |
| 224 | +): AdminUserDirectory => | |
| 225 | + directory === undefined | |
| 226 | + ? {} | |
| 227 | + : typeof directory === "function" | |
| 228 | + ? { identities: directory } | |
| 229 | + : directory; | |
| 230 | + | |
| 231 | +/** | |
| 232 | + * Turn an `?email=` filter into the id to keep, or `null` for "nothing can | |
| 233 | + * match" (unknown email, or a host with no resolver — an email filter no host | |
| 234 | + * can answer must return nothing, never an unfiltered page). | |
| 235 | + * | |
| 236 | + * A resolver FAILURE is a 500, not an empty page: silently reporting "no such | |
| 237 | + * user" because a directory was unreachable is the kind of wrong answer an | |
| 238 | + * operator would act on. | |
| 239 | + */ | |
| 240 | +const resolveEmailFilter = ( | |
| 241 | + directory: AdminUserDirectory, | |
| 242 | + email: string, | |
| 243 | +): Effect.Effect<string | null, AdminUsersError> => { | |
| 244 | + const resolve = directory.resolveEmail; | |
| 245 | + if (!resolve) return Effect.succeed(null); | |
| 246 | + return resolve(email).pipe( | |
| 247 | + Effect.mapError(() => new AdminUsersError({ message: "Failed to resolve the email address" })), | |
| 248 | + ); | |
| 249 | +}; | |
| 250 | + | |
| 251 | +/** Keep only the subject naming `externalId`. The filter is applied AFTER the | |
| 252 | + * page read, so it narrows the requested page rather than scanning past it — | |
| 253 | + * see the contract's note on paging a filtered result. */ | |
| 254 | +const filterToId = <T extends { readonly externalId: string }>( | |
| 255 | + subjects: readonly T[], | |
| 256 | + externalId: string | null, | |
| 257 | +): readonly T[] => | |
| 258 | + externalId === null ? [] : subjects.filter((subject) => subject.externalId === externalId); | |
| 259 | + | |
| 260 | +export const listUsers = ( | |
| 261 | + admin: ExecutorAdmin, | |
| 262 | + options: AdminUsersListOptions, | |
| 263 | + directory?: AdminIdentityDirectory | AdminUserDirectory, | |
| 264 | +): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> => | |
| 265 | + Effect.gen(function* () { | |
| 266 | + const dir = asDirectory(directory); | |
| 267 | + const wanted = | |
| 268 | + options.email === undefined ? undefined : yield* resolveEmailFilter(dir, options.email); | |
| 269 | + const all = yield* admin.listSubjects(options).pipe(Effect.mapError(readFailed("users"))); | |
| 270 | + const subjects = wanted === undefined ? all : filterToId(all, wanted); | |
| 271 | + // One directory read for the page that was actually returned, joined in | |
| 272 | + // memory — never a lookup per user. | |
| 273 | + const identities = yield* resolveIdentities( | |
| 274 | + dir.identities, | |
| 275 | + subjects.map((subject) => subject.externalId), | |
| 276 | + ); | |
| 277 | + return { users: subjects.map((subject) => toUser(subject, identities)) }; | |
| 278 | + }); | |
| 279 | + | |
| 280 | +export const listUsersWithConnections = ( | |
| 281 | + admin: ExecutorAdmin, | |
| 282 | + options: AdminUsersListOptions, | |
| 283 | + directory?: AdminIdentityDirectory | AdminUserDirectory, | |
| 284 | +): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> => | |
| 285 | + Effect.gen(function* () { | |
| 286 | + const dir = asDirectory(directory); | |
| 287 | + const wanted = | |
| 288 | + options.email === undefined ? undefined : yield* resolveEmailFilter(dir, options.email); | |
| 289 | + const all = yield* admin | |
| 290 | + .listSubjectsWithConnections(options) | |
| 291 | + .pipe(Effect.mapError(readFailed("users"))); | |
| 292 | + const subjects = wanted === undefined ? all : filterToId(all, wanted); | |
| 293 | + const identities = yield* resolveIdentities( | |
| 294 | + dir.identities, | |
| 295 | + subjects.map((subject) => subject.externalId), | |
| 296 | + ); | |
| 297 | + return { users: subjects.map((subject) => toUserWithConnections(subject, identities)) }; | |
| 298 | + }); | |
| 299 | + | |
| 300 | +/** | |
| 301 | + * One user by `externalId` OR email, with connections. | |
| 302 | + * | |
| 303 | + * RESOLUTION ORDER. An identifier containing "@" is tried as an email first; | |
| 304 | + * if the directory knows no such member, or the id it resolves to has no | |
| 305 | + * subject row, the identifier is then tried as a literal `externalId`. That | |
| 306 | + * fallback is what keeps `externalId` opaque: no host mints an id containing | |
| 307 | + * "@" today, but the contract promises nothing may parse the id, so the "@" | |
| 308 | + * test is only ever a routing hint that costs one extra keyed read in a case | |
| 309 | + * that does not currently arise. | |
| 310 | + * | |
| 311 | + * A directory hit with no subject row is `AdminUserNotFound`, the same as an | |
| 312 | + * unknown identifier — the plane reports footprint, not org membership. See | |
| 313 | + * the `getUser` contract docs for why. | |
| 314 | + */ | |
| 315 | +export const getUser = ( | |
| 316 | + admin: ExecutorAdmin, | |
| 317 | + identifier: string, | |
| 318 | + directory?: AdminIdentityDirectory | AdminUserDirectory, | |
| 319 | +): Effect.Effect<typeof AdminUserResponse.Type, AdminUsersError | AdminUserNotFound> => | |
| 320 | + Effect.gen(function* () { | |
| 321 | + const dir = asDirectory(directory); | |
| 322 | + const read = (externalId: string) => | |
| 323 | + admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("user"))); | |
| 324 | + | |
| 325 | + const subject = yield* identifier.includes("@") | |
| 326 | + ? Effect.gen(function* () { | |
| 327 | + const resolved = yield* resolveEmailFilter(dir, normalizeEmail(identifier)); | |
| 328 | + const byEmail = resolved === null ? null : yield* read(resolved); | |
| 329 | + return byEmail ?? (yield* read(identifier)); | |
| 330 | + }) | |
| 331 | + : read(identifier); | |
| 332 | + | |
| 333 | + if (subject === null) return yield* new AdminUserNotFound(); | |
| 334 | + | |
| 335 | + // Identity is joined through the SAME batched seam the list uses, so the | |
| 336 | + // single read reports exactly the fields the list would for this row. | |
| 337 | + const identities = yield* resolveIdentities(dir.identities, [subject.externalId]); | |
| 338 | + return { user: toUserWithConnections(subject, identities) }; | |
| 339 | + }); | |
| 340 | + | |
| 341 | +export const listUserConnections = ( | |
| 342 | + admin: ExecutorAdmin, | |
| 343 | + externalId: string, | |
| 344 | +): Effect.Effect<typeof AdminUserConnectionsResponse.Type, AdminUsersError> => | |
| 345 | + admin.listSubjectConnections(externalId).pipe( | |
| 346 | + Effect.map((connections) => ({ connections: connections.map(toConnection) })), | |
| 347 | + Effect.mapError(readFailed("connections")), | |
| 348 | + ); | |
| @@ -0,0 +1,81 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// AdminUsersProvider — the provider seam behind the neutral Admin Users API. | |
| 3 | +// | |
| 4 | +// The shared `AdminUsersHandlers` are generic: they read the request headers, | |
| 5 | +// authorize through this service, and project the SDK's platform view onto the | |
| 6 | +// wire shapes. Each host implements the AUTHORIZATION half differently: | |
| 7 | +// - cloud → an org-scoped API key (PlatformAuth) OR an admin session member | |
| 8 | +// - self-host → a Better Auth owner/admin member of the single org | |
| 9 | +// while the READ half is identical everywhere (`executor.admin`). | |
| 10 | +// | |
| 11 | +// This mirrors `AccountProvider`: one set of handlers, one contract, per-host | |
| 12 | +// implementations. Methods take the raw request headers (cookie / bearer) so | |
| 13 | +// the implementation can resolve and authorize the caller itself — the admin | |
| 14 | +// plane is NOT behind the execution-stack middleware, because that middleware | |
| 15 | +// binds a product-view executor to one acting subject and the admin plane needs | |
| 16 | +// a subject-less, tenant-reach one. | |
| 17 | +// --------------------------------------------------------------------------- | |
| 18 | + | |
| 19 | +import { Context, type Effect } from "effect"; | |
| 20 | + | |
| 21 | +import { | |
| 22 | + type AdminUserNotFound, | |
| 23 | + type AdminUsersError, | |
| 24 | + type AdminUsersForbidden, | |
| 25 | + type AdminUsersUnauthorized, | |
| 26 | + AdminUserResponse, | |
| 27 | + AdminUsersResponse, | |
| 28 | + AdminUserConnectionsResponse, | |
| 29 | + AdminUsersWithConnectionsResponse, | |
| 30 | +} from "./api"; | |
| 31 | + | |
| 32 | +export type AdminUsersHeaders = Record<string, string>; | |
| 33 | + | |
| 34 | +/** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus | |
| 35 | + * the contract's `?email=`. The email arrives already trimmed and lower-cased | |
| 36 | + * by the contract schema, so a provider never re-normalizes it. */ | |
| 37 | +export interface AdminUsersListOptions { | |
| 38 | + readonly limit?: number; | |
| 39 | + readonly offset?: number; | |
| 40 | + readonly email?: string; | |
| 41 | +} | |
| 42 | + | |
| 43 | +type User = typeof AdminUserResponse.Type; | |
| 44 | +type Users = typeof AdminUsersResponse.Type; | |
| 45 | +type UserConnections = typeof AdminUserConnectionsResponse.Type; | |
| 46 | +type UsersWithConnections = typeof AdminUsersWithConnectionsResponse.Type; | |
| 47 | + | |
| 48 | +/** Every admin read: authorized, and failing with the neutral triple. */ | |
| 49 | +type Authorized<A> = Effect.Effect< | |
| 50 | + A, | |
| 51 | + AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden | |
| 52 | +>; | |
| 53 | + | |
| 54 | +export interface AdminUsersProviderShape { | |
| 55 | + readonly listUsers: ( | |
| 56 | + headers: AdminUsersHeaders, | |
| 57 | + options: AdminUsersListOptions, | |
| 58 | + ) => Authorized<Users>; | |
| 59 | + readonly listUsersWithConnections: ( | |
| 60 | + headers: AdminUsersHeaders, | |
| 61 | + options: AdminUsersListOptions, | |
| 62 | + ) => Authorized<UsersWithConnections>; | |
| 63 | + readonly listUserConnections: ( | |
| 64 | + headers: AdminUsersHeaders, | |
| 65 | + externalId: string, | |
| 66 | + ) => Authorized<UserConnections>; | |
| 67 | + /** One user by `externalId` or email. Adds 404 to the neutral triple — the | |
| 68 | + * only read here that can honestly say "no such user of yours". */ | |
| 69 | + readonly getUser: ( | |
| 70 | + headers: AdminUsersHeaders, | |
| 71 | + identifier: string, | |
| 72 | + ) => Effect.Effect< | |
| 73 | + User, | |
| 74 | + AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden | AdminUserNotFound | |
| 75 | + >; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export class AdminUsersProvider extends Context.Service< | |
| 79 | + AdminUsersProvider, | |
| 80 | + AdminUsersProviderShape | |
| 81 | +>()("@executor-js/api/AdminUsersProvider") {} | |
| @@ -14,3 +14,16 @@ export { · 13 unmodified lines | ||
| 14 | 14 | AccountNoOrganization, |
| 15 | 15 | AccountUnauthorized, |
| 16 | 16 | } from "./account/api"; |
| 17 | +export { | |
| 18 | + AdminUsersApi, | |
| 19 | + AdminUsersHttpApi, | |
| 20 | + AdminUsersError, | |
| 21 | + AdminUsersForbidden, | |
| 22 | + AdminUsersUnauthorized, | |
| 23 | + AdminUser, | |
| 24 | + AdminUserConnection, | |
| 25 | + AdminUserWithConnections, | |
| 26 | + AdminUsersResponse, | |
| 27 | + AdminUserConnectionsResponse, | |
| 28 | + AdminUsersWithConnectionsResponse, | |
| 29 | +} from "./admin/api"; | |
| @@ -46,6 +46,7 @@ export { · 45 unmodified lines | ||
| 46 | 46 | AccountMeResponse, |
| 47 | 47 | ApiKeySummary, |
| 48 | 48 | ApiKeysResponse, |
| 49 | + OrgApiKeysResponse, | |
| 49 | 50 | CreateApiKeyBody, |
| 50 | 51 | CreatedApiKeyResponse, |
| 51 | 52 | OrgMember, |
| @@ -60,6 +61,21 @@ export { · 8 unmodified lines | ||
| 60 | 61 | UpdateOrgNameResponse, |
| 61 | 62 | SuccessResponse, |
| 62 | 63 | } from "./account/api"; |
| 64 | +export { | |
| 65 | + AdminUsersApi, | |
| 66 | + AdminUsersHttpApi, | |
| 67 | + AdminUsersError, | |
| 68 | + AdminUsersForbidden, | |
| 69 | + AdminUsersUnauthorized, | |
| 70 | + AdminUserNotFound, | |
| 71 | + AdminUser, | |
| 72 | + AdminUserConnection, | |
| 73 | + AdminUserWithConnections, | |
| 74 | + AdminUserResponse, | |
| 75 | + AdminUsersResponse, | |
| 76 | + AdminUserConnectionsResponse, | |
| 77 | + AdminUsersWithConnectionsResponse, | |
| 78 | +} from "./admin/api"; | |
| 63 | 79 | export { |
| 64 | 80 | RESERVED_ORG_SLUGS, |
| 65 | 81 | generateOrgSlug, |
| @@ -18,6 +18,25 @@ export { · 17 unmodified lines | ||
| 18 | 18 | } from "./plugin-routes"; |
| 19 | 19 | export { AccountProvider, type AccountProviderShape, type AccountHeaders } from "./account/service"; |
| 20 | 20 | export { AccountHandlers } from "./account/handlers"; |
| 21 | +export { | |
| 22 | + AdminUsersProvider, | |
| 23 | + type AdminUsersProviderShape, | |
| 24 | + type AdminUsersHeaders, | |
| 25 | + type AdminUsersListOptions, | |
| 26 | +} from "./admin/service"; | |
| 27 | +export { AdminUsersHandlers } from "./admin/handlers"; | |
| 28 | +export { | |
| 29 | + platformViewOf, | |
| 30 | + listUsers as listAdminUsers, | |
| 31 | + listUsersWithConnections as listAdminUsersWithConnections, | |
| 32 | + listUserConnections as listAdminUserConnections, | |
| 33 | + getUser as getAdminUser, | |
| 34 | + normalizeEmail as normalizeAdminUserEmail, | |
| 35 | + type AdminEmailResolver, | |
| 36 | + type AdminIdentityDirectory, | |
| 37 | + type AdminUserDirectory, | |
| 38 | + type AdminUserIdentity, | |
| 39 | +} from "./admin/reads"; | |
| 21 | 40 | export { requestScopedMiddleware } from "./server/request-scoped"; |
| 22 | 41 | export { RouterConfigLive } from "./server/router-config"; |
| 23 | 42 | export { consoleErrorCapture } from "./server/console-error-capture"; |
| @@ -53,6 +72,7 @@ export { · 29 unmodified lines | ||
| 53 | 72 | } from "./server/executor-fuma-db"; |
| 54 | 73 | export { |
| 55 | 74 | makeScopedExecutor, |
| 75 | + makePlatformExecutor, | |
| 56 | 76 | HostConfig, |
| 57 | 77 | PluginsProvider, |
| 58 | 78 | RequestWebOrigin, |
| @@ -89,10 +109,12 @@ export { · 30 unmodified lines | ||
| 89 | 109 | export { |
| 90 | 110 | makeProtectedApiLayer, |
| 91 | 111 | makeAccountApiLayer, |
| 112 | + makeAdminUsersApiLayer, | |
| 92 | 113 | accountProviderMiddlewareLayer, |
| 93 | 114 | toApiHandler, |
| 94 | 115 | type MakeProtectedApiLayerOptions, |
| 95 | 116 | type MakeAccountApiLayerOptions, |
| 117 | + type MakeAdminUsersApiLayerOptions, | |
| 96 | 118 | type ApiHandler, |
| 97 | 119 | } from "./server/host-foundation"; |
| 98 | 120 | export { |
| @@ -40,6 +40,8 @@ import { observabilityMiddleware, type ErrorCapture } from "../observability"; · 39 unmodified lines | ||
| 40 | 40 | import { AccountHttpApi } from "../account/api"; |
| 41 | 41 | import { AccountHandlers } from "../account/handlers"; |
| 42 | 42 | import type { AccountProvider } from "../account/service"; |
| 43 | +import { AdminUsersHttpApi } from "../admin/api"; | |
| 44 | +import { AdminUsersHandlers } from "../admin/handlers"; | |
| 43 | 45 | import { composePluginApi, composePluginHandlerLayer } from "../plugin-routes"; |
| 44 | 46 | import { CoreHandlers } from "../handlers"; |
| 45 | 47 | import { requestScopedMiddleware } from "./request-scoped"; |
| @@ -179,6 +181,46 @@ export const accountProviderMiddlewareLayer = ( · 133 unmodified lines | ||
| 179 | 181 | accountProviderLayer: Layer.Layer<AccountProvider>, |
| 180 | 182 | ) => requestScopedMiddleware(accountProviderLayer).layer; |
| 181 | 183 | |
| 184 | +// --------------------------------------------------------------------------- | |
| 185 | +// Admin Users API | |
| 186 | +// --------------------------------------------------------------------------- | |
| 187 | + | |
| 188 | +export interface MakeAdminUsersApiLayerOptions { | |
| 189 | + /** | |
| 190 | + * Optional prefixed `HttpRouter` view, matching the protected API's prefix so | |
| 191 | + * the admin routes register on the same `/api`-prefixed router. | |
| 192 | + */ | |
| 193 | + readonly router?: RouterLayer; | |
| 194 | +} | |
| 195 | + | |
| 196 | +/** | |
| 197 | + * Mount the shared, provider-neutral `AdminUsersHandlers` (the tenant-wide | |
| 198 | + * users + connections reads) behind a per-request `AdminUsersProvider`. | |
| 199 | + * | |
| 200 | + * Structurally identical to `makeAccountApiLayer`, and for the same reason: an | |
| 201 | + * HttpApi handler's service requirement is NOT erased by a plain | |
| 202 | + * `Layer.provide` on the builder layer (it leaks into the app layer's | |
| 203 | + * requirements and breaks the host build), so the provider arrives through a | |
| 204 | + * per-request router middleware. | |
| 205 | + * | |
| 206 | + * This is mounted as an app EXTENSION route rather than added to | |
| 207 | + * `CoreExecutorApi`, because the core API is served behind the execution-stack | |
| 208 | + * middleware — which authenticates a member `Principal` and binds a | |
| 209 | + * product-view executor to them. The admin plane's callers (an org API key with | |
| 210 | + * no member behind it) and its executor (subject-less, tenant reach) are both | |
| 211 | + * the opposite of that, so it gets its own mount and its own auth. | |
| 212 | + */ | |
| 213 | +export const makeAdminUsersApiLayer = <MOut, ME, MR>( | |
| 214 | + adminUsersProviderMiddleware: Layer.Layer<MOut, ME, MR>, | |
| 215 | + options: MakeAdminUsersApiLayerOptions = {}, | |
| 216 | +) => { | |
| 217 | + const base = HttpApiBuilder.layer(AdminUsersHttpApi).pipe( | |
| 218 | + Layer.provide(AdminUsersHandlers), | |
| 219 | + Layer.provide(adminUsersProviderMiddleware), | |
| 220 | + ); | |
| 221 | + return options.router ? base.pipe(Layer.provide(options.router)) : base; | |
| 222 | +}; | |
| 223 | + | |
| 182 | 224 | // --------------------------------------------------------------------------- |
| 183 | 225 | // App-layer web-handler binding |
| 184 | 226 | // --------------------------------------------------------------------------- |
| @@ -42,7 +42,11 @@ import { · 41 unmodified lines | ||
| 42 | 42 | type Executor, |
| 43 | 43 | type StorageFailure, |
| 44 | 44 | } from "@executor-js/sdk"; |
| 45 | −import { makeHostedFetch, makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; | |
| 45 | +import { | |
| 46 | + makeHostedFetch, | |
| 47 | + makeHostedHttpClientLayer, | |
| 48 | + touchSubject, | |
| 49 | +} from "@executor-js/sdk/host-internal"; | |
| 46 | 50 | |
| 47 | 51 | import { DbProvider } from "./executor-fuma-db"; |
| 48 | 52 | |
| @@ -281,8 +285,85 @@ export const makeScopedExecutor = < · 232 unmodified lines | ||
| 281 | 285 | includeProviders: config.exposeCredentialProviders ?? true, |
| 282 | 286 | }, |
| 283 | 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 | 298 | // The seam erases the plugin tuple type; the caller re-narrows via the |
| 285 | 299 | // `TPlugins` phantom. Runtime shape is identical to a typed |
| 286 | 300 | // `createExecutor({ plugins })` call. |
| 287 | 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 | + | |
| 337 | +export const makePlatformExecutor = ( | |
| 338 | + organizationId: string, | |
| 339 | +): Effect.Effect<Executor, StorageFailure, DbProvider | PluginsProvider | HostConfig> => | |
| 340 | + Effect.gen(function* () { | |
| 341 | + const { db, blobs } = yield* DbProvider.asEffect().pipe( | |
| 342 | + Effect.withSpan("executor.platform.db_provider"), | |
| 343 | + ); | |
| 344 | + const { plugins: pluginsFactory } = yield* PluginsProvider.asEffect().pipe( | |
| 345 | + Effect.withSpan("executor.platform.plugins_provider"), | |
| 346 | + ); | |
| 347 | + const config = yield* HostConfig.asEffect().pipe( | |
| 348 | + Effect.withSpan("executor.platform.host_config"), | |
| 349 | + ); | |
| 350 | + | |
| 351 | + // The plugin set still has to be built: the platform view reads the | |
| 352 | + // `connection` table, whose rows name integrations a plugin owns, and the | |
| 353 | + // executor's construction validates against the registered plugin set. | |
| 354 | + const plugins = yield* Effect.sync(() => pluginsFactory()).pipe( | |
| 355 | + Effect.withSpan("executor.platform.plugins.init"), | |
| 356 | + ); | |
| 357 | + const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork }; | |
| 358 | + | |
| 359 | + return yield* createExecutor({ | |
| 360 | + tenant: Tenant.make(organizationId), | |
| 361 | + db, | |
| 362 | + blobs, | |
| 363 | + plugins, | |
| 364 | + httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions), | |
| 365 | + fetch: makeHostedFetch(hostedHttpOptions), | |
| 366 | + onElicitation: "accept-all", | |
| 367 | + platformView: true, | |
| 368 | + }).pipe(Effect.withSpan("executor.platform.create_executor")); | |
| 369 | + }); | |
| @@ -5,6 +5,7 @@ import { StorageError, type FumaRow } from "./fuma-runtime"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | assertOwnerPatch, |
| 7 | 7 | assertOwnerWritable, |
| 8 | + assertReachReadOnly, | |
| 8 | 9 | executorOwnerPolicyName, |
| 9 | 10 | executorTenantPolicyName, |
| 10 | 11 | executorUnscopedPolicyName, |
| @@ -67,6 +68,10 @@ const tenantExecutorTable = <const TColumns extends UserColumns>( · 56 unmodified lines | ||
| 67 | 68 | name: executorTenantPolicyName, |
| 68 | 69 | onRead: ({ builder, context }) => builder("tenant", "=", context.tenant), |
| 69 | 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 | 75 | if (values.tenant !== context.tenant) { |
| 71 | 76 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB table policy callbacks are promise callbacks, not Effect effects |
| 72 | 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), | |
| 79 | − onDelete: ({ builder, context }) => builder("tenant", "=", context.tenant), | |
| 83 | + onUpdate: ({ builder, context }) => { | |
| 84 | + assertReachReadOnly(name, "write", context); | |
| 85 | + return builder("tenant", "=", context.tenant); | |
| 86 | + }, | |
| 87 | + onDelete: ({ builder, context }) => { | |
| 88 | + assertReachReadOnly(name, "delete", context); | |
| 89 | + return builder("tenant", "=", context.tenant); | |
| 90 | + }, | |
| 80 | 91 | }); |
| 81 | 92 | }; |
| 82 | 93 | |
| @@ -104,7 +115,13 @@ const ownedExecutorTable = <const TColumns extends UserColumns>( · 21 unmodified lines | ||
| 104 | 115 | assertOwnerPatch(name, create, context); |
| 105 | 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 | 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 | 200 | // THE saved credential, one per (owner, integration, name). Resolves each named |
| 155 | 201 | // input via `provider` + the `item_ids` map (variable → provider item id). A |
| 156 | 202 | // single-secret connection is `{ "token": <id> }`; an apiKey method with two |
| @@ -338,6 +384,7 @@ export const coreSchema = coreTables; · 181 unmodified lines | ||
| 338 | 384 | export type CoreSchema = typeof coreTables; |
| 339 | 385 | |
| 340 | 386 | export type IntegrationRow = FumaRow<CoreSchema["integration"]>; |
| 387 | +export type SubjectRow = FumaRow<CoreSchema["subject"]>; | |
| 341 | 388 | export type ConnectionRow = FumaRow<CoreSchema["connection"]>; |
| 342 | 389 | export type OAuthClientRow = FumaRow<CoreSchema["oauth_client"]>; |
| 343 | 390 | export type OAuthSessionRow = FumaRow<CoreSchema["oauth_session"]>; |
| @@ -107,6 +107,7 @@ import { · 106 unmodified lines | ||
| 107 | 107 | type UpdateToolPolicyInput, |
| 108 | 108 | } from "./policies"; |
| 109 | 109 | import type { CredentialProvider, ProviderEntry } from "./provider"; |
| 110 | +import { touchSubject } from "./subject-registry"; | |
| 110 | 111 | import type { |
| 111 | 112 | AnyPlugin, |
| 112 | 113 | Elicit, |
| @@ -368,6 +369,15 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 255 unmodified lines | ||
| 368 | 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 | 381 | readonly execute: ( |
| 372 | 382 | address: ToolAddress, |
| 373 | 383 | args: unknown, |
| @@ -377,6 +387,148 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 3 unmodified lines | ||
| 377 | 387 | readonly close: () => Effect.Effect<void, StorageFailure>; |
| 378 | 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). */ | |
| 409 | +export interface AdminSubject { | |
| 410 | + /** The host-auth principal id. Opaque — it also carries host sentinels | |
| 411 | + * like "local", so nothing may parse it. */ | |
| 412 | + readonly externalId: string; | |
| 413 | + readonly createdAt: Date; | |
| 414 | + /** Epoch ms of the last sighting on the request path; null when never seen | |
| 415 | + * (a subject can be created at connection-create before any sighting). */ | |
| 416 | + readonly lastSeenAt: number | null; | |
| 417 | + readonly status: string | null; | |
| 418 | +} | |
| 419 | + | |
| 420 | +/** A connection as the platform view sees it: enough to answer "what has this | |
| 421 | + * user connected and is it healthy", and nothing that could resolve a | |
| 422 | + * credential. */ | |
| 423 | +export interface AdminConnection { | |
| 424 | + readonly owner: Owner; | |
| 425 | + /** The owning principal — `null` for org-owned connections, which belong to | |
| 426 | + * the tenant rather than to any one user. */ | |
| 427 | + readonly subject: string | null; | |
| 428 | + readonly integration: IntegrationSlug; | |
| 429 | + readonly name: ConnectionName; | |
| 430 | + /** The scope set the provider actually granted, space-delimited as recorded | |
| 431 | + * at connect/refresh. Null for static credentials. A summary of ACCESS — | |
| 432 | + * never a token. */ | |
| 433 | + readonly oauthScope: string | null; | |
| 434 | + readonly lastHealth: HealthCheckResult | null; | |
| 435 | +} | |
| 436 | + | |
| 437 | +/** A subject together with every connection it owns in the tenant. */ | |
| 438 | +export interface AdminSubjectWithConnections extends AdminSubject { | |
| 439 | + readonly connections: readonly AdminConnection[]; | |
| 440 | +} | |
| 441 | + | |
| 442 | +export interface AdminListSubjectsOptions { | |
| 443 | + readonly limit?: number; | |
| 444 | + readonly offset?: number; | |
| 445 | +} | |
| 446 | + | |
| 447 | +/** | |
| 448 | + * Page size applied when a caller names none. Every admin list is BOUNDED: | |
| 449 | + * `listSubjects()` with no arguments is the obvious call, and unbounded it | |
| 450 | + * returns every subject in the tenant — which `listSubjectsWithConnections` | |
| 451 | + * then turns into one sequential connection query PER SUBJECT, inside a single | |
| 452 | + * request, over a per-request socket on cloud. A default is what keeps the | |
| 453 | + * no-args call honest; a caller who wants more asks for more, up to | |
| 454 | + * {@link ADMIN_MAX_PAGE_SIZE}. | |
| 455 | + * | |
| 456 | + * 100 rather than the maximum: large enough that no realistic operator UI pages | |
| 457 | + * twice for a first screen, small enough that the joined read's fan-out stays a | |
| 458 | + * bounded cost even at its worst. | |
| 459 | + */ | |
| 460 | +export const ADMIN_DEFAULT_PAGE_SIZE = 100; | |
| 461 | + | |
| 462 | +/** Hard ceiling on an admin page, matching the HTTP contract's `limit` maximum. | |
| 463 | + * A larger `limit` is clamped rather than honored. */ | |
| 464 | +export const ADMIN_MAX_PAGE_SIZE = 500; | |
| 465 | + | |
| 466 | +/** | |
| 467 | + * Normalize paging for every admin list. | |
| 468 | + * | |
| 469 | + * THREE things happen here, each fixing a real failure: | |
| 470 | + * - a `limit` is ALWAYS produced. Drizzle's SQLite dialect emits OFFSET only | |
| 471 | + * alongside LIMIT, and SQLite rejects a bare OFFSET, so `{ offset: 25 }` | |
| 472 | + * was a syntax error on every SQLite host (local, self-host, D1). | |
| 473 | + * - the limit is clamped to `[1, ADMIN_MAX_PAGE_SIZE]`, so no caller can ask | |
| 474 | + * for an unbounded scan. | |
| 475 | + * - both values are floored to integers. The HTTP contract rejects a | |
| 476 | + * fractional `?limit=` outright (a 400, not a coerced value); this is the | |
| 477 | + * SDK-level backstop so no driver ever receives a fraction and answers with | |
| 478 | + * `datatype mismatch`. | |
| 479 | + */ | |
| 480 | +const normalizeAdminPaging = ( | |
| 481 | + options: AdminListSubjectsOptions | undefined, | |
| 482 | +): { readonly limit: number; readonly offset: number } => { | |
| 483 | + const requested = options?.limit ?? ADMIN_DEFAULT_PAGE_SIZE; | |
| 484 | + const limit = Math.min(Math.max(Math.floor(requested), 1), ADMIN_MAX_PAGE_SIZE); | |
| 485 | + const offset = Math.max(Math.floor(options?.offset ?? 0), 0); | |
| 486 | + return { limit, offset }; | |
| 487 | +}; | |
| 488 | + | |
| 489 | +export interface ExecutorAdmin { | |
| 490 | + /** One page of subjects under the tenant, oldest first (stable: ties break on | |
| 491 | + * `external_id`). ALWAYS bounded: no arguments means | |
| 492 | + * {@link ADMIN_DEFAULT_PAGE_SIZE} rows from offset 0, and `limit` is clamped | |
| 493 | + * to {@link ADMIN_MAX_PAGE_SIZE}. There is no way to ask for every subject | |
| 494 | + * in one call. */ | |
| 495 | + readonly listSubjects: ( | |
| 496 | + options?: AdminListSubjectsOptions, | |
| 497 | + ) => Effect.Effect<readonly AdminSubject[], StorageFailure>; | |
| 498 | + /** | |
| 499 | + * One subject by its `external_id`, or `null` when the tenant has no such | |
| 500 | + * row. A keyed read on the `(tenant, external_id)` unique index rather than | |
| 501 | + * a filtered `listSubjects`, because the caller asking for ONE principal | |
| 502 | + * must not pay for a tenant-wide scan — this is the read behind a per-user | |
| 503 | + * check that runs far more often than the bulk list. | |
| 504 | + * | |
| 505 | + * `null` is a normal answer, not a failure: it means "this tenant has never | |
| 506 | + * recorded that principal". Distinguishing that from a storage fault is the | |
| 507 | + * whole point of the nullable return. | |
| 508 | + */ | |
| 509 | + readonly getSubject: (externalId: string) => Effect.Effect<AdminSubject | null, StorageFailure>; | |
| 510 | + /** Every connection in the tenant owned by `externalId`. Org-owned | |
| 511 | + * connections are NOT attributed to a user and are excluded. */ | |
| 512 | + readonly listSubjectConnections: ( | |
| 513 | + externalId: string, | |
| 514 | + ) => Effect.Effect<readonly AdminConnection[], StorageFailure>; | |
| 515 | + /** `listSubjects` joined with each subject's connections — one connection | |
| 516 | + * query per subject IN THE PAGE, sequentially. The paging bound is what | |
| 517 | + * makes that fan-out affordable: it is capped at | |
| 518 | + * {@link ADMIN_DEFAULT_PAGE_SIZE} round trips by default and | |
| 519 | + * {@link ADMIN_MAX_PAGE_SIZE} at worst, never "every subject in the | |
| 520 | + * tenant". */ | |
| 521 | + readonly listSubjectsWithConnections: ( | |
| 522 | + options?: AdminListSubjectsOptions, | |
| 523 | + ) => Effect.Effect<readonly AdminSubjectWithConnections[], StorageFailure>; | |
| 524 | + /** `getSubject` joined with that subject's connections, in ONE call — the | |
| 525 | + * shape a per-user check needs. `null` on the same terms as `getSubject`, | |
| 526 | + * and no connection read is issued when the subject row is absent. */ | |
| 527 | + readonly getSubjectWithConnections: ( | |
| 528 | + externalId: string, | |
| 529 | + ) => Effect.Effect<AdminSubjectWithConnections | null, StorageFailure>; | |
| 530 | +} | |
| 531 | + | |
| 380 | 532 | export interface ExecutorDb { |
| 381 | 533 | readonly db: FumaDb<any>; |
| 382 | 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 | 599 | * config-revision re-sync still apply). |
| 448 | 600 | */ |
| 449 | 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 | 623 | /** 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 | 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 | 1559 | const rootDb = withQueryContext(rootDbUntyped, ownerContext); |
| 1374 | 1560 | const fuma = makeFumaClient(rootDb); |
| 1375 | 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 | 2702 | const ref: ConnectionRef = { |
| 2507 | 2703 | owner: input.owner, |
| 2508 | 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 | 4490 | // close |
| 4165 | 4491 | // ------------------------------------------------------------------ |
| @@ -4231,6 +4557,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 65 unmodified lines | ||
| 4231 | 4557 | remove: policiesRemove, |
| 4232 | 4558 | resolve: policiesResolve, |
| 4233 | 4559 | }, |
| 4560 | + ...(admin ? { admin } : {}), | |
| 4234 | 4561 | execute, |
| 4235 | 4562 | close, |
| 4236 | 4563 | }; |
| @@ -20,6 +20,13 @@ · 19 unmodified lines | ||
| 20 | 20 | // endpoint SSRF guard + default fetch timeout. The SDK's own OAuth flow uses |
| 21 | 21 | // them; the host's connection-identity handler reuses the same guard/timeout |
| 22 | 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 | |
| 25 | 32 | export { |
| @@ -31,6 +38,13 @@ export { · 5 unmodified lines | ||
| 31 | 38 | |
| 32 | 39 | export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers"; |
| 33 | 40 | |
| 41 | +export { | |
| 42 | + DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS, | |
| 43 | + resetSubjectTouchCache, | |
| 44 | + touchSubject, | |
| 45 | + type TouchSubjectInput, | |
| 46 | +} from "./subject-registry"; | |
| 47 | + | |
| 34 | 48 | export { |
| 35 | 49 | createExecutorFumaDb, |
| 36 | 50 | type CreateExecutorFumaDbOptions, |
| @@ -142,6 +142,7 @@ export { · 141 unmodified lines | ||
| 142 | 142 | TOOL_POLICY_ACTIONS, |
| 143 | 143 | type CoreSchema, |
| 144 | 144 | type IntegrationRow, |
| 145 | + type SubjectRow, | |
| 145 | 146 | type ConnectionRow, |
| 146 | 147 | type OAuthClientRow, |
| 147 | 148 | type OAuthSessionRow, |
| @@ -160,6 +161,8 @@ export { · 12 unmodified lines | ||
| 160 | 161 | executorOwnerPolicyName, |
| 161 | 162 | executorUnscopedPolicyName, |
| 162 | 163 | type ExecutorOwnerPolicyContext, |
| 164 | + type ExecutorReach, | |
| 165 | + type ExecutorWrites, | |
| 163 | 166 | } from "./owner-policy"; |
| 164 | 167 | |
| 165 | 168 | // Provider item-id owner grammar — the partition credential providers file |
| @@ -342,7 +345,14 @@ export { · 176 unmodified lines | ||
| 342 | 345 | // local/cloud DB bring-up). Its definition stays here because `createExecutor` |
| 343 | 346 | // uses it; the host surface (`@executor-js/api/server`) re-exports it. |
| 344 | 347 | export { |
| 348 | + ADMIN_DEFAULT_PAGE_SIZE, | |
| 349 | + ADMIN_MAX_PAGE_SIZE, | |
| 350 | + type AdminConnection, | |
| 351 | + type AdminListSubjectsOptions, | |
| 352 | + type AdminSubject, | |
| 353 | + type AdminSubjectWithConnections, | |
| 345 | 354 | type Executor, |
| 355 | + type ExecutorAdmin, | |
| 346 | 356 | type ExecutorConfig, |
| 347 | 357 | type ExecutorDb, |
| 348 | 358 | type ExecutorDbFactory, |
| @@ -0,0 +1,318 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | +import { withQueryContext } from "@executor-js/fumadb/query"; | |
| 4 | + | |
| 5 | +import { collectTables } from "./executor"; | |
| 6 | +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; | |
| 7 | + | |
| 8 | +// The platform view is `reach: "tenant"` on the owner-policy context: reads | |
| 9 | +// widen from {this subject + org rows} to {the whole tenant}, and writes do not | |
| 10 | +// widen at all. These tests pin BOTH halves against the real SQLite bring-up — | |
| 11 | +// the widening is only useful if the not-widening is airtight. | |
| 12 | + | |
| 13 | +const TENANT = "t1"; | |
| 14 | +const OTHER_TENANT = "t2"; | |
| 15 | +const SUBJECT_A = "user_a"; | |
| 16 | +const SUBJECT_B = "user_b"; | |
| 17 | + | |
| 18 | +const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> => | |
| 19 | + Effect.runPromise( | |
| 20 | + Effect.acquireUseRelease( | |
| 21 | + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), | |
| 22 | + (db) => Effect.promise(() => body(db)), | |
| 23 | + (db) => Effect.promise(() => db.close()), | |
| 24 | + ), | |
| 25 | + ); | |
| 26 | + | |
| 27 | +/** Insert a connection row directly, bypassing the policy — these tests are | |
| 28 | + * about what a context may SEE, so the seeding must be able to write rows the | |
| 29 | + * reader is not bound to. */ | |
| 30 | +const insertConnection = ( | |
| 31 | + db: SqliteTestFumaDb, | |
| 32 | + row: { | |
| 33 | + readonly rowId: string; | |
| 34 | + readonly tenant: string; | |
| 35 | + readonly owner: string; | |
| 36 | + readonly subject: string; | |
| 37 | + readonly name: string; | |
| 38 | + }, | |
| 39 | +): Promise<unknown> => | |
| 40 | + db.client.execute({ | |
| 41 | + sql: `INSERT INTO connection ( | |
| 42 | + row_id, tenant, owner, subject, integration, name, template, provider, | |
| 43 | + item_ids, created_at, updated_at | |
| 44 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 45 | + args: [ | |
| 46 | + row.rowId, | |
| 47 | + row.tenant, | |
| 48 | + row.owner, | |
| 49 | + row.subject, | |
| 50 | + "github", | |
| 51 | + row.name, | |
| 52 | + "apiKey", | |
| 53 | + "memory", | |
| 54 | + JSON.stringify({ token: `item-${row.rowId}` }), | |
| 55 | + Date.now(), | |
| 56 | + Date.now(), | |
| 57 | + ], | |
| 58 | + }); | |
| 59 | + | |
| 60 | +const seedTwoSubjects = async (db: SqliteTestFumaDb): Promise<void> => { | |
| 61 | + await insertConnection(db, { | |
| 62 | + rowId: "c-a", | |
| 63 | + tenant: TENANT, | |
| 64 | + owner: "user", | |
| 65 | + subject: SUBJECT_A, | |
| 66 | + name: "a-personal", | |
| 67 | + }); | |
| 68 | + await insertConnection(db, { | |
| 69 | + rowId: "c-b", | |
| 70 | + tenant: TENANT, | |
| 71 | + owner: "user", | |
| 72 | + subject: SUBJECT_B, | |
| 73 | + name: "b-personal", | |
| 74 | + }); | |
| 75 | + await insertConnection(db, { | |
| 76 | + rowId: "c-org", | |
| 77 | + tenant: TENANT, | |
| 78 | + owner: "org", | |
| 79 | + subject: "", | |
| 80 | + name: "shared", | |
| 81 | + }); | |
| 82 | + // Another tenant entirely — never visible at any reach. | |
| 83 | + await insertConnection(db, { | |
| 84 | + rowId: "c-other", | |
| 85 | + tenant: OTHER_TENANT, | |
| 86 | + owner: "user", | |
| 87 | + subject: SUBJECT_A, | |
| 88 | + name: "other-tenant", | |
| 89 | + }); | |
| 90 | +}; | |
| 91 | + | |
| 92 | +describe("owner policy reach", () => { | |
| 93 | + it.effect("bound reach still sees only the subject's own rows plus org rows", () => | |
| 94 | + Effect.promise(() => | |
| 95 | + withDb(async (db) => { | |
| 96 | + await seedTwoSubjects(db); | |
| 97 | + | |
| 98 | + const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); | |
| 99 | + const rows = await bound.findMany("connection", {}); | |
| 100 | + | |
| 101 | + expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "shared"]); | |
| 102 | + }), | |
| 103 | + ), | |
| 104 | + ); | |
| 105 | + | |
| 106 | + it.effect("tenant reach reads across every subject in the tenant", () => | |
| 107 | + Effect.promise(() => | |
| 108 | + withDb(async (db) => { | |
| 109 | + await seedTwoSubjects(db); | |
| 110 | + | |
| 111 | + const platform = withQueryContext(db.db, { | |
| 112 | + tenant: TENANT, | |
| 113 | + subject: SUBJECT_A, | |
| 114 | + reach: "tenant", | |
| 115 | + }); | |
| 116 | + const rows = await platform.findMany("connection", {}); | |
| 117 | + | |
| 118 | + // The other subject's rows are now visible — that is the whole point. | |
| 119 | + expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "b-personal", "shared"]); | |
| 120 | + }), | |
| 121 | + ), | |
| 122 | + ); | |
| 123 | + | |
| 124 | + it.effect("tenant reach NEVER reads across tenants", () => | |
| 125 | + Effect.promise(() => | |
| 126 | + withDb(async (db) => { | |
| 127 | + await seedTwoSubjects(db); | |
| 128 | + | |
| 129 | + const platform = withQueryContext(db.db, { | |
| 130 | + tenant: TENANT, | |
| 131 | + subject: SUBJECT_A, | |
| 132 | + reach: "tenant", | |
| 133 | + }); | |
| 134 | + const rows = await platform.findMany("connection", {}); | |
| 135 | + | |
| 136 | + // `tenant` is the one clause reach never relaxes. | |
| 137 | + expect(rows.every((row) => row.tenant === TENANT)).toBe(true); | |
| 138 | + expect(rows.map((row) => row.name)).not.toContain("other-tenant"); | |
| 139 | + }), | |
| 140 | + ), | |
| 141 | + ); | |
| 142 | + | |
| 143 | + it.effect("tenant reach with a null subject reads the whole tenant, not just org rows", () => | |
| 144 | + Effect.promise(() => | |
| 145 | + withDb(async (db) => { | |
| 146 | + await seedTwoSubjects(db); | |
| 147 | + | |
| 148 | + // The shape an org-level API key produces: no bound subject at all. | |
| 149 | + const platform = withQueryContext(db.db, { | |
| 150 | + tenant: TENANT, | |
| 151 | + subject: null, | |
| 152 | + reach: "tenant", | |
| 153 | + }); | |
| 154 | + const rows = await platform.findMany("connection", {}); | |
| 155 | + | |
| 156 | + expect(rows.map((row) => row.name).sort()).toEqual(["a-personal", "b-personal", "shared"]); | |
| 157 | + }), | |
| 158 | + ), | |
| 159 | + ); | |
| 160 | + | |
| 161 | + // ------------------------------------------------------------------------- | |
| 162 | + // The security property: reach widens READS ONLY. | |
| 163 | + // ------------------------------------------------------------------------- | |
| 164 | + | |
| 165 | + it.effect("tenant reach cannot create rows", () => | |
| 166 | + Effect.promise(() => | |
| 167 | + withDb(async (db) => { | |
| 168 | + const platform = withQueryContext(db.db, { | |
| 169 | + tenant: TENANT, | |
| 170 | + subject: SUBJECT_A, | |
| 171 | + reach: "tenant", | |
| 172 | + }); | |
| 173 | + | |
| 174 | + await expect( | |
| 175 | + platform.create("connection", { | |
| 176 | + tenant: TENANT, | |
| 177 | + owner: "user", | |
| 178 | + subject: SUBJECT_A, | |
| 179 | + integration: "github", | |
| 180 | + name: "written-by-platform-view", | |
| 181 | + template: "apiKey", | |
| 182 | + provider: "memory", | |
| 183 | + item_ids: { token: "item-x" }, | |
| 184 | + created_at: new Date(), | |
| 185 | + updated_at: new Date(), | |
| 186 | + }), | |
| 187 | + ).rejects.toThrow(/platform view is read-only/); | |
| 188 | + }), | |
| 189 | + ), | |
| 190 | + ); | |
| 191 | + | |
| 192 | + it.effect("tenant reach cannot update another subject's rows", () => | |
| 193 | + Effect.promise(() => | |
| 194 | + withDb(async (db) => { | |
| 195 | + await seedTwoSubjects(db); | |
| 196 | + | |
| 197 | + const platform = withQueryContext(db.db, { | |
| 198 | + tenant: TENANT, | |
| 199 | + subject: SUBJECT_A, | |
| 200 | + reach: "tenant", | |
| 201 | + }); | |
| 202 | + | |
| 203 | + // Without the reach guard the widened read condition would MATCH | |
| 204 | + // subject B's row and this update would land on it. | |
| 205 | + await expect( | |
| 206 | + platform.updateMany("connection", { | |
| 207 | + where: (b) => b("name", "=", "b-personal"), | |
| 208 | + set: { identity_label: "hijacked" }, | |
| 209 | + }), | |
| 210 | + ).rejects.toThrow(/platform view is read-only/); | |
| 211 | + | |
| 212 | + const rows = await db.client.execute( | |
| 213 | + "SELECT identity_label FROM connection WHERE row_id = 'c-b'", | |
| 214 | + ); | |
| 215 | + expect(rows.rows[0]?.["identity_label"]).toBeNull(); | |
| 216 | + }), | |
| 217 | + ), | |
| 218 | + ); | |
| 219 | + | |
| 220 | + it.effect("tenant reach cannot delete another subject's rows", () => | |
| 221 | + Effect.promise(() => | |
| 222 | + withDb(async (db) => { | |
| 223 | + await seedTwoSubjects(db); | |
| 224 | + | |
| 225 | + const platform = withQueryContext(db.db, { | |
| 226 | + tenant: TENANT, | |
| 227 | + subject: SUBJECT_A, | |
| 228 | + reach: "tenant", | |
| 229 | + }); | |
| 230 | + | |
| 231 | + await expect( | |
| 232 | + platform.deleteMany("connection", { where: (b) => b("name", "=", "b-personal") }), | |
| 233 | + ).rejects.toThrow(/platform view is read-only/); | |
| 234 | + | |
| 235 | + const rows = await db.client.execute("SELECT row_id FROM connection ORDER BY row_id"); | |
| 236 | + expect(rows.rows.map((row) => String(row["row_id"]))).toEqual([ | |
| 237 | + "c-a", | |
| 238 | + "c-b", | |
| 239 | + "c-org", | |
| 240 | + "c-other", | |
| 241 | + ]); | |
| 242 | + }), | |
| 243 | + ), | |
| 244 | + ); | |
| 245 | + | |
| 246 | + it.effect("tenant reach cannot write the tenant-scoped subject table", () => | |
| 247 | + Effect.promise(() => | |
| 248 | + withDb(async (db) => { | |
| 249 | + const platform = withQueryContext(db.db, { | |
| 250 | + tenant: TENANT, | |
| 251 | + subject: SUBJECT_A, | |
| 252 | + reach: "tenant", | |
| 253 | + }); | |
| 254 | + | |
| 255 | + // `subject` is tenant-scoped, so its reads were already tenant-wide — | |
| 256 | + // the read-only property still has to hold there. | |
| 257 | + await expect( | |
| 258 | + platform.create("subject", { | |
| 259 | + tenant: TENANT, | |
| 260 | + external_id: SUBJECT_B, | |
| 261 | + created_at: new Date(), | |
| 262 | + last_seen_at: Date.now(), | |
| 263 | + status: null, | |
| 264 | + }), | |
| 265 | + ).rejects.toThrow(/platform view is read-only/); | |
| 266 | + }), | |
| 267 | + ), | |
| 268 | + ); | |
| 269 | + | |
| 270 | + it.effect("bound reach writes are unaffected by the reach guard", () => | |
| 271 | + Effect.promise(() => | |
| 272 | + withDb(async (db) => { | |
| 273 | + // The guard must not disturb the product view: an ordinary bound | |
| 274 | + // context writes exactly as before. | |
| 275 | + const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); | |
| 276 | + | |
| 277 | + await bound.create("connection", { | |
| 278 | + tenant: TENANT, | |
| 279 | + owner: "user", | |
| 280 | + subject: SUBJECT_A, | |
| 281 | + integration: "github", | |
| 282 | + name: "written-by-bound-view", | |
| 283 | + template: "apiKey", | |
| 284 | + provider: "memory", | |
| 285 | + item_ids: { token: "item-y" }, | |
| 286 | + created_at: new Date(), | |
| 287 | + updated_at: new Date(), | |
| 288 | + }); | |
| 289 | + | |
| 290 | + const rows = await bound.findMany("connection", {}); | |
| 291 | + expect(rows.map((row) => row.name)).toEqual(["written-by-bound-view"]); | |
| 292 | + }), | |
| 293 | + ), | |
| 294 | + ); | |
| 295 | + | |
| 296 | + it.effect("a bound context still cannot write outside its own subject", () => | |
| 297 | + Effect.promise(() => | |
| 298 | + withDb(async (db) => { | |
| 299 | + const bound = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT_A }); | |
| 300 | + | |
| 301 | + await expect( | |
| 302 | + bound.create("connection", { | |
| 303 | + tenant: TENANT, | |
| 304 | + owner: "user", | |
| 305 | + subject: SUBJECT_B, | |
| 306 | + integration: "github", | |
| 307 | + name: "cross-subject", | |
| 308 | + template: "apiKey", | |
| 309 | + provider: "memory", | |
| 310 | + item_ids: { token: "item-z" }, | |
| 311 | + created_at: new Date(), | |
| 312 | + updated_at: new Date(), | |
| 313 | + }), | |
| 314 | + ).rejects.toThrow(/outside the bound subject/); | |
| 315 | + }), | |
| 316 | + ), | |
| 317 | + ); | |
| 318 | +}); | |
| @@ -25,11 +25,47 @@ export const ORG_SUBJECT = ""; · 24 unmodified lines | ||
| 25 | 25 | |
| 26 | 26 | const unscopedExecutorTables = new Set(["blob"]); |
| 27 | 27 | |
| 28 | +/** How far a context's READS reach. Writes ignore this entirely — see | |
| 29 | + * `assertOwnerWritable`. */ | |
| 30 | +export type ExecutorReach = | |
| 31 | + /** The product view: this subject's rows plus the tenant's org rows. */ | |
| 32 | + | "bound" | |
| 33 | + /** The platform view: every row in the tenant, whatever subject owns it. | |
| 34 | + * READ-ONLY by construction — it widens `ownerVisibilityCondition` and | |
| 35 | + * nothing else. */ | |
| 36 | + | "tenant"; | |
| 37 | + | |
| 38 | +/** Whether a context may mutate at all. Orthogonal to `reach`: reach says how | |
| 39 | + * far reads SEE, this says whether writes are permitted. */ | |
| 40 | +export type ExecutorWrites = | |
| 41 | + /** The default: writes are allowed, bounded by `assertOwnerWritable`. */ | |
| 42 | + | "allowed" | |
| 43 | + /** Every create/update/delete is rejected outright, at every reach. */ | |
| 44 | + | "denied"; | |
| 45 | + | |
| 28 | 46 | export interface ExecutorOwnerPolicyContext { |
| 29 | 47 | readonly tenant: string; |
| 30 | 48 | /** The acting member, or null for a pure-org executor (no `owner:"user"` |
| 31 | 49 | * reads/writes are allowed when null). */ |
| 32 | 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 | |
| 35 | 71 | type AnyConditionBuilder = ConditionBuilder<Record<string, AnyColumn>>; |
| @@ -51,11 +87,20 @@ const requireContext = ( · 15 unmodified lines | ||
| 51 | 87 | }; |
| 52 | 88 | |
| 53 | 89 | /** 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. */ | |
| 55 | 97 | export const ownerVisibilityCondition = ( |
| 56 | 98 | builder: AnyConditionBuilder, |
| 57 | 99 | context: ExecutorOwnerPolicyContext, |
| 58 | 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 | 104 | const orgClause = builder.and( |
| 60 | 105 | builder("tenant", "=", context.tenant), |
| 61 | 106 | builder("owner", "=", "org"), |
| @@ -69,6 +114,36 @@ export const ownerVisibilityCondition = ( · 7 unmodified lines | ||
| 69 | 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 | + */ | |
| 135 | +export const assertReachReadOnly = ( | |
| 136 | + tableName: string, | |
| 137 | + access: string, | |
| 138 | + context: ExecutorOwnerPolicyContext | undefined, | |
| 139 | +): void => { | |
| 140 | + if (context === undefined) return; | |
| 141 | + if (context.reach !== "tenant" && context.writes !== "denied") return; | |
| 142 | + policyViolation( | |
| 143 | + `Storage ${access} on table "${tableName}" is not allowed: the platform view is read-only.`, | |
| 144 | + ); | |
| 145 | +}; | |
| 146 | + | |
| 72 | 147 | /** Assert a create/upsert writes a row inside the bound partition. */ |
| 73 | 148 | export const assertOwnerWritable = ( |
| 74 | 149 | tableName: string, |
| @@ -76,6 +151,7 @@ export const assertOwnerWritable = ( · 1 unmodified line | ||
| 76 | 151 | context: ExecutorOwnerPolicyContext | undefined, |
| 77 | 152 | ): void => { |
| 78 | 153 | const ctx = requireContext(tableName, "write", context); |
| 154 | + assertReachReadOnly(tableName, "write", ctx); | |
| 79 | 155 | if (values.tenant !== ctx.tenant) { |
| 80 | 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 | 182 | context: ExecutorOwnerPolicyContext | undefined, |
| 107 | 183 | ): void => { |
| 108 | 184 | const ctx = requireContext(tableName, "write", context); |
| 185 | + assertReachReadOnly(tableName, "write", ctx); | |
| 109 | 186 | if (!patch) return; |
| 110 | 187 | if (patch.tenant !== undefined && patch.tenant !== ctx.tenant) { |
| 111 | 188 | policyViolation(`Storage write on table "${tableName}" cannot move a row across tenants.`); |
| @@ -0,0 +1,814 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + ADMIN_DEFAULT_PAGE_SIZE, | |
| 6 | + ADMIN_MAX_PAGE_SIZE, | |
| 7 | + collectTables, | |
| 8 | + createExecutor, | |
| 9 | + type Executor, | |
| 10 | +} from "./executor"; | |
| 11 | +import { StorageError } from "./fuma-runtime"; | |
| 12 | +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; | |
| 13 | +import { | |
| 14 | + ConnectionName, | |
| 15 | + IntegrationSlug, | |
| 16 | + OAuthClientSlug, | |
| 17 | + ProviderItemId, | |
| 18 | + ProviderKey, | |
| 19 | + Subject, | |
| 20 | + Tenant, | |
| 21 | +} from "./ids"; | |
| 22 | +import { definePlugin } from "./plugin"; | |
| 23 | +import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; | |
| 24 | + | |
| 25 | +// The platform view: `executor.admin`, an OPT-IN read-only surface that reads | |
| 26 | +// across every subject in the tenant. Written against the real SQLite bring-up | |
| 27 | +// so the tenant-reach policy, the bigint `last_seen_at` round-trip, and the | |
| 28 | +// field allowlist are all live. | |
| 29 | + | |
| 30 | +const TENANT = "t1"; | |
| 31 | +const OTHER_TENANT = "t2"; | |
| 32 | +const SUBJECT_A = "user_a"; | |
| 33 | +const SUBJECT_B = "user_b"; | |
| 34 | + | |
| 35 | +/** Every column on `connection` that could resolve, or help resolve, a | |
| 36 | + * credential. NONE of these may appear on an admin shape — enumerated so a | |
| 37 | + * future column addition has to be argued with this list. */ | |
| 38 | +const FORBIDDEN_CONNECTION_FIELDS = [ | |
| 39 | + "item_ids", | |
| 40 | + "itemIds", | |
| 41 | + "refresh_item_id", | |
| 42 | + "refreshItemId", | |
| 43 | + "provider", | |
| 44 | + "provider_state", | |
| 45 | + "providerState", | |
| 46 | + "oauth_token_url", | |
| 47 | + "oauthTokenUrl", | |
| 48 | + "template", | |
| 49 | + "client_secret", | |
| 50 | + "clientSecret", | |
| 51 | + "secret", | |
| 52 | + "token", | |
| 53 | +] as const; | |
| 54 | + | |
| 55 | +/** | |
| 56 | + * The upstream-derived halves of a stored health verdict: the account identity | |
| 57 | + * extracted from the configured `identityField` (typically the user's email AT | |
| 58 | + * the provider), a raw diagnostic that can quote an upstream error body, and a | |
| 59 | + * bounded sample of the real response body. | |
| 60 | + * | |
| 61 | + * These are NOT on the list above, and that is deliberate rather than an | |
| 62 | + * oversight. This layer is the in-process platform view, not a wire shape: | |
| 63 | + * `AdminConnection.lastHealth` is the full `HealthCheckResult`, and the | |
| 64 | + * narrowing to status + checkedAt happens where the response is BUILT, in | |
| 65 | + * `@executor-js/api`'s `admin/reads.ts` → `AdminConnectionHealth`. Listing them | |
| 66 | + * as forbidden top-level connection keys would pass without testing anything, | |
| 67 | + * because they never were top-level keys — they live nested inside | |
| 68 | + * `lastHealth`. That vacuous-green assertion is precisely the failure mode this | |
| 69 | + * comment exists to prevent. | |
| 70 | + * | |
| 71 | + * So the assertion below pins the SEAM instead: this layer DOES carry them, and | |
| 72 | + * anyone who "fixes" the leak here rather than at the projection has moved the | |
| 73 | + * boundary. The absence assertions live in `packages/core/api`'s | |
| 74 | + * `admin-users.test.ts`, against real HTTP responses. | |
| 75 | + */ | |
| 76 | +const UPSTREAM_HEALTH_FIELDS = ["identity", "detail", "responseSample"] as const; | |
| 77 | + | |
| 78 | +const withDb = <A, E>(body: (db: SqliteTestFumaDb) => Effect.Effect<A, E>): Effect.Effect<A, E> => | |
| 79 | + Effect.acquireUseRelease( | |
| 80 | + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), | |
| 81 | + body, | |
| 82 | + (db) => Effect.promise(() => db.close()), | |
| 83 | + ); | |
| 84 | + | |
| 85 | +const insertConnection = ( | |
| 86 | + db: SqliteTestFumaDb, | |
| 87 | + row: { | |
| 88 | + readonly rowId: string; | |
| 89 | + readonly tenant: string; | |
| 90 | + readonly owner: string; | |
| 91 | + readonly subject: string; | |
| 92 | + readonly integration: string; | |
| 93 | + readonly name: string; | |
| 94 | + readonly oauthScope?: string | null; | |
| 95 | + }, | |
| 96 | +): Effect.Effect<void> => | |
| 97 | + Effect.promise(async () => { | |
| 98 | + await db.client.execute({ | |
| 99 | + sql: `INSERT INTO connection ( | |
| 100 | + row_id, tenant, owner, subject, integration, name, template, provider, | |
| 101 | + item_ids, refresh_item_id, oauth_scope, last_health, created_at, updated_at | |
| 102 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 103 | + args: [ | |
| 104 | + row.rowId, | |
| 105 | + row.tenant, | |
| 106 | + row.owner, | |
| 107 | + row.subject, | |
| 108 | + row.integration, | |
| 109 | + row.name, | |
| 110 | + "oauth2", | |
| 111 | + "memory", | |
| 112 | + // Deliberately secret-bearing: the assertions below prove these never | |
| 113 | + // leave the storage layer. | |
| 114 | + JSON.stringify({ token: `SECRET-item-${row.rowId}` }), | |
| 115 | + `SECRET-refresh-${row.rowId}`, | |
| 116 | + row.oauthScope ?? null, | |
| 117 | + // A full stored verdict, upstream-derived fields included — that is | |
| 118 | + // what a real health check writes. This layer is entitled to read them | |
| 119 | + // back (see UPSTREAM_HEALTH_FIELDS); the API's projection is what keeps | |
| 120 | + // them off the wire. | |
| 121 | + JSON.stringify({ | |
| 122 | + status: "healthy", | |
| 123 | + checkedAt: 1_700_000_000_000, | |
| 124 | + identity: `UPSTREAM-identity-${row.rowId}@provider.test`, | |
| 125 | + detail: `UPSTREAM-detail-${row.rowId}`, | |
| 126 | + responseSample: [{ path: "user.email", value: `UPSTREAM-sample-${row.rowId}` }], | |
| 127 | + }), | |
| 128 | + Date.now(), | |
| 129 | + Date.now(), | |
| 130 | + ], | |
| 131 | + }); | |
| 132 | + }); | |
| 133 | + | |
| 134 | +/** A tenant-scoped `integration` row, inserted raw. Needed because | |
| 135 | + * `integrations.remove` short-circuits on an absent slug — without a real row | |
| 136 | + * the call returns success having touched no storage, which would make a | |
| 137 | + * read-only assertion vacuous. */ | |
| 138 | +const insertIntegration = (db: SqliteTestFumaDb, slug: string): Effect.Effect<void> => | |
| 139 | + Effect.promise(async () => { | |
| 140 | + await db.client.execute({ | |
| 141 | + sql: `INSERT INTO integration ( | |
| 142 | + row_id, tenant, slug, plugin_id, name, description, config, can_remove, | |
| 143 | + can_refresh, created_at, updated_at | |
| 144 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 145 | + args: [ | |
| 146 | + `i-${slug}`, | |
| 147 | + TENANT, | |
| 148 | + slug, | |
| 149 | + "demo", | |
| 150 | + slug, | |
| 151 | + null, | |
| 152 | + JSON.stringify({}), | |
| 153 | + 1, | |
| 154 | + 0, | |
| 155 | + Date.now(), | |
| 156 | + Date.now(), | |
| 157 | + ], | |
| 158 | + }); | |
| 159 | + }); | |
| 160 | + | |
| 161 | +/** Seed two users and an org connection under `t1`, plus an untouchable other | |
| 162 | + * tenant. Subjects go in through `touchSubject` (the only legitimate writer); | |
| 163 | + * connections go in raw, because no single bound executor could have written | |
| 164 | + * another subject's rows. */ | |
| 165 | +const seed = (db: SqliteTestFumaDb): Effect.Effect<void> => | |
| 166 | + Effect.gen(function* () { | |
| 167 | + // `touchSubject` keeps a process-local memory of principals it already | |
| 168 | + // filed, so a second seed against a FRESH database would be skipped as a | |
| 169 | + // repeat sighting and leave the table empty. Each seed is a new world. | |
| 170 | + resetSubjectTouchCache(); | |
| 171 | + | |
| 172 | + yield* touchSubject(db.db, { tenant: TENANT, externalId: SUBJECT_A }); | |
| 173 | + yield* touchSubject(db.db, { tenant: TENANT, externalId: SUBJECT_B }); | |
| 174 | + yield* touchSubject(db.db, { tenant: OTHER_TENANT, externalId: "user_elsewhere" }); | |
| 175 | + | |
| 176 | + yield* insertConnection(db, { | |
| 177 | + rowId: "c-a1", | |
| 178 | + tenant: TENANT, | |
| 179 | + owner: "user", | |
| 180 | + subject: SUBJECT_A, | |
| 181 | + integration: "github", | |
| 182 | + name: "personal", | |
| 183 | + oauthScope: "repo read:user", | |
| 184 | + }); | |
| 185 | + yield* insertConnection(db, { | |
| 186 | + rowId: "c-a2", | |
| 187 | + tenant: TENANT, | |
| 188 | + owner: "user", | |
| 189 | + subject: SUBJECT_A, | |
| 190 | + integration: "linear", | |
| 191 | + name: "work", | |
| 192 | + }); | |
| 193 | + yield* insertConnection(db, { | |
| 194 | + rowId: "c-b1", | |
| 195 | + tenant: TENANT, | |
| 196 | + owner: "user", | |
| 197 | + subject: SUBJECT_B, | |
| 198 | + integration: "github", | |
| 199 | + name: "b-personal", | |
| 200 | + oauthScope: "repo", | |
| 201 | + }); | |
| 202 | + yield* insertConnection(db, { | |
| 203 | + rowId: "c-org", | |
| 204 | + tenant: TENANT, | |
| 205 | + owner: "org", | |
| 206 | + subject: "", | |
| 207 | + integration: "stripe", | |
| 208 | + name: "shared", | |
| 209 | + }); | |
| 210 | + yield* insertConnection(db, { | |
| 211 | + rowId: "c-other", | |
| 212 | + tenant: OTHER_TENANT, | |
| 213 | + owner: "user", | |
| 214 | + subject: SUBJECT_A, | |
| 215 | + integration: "github", | |
| 216 | + name: "other-tenant", | |
| 217 | + }); | |
| 218 | + }); | |
| 219 | + | |
| 220 | +/** | |
| 221 | + * A plugin contributing one writable credential provider. | |
| 222 | + * | |
| 223 | + * `oauth.createClient` stores the client secret out-of-band and fails BEFORE | |
| 224 | + * touching storage when no writable provider is registered. Without this, the | |
| 225 | + * read-only assertion below would pass on the wrong error and prove nothing — | |
| 226 | + * so the provider is here to make the write actually reach the owner policy. | |
| 227 | + */ | |
| 228 | +const providerPlugin = definePlugin(() => { | |
| 229 | + const store = new Map<string, string>(); | |
| 230 | + return { | |
| 231 | + id: "memory-provider" as const, | |
| 232 | + storage: () => ({}), | |
| 233 | + credentialProviders: [ | |
| 234 | + { | |
| 235 | + key: ProviderKey.make("memory"), | |
| 236 | + writable: true, | |
| 237 | + get: (id: ProviderItemId) => Effect.sync(() => store.get(String(id)) ?? null), | |
| 238 | + set: (id: ProviderItemId, value: string) => | |
| 239 | + Effect.sync(() => void store.set(String(id), value)), | |
| 240 | + delete: (id: ProviderItemId) => Effect.sync(() => void store.delete(String(id))), | |
| 241 | + }, | |
| 242 | + ], | |
| 243 | + }; | |
| 244 | +})(); | |
| 245 | + | |
| 246 | +const makePlatformExecutor = ( | |
| 247 | + db: SqliteTestFumaDb, | |
| 248 | + options?: { readonly platformView?: boolean; readonly subject?: string | null }, | |
| 249 | +): Effect.Effect<Executor, never> => | |
| 250 | + createExecutor({ | |
| 251 | + tenant: Tenant.make(TENANT), | |
| 252 | + ...(options?.subject === null ? {} : { subject: Subject.make(options?.subject ?? SUBJECT_A) }), | |
| 253 | + db: db.db, | |
| 254 | + plugins: [providerPlugin], | |
| 255 | + onElicitation: "accept-all", | |
| 256 | + ...(options?.platformView === false ? {} : { platformView: true }), | |
| 257 | + }).pipe(Effect.orDie); | |
| 258 | + | |
| 259 | +const requireAdmin = (executor: Executor) => { | |
| 260 | + const admin = executor.admin; | |
| 261 | + if (!admin) return Effect.die("expected the platform view to be enabled"); | |
| 262 | + return Effect.succeed(admin); | |
| 263 | +}; | |
| 264 | + | |
| 265 | +describe("platform view — admin.listSubjects", () => { | |
| 266 | + it.effect("lists every subject in the tenant and no other tenant's", () => | |
| 267 | + withDb((db) => | |
| 268 | + Effect.gen(function* () { | |
| 269 | + yield* seed(db); | |
| 270 | + const executor = yield* makePlatformExecutor(db); | |
| 271 | + const admin = yield* requireAdmin(executor); | |
| 272 | + | |
| 273 | + const subjects = yield* admin.listSubjects(); | |
| 274 | + | |
| 275 | + // `user_elsewhere` lives under t2 — invisible at any reach. | |
| 276 | + expect(subjects.map((entry) => entry.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]); | |
| 277 | + }), | |
| 278 | + ), | |
| 279 | + ); | |
| 280 | + | |
| 281 | + it.effect("returns created_at and the bigint last_seen_at through the ORM", () => | |
| 282 | + withDb((db) => | |
| 283 | + Effect.gen(function* () { | |
| 284 | + const before = Date.now(); | |
| 285 | + yield* seed(db); | |
| 286 | + const executor = yield* makePlatformExecutor(db); | |
| 287 | + const admin = yield* requireAdmin(executor); | |
| 288 | + | |
| 289 | + const subjects = yield* admin.listSubjects(); | |
| 290 | + const entry = subjects.find((row) => row.externalId === SUBJECT_A); | |
| 291 | + | |
| 292 | + expect(entry?.createdAt).toBeInstanceOf(Date); | |
| 293 | + // A raw SQL read would hand back an unusable blob here. | |
| 294 | + expect(typeof entry?.lastSeenAt).toBe("number"); | |
| 295 | + expect(entry?.lastSeenAt ?? 0).toBeGreaterThanOrEqual(before); | |
| 296 | + expect(entry?.status).toBeNull(); | |
| 297 | + }), | |
| 298 | + ), | |
| 299 | + ); | |
| 300 | + | |
| 301 | + it.effect("pages with a stable order", () => | |
| 302 | + withDb((db) => | |
| 303 | + Effect.gen(function* () { | |
| 304 | + yield* seed(db); | |
| 305 | + const executor = yield* makePlatformExecutor(db); | |
| 306 | + const admin = yield* requireAdmin(executor); | |
| 307 | + | |
| 308 | + const all = yield* admin.listSubjects(); | |
| 309 | + const first = yield* admin.listSubjects({ limit: 1 }); | |
| 310 | + const second = yield* admin.listSubjects({ limit: 1, offset: 1 }); | |
| 311 | + | |
| 312 | + expect(all).toHaveLength(2); | |
| 313 | + expect(first.map((entry) => entry.externalId)).toEqual([all[0]?.externalId]); | |
| 314 | + expect(second.map((entry) => entry.externalId)).toEqual([all[1]?.externalId]); | |
| 315 | + }), | |
| 316 | + ), | |
| 317 | + ); | |
| 318 | + | |
| 319 | + // Drizzle's SQLite dialect emits OFFSET only alongside LIMIT, and SQLite | |
| 320 | + // rejects a bare OFFSET — so forwarding the two independently made | |
| 321 | + // `{ offset }` a SQL syntax error on every SQLite host. The default limit is | |
| 322 | + // what makes an offset-only page a legal query at all. | |
| 323 | + it.effect("accepts an offset with no limit", () => | |
| 324 | + withDb((db) => | |
| 325 | + Effect.gen(function* () { | |
| 326 | + yield* seed(db); | |
| 327 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 328 | + | |
| 329 | + const all = yield* admin.listSubjects(); | |
| 330 | + const skipped = yield* admin.listSubjects({ offset: 1 }); | |
| 331 | + | |
| 332 | + expect(skipped.map((entry) => entry.externalId)).toEqual([all[1]?.externalId]); | |
| 333 | + // Past the end is an empty page, not an error. | |
| 334 | + expect(yield* admin.listSubjects({ offset: 50 })).toEqual([]); | |
| 335 | + expect(yield* admin.listSubjectsWithConnections({ offset: 1 })).toHaveLength(1); | |
| 336 | + }), | |
| 337 | + ), | |
| 338 | + ); | |
| 339 | + | |
| 340 | + // A fraction is a 400 at the HTTP contract, but the SDK is a public entry | |
| 341 | + // point of its own: a fractional limit reaching the driver is a `datatype | |
| 342 | + // mismatch` failure, so it is floored here rather than forwarded. | |
| 343 | + it.effect("floors a fractional limit and offset instead of failing the query", () => | |
| 344 | + withDb((db) => | |
| 345 | + Effect.gen(function* () { | |
| 346 | + yield* seed(db); | |
| 347 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 348 | + | |
| 349 | + const all = yield* admin.listSubjects(); | |
| 350 | + | |
| 351 | + expect(yield* admin.listSubjects({ limit: 1.5 })).toEqual([all[0]]); | |
| 352 | + expect(yield* admin.listSubjects({ offset: 1.9 })).toEqual([all[1]]); | |
| 353 | + }), | |
| 354 | + ), | |
| 355 | + ); | |
| 356 | + | |
| 357 | + // The no-args call is the one an API consumer writes by default, and | |
| 358 | + // `listSubjectsWithConnections` turns each returned row into its own | |
| 359 | + // sequential connection query. Unbounded, that is the whole tenant inside one | |
| 360 | + // request. | |
| 361 | + it.effect("bounds an unpaged call to the default page size, and clamps past the maximum", () => | |
| 362 | + withDb((db) => | |
| 363 | + Effect.gen(function* () { | |
| 364 | + // One more subject than a default page holds, so "no arguments" and | |
| 365 | + // "everything" are distinguishable. | |
| 366 | + for (let index = 0; index < ADMIN_DEFAULT_PAGE_SIZE + 5; index += 1) { | |
| 367 | + yield* touchSubject(db.db, { | |
| 368 | + tenant: TENANT, | |
| 369 | + // Zero-padded: `external_id` breaks `created_at` ties, and these | |
| 370 | + // rows are written inside the same millisecond. | |
| 371 | + externalId: `user_${String(index).padStart(4, "0")}`, | |
| 372 | + }); | |
| 373 | + } | |
| 374 | + | |
| 375 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 376 | + | |
| 377 | + expect(yield* admin.listSubjects()).toHaveLength(ADMIN_DEFAULT_PAGE_SIZE); | |
| 378 | + expect(yield* admin.listSubjectsWithConnections()).toHaveLength(ADMIN_DEFAULT_PAGE_SIZE); | |
| 379 | + // A caller may ask for more, up to the ceiling — and no further. | |
| 380 | + expect(yield* admin.listSubjects({ limit: ADMIN_MAX_PAGE_SIZE * 10 })).toHaveLength( | |
| 381 | + ADMIN_DEFAULT_PAGE_SIZE + 5, | |
| 382 | + ); | |
| 383 | + }), | |
| 384 | + ), | |
| 385 | + ); | |
| 386 | +}); | |
| 387 | + | |
| 388 | +// --------------------------------------------------------------------------- | |
| 389 | +// The platform view is read-only across EVERY surface, not just `admin`. | |
| 390 | +// | |
| 391 | +// A platform executor is subject-less but still bound to the tenant's ORG | |
| 392 | +// partition, so before `writes: "denied"` its ordinary product surfaces formed | |
| 393 | +// an unguarded pure-org executor: it could create, update and delete every | |
| 394 | +// `owner: "org"` row in the tenant through `connections`, `policies`, | |
| 395 | +// `integrations` and `oauth`, while only the `admin` handle was actually | |
| 396 | +// guarded. These pin that the guarantee now covers the whole executor. | |
| 397 | +// --------------------------------------------------------------------------- | |
| 398 | + | |
| 399 | +/** | |
| 400 | + * The owner policy refuses a write by failing the storage effect. `Effect.flip` | |
| 401 | + * makes a SUCCEEDING write a test failure outright rather than something to | |
| 402 | + * assert on afterwards. | |
| 403 | + * | |
| 404 | + * The message is checked too, not just the failure: several of these surfaces | |
| 405 | + * can fail for unrelated reasons BEFORE reaching storage (an absent row, a | |
| 406 | + * missing credential provider), and such a failure would let the assertion pass | |
| 407 | + * without the policy ever having been consulted. | |
| 408 | + */ | |
| 409 | +const expectWriteRefused = <A, E extends { readonly _tag: string }>( | |
| 410 | + effect: Effect.Effect<A, E | StorageError>, | |
| 411 | +) => | |
| 412 | + // The surfaces under test fail with different unions (StorageFailure, | |
| 413 | + // ConnectionNotFoundError, …). The refusal this pins is always a | |
| 414 | + // StorageError, so the effect is widened for the catch; `orDie` below still | |
| 415 | + // turns every OTHER tag into a test failure, and a write that SUCCEEDS dies | |
| 416 | + // rather than reaching an assertion — so the hole can never pass silently. | |
| 417 | + (effect as Effect.Effect<A, StorageError>).pipe( | |
| 418 | + Effect.flatMap(() => Effect.die("expected the platform view to refuse this write")), | |
| 419 | + Effect.catchTag("StorageError", (error: StorageError) => { | |
| 420 | + expect(error.message).toContain("read-only"); | |
| 421 | + return Effect.void; | |
| 422 | + }), | |
| 423 | + Effect.orDie, | |
| 424 | + ); | |
| 425 | + | |
| 426 | +describe("platform view — read-only across every surface", () => { | |
| 427 | + it.effect("refuses org-row writes through policies and oauth", () => | |
| 428 | + withDb((db) => | |
| 429 | + Effect.gen(function* () { | |
| 430 | + yield* seed(db); | |
| 431 | + const executor = yield* makePlatformExecutor(db, { subject: null }); | |
| 432 | + | |
| 433 | + yield* expectWriteRefused( | |
| 434 | + executor.policies.create({ owner: "org", pattern: "tools.*", action: "block" }), | |
| 435 | + ); | |
| 436 | + yield* expectWriteRefused( | |
| 437 | + executor.oauth.createClient({ | |
| 438 | + owner: "org", | |
| 439 | + slug: OAuthClientSlug.make("intruder"), | |
| 440 | + authorizationUrl: "https://acme.test/authorize", | |
| 441 | + tokenUrl: "https://acme.test/token", | |
| 442 | + grant: "authorization_code", | |
| 443 | + clientId: "intruder-id", | |
| 444 | + clientSecret: "intruder-secret", | |
| 445 | + }), | |
| 446 | + ); | |
| 447 | + | |
| 448 | + // Nothing landed. | |
| 449 | + expect(yield* executor.policies.list()).toEqual([]); | |
| 450 | + expect(yield* executor.oauth.listClients()).toEqual([]); | |
| 451 | + }), | |
| 452 | + ), | |
| 453 | + ); | |
| 454 | + | |
| 455 | + // A DELETE is the sharpest case: it carries no values to check against, so | |
| 456 | + // the reach/writes guard is the only thing between this executor and the | |
| 457 | + // tenant's org rows. | |
| 458 | + it.effect("refuses to delete the tenant's org rows", () => | |
| 459 | + withDb((db) => | |
| 460 | + Effect.gen(function* () { | |
| 461 | + yield* seed(db); | |
| 462 | + // A real `integration` row: `integrations.remove` returns success | |
| 463 | + // without touching storage when the slug is absent, so asserting | |
| 464 | + // against a missing row would prove nothing. | |
| 465 | + yield* insertIntegration(db, "stripe"); | |
| 466 | + const executor = yield* makePlatformExecutor(db, { subject: null }); | |
| 467 | + | |
| 468 | + yield* expectWriteRefused( | |
| 469 | + executor.connections.remove({ | |
| 470 | + owner: "org", | |
| 471 | + integration: IntegrationSlug.make("stripe"), | |
| 472 | + name: ConnectionName.make("shared"), | |
| 473 | + }), | |
| 474 | + ); | |
| 475 | + yield* expectWriteRefused( | |
| 476 | + executor.oauth.removeClient("org", OAuthClientSlug.make("anything")), | |
| 477 | + ); | |
| 478 | + yield* expectWriteRefused(executor.integrations.remove(IntegrationSlug.make("stripe"))); | |
| 479 | + | |
| 480 | + // Every row survived. | |
| 481 | + const admin = yield* requireAdmin(executor); | |
| 482 | + expect(yield* admin.listSubjects()).toHaveLength(2); | |
| 483 | + const connections = yield* Effect.promise(() => | |
| 484 | + db.client.execute({ sql: `SELECT name FROM connection WHERE owner = 'org'`, args: [] }), | |
| 485 | + ); | |
| 486 | + expect(connections.rows).toHaveLength(1); | |
| 487 | + const integrations = yield* Effect.promise(() => | |
| 488 | + db.client.execute({ sql: `SELECT slug FROM integration`, args: [] }), | |
| 489 | + ); | |
| 490 | + expect(integrations.rows).toHaveLength(1); | |
| 491 | + }), | |
| 492 | + ), | |
| 493 | + ); | |
| 494 | + | |
| 495 | + // Read-only is the WRITE axis only. The ordinary surfaces keep BOUND reach on | |
| 496 | + // purpose: widening them would hand every subject's connection row — | |
| 497 | + // credential item ids included — to a surface that only ever needed the admin | |
| 498 | + // projection. | |
| 499 | + it.effect("does not widen the ordinary surfaces' reads to other subjects", () => | |
| 500 | + withDb((db) => | |
| 501 | + Effect.gen(function* () { | |
| 502 | + yield* seed(db); | |
| 503 | + const executor = yield* makePlatformExecutor(db, { subject: null }); | |
| 504 | + | |
| 505 | + const connections = yield* executor.connections.list().pipe(Effect.orDie); | |
| 506 | + | |
| 507 | + // Only the tenant's org row: A's and B's user connections stay invisible | |
| 508 | + // to this surface, even though `admin` can see both. | |
| 509 | + expect(connections.map((entry) => entry.name)).toEqual(["shared"]); | |
| 510 | + }), | |
| 511 | + ), | |
| 512 | + ); | |
| 513 | + | |
| 514 | + // The guarantee must be exactly as narrow as advertised: an executor WITHOUT | |
| 515 | + // the opt-in is a normal product executor and writes normally. A read-only | |
| 516 | + // default would be a silent behavior change for every existing host. | |
| 517 | + it.effect("leaves an ordinary product executor fully writable", () => | |
| 518 | + withDb((db) => | |
| 519 | + Effect.gen(function* () { | |
| 520 | + yield* seed(db); | |
| 521 | + yield* insertIntegration(db, "stripe"); | |
| 522 | + const executor = yield* makePlatformExecutor(db, { | |
| 523 | + platformView: false, | |
| 524 | + subject: SUBJECT_A, | |
| 525 | + }); | |
| 526 | + | |
| 527 | + const policy = yield* executor.policies | |
| 528 | + .create({ owner: "org", pattern: "tools.*", action: "block" }) | |
| 529 | + .pipe(Effect.orDie); | |
| 530 | + expect(policy.pattern).toBe("tools.*"); | |
| 531 | + | |
| 532 | + yield* executor.oauth | |
| 533 | + .createClient({ | |
| 534 | + owner: "org", | |
| 535 | + slug: OAuthClientSlug.make("legit"), | |
| 536 | + authorizationUrl: "https://acme.test/authorize", | |
| 537 | + tokenUrl: "https://acme.test/token", | |
| 538 | + grant: "authorization_code", | |
| 539 | + clientId: "legit-id", | |
| 540 | + clientSecret: "legit-secret", | |
| 541 | + }) | |
| 542 | + .pipe(Effect.orDie); | |
| 543 | + expect(yield* executor.policies.list().pipe(Effect.orDie)).toHaveLength(1); | |
| 544 | + expect(yield* executor.oauth.listClients().pipe(Effect.orDie)).toHaveLength(1); | |
| 545 | + | |
| 546 | + yield* executor.connections | |
| 547 | + .remove({ | |
| 548 | + owner: "org", | |
| 549 | + integration: IntegrationSlug.make("stripe"), | |
| 550 | + name: ConnectionName.make("shared"), | |
| 551 | + }) | |
| 552 | + .pipe(Effect.orDie); | |
| 553 | + yield* executor.integrations.remove(IntegrationSlug.make("stripe")).pipe(Effect.orDie); | |
| 554 | + | |
| 555 | + const connections = yield* Effect.promise(() => | |
| 556 | + db.client.execute({ sql: `SELECT name FROM connection WHERE owner = 'org'`, args: [] }), | |
| 557 | + ); | |
| 558 | + expect(connections.rows).toHaveLength(0); | |
| 559 | + const integrations = yield* Effect.promise(() => | |
| 560 | + db.client.execute({ sql: `SELECT slug FROM integration`, args: [] }), | |
| 561 | + ); | |
| 562 | + expect(integrations.rows).toHaveLength(0); | |
| 563 | + }), | |
| 564 | + ), | |
| 565 | + ); | |
| 566 | +}); | |
| 567 | + | |
| 568 | +describe("platform view — admin.listSubjectConnections", () => { | |
| 569 | + it.effect("lists another subject's connections across the tenant", () => | |
| 570 | + withDb((db) => | |
| 571 | + Effect.gen(function* () { | |
| 572 | + yield* seed(db); | |
| 573 | + // Bound to A; reading B is exactly what the product view cannot do. | |
| 574 | + const executor = yield* makePlatformExecutor(db, { subject: SUBJECT_A }); | |
| 575 | + const admin = yield* requireAdmin(executor); | |
| 576 | + | |
| 577 | + const connections = yield* admin.listSubjectConnections(SUBJECT_B); | |
| 578 | + | |
| 579 | + expect(connections.map((entry) => entry.name)).toEqual(["b-personal"]); | |
| 580 | + expect(connections[0]?.integration).toBe("github"); | |
| 581 | + expect(connections[0]?.owner).toBe("user"); | |
| 582 | + expect(connections[0]?.subject).toBe(SUBJECT_B); | |
| 583 | + expect(connections[0]?.oauthScope).toBe("repo"); | |
| 584 | + expect(connections[0]?.lastHealth?.status).toBe("healthy"); | |
| 585 | + }), | |
| 586 | + ), | |
| 587 | + ); | |
| 588 | + | |
| 589 | + it.effect("does not attribute org-owned connections to a user", () => | |
| 590 | + withDb((db) => | |
| 591 | + Effect.gen(function* () { | |
| 592 | + yield* seed(db); | |
| 593 | + const executor = yield* makePlatformExecutor(db); | |
| 594 | + const admin = yield* requireAdmin(executor); | |
| 595 | + | |
| 596 | + const forA = yield* admin.listSubjectConnections(SUBJECT_A); | |
| 597 | + | |
| 598 | + // The org's `stripe/shared` belongs to the tenant, not to A. | |
| 599 | + expect(forA.map((entry) => entry.integration).sort()).toEqual(["github", "linear"]); | |
| 600 | + expect(forA.every((entry) => entry.owner === "user")).toBe(true); | |
| 601 | + }), | |
| 602 | + ), | |
| 603 | + ); | |
| 604 | + | |
| 605 | + it.effect("never returns credential-bearing fields", () => | |
| 606 | + withDb((db) => | |
| 607 | + Effect.gen(function* () { | |
| 608 | + yield* seed(db); | |
| 609 | + const executor = yield* makePlatformExecutor(db); | |
| 610 | + const admin = yield* requireAdmin(executor); | |
| 611 | + | |
| 612 | + const connections = yield* admin.listSubjectConnections(SUBJECT_A); | |
| 613 | + expect(connections.length).toBeGreaterThan(0); | |
| 614 | + | |
| 615 | + for (const connection of connections) { | |
| 616 | + const keys = Object.keys(connection); | |
| 617 | + for (const forbidden of FORBIDDEN_CONNECTION_FIELDS) { | |
| 618 | + expect(keys).not.toContain(forbidden); | |
| 619 | + } | |
| 620 | + // Belt and braces: no seeded secret value appears anywhere in the | |
| 621 | + // serialized shape, whatever it is keyed under. | |
| 622 | + expect(JSON.stringify(connection)).not.toContain("SECRET-"); | |
| 623 | + } | |
| 624 | + | |
| 625 | + // The SEAM, pinned. This layer carries the whole stored verdict — | |
| 626 | + // upstream account content and all — because it is in-process and the | |
| 627 | + // caller is the platform itself. The narrowing to status + checkedAt is | |
| 628 | + // `@executor-js/api`'s `AdminConnectionHealth`, applied where the | |
| 629 | + // RESPONSE is built. If this assertion ever fails because the fields | |
| 630 | + // stopped arriving here, the projection in `admin/reads.ts` is no | |
| 631 | + // longer the thing holding the line and the API's own absence tests | |
| 632 | + // have quietly become vacuous. | |
| 633 | + const health = connections[0]?.lastHealth; | |
| 634 | + for (const field of UPSTREAM_HEALTH_FIELDS) { | |
| 635 | + expect( | |
| 636 | + Object.keys(health ?? {}), | |
| 637 | + `the platform view still carries "${field}"; the API projection is the boundary`, | |
| 638 | + ).toContain(field); | |
| 639 | + } | |
| 640 | + }), | |
| 641 | + ), | |
| 642 | + ); | |
| 643 | +}); | |
| 644 | + | |
| 645 | +describe("platform view — admin.listSubjectsWithConnections", () => { | |
| 646 | + it.effect("joins each subject to its own connections", () => | |
| 647 | + withDb((db) => | |
| 648 | + Effect.gen(function* () { | |
| 649 | + yield* seed(db); | |
| 650 | + const executor = yield* makePlatformExecutor(db); | |
| 651 | + const admin = yield* requireAdmin(executor); | |
| 652 | + | |
| 653 | + const rows = yield* admin.listSubjectsWithConnections(); | |
| 654 | + const byId = new Map(rows.map((row) => [row.externalId, row])); | |
| 655 | + | |
| 656 | + expect(rows.map((row) => row.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]); | |
| 657 | + expect( | |
| 658 | + byId | |
| 659 | + .get(SUBJECT_A) | |
| 660 | + ?.connections.map((c) => c.name) | |
| 661 | + .sort(), | |
| 662 | + ).toEqual(["personal", "work"]); | |
| 663 | + expect(byId.get(SUBJECT_B)?.connections.map((c) => c.name)).toEqual(["b-personal"]); | |
| 664 | + }), | |
| 665 | + ), | |
| 666 | + ); | |
| 667 | + | |
| 668 | + it.effect("never returns credential-bearing fields", () => | |
| 669 | + withDb((db) => | |
| 670 | + Effect.gen(function* () { | |
| 671 | + yield* seed(db); | |
| 672 | + const executor = yield* makePlatformExecutor(db); | |
| 673 | + const admin = yield* requireAdmin(executor); | |
| 674 | + | |
| 675 | + const rows = yield* admin.listSubjectsWithConnections(); | |
| 676 | + | |
| 677 | + for (const row of rows) { | |
| 678 | + for (const connection of row.connections) { | |
| 679 | + const keys = Object.keys(connection); | |
| 680 | + for (const forbidden of FORBIDDEN_CONNECTION_FIELDS) { | |
| 681 | + expect(keys).not.toContain(forbidden); | |
| 682 | + } | |
| 683 | + } | |
| 684 | + } | |
| 685 | + expect(JSON.stringify(rows)).not.toContain("SECRET-"); | |
| 686 | + }), | |
| 687 | + ), | |
| 688 | + ); | |
| 689 | + | |
| 690 | + it.effect("works for a pure-org executor with no bound subject", () => | |
| 691 | + withDb((db) => | |
| 692 | + Effect.gen(function* () { | |
| 693 | + yield* seed(db); | |
| 694 | + // The shape an org-level API key produces: platform view on, no | |
| 695 | + // subject bound at all. | |
| 696 | + const executor = yield* makePlatformExecutor(db, { subject: null }); | |
| 697 | + const admin = yield* requireAdmin(executor); | |
| 698 | + | |
| 699 | + const rows = yield* admin.listSubjectsWithConnections(); | |
| 700 | + | |
| 701 | + expect(rows.map((row) => row.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]); | |
| 702 | + expect(rows.flatMap((row) => row.connections)).toHaveLength(3); | |
| 703 | + }), | |
| 704 | + ), | |
| 705 | + ); | |
| 706 | +}); | |
| 707 | + | |
| 708 | +describe("platform view — admin.getSubject", () => { | |
| 709 | + it.effect("reads one subject by external_id", () => | |
| 710 | + withDb((db) => | |
| 711 | + Effect.gen(function* () { | |
| 712 | + yield* seed(db); | |
| 713 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 714 | + | |
| 715 | + const subject = yield* admin.getSubject(SUBJECT_B); | |
| 716 | + | |
| 717 | + expect(subject?.externalId).toBe(SUBJECT_B); | |
| 718 | + // The same field discipline as the list: a keyed read is not a | |
| 719 | + // different projection. | |
| 720 | + expect(Object.keys(subject!).sort()).toEqual([ | |
| 721 | + "createdAt", | |
| 722 | + "externalId", | |
| 723 | + "lastSeenAt", | |
| 724 | + "status", | |
| 725 | + ]); | |
| 726 | + }), | |
| 727 | + ), | |
| 728 | + ); | |
| 729 | + | |
| 730 | + it.effect("returns null for an unknown id and for another tenant's subject", () => | |
| 731 | + withDb((db) => | |
| 732 | + Effect.gen(function* () { | |
| 733 | + yield* seed(db); | |
| 734 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 735 | + | |
| 736 | + expect(yield* admin.getSubject("user_nobody")).toBeNull(); | |
| 737 | + // `user_elsewhere` exists — under t2. The tenant policy filters the | |
| 738 | + // keyed read exactly as it filters the list. | |
| 739 | + expect(yield* admin.getSubject("user_elsewhere")).toBeNull(); | |
| 740 | + }), | |
| 741 | + ), | |
| 742 | + ); | |
| 743 | + | |
| 744 | + it.effect("joins connections in one call, and issues none for an absent subject", () => | |
| 745 | + withDb((db) => | |
| 746 | + Effect.gen(function* () { | |
| 747 | + yield* seed(db); | |
| 748 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 749 | + | |
| 750 | + const joined = yield* admin.getSubjectWithConnections(SUBJECT_A); | |
| 751 | + expect(joined?.externalId).toBe(SUBJECT_A); | |
| 752 | + expect(joined?.connections.map((entry) => entry.integration).sort()).toEqual([ | |
| 753 | + "github", | |
| 754 | + "linear", | |
| 755 | + ]); | |
| 756 | + // Org-owned rows belong to the tenant, not to any user, so the | |
| 757 | + // keyed join excludes them exactly as the list join does. | |
| 758 | + expect(joined?.connections.every((entry) => entry.owner === "user")).toBe(true); | |
| 759 | + | |
| 760 | + expect(yield* admin.getSubjectWithConnections("user_nobody")).toBeNull(); | |
| 761 | + }), | |
| 762 | + ), | |
| 763 | + ); | |
| 764 | + | |
| 765 | + it.effect("never returns credential-bearing fields on the keyed join", () => | |
| 766 | + withDb((db) => | |
| 767 | + Effect.gen(function* () { | |
| 768 | + yield* seed(db); | |
| 769 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 770 | + | |
| 771 | + const joined = yield* admin.getSubjectWithConnections(SUBJECT_A); | |
| 772 | + | |
| 773 | + for (const connection of joined!.connections) { | |
| 774 | + const keys = Object.keys(connection); | |
| 775 | + for (const forbidden of FORBIDDEN_CONNECTION_FIELDS) { | |
| 776 | + expect(keys).not.toContain(forbidden); | |
| 777 | + } | |
| 778 | + } | |
| 779 | + expect(JSON.stringify(joined)).not.toContain("SECRET-"); | |
| 780 | + }), | |
| 781 | + ), | |
| 782 | + ); | |
| 783 | +}); | |
| 784 | + | |
| 785 | +describe("platform view — default off", () => { | |
| 786 | + it.effect("an executor without the opt-in has no admin surface", () => | |
| 787 | + withDb((db) => | |
| 788 | + Effect.gen(function* () { | |
| 789 | + yield* seed(db); | |
| 790 | + const executor = yield* makePlatformExecutor(db, { platformView: false }); | |
| 791 | + | |
| 792 | + expect(executor.admin).toBeUndefined(); | |
| 793 | + }), | |
| 794 | + ), | |
| 795 | + ); | |
| 796 | + | |
| 797 | + it.effect("the product view still reads only the bound subject when the opt-in is on", () => | |
| 798 | + withDb((db) => | |
| 799 | + Effect.gen(function* () { | |
| 800 | + yield* seed(db); | |
| 801 | + // Enabling the platform view must not widen ANY existing surface. | |
| 802 | + const executor = yield* makePlatformExecutor(db, { subject: SUBJECT_A }); | |
| 803 | + | |
| 804 | + const connections = yield* executor.connections.list().pipe(Effect.orDie); | |
| 805 | + | |
| 806 | + expect(connections.map((entry) => entry.name).sort()).toEqual([ | |
| 807 | + "personal", | |
| 808 | + "shared", | |
| 809 | + "work", | |
| 810 | + ]); | |
| 811 | + }), | |
| 812 | + ), | |
| 813 | + ); | |
| 814 | +}); | |
| @@ -0,0 +1,418 @@ | ||
| 1 | +import { beforeEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | +import { withQueryContext } from "@executor-js/fumadb/query"; | |
| 4 | + | |
| 5 | +import { collectTables } from "./executor"; | |
| 6 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids"; | |
| 7 | +import type { ExecutorOwnerPolicyContext } from "./owner-policy"; | |
| 8 | +import { definePlugin } from "./plugin"; | |
| 9 | +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; | |
| 10 | +import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; | |
| 11 | +import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | |
| 12 | + | |
| 13 | +// `touchSubject` is the only writer of the `subject` table. Written against the | |
| 14 | +// real SQLite bring-up (the same statements local/self-host/D1 boot with) so | |
| 15 | +// the tenant policy, the unique index, and the bigint round-trip are all live. | |
| 16 | + | |
| 17 | +const TENANT = "t1"; | |
| 18 | +const OTHER_TENANT = "t2"; | |
| 19 | +const THROTTLE_MS = 60_000; | |
| 20 | + | |
| 21 | +const withDb = <A, E>(body: (db: SqliteTestFumaDb) => Effect.Effect<A, E>): Effect.Effect<A, E> => | |
| 22 | + Effect.acquireUseRelease( | |
| 23 | + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), | |
| 24 | + body, | |
| 25 | + (db) => Effect.promise(() => db.close()), | |
| 26 | + ); | |
| 27 | + | |
| 28 | +// Read the way PR 1.3's readers will: through a tenant-bound query context. | |
| 29 | +// `last_seen_at` is a bigint column stored as a SQLite blob, so a raw SQL read | |
| 30 | +// would hand back an unusable buffer — the ORM owns the round-trip. | |
| 31 | +const scopedTo = (db: SqliteTestFumaDb, tenant: string) => | |
| 32 | + withQueryContext(db.db, { | |
| 33 | + tenant, | |
| 34 | + subject: null, | |
| 35 | + } satisfies ExecutorOwnerPolicyContext); | |
| 36 | + | |
| 37 | +const storedSubjects = ( | |
| 38 | + db: SqliteTestFumaDb, | |
| 39 | + tenant = TENANT, | |
| 40 | +): Effect.Effect<readonly { readonly externalId: string; readonly lastSeenMs: number | null }[]> => | |
| 41 | + Effect.promise(async () => { | |
| 42 | + const rows = await scopedTo(db, tenant).findMany("subject", { | |
| 43 | + orderBy: ["external_id", "asc"], | |
| 44 | + }); | |
| 45 | + return rows.map((row) => ({ | |
| 46 | + externalId: String(row.external_id), | |
| 47 | + lastSeenMs: row.last_seen_at == null ? null : Number(row.last_seen_at), | |
| 48 | + })); | |
| 49 | + }); | |
| 50 | + | |
| 51 | +// `touchSubject` remembers, per process, which principals it already filed | |
| 52 | +// inside the throttle window. Every case below reuses the same tenant/principal | |
| 53 | +// against a FRESH database, so that memory has to be dropped between them or a | |
| 54 | +// later case would skip the query its assertion depends on. | |
| 55 | +beforeEach(() => { | |
| 56 | + resetSubjectTouchCache(); | |
| 57 | +}); | |
| 58 | + | |
| 59 | +describe("touchSubject", () => { | |
| 60 | + it.effect("creates the row on first sight", () => | |
| 61 | + withDb((db) => | |
| 62 | + Effect.gen(function* () { | |
| 63 | + const before = Date.now(); | |
| 64 | + yield* touchSubject(db.db, { tenant: TENANT, externalId: "user_a" }); | |
| 65 | + | |
| 66 | + const rows = yield* storedSubjects(db); | |
| 67 | + expect(rows).toHaveLength(1); | |
| 68 | + expect(rows[0]?.externalId).toBe("user_a"); | |
| 69 | + // A first sighting IS a sighting: `last_seen_at` is stamped, not left | |
| 70 | + // null for the next request to fill in. | |
| 71 | + expect(rows[0]?.lastSeenMs).toBeGreaterThanOrEqual(before); | |
| 72 | + }), | |
| 73 | + ), | |
| 74 | + ); | |
| 75 | + | |
| 76 | + it.effect("leaves last_seen_at alone while the sighting is inside the throttle", () => | |
| 77 | + withDb((db) => | |
| 78 | + Effect.gen(function* () { | |
| 79 | + yield* touchSubject(db.db, { | |
| 80 | + tenant: TENANT, | |
| 81 | + externalId: "user_a", | |
| 82 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 83 | + }); | |
| 84 | + const [first] = yield* storedSubjects(db); | |
| 85 | + | |
| 86 | + // The hot path: N requests in quick succession must cost zero writes, | |
| 87 | + // so the persisted stamp is unchanged afterwards. | |
| 88 | + yield* Effect.forEach([1, 2, 3], () => | |
| 89 | + touchSubject(db.db, { | |
| 90 | + tenant: TENANT, | |
| 91 | + externalId: "user_a", | |
| 92 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 93 | + }), | |
| 94 | + ); | |
| 95 | + | |
| 96 | + const rows = yield* storedSubjects(db); | |
| 97 | + expect(rows).toHaveLength(1); | |
| 98 | + expect(rows[0]?.lastSeenMs).toBe(first?.lastSeenMs); | |
| 99 | + }), | |
| 100 | + ), | |
| 101 | + ); | |
| 102 | + | |
| 103 | + it.effect("bumps last_seen_at once the sighting falls outside the throttle", () => | |
| 104 | + withDb((db) => | |
| 105 | + Effect.gen(function* () { | |
| 106 | + yield* touchSubject(db.db, { tenant: TENANT, externalId: "user_a" }); | |
| 107 | + const [first] = yield* storedSubjects(db); | |
| 108 | + | |
| 109 | + // Age the row rather than sleeping through the interval. A throttle of | |
| 110 | + // zero would pass even if the staleness comparison were inverted; a row | |
| 111 | + // aged well past the interval only passes if it reads the right way. | |
| 112 | + const stale = (first?.lastSeenMs ?? 0) - 10 * THROTTLE_MS; | |
| 113 | + yield* Effect.promise(() => | |
| 114 | + scopedTo(db, TENANT).updateMany("subject", { | |
| 115 | + where: (b) => b("external_id", "=", "user_a"), | |
| 116 | + set: { last_seen_at: BigInt(stale) }, | |
| 117 | + }), | |
| 118 | + ); | |
| 119 | + // Backdating the row stands in for elapsed time, and elapsed time ages | |
| 120 | + // the process-local sighting memory too — otherwise this would assert | |
| 121 | + // against a clock the memory never saw. Expiring it is what the passage | |
| 122 | + // of time does; the assertion below is unchanged. | |
| 123 | + resetSubjectTouchCache(); | |
| 124 | + | |
| 125 | + yield* touchSubject(db.db, { | |
| 126 | + tenant: TENANT, | |
| 127 | + externalId: "user_a", | |
| 128 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 129 | + }); | |
| 130 | + | |
| 131 | + const rows = yield* storedSubjects(db); | |
| 132 | + expect(rows).toHaveLength(1); | |
| 133 | + expect(rows[0]?.lastSeenMs).toBeGreaterThan(stale); | |
| 134 | + }), | |
| 135 | + ), | |
| 136 | + ); | |
| 137 | + | |
| 138 | + it.effect("issues no query at all for a repeat sighting inside the throttle", () => | |
| 139 | + withDb((db) => | |
| 140 | + Effect.gen(function* () { | |
| 141 | + // THE regression this guards: the request seam runs `touchSubject` on | |
| 142 | + // every HTTP request and MCP session, so a sighting that still costs an | |
| 143 | + // indexed read per request adds a round-trip to every request in the | |
| 144 | + // product. Dropping the table after the first sighting makes any | |
| 145 | + // further query fail loudly — and `touchSubject` swallows storage | |
| 146 | + // failures, so a surviving query would be invisible in the row state. | |
| 147 | + // Counting statements is the only way to see it. | |
| 148 | + yield* touchSubject(db.db, { | |
| 149 | + tenant: TENANT, | |
| 150 | + externalId: "user_a", | |
| 151 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 152 | + }); | |
| 153 | + | |
| 154 | + let queries = 0; | |
| 155 | + const client = db.client as { execute: (...args: never[]) => unknown }; | |
| 156 | + const execute = client.execute.bind(client) as (...args: never[]) => unknown; | |
| 157 | + client.execute = (...args: never[]) => { | |
| 158 | + queries += 1; | |
| 159 | + return execute(...args); | |
| 160 | + }; | |
| 161 | + | |
| 162 | + yield* Effect.forEach([1, 2, 3], () => | |
| 163 | + touchSubject(db.db, { | |
| 164 | + tenant: TENANT, | |
| 165 | + externalId: "user_a", | |
| 166 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 167 | + }), | |
| 168 | + ); | |
| 169 | + | |
| 170 | + client.execute = execute as typeof client.execute; | |
| 171 | + expect(queries).toBe(0); | |
| 172 | + }), | |
| 173 | + ), | |
| 174 | + ); | |
| 175 | + | |
| 176 | + it.effect("re-reads once the remembered sighting ages past the throttle", () => | |
| 177 | + withDb((db) => | |
| 178 | + Effect.gen(function* () { | |
| 179 | + // The memory must expire with the throttle, not pin a principal as | |
| 180 | + // "recently seen" forever: `last_seen_at` still has to advance for a | |
| 181 | + // principal who keeps calling across windows. | |
| 182 | + yield* touchSubject(db.db, { | |
| 183 | + tenant: TENANT, | |
| 184 | + externalId: "user_a", | |
| 185 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 186 | + }); | |
| 187 | + const [first] = yield* storedSubjects(db); | |
| 188 | + | |
| 189 | + // A zero throttle makes every remembered sighting instantly stale, | |
| 190 | + // which is what a caller crossing the window observes. | |
| 191 | + yield* touchSubject(db.db, { | |
| 192 | + tenant: TENANT, | |
| 193 | + externalId: "user_a", | |
| 194 | + lastSeenThrottleMs: 0, | |
| 195 | + }); | |
| 196 | + | |
| 197 | + const rows = yield* storedSubjects(db); | |
| 198 | + expect(rows).toHaveLength(1); | |
| 199 | + expect(rows[0]?.lastSeenMs).toBeGreaterThanOrEqual(first?.lastSeenMs ?? 0); | |
| 200 | + }), | |
| 201 | + ), | |
| 202 | + ); | |
| 203 | + | |
| 204 | + it.effect("remembers a first sighting per tenant, not per principal", () => | |
| 205 | + withDb((db) => | |
| 206 | + Effect.gen(function* () { | |
| 207 | + // One account acting in two orgs is two rows. A memory keyed on the | |
| 208 | + // principal alone would file the first tenant and skip the second, | |
| 209 | + // losing the row the other tenant's admin view depends on. | |
| 210 | + yield* touchSubject(db.db, { | |
| 211 | + tenant: TENANT, | |
| 212 | + externalId: "user_a", | |
| 213 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 214 | + }); | |
| 215 | + yield* touchSubject(db.db, { | |
| 216 | + tenant: OTHER_TENANT, | |
| 217 | + externalId: "user_a", | |
| 218 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 219 | + }); | |
| 220 | + | |
| 221 | + expect((yield* storedSubjects(db, OTHER_TENANT)).map((row) => row.externalId)).toEqual([ | |
| 222 | + "user_a", | |
| 223 | + ]); | |
| 224 | + }), | |
| 225 | + ), | |
| 226 | + ); | |
| 227 | + | |
| 228 | + it.effect("does not remember a sighting that failed to persist", () => | |
| 229 | + withDb((db) => | |
| 230 | + Effect.gen(function* () { | |
| 231 | + // A storage failure is swallowed so the request survives. It must not | |
| 232 | + // also be recorded as a successful sighting, or the principal would | |
| 233 | + // stay unfiled for a whole throttle window after the fault cleared. | |
| 234 | + yield* Effect.promise(() => db.client.execute("DROP TABLE subject")); | |
| 235 | + yield* touchSubject(db.db, { | |
| 236 | + tenant: TENANT, | |
| 237 | + externalId: "user_a", | |
| 238 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 239 | + }); | |
| 240 | + | |
| 241 | + yield* Effect.promise(() => | |
| 242 | + db.client.execute( | |
| 243 | + "CREATE TABLE subject (row_id text PRIMARY KEY NOT NULL, tenant text NOT NULL, external_id text NOT NULL, created_at integer NOT NULL, last_seen_at blob, status text)", | |
| 244 | + ), | |
| 245 | + ); | |
| 246 | + yield* touchSubject(db.db, { | |
| 247 | + tenant: TENANT, | |
| 248 | + externalId: "user_a", | |
| 249 | + lastSeenThrottleMs: THROTTLE_MS, | |
| 250 | + }); | |
| 251 | + | |
| 252 | + expect((yield* storedSubjects(db)).map((row) => row.externalId)).toEqual(["user_a"]); | |
| 253 | + }), | |
| 254 | + ), | |
| 255 | + ); | |
| 256 | + | |
| 257 | + it.effect("records nothing for a pure-org executor", () => | |
| 258 | + withDb((db) => | |
| 259 | + Effect.gen(function* () { | |
| 260 | + yield* touchSubject(db.db, { tenant: TENANT, externalId: null }); | |
| 261 | + expect(yield* storedSubjects(db)).toEqual([]); | |
| 262 | + }), | |
| 263 | + ), | |
| 264 | + ); | |
| 265 | + | |
| 266 | + it.effect("records the host sentinel subject id", () => | |
| 267 | + withDb((db) => | |
| 268 | + Effect.gen(function* () { | |
| 269 | + // apps/local binds every request to the literal subject "local". It is | |
| 270 | + // a real principal here, not a placeholder to skip. | |
| 271 | + yield* touchSubject(db.db, { tenant: TENANT, externalId: "local" }); | |
| 272 | + expect((yield* storedSubjects(db)).map((row) => row.externalId)).toEqual(["local"]); | |
| 273 | + }), | |
| 274 | + ), | |
| 275 | + ); | |
| 276 | + | |
| 277 | + it.effect("files the same principal separately under each tenant", () => | |
| 278 | + withDb((db) => | |
| 279 | + Effect.gen(function* () { | |
| 280 | + // The tenant written is always the one bound to the touch, so the | |
| 281 | + // tenant policy's create check can never reject it — and one account in | |
| 282 | + // two orgs is two rows under two tenants, not a unique-index collision. | |
| 283 | + yield* touchSubject(db.db, { tenant: TENANT, externalId: "user_a" }); | |
| 284 | + yield* touchSubject(db.db, { | |
| 285 | + tenant: OTHER_TENANT, | |
| 286 | + externalId: "user_a", | |
| 287 | + }); | |
| 288 | + | |
| 289 | + expect((yield* storedSubjects(db, TENANT)).map((row) => row.externalId)).toEqual([ | |
| 290 | + "user_a", | |
| 291 | + ]); | |
| 292 | + expect((yield* storedSubjects(db, OTHER_TENANT)).map((row) => row.externalId)).toEqual([ | |
| 293 | + "user_a", | |
| 294 | + ]); | |
| 295 | + }), | |
| 296 | + ), | |
| 297 | + ); | |
| 298 | + | |
| 299 | + it.effect("survives a storage failure without failing its caller", () => | |
| 300 | + withDb((db) => | |
| 301 | + Effect.gen(function* () { | |
| 302 | + // A sighting is bookkeeping. A storage failure must not take down the | |
| 303 | + // request that produced it — `touchSubject` logs and returns void. | |
| 304 | + // Dropping the table is the real shape of the failure this guards | |
| 305 | + // against (a host whose bring-up hasn't reached the new table yet). | |
| 306 | + yield* Effect.promise(() => db.client.execute("DROP TABLE subject")); | |
| 307 | + | |
| 308 | + yield* touchSubject(db.db, { tenant: TENANT, externalId: "user_a" }); | |
| 309 | + }), | |
| 310 | + ), | |
| 311 | + ); | |
| 312 | +}); | |
| 313 | + | |
| 314 | +// --------------------------------------------------------------------------- | |
| 315 | +// The second writer: `connections.create`. The request seam covers every hosted | |
| 316 | +// call, so this is the belt for a direct SDK/CLI caller that never passes | |
| 317 | +// through it. | |
| 318 | +// --------------------------------------------------------------------------- | |
| 319 | + | |
| 320 | +const INTEG = IntegrationSlug.make("vercel"); | |
| 321 | +const TEMPLATE = AuthTemplateSlug.make("apiKey"); | |
| 322 | + | |
| 323 | +const demoPlugin = definePlugin(() => ({ | |
| 324 | + id: "demo" as const, | |
| 325 | + storage: () => ({}), | |
| 326 | + resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("deploy") }] }), | |
| 327 | + invokeTool: () => Effect.succeed(null), | |
| 328 | + extension: (ctx) => ({ | |
| 329 | + seed: () => | |
| 330 | + ctx.core.integrations.register({ | |
| 331 | + slug: INTEG, | |
| 332 | + description: "Vercel", | |
| 333 | + config: {}, | |
| 334 | + }), | |
| 335 | + }), | |
| 336 | +}))(); | |
| 337 | + | |
| 338 | +const plugins = [memoryCredentialsPlugin(), demoPlugin] as const; | |
| 339 | + | |
| 340 | +const subjectsOf = ( | |
| 341 | + harness: { readonly config: { readonly db: unknown } }, | |
| 342 | + tenant: string, | |
| 343 | +): Effect.Effect<readonly string[]> => | |
| 344 | + Effect.promise(async () => { | |
| 345 | + const rows = await withQueryContext(harness.config.db as never, { | |
| 346 | + tenant, | |
| 347 | + subject: null, | |
| 348 | + } satisfies ExecutorOwnerPolicyContext).findMany("subject", {}); | |
| 349 | + return rows.map((row) => String(row.external_id)); | |
| 350 | + }); | |
| 351 | + | |
| 352 | +describe("connections.create subject sighting", () => { | |
| 353 | + it.effect("records the bound subject", () => | |
| 354 | + Effect.scoped( | |
| 355 | + Effect.gen(function* () { | |
| 356 | + const harness = yield* makeTestWorkspaceHarness({ | |
| 357 | + plugins, | |
| 358 | + tenant: TENANT, | |
| 359 | + }); | |
| 360 | + yield* harness.executor.demo.seed(); | |
| 361 | + yield* harness.executor.connections.create({ | |
| 362 | + owner: "user", | |
| 363 | + name: ConnectionName.make("main"), | |
| 364 | + integration: INTEG, | |
| 365 | + template: TEMPLATE, | |
| 366 | + value: "secret-token", | |
| 367 | + }); | |
| 368 | + | |
| 369 | + expect(yield* subjectsOf(harness, TENANT)).toEqual([harness.subject]); | |
| 370 | + }), | |
| 371 | + ), | |
| 372 | + ); | |
| 373 | + | |
| 374 | + it.effect("records the bound subject even for an org-owned connection", () => | |
| 375 | + Effect.scoped( | |
| 376 | + Effect.gen(function* () { | |
| 377 | + // The row names the acting principal, not the connection's owner tier: | |
| 378 | + // a member who only ever creates org connections still exists. | |
| 379 | + const harness = yield* makeTestWorkspaceHarness({ | |
| 380 | + plugins, | |
| 381 | + tenant: TENANT, | |
| 382 | + }); | |
| 383 | + yield* harness.executor.demo.seed(); | |
| 384 | + yield* harness.executor.connections.create({ | |
| 385 | + owner: "org", | |
| 386 | + name: ConnectionName.make("shared"), | |
| 387 | + integration: INTEG, | |
| 388 | + template: TEMPLATE, | |
| 389 | + value: "secret-token", | |
| 390 | + }); | |
| 391 | + | |
| 392 | + expect(yield* subjectsOf(harness, TENANT)).toEqual([harness.subject]); | |
| 393 | + }), | |
| 394 | + ), | |
| 395 | + ); | |
| 396 | + | |
| 397 | + it.effect("records nothing when the executor has no subject", () => | |
| 398 | + Effect.scoped( | |
| 399 | + Effect.gen(function* () { | |
| 400 | + const harness = yield* makeTestWorkspaceHarness({ | |
| 401 | + plugins, | |
| 402 | + tenant: TENANT, | |
| 403 | + subject: null, | |
| 404 | + }); | |
| 405 | + yield* harness.executor.demo.seed(); | |
| 406 | + yield* harness.executor.connections.create({ | |
| 407 | + owner: "org", | |
| 408 | + name: ConnectionName.make("shared"), | |
| 409 | + integration: INTEG, | |
| 410 | + template: TEMPLATE, | |
| 411 | + value: "secret-token", | |
| 412 | + }); | |
| 413 | + | |
| 414 | + expect(yield* subjectsOf(harness, TENANT)).toEqual([]); | |
| 415 | + }), | |
| 416 | + ), | |
| 417 | + ); | |
| 418 | +}); | |
Binary file not shown.
| @@ -0,0 +1,103 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | +import { withQueryContext } from "@executor-js/fumadb/query"; | |
| 4 | + | |
| 5 | +import { collectTables } from "./executor"; | |
| 6 | +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; | |
| 7 | + | |
| 8 | +// The subject table has no readers or writers yet (it is populated in a later | |
| 9 | +// change), so this pins the two properties the schema alone is responsible | |
| 10 | +// for: the boot bring-up actually creates it, and it is TENANT-scoped rather | |
| 11 | +// than owner-scoped — any executor bound to the tenant may read it, which is | |
| 12 | +// what a cross-subject view needs. Written against the real SQLite bring-up | |
| 13 | +// (`createSqliteTestFumaDb` runs the same statements the local/self-host/D1 | |
| 14 | +// hosts boot with) instead of asserting on the table definition object. | |
| 15 | + | |
| 16 | +const TENANT = "t1"; | |
| 17 | +const OTHER_TENANT = "t2"; | |
| 18 | + | |
| 19 | +const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> => | |
| 20 | + Effect.runPromise( | |
| 21 | + Effect.acquireUseRelease( | |
| 22 | + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), | |
| 23 | + (db) => Effect.promise(() => body(db)), | |
| 24 | + (db) => Effect.promise(() => db.close()), | |
| 25 | + ), | |
| 26 | + ); | |
| 27 | + | |
| 28 | +const insertSubject = ( | |
| 29 | + db: SqliteTestFumaDb, | |
| 30 | + row: { readonly rowId: string; readonly tenant: string; readonly externalId: string }, | |
| 31 | +): Promise<unknown> => | |
| 32 | + db.client.execute({ | |
| 33 | + sql: `INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status) | |
| 34 | + VALUES (?, ?, ?, ?, ?, ?)`, | |
| 35 | + args: [row.rowId, row.tenant, row.externalId, Date.now(), null, null], | |
| 36 | + }); | |
| 37 | + | |
| 38 | +describe("subject table", () => { | |
| 39 | + it.effect("is created by the runtime schema bring-up", () => | |
| 40 | + Effect.promise(() => | |
| 41 | + withDb(async (db) => { | |
| 42 | + const info = await db.client.execute("PRAGMA table_info('subject')"); | |
| 43 | + const columns = info.rows.map((row) => String(row["name"])); | |
| 44 | + expect(columns).toEqual( | |
| 45 | + expect.arrayContaining([ | |
| 46 | + "row_id", | |
| 47 | + "tenant", | |
| 48 | + "external_id", | |
| 49 | + "created_at", | |
| 50 | + "last_seen_at", | |
| 51 | + "status", | |
| 52 | + ]), | |
| 53 | + ); | |
| 54 | + }), | |
| 55 | + ), | |
| 56 | + ); | |
| 57 | + | |
| 58 | + it.effect("keys one row per (tenant, external_id)", () => | |
| 59 | + Effect.promise(() => | |
| 60 | + withDb(async (db) => { | |
| 61 | + await insertSubject(db, { rowId: "s1", tenant: TENANT, externalId: "user_a" }); | |
| 62 | + | |
| 63 | + // The same principal id under a DIFFERENT tenant is a different row — | |
| 64 | + // external ids are only unique within their tenant. | |
| 65 | + await insertSubject(db, { rowId: "s2", tenant: OTHER_TENANT, externalId: "user_a" }); | |
| 66 | + | |
| 67 | + await expect( | |
| 68 | + insertSubject(db, { rowId: "s3", tenant: TENANT, externalId: "user_a" }), | |
| 69 | + ).rejects.toThrow(); | |
| 70 | + | |
| 71 | + const rows = await db.client.execute("SELECT row_id FROM subject ORDER BY row_id"); | |
| 72 | + expect(rows.rows.map((row) => String(row["row_id"]))).toEqual(["s1", "s2"]); | |
| 73 | + }), | |
| 74 | + ), | |
| 75 | + ); | |
| 76 | + | |
| 77 | + it.effect("tolerates the host sentinel subject id", () => | |
| 78 | + Effect.promise(() => | |
| 79 | + withDb(async (db) => { | |
| 80 | + // Local/self-host bind every request to the literal subject "local"; | |
| 81 | + // nothing may assume external ids are opaque provider identifiers. | |
| 82 | + await insertSubject(db, { rowId: "s1", tenant: TENANT, externalId: "local" }); | |
| 83 | + const rows = await db.client.execute("SELECT external_id FROM subject"); | |
| 84 | + expect(rows.rows.map((row) => String(row["external_id"]))).toEqual(["local"]); | |
| 85 | + }), | |
| 86 | + ), | |
| 87 | + ); | |
| 88 | + | |
| 89 | + it.effect("reads only within the bound tenant", () => | |
| 90 | + Effect.promise(() => | |
| 91 | + withDb(async (db) => { | |
| 92 | + await insertSubject(db, { rowId: "s1", tenant: TENANT, externalId: "user_a" }); | |
| 93 | + await insertSubject(db, { rowId: "s2", tenant: OTHER_TENANT, externalId: "user_b" }); | |
| 94 | + | |
| 95 | + // No `subject` in the context: a pure-org executor still reads the | |
| 96 | + // table, which is the whole point of tenant scoping. | |
| 97 | + const scoped = withQueryContext(db.db, { tenant: TENANT, subject: null }); | |
| 98 | + const visible = await scoped.findMany("subject", {}); | |
| 99 | + expect(visible.map((row) => row.external_id)).toEqual(["user_a"]); | |
| 100 | + }), | |
| 101 | + ), | |
| 102 | + ); | |
| 103 | +}); | |
| @@ -28,11 +28,25 @@ export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey") · 27 unmodified lines | ||
| 28 | 28 | |
| 29 | 29 | // ── Organization members ───────────────────────────────────────────────────── |
| 30 | 30 | |
| 31 | −export const orgMembersAtom = Atom.refreshOnWindowFocus( | |
| 32 | − AccountApiClient.query("account", "listMembers", { | |
| 33 | − timeToLive: "30 seconds", | |
| 34 | − reactivityKeys: [ReactivityKey.orgMembers], | |
| 35 | − }), | |
| 31 | +// `refreshOnWindowFocus` is BROWSER-ONLY: its signal atom subscribes to | |
| 32 | +// `window`/`document.visibilityState` the first time the atom is read, so | |
| 33 | +// evaluating it on a server throws `ReferenceError: window is not defined` — | |
| 34 | +// synchronously, in render, where no Suspense boundary can absorb it. | |
| 35 | +// | |
| 36 | +// `withServerValueInitial` gives the atom an explicit server snapshot | |
| 37 | +// (`AsyncResult.initial(true)`) that `useAtomValue`'s `getServerSnapshot` reads | |
| 38 | +// INSTEAD of evaluating the atom, so the read function never runs server-side. | |
| 39 | +// Every reader already handles the initial state (it is the ordinary loading | |
| 40 | +// state), and the client refetches on mount. Without this, any SSR'd tree that | |
| 41 | +// reads the member list — cloud's shell does, to decide the admin nav — fails | |
| 42 | +// the whole document render. | |
| 43 | +export const orgMembersAtom = Atom.withServerValueInitial( | |
| 44 | + Atom.refreshOnWindowFocus( | |
| 45 | + AccountApiClient.query("account", "listMembers", { | |
| 46 | + timeToLive: "30 seconds", | |
| 47 | + reactivityKeys: [ReactivityKey.orgMembers], | |
| 48 | + }), | |
| 49 | + ), | |
| 36 | 50 | ); |
| 37 | 51 | |
| 38 | 52 | export const orgRolesAtom = AccountApiClient.query("account", "listRoles", { |
| @@ -0,0 +1,53 @@ | ||
| 1 | +import * as Atom from "effect/unstable/reactivity/Atom"; | |
| 2 | + | |
| 3 | +import { AdminApiClient } from "./admin-client"; | |
| 4 | +import { ReactivityKey } from "./reactivity-keys"; | |
| 5 | + | |
| 6 | +// --------------------------------------------------------------------------- | |
| 7 | +// Admin atoms — the tenant-wide users view behind `/admin/users*`. | |
| 8 | +// | |
| 9 | +// Read-only by construction: the admin plane serves GETs only (the owner policy | |
| 10 | +// rejects writes at tenant reach), so there are no mutations here and every | |
| 11 | +// atom carries the same reactivity key. | |
| 12 | +// | |
| 13 | +// Paging is part of the atom identity, so each page is its own cache entry and | |
| 14 | +// stepping back to a visited page is instant. `Atom.family` (not a bare arrow) | |
| 15 | +// because the page component re-derives the key object on every render — a | |
| 16 | +// fresh atom per render would refetch in a loop. | |
| 17 | +// --------------------------------------------------------------------------- | |
| 18 | + | |
| 19 | +/** How many users one page of the list shows. Well inside the contract's | |
| 20 | + * 1..500 bound; the joined endpoint reads per-user connections, so a modest | |
| 21 | + * page keeps that join cheap. */ | |
| 22 | +export const ADMIN_USERS_PAGE_SIZE = 25; | |
| 23 | + | |
| 24 | +export interface AdminUsersPage { | |
| 25 | + readonly limit: number; | |
| 26 | + readonly offset: number; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * One page of users joined with their connections — what the list renders. | |
| 31 | + * | |
| 32 | + * Asks for one row MORE than the page size so the pager can tell "there is a | |
| 33 | + * next page" from "this is the last one" without a count endpoint (the contract | |
| 34 | + * returns no total). The page component drops the extra row. | |
| 35 | + */ | |
| 36 | +export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) => | |
| 37 | + AdminApiClient.query("adminUsers", "listUsersWithConnections", { | |
| 38 | + query: { limit: page.limit + 1, offset: page.offset }, | |
| 39 | + timeToLive: "30 seconds", | |
| 40 | + reactivityKeys: [ReactivityKey.adminUsers], | |
| 41 | + }), | |
| 42 | +); | |
| 43 | + | |
| 44 | +/** One user's connections, for the detail pane. A separate read rather than a | |
| 45 | + * slice of the list so opening a user is correct even when the list page it | |
| 46 | + * came from has since gone stale. */ | |
| 47 | +export const adminUserConnectionsAtom = Atom.family((externalId: string) => | |
| 48 | + AdminApiClient.query("adminUsers", "listUserConnections", { | |
| 49 | + params: { externalId }, | |
| 50 | + timeToLive: "30 seconds", | |
| 51 | + reactivityKeys: [ReactivityKey.adminUsers], | |
| 52 | + }), | |
| 53 | +); | |
| @@ -0,0 +1,53 @@ | ||
| 1 | +import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; | |
| 2 | +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 3 | +import { AdminUsersHttpApi } from "@executor-js/api/client"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | + | |
| 6 | +import { reportApiClientInfrastructureCause } from "./client"; | |
| 7 | +import { | |
| 8 | + EXECUTOR_ORG_HEADER, | |
| 9 | + getActiveOrgSlug, | |
| 10 | + getExecutorApiBaseUrl, | |
| 11 | + getExecutorServerAuthorizationHeader, | |
| 12 | +} from "./server-connection"; | |
| 13 | + | |
| 14 | +// --------------------------------------------------------------------------- | |
| 15 | +// Shared admin client — the tenant-wide `/admin/users*` operator surface. | |
| 16 | +// | |
| 17 | +// A third AtomHttpApi service alongside `ExecutorApiClient` (the product plane: | |
| 18 | +// "what can I reach") and `AccountApiClient` (identity + org membership). This | |
| 19 | +// one answers the OWNER's question: who are my users and what have they | |
| 20 | +// connected. Its paths are `/admin/*` with no `/api` — `transformClient` | |
| 21 | +// prepends the API base, exactly like the account client. | |
| 22 | +// | |
| 23 | +// Both hosts that serve it (cloud: an org API key or an admin-role session; | |
| 24 | +// self-host: a Better Auth owner/admin) implement identical routes, so one | |
| 25 | +// client works for both. Requests carry the same session cookie the browser | |
| 26 | +// sends anyway; the org header scopes cloud to the org the console URL is on. | |
| 27 | +// | |
| 28 | +// `reportApiClientInfrastructureCause` (not `handleApiClientCause`) on purpose: | |
| 29 | +// a 401 here means "not an admin of this tenant", which must render a denied | |
| 30 | +// state on the page — it must NOT pop the local-auth gate the core client | |
| 31 | +// raises when the whole product plane is unreachable. | |
| 32 | +// --------------------------------------------------------------------------- | |
| 33 | + | |
| 34 | +const AdminApiClient = AtomHttpApi.Service<"AdminApiClient">()("AdminApiClient", { | |
| 35 | + api: AdminUsersHttpApi, | |
| 36 | + httpClient: FetchHttpClient.layer, | |
| 37 | + transformClient: HttpClient.mapRequest((request) => { | |
| 38 | + let next = HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl()); | |
| 39 | + const authorization = getExecutorServerAuthorizationHeader(); | |
| 40 | + if (authorization) { | |
| 41 | + next = HttpClientRequest.setHeader(next, "authorization", authorization); | |
| 42 | + } | |
| 43 | + // Scope to the org the console URL is on (see server-connection). | |
| 44 | + const orgSlug = getActiveOrgSlug(); | |
| 45 | + if (orgSlug) { | |
| 46 | + next = HttpClientRequest.setHeader(next, EXECUTOR_ORG_HEADER, orgSlug); | |
| 47 | + } | |
| 48 | + return next; | |
| 49 | + }), | |
| 50 | + transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), | |
| 51 | +}); | |
| 52 | + | |
| 53 | +export { AdminApiClient }; | |
| @@ -36,6 +36,10 @@ export const ReactivityKey = { · 35 unmodified lines | ||
| 36 | 36 | orgInfo: "org:info", |
| 37 | 37 | apiKeys: "api-keys", |
| 38 | 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 | 43 | } as const; |
| 40 | 44 | |
| 41 | 45 | /** Mutations that add/remove/refresh an integration also affect tool listings. */ |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { RESERVED_ORG_SLUGS } from "@executor-js/api"; | |
| 2 | 3 | |
| 3 | 4 | import { CONSOLE_ROUTE_PATHS, ORG_SLUG_SEGMENT, consoleRoutes } from "./console-routes"; |
| 4 | 5 | import { routeTree } from "./routes/routeTree.gen"; |
| @@ -52,6 +53,29 @@ describe("console route contract", () => { · 47 unmodified lines | ||
| 52 | 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 | 79 | it("orgScoped extras mount inside the org scope", () => { |
| 56 | 80 | const [scope] = consoleRoutes({ |
| 57 | 81 | dir: "shared", |
| @@ -34,11 +34,13 @@ export const ORG_SLUG_SEGMENT = "{-$orgSlug}"; · 33 unmodified lines | ||
| 34 | 34 | * are these paths prefixed with `/{-$orgSlug}`. */ |
| 35 | 35 | export const CONSOLE_ROUTE_PATHS = [ |
| 36 | 36 | "/", |
| 37 | + "/connect/$integrationSlug", | |
| 37 | 38 | "/integrations/$namespace", |
| 38 | 39 | "/integrations/add/$pluginKey", |
| 39 | 40 | "/policies", |
| 40 | 41 | "/secrets", |
| 41 | 42 | "/tools", |
| 43 | + "/users", | |
| 42 | 44 | "/toolkits", |
| 43 | 45 | "/toolkits/$toolkitSlug", |
| 44 | 46 | "/resume/$executionId", |
| @@ -62,6 +64,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 17 unmodified lines | ||
| 62 | 64 | const file = (name: string): string => `${options.dir}/${name}`; |
| 63 | 65 | const entries: ReadonlyArray<readonly [ConsoleRoutePath, VirtualRouteNode]> = [ |
| 64 | 66 | ["/", index(file("index.tsx"))], |
| 67 | + [ | |
| 68 | + "/connect/$integrationSlug", | |
| 69 | + route("/connect/$integrationSlug", file("connect.$integrationSlug.tsx")), | |
| 70 | + ], | |
| 65 | 71 | [ |
| 66 | 72 | "/integrations/$namespace", |
| 67 | 73 | route("/integrations/$namespace", file("integrations.$namespace.tsx")), |
| @@ -73,6 +79,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 5 unmodified lines | ||
| 73 | 79 | ["/policies", route("/policies", file("policies.tsx"))], |
| 74 | 80 | ["/secrets", route("/secrets", file("secrets.tsx"))], |
| 75 | 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 | 86 | ["/toolkits", route("/toolkits", file("toolkits.tsx"))], |
| 77 | 87 | ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))], |
| 78 | 88 | ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))], |
| @@ -0,0 +1,52 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { isTenantAdminMember, type TenantMemberRow } from "./admin-access"; | |
| 4 | + | |
| 5 | +const member = (overrides: Partial<TenantMemberRow> = {}): TenantMemberRow => ({ | |
| 6 | + role: "member", | |
| 7 | + status: "active", | |
| 8 | + isCurrentUser: false, | |
| 9 | + ...overrides, | |
| 10 | +}); | |
| 11 | + | |
| 12 | +describe("isTenantAdminMember", () => { | |
| 13 | + it("an active admin of the tenant may see the admin sections", () => { | |
| 14 | + expect(isTenantAdminMember([member({ role: "admin", isCurrentUser: true })])).toBe(true); | |
| 15 | + }); | |
| 16 | + | |
| 17 | + // Self-host (Better Auth) issues `owner` where cloud (WorkOS) issues `admin`; | |
| 18 | + // both hosts' admin middleware accept either. | |
| 19 | + it("self-host's owner role counts too", () => { | |
| 20 | + expect(isTenantAdminMember([member({ role: "owner", isCurrentUser: true })])).toBe(true); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it("a plain member does not", () => { | |
| 24 | + expect(isTenantAdminMember([member({ role: "member", isCurrentUser: true })])).toBe(false); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + // A pending invite carries a role but no access yet — treating it as admin | |
| 28 | + // would show a section the server refuses. | |
| 29 | + it("a pending admin invite is not yet an admin", () => { | |
| 30 | + expect( | |
| 31 | + isTenantAdminMember([member({ role: "admin", status: "pending", isCurrentUser: true })]), | |
| 32 | + ).toBe(false); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("another member's admin role says nothing about the viewer", () => { | |
| 36 | + expect( | |
| 37 | + isTenantAdminMember([ | |
| 38 | + member({ role: "admin", isCurrentUser: false }), | |
| 39 | + member({ role: "member", isCurrentUser: true }), | |
| 40 | + ]), | |
| 41 | + ).toBe(false); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it("fails closed when the list has no row for the viewer", () => { | |
| 45 | + expect(isTenantAdminMember([])).toBe(false); | |
| 46 | + expect(isTenantAdminMember([member({ role: "admin" })])).toBe(false); | |
| 47 | + }); | |
| 48 | + | |
| 49 | + it("an unknown role is not an admin", () => { | |
| 50 | + expect(isTenantAdminMember([member({ role: "billing", isCurrentUser: true })])).toBe(false); | |
| 51 | + }); | |
| 52 | +}); | |
| @@ -0,0 +1,46 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Who may see the admin sections. | |
| 3 | +// | |
| 4 | +// There are two independent gates and both are load-bearing: | |
| 5 | +// | |
| 6 | +// 1. NAV VISIBILITY (this file). The console has no role on the session — | |
| 7 | +// `AccountMeResponse` carries a user and an organization, not a role — so | |
| 8 | +// the only client-side role signal available today is the member list: | |
| 9 | +// find the row flagged `isCurrentUser` and read its role. Cloud's org page | |
| 10 | +// already derives its danger zone this way; this generalizes it. | |
| 11 | +// 2. ACCESS ITSELF (the page). The server decides. Every admin endpoint is | |
| 12 | +// gated per host (cloud: an org API key or an admin-role session; | |
| 13 | +// self-host: a Better Auth owner/admin), and the page renders a denied | |
| 14 | +// state on 401/403 regardless of what this file concluded. | |
| 15 | +// | |
| 16 | +// The nav gate is a convenience, not security: it exists so a plain member | |
| 17 | +// isn't shown a section that would only refuse them. A member who guesses the | |
| 18 | +// URL hits gate 2, which is the real one. | |
| 19 | +// --------------------------------------------------------------------------- | |
| 20 | + | |
| 21 | +/** Roles that may read the tenant-wide admin plane. Cloud (WorkOS) issues | |
| 22 | + * `admin` / `member`; self-host (Better Auth) issues `owner` / `admin` / | |
| 23 | + * `member`. Both hosts' admin middleware accept exactly this set. */ | |
| 24 | +const TENANT_ADMIN_ROLES: ReadonlySet<string> = new Set(["admin", "owner"]); | |
| 25 | + | |
| 26 | +/** A member row as `/account/members` returns it, typed structurally. */ | |
| 27 | +export interface TenantMemberRow { | |
| 28 | + readonly role: string; | |
| 29 | + readonly status: string; | |
| 30 | + readonly isCurrentUser: boolean; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * Whether the current member may read the admin plane, per the member list. | |
| 35 | + * | |
| 36 | + * Only an ACTIVE membership counts: a pending invite carries a role but no | |
| 37 | + * access yet, and treating it as admin would show a section the server refuses. | |
| 38 | + * A list with no `isCurrentUser` row (self-host before the org is populated, a | |
| 39 | + * host that doesn't serve members) is not an admin — the safe default, since | |
| 40 | + * the page's own denied state is the honest surface for that case. | |
| 41 | + */ | |
| 42 | +export const isTenantAdminMember = (members: readonly TenantMemberRow[]): boolean => | |
| 43 | + members.some( | |
| 44 | + (member) => | |
| 45 | + member.isCurrentUser && member.status === "active" && TENANT_ADMIN_ROLES.has(member.role), | |
| 46 | + ); | |
| @@ -0,0 +1,326 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import type { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + adminUserCopyableEmail, | |
| 6 | + adminUserTitle, | |
| 7 | + connectionHealthStatus, | |
| 8 | + connectLinkUrl, | |
| 9 | + formatLastSeen, | |
| 10 | + integrationConnectionStates, | |
| 11 | + isConnectableIntegration, | |
| 12 | + isLocalSubject, | |
| 13 | + lastSeenTitle, | |
| 14 | + pageNumber, | |
| 15 | + shortenExternalId, | |
| 16 | + splitPage, | |
| 17 | + type AdminCatalogRow, | |
| 18 | + type AdminConnectionRow, | |
| 19 | +} from "./admin-users-display"; | |
| 20 | + | |
| 21 | +const slug = (value: string): IntegrationSlug => value as IntegrationSlug; | |
| 22 | + | |
| 23 | +/** A catalog row for a normal, connectable integration. `kind` is the owning | |
| 24 | + * plugin's key on a real row, so "openapi" is what the catalog actually says. */ | |
| 25 | +const catalogRow = (value: string, kind = "openapi"): AdminCatalogRow => ({ | |
| 26 | + slug: slug(value), | |
| 27 | + kind, | |
| 28 | +}); | |
| 29 | + | |
| 30 | +/** The built-in Executor row, exactly as a real catalog read returns it: | |
| 31 | + * `kind: "built-in"` with `authMethods: []` (verified against | |
| 32 | + * `executor.integrations.list()`). */ | |
| 33 | +const builtInRow: AdminCatalogRow = { slug: slug("executor"), kind: "built-in" }; | |
| 34 | + | |
| 35 | +const connection = (overrides: Partial<AdminConnectionRow> = {}): AdminConnectionRow => ({ | |
| 36 | + owner: "user", | |
| 37 | + integration: slug("acme"), | |
| 38 | + name: "default", | |
| 39 | + oauthScope: null, | |
| 40 | + lastHealth: null, | |
| 41 | + ...overrides, | |
| 42 | +}); | |
| 43 | + | |
| 44 | +describe("formatLastSeen", () => { | |
| 45 | + const now = Date.UTC(2026, 6, 27, 12, 0, 0); | |
| 46 | + | |
| 47 | + it("never seen reads as Never, not as a stale timestamp", () => { | |
| 48 | + expect(formatLastSeen(null, now)).toBe("Never"); | |
| 49 | + }); | |
| 50 | + | |
| 51 | + // The writer throttles lastSeenAt to ~1h, so anything inside that window is | |
| 52 | + // one bucket. Reporting minutes would claim precision the field doesn't have. | |
| 53 | + it("collapses everything inside the throttle window to one honest bucket", () => { | |
| 54 | + expect(formatLastSeen(now - 60_000, now)).toBe("Within the hour"); | |
| 55 | + expect(formatLastSeen(now - 45 * 60_000, now)).toBe("Within the hour"); | |
| 56 | + expect(formatLastSeen(now, now)).toBe("Within the hour"); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("counts hours and days above the window, hedged with About", () => { | |
| 60 | + expect(formatLastSeen(now - 60 * 60_000, now)).toBe("About 1 hour ago"); | |
| 61 | + expect(formatLastSeen(now - 5 * 60 * 60_000, now)).toBe("About 5 hours ago"); | |
| 62 | + expect(formatLastSeen(now - 24 * 60 * 60_000, now)).toBe("About 1 day ago"); | |
| 63 | + expect(formatLastSeen(now - 9 * 24 * 60 * 60_000, now)).toBe("About 9 days ago"); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it("falls back to an absolute date once relative wording stops being useful", () => { | |
| 67 | + expect(formatLastSeen(now - 200 * 24 * 60 * 60_000, now)).not.toContain("ago"); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it("the tooltip carries the exact instant and the coarseness caveat", () => { | |
| 71 | + expect(lastSeenTitle(null)).toBe("Never seen on a request"); | |
| 72 | + expect(lastSeenTitle(now)).toContain("at most once an hour"); | |
| 73 | + }); | |
| 74 | +}); | |
| 75 | + | |
| 76 | +describe("externalId display", () => { | |
| 77 | + it("recognizes the single-player host sentinel", () => { | |
| 78 | + expect(isLocalSubject("local")).toBe(true); | |
| 79 | + expect(isLocalSubject("user_01JABCDEF")).toBe(false); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it("leaves short ids exactly as the server sent them", () => { | |
| 83 | + expect(shortenExternalId("user_01JAB")).toBe("user_01JAB"); | |
| 84 | + }); | |
| 85 | + | |
| 86 | + // Truncation is visual only — the id is opaque, so both ends are kept and two | |
| 87 | + // ids differing in the middle stay distinguishable. | |
| 88 | + it("shortens long ids from the middle, keeping both ends", () => { | |
| 89 | + const long = "user_01JABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; | |
| 90 | + const short = shortenExternalId(long, 21); | |
| 91 | + expect(short.length).toBe(21); | |
| 92 | + expect(short.startsWith("user_01JAB")).toBe(true); | |
| 93 | + expect(short.endsWith("0123456789")).toBe(true); | |
| 94 | + expect(short).toContain("…"); | |
| 95 | + }); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +describe("adminUserTitle", () => { | |
| 99 | + // The email is what an operator invites, searches and supports against, so it | |
| 100 | + // leads — and the opaque id drops to the supporting line rather than vanishing. | |
| 101 | + it("leads with the email and keeps the id as the supporting line", () => { | |
| 102 | + expect( | |
| 103 | + adminUserTitle({ externalId: "user_01JAB", email: "ada@e2e.test", displayName: "Ada L" }), | |
| 104 | + ).toEqual({ primary: "ada@e2e.test", secondary: "user_01JAB", primaryIsId: false }); | |
| 105 | + }); | |
| 106 | + | |
| 107 | + it("falls back to the display name when the host resolved no email", () => { | |
| 108 | + expect(adminUserTitle({ externalId: "user_01JAB", email: null, displayName: "Ada L" })).toEqual( | |
| 109 | + { primary: "Ada L", secondary: "user_01JAB", primaryIsId: false }, | |
| 110 | + ); | |
| 111 | + }); | |
| 112 | + | |
| 113 | + // The pre-identity rendering, unchanged: one mono id line and no subtitle. A | |
| 114 | + // row the host cannot name must not degrade to a gap where a name would be. | |
| 115 | + it("renders exactly the old id-only row when the host resolved neither", () => { | |
| 116 | + expect(adminUserTitle({ externalId: "user_01JAB", email: null, displayName: null })).toEqual({ | |
| 117 | + primary: "user_01JAB", | |
| 118 | + secondary: null, | |
| 119 | + primaryIsId: true, | |
| 120 | + }); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + // A member who left the org while their connections remain arrives with the | |
| 124 | + // fields simply missing rather than explicitly null. | |
| 125 | + it("treats missing identity fields the same as null ones", () => { | |
| 126 | + expect(adminUserTitle({ externalId: "user_01JAB" }).primaryIsId).toBe(true); | |
| 127 | + }); | |
| 128 | + | |
| 129 | + // A directory that stores empty strings must not produce a blank headline. | |
| 130 | + it("does not let a blank email or name become the headline", () => { | |
| 131 | + expect(adminUserTitle({ externalId: "user_01JAB", email: " ", displayName: "" })).toEqual({ | |
| 132 | + primary: "user_01JAB", | |
| 133 | + secondary: null, | |
| 134 | + primaryIsId: true, | |
| 135 | + }); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + it("spells out the single-player host sentinel, which names no member", () => { | |
| 139 | + expect(adminUserTitle({ externalId: "local", email: null, displayName: null })).toEqual({ | |
| 140 | + primary: "local", | |
| 141 | + secondary: null, | |
| 142 | + primaryIsId: true, | |
| 143 | + }); | |
| 144 | + }); | |
| 145 | +}); | |
| 146 | + | |
| 147 | +describe("adminUserCopyableEmail", () => { | |
| 148 | + it("offers the email when the host resolved one", () => { | |
| 149 | + expect(adminUserCopyableEmail({ externalId: "user_01JAB", email: "ada@e2e.test" })).toBe( | |
| 150 | + "ada@e2e.test", | |
| 151 | + ); | |
| 152 | + }); | |
| 153 | + | |
| 154 | + // A name is not an address: a copy button that yields something unusable is | |
| 155 | + // worse than no button, so a name-only row offers nothing to copy. | |
| 156 | + it("offers nothing when only a display name is known", () => { | |
| 157 | + expect( | |
| 158 | + adminUserCopyableEmail({ externalId: "user_01JAB", email: null, displayName: "Ada L" }), | |
| 159 | + ).toBeNull(); | |
| 160 | + }); | |
| 161 | + | |
| 162 | + it("treats a blank email as absent", () => { | |
| 163 | + expect(adminUserCopyableEmail({ externalId: "user_01JAB", email: " " })).toBeNull(); | |
| 164 | + }); | |
| 165 | +}); | |
| 166 | + | |
| 167 | +describe("connectionHealthStatus", () => { | |
| 168 | + it("reads the stored verdict", () => { | |
| 169 | + expect(connectionHealthStatus(connection({ lastHealth: { status: "expired" } }))).toBe( | |
| 170 | + "expired", | |
| 171 | + ); | |
| 172 | + }); | |
| 173 | + | |
| 174 | + it("a never-probed connection is unchecked, not healthy", () => { | |
| 175 | + expect(connectionHealthStatus(connection({ lastHealth: null }))).toBe("unknown"); | |
| 176 | + }); | |
| 177 | +}); | |
| 178 | + | |
| 179 | +describe("isConnectableIntegration", () => { | |
| 180 | + it("a built-in integration has no connect flow to send anyone to", () => { | |
| 181 | + expect(isConnectableIntegration(builtInRow)).toBe(false); | |
| 182 | + // The product page pins the reserved slug as well as the kind | |
| 183 | + // (`integration-detail.tsx`), so both spellings are refused. | |
| 184 | + expect(isConnectableIntegration({ slug: slug("executor"), kind: "openapi" })).toBe(false); | |
| 185 | + }); | |
| 186 | + | |
| 187 | + // The trap: an openapi/mcp/graphql integration can carry `authMethods: []` | |
| 188 | + // and STILL be connectable, because its accounts panel supplies | |
| 189 | + // `createCustomMethod`. Empty auth methods must never be read as "no connect | |
| 190 | + // flow" — only `built-in` means that. | |
| 191 | + it("a normal integration stays connectable regardless of declared auth methods", () => { | |
| 192 | + expect(isConnectableIntegration(catalogRow("acme"))).toBe(true); | |
| 193 | + expect(isConnectableIntegration(catalogRow("beta", "mcp"))).toBe(true); | |
| 194 | + expect(isConnectableIntegration(catalogRow("gamma", "graphql"))).toBe(true); | |
| 195 | + }); | |
| 196 | +}); | |
| 197 | + | |
| 198 | +describe("integrationConnectionStates", () => { | |
| 199 | + it("shows the whole catalog, marking what this user has connected", () => { | |
| 200 | + const states = integrationConnectionStates( | |
| 201 | + [catalogRow("acme"), catalogRow("beta"), catalogRow("gamma")], | |
| 202 | + [connection({ integration: slug("beta") })], | |
| 203 | + ); | |
| 204 | + expect(states.map((state) => state.integration)).toEqual(["beta", "acme", "gamma"]); | |
| 205 | + expect(states.map((state) => state.connected)).toEqual([true, false, false]); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + it("keeps an available integration listed even when nobody connected it", () => { | |
| 209 | + const states = integrationConnectionStates([catalogRow("acme")], []); | |
| 210 | + expect(states).toEqual([{ integration: "acme", connected: false, connections: [] }]); | |
| 211 | + }); | |
| 212 | + | |
| 213 | + // The reported bug: the built-in Executor integration appeared under "not | |
| 214 | + // connected" with a copyable link to a flow that does not exist. It is not a | |
| 215 | + // slot, not a denominator, and not a link. | |
| 216 | + it("drops a non-connectable integration from the view and the counts entirely", () => { | |
| 217 | + const states = integrationConnectionStates( | |
| 218 | + [catalogRow("acme"), builtInRow], | |
| 219 | + [connection({ integration: slug("acme") })], | |
| 220 | + ); | |
| 221 | + expect(states.map((state) => state.integration)).toEqual(["acme"]); | |
| 222 | + }); | |
| 223 | + | |
| 224 | + it("a catalog of nothing but non-connectable integrations yields no slots at all", () => { | |
| 225 | + expect(integrationConnectionStates([builtInRow], [])).toEqual([]); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + // Data honesty beats the filter: if a user somehow HOLDS a connection on a | |
| 229 | + // non-connectable integration, hiding it would misreport what they have. | |
| 230 | + it("still reports a held connection on a non-connectable integration, as connected", () => { | |
| 231 | + const states = integrationConnectionStates( | |
| 232 | + [catalogRow("acme"), builtInRow], | |
| 233 | + [connection({ integration: slug("executor"), name: "coreTools" })], | |
| 234 | + ); | |
| 235 | + expect(states.map((state) => state.integration)).toEqual(["executor", "acme"]); | |
| 236 | + expect(states.map((state) => state.connected)).toEqual([true, false]); | |
| 237 | + }); | |
| 238 | + | |
| 239 | + // Dropping the user's own credential because the catalog read missed it would | |
| 240 | + // under-report what they actually hold. | |
| 241 | + it("still reports a connection whose integration is missing from the catalog", () => { | |
| 242 | + const states = integrationConnectionStates([], [connection({ integration: slug("ghost") })]); | |
| 243 | + expect(states.map((state) => state.integration)).toEqual(["ghost"]); | |
| 244 | + expect(states[0]?.connected).toBe(true); | |
| 245 | + }); | |
| 246 | + | |
| 247 | + it("groups every connection a user holds on one integration", () => { | |
| 248 | + const states = integrationConnectionStates( | |
| 249 | + [catalogRow("acme")], | |
| 250 | + [ | |
| 251 | + connection({ integration: slug("acme"), name: "work" }), | |
| 252 | + connection({ integration: slug("acme"), name: "personal" }), | |
| 253 | + ], | |
| 254 | + ); | |
| 255 | + expect(states[0]?.connections.map((row) => row.name)).toEqual(["work", "personal"]); | |
| 256 | + }); | |
| 257 | + | |
| 258 | + it("orders connected first, then alphabetically, so the order is stable per user", () => { | |
| 259 | + const states = integrationConnectionStates( | |
| 260 | + [catalogRow("zulu"), catalogRow("alpha"), catalogRow("mike")], | |
| 261 | + [connection({ integration: slug("zulu") })], | |
| 262 | + ); | |
| 263 | + expect(states.map((state) => state.integration)).toEqual(["zulu", "alpha", "mike"]); | |
| 264 | + }); | |
| 265 | +}); | |
| 266 | + | |
| 267 | +describe("connectLinkUrl", () => { | |
| 268 | + // The org segment is the SENDING admin's. A recipient in several orgs would | |
| 269 | + // otherwise land in whichever their session defaults to and connect the | |
| 270 | + // credential in the wrong workspace — succeeding, silently, in the wrong place. | |
| 271 | + it("carries the sending admin's org so a multi-org recipient lands in the right one", () => { | |
| 272 | + expect(connectLinkUrl(slug("acme"), "https://console.example.com", "north-wind")).toBe( | |
| 273 | + "https://console.example.com/north-wind/connect/acme", | |
| 274 | + ); | |
| 275 | + }); | |
| 276 | + | |
| 277 | + // Self-host and local have no orgs, so there is no segment to carry and the | |
| 278 | + // bare form is the whole route. | |
| 279 | + it("builds the bare form on a host with no org slug", () => { | |
| 280 | + expect(connectLinkUrl(slug("acme"), "https://console.example.com")).toBe( | |
| 281 | + "https://console.example.com/connect/acme", | |
| 282 | + ); | |
| 283 | + expect(connectLinkUrl(slug("acme"), "https://console.example.com", undefined)).toBe( | |
| 284 | + "https://console.example.com/connect/acme", | |
| 285 | + ); | |
| 286 | + // An empty or whitespace slug is absence, not an empty path segment — it | |
| 287 | + // must never render "//connect/acme". | |
| 288 | + expect(connectLinkUrl(slug("acme"), "https://console.example.com", "")).toBe( | |
| 289 | + "https://console.example.com/connect/acme", | |
| 290 | + ); | |
| 291 | + expect(connectLinkUrl(slug("acme"), "https://console.example.com", " ")).toBe( | |
| 292 | + "https://console.example.com/connect/acme", | |
| 293 | + ); | |
| 294 | + }); | |
| 295 | + | |
| 296 | + it("does not double the separator when the origin carries a trailing slash", () => { | |
| 297 | + expect(connectLinkUrl(slug("acme"), "https://console.example.com/")).toBe( | |
| 298 | + "https://console.example.com/connect/acme", | |
| 299 | + ); | |
| 300 | + expect(connectLinkUrl(slug("acme"), "https://console.example.com/", "north-wind")).toBe( | |
| 301 | + "https://console.example.com/north-wind/connect/acme", | |
| 302 | + ); | |
| 303 | + }); | |
| 304 | +}); | |
| 305 | + | |
| 306 | +describe("paging", () => { | |
| 307 | + // The contract returns no total, so the page over-fetches by one row and the | |
| 308 | + // extra row IS the answer to "is there a next page". | |
| 309 | + it("an over-fetched page yields the page's rows plus a next-page signal", () => { | |
| 310 | + expect(splitPage([1, 2, 3, 4], 3)).toEqual({ rows: [1, 2, 3], hasNext: true }); | |
| 311 | + }); | |
| 312 | + | |
| 313 | + it("a page that came back short is the last page", () => { | |
| 314 | + expect(splitPage([1, 2], 3)).toEqual({ rows: [1, 2], hasNext: false }); | |
| 315 | + }); | |
| 316 | + | |
| 317 | + it("an exactly-full page without the extra row is also the last page", () => { | |
| 318 | + expect(splitPage([1, 2, 3], 3)).toEqual({ rows: [1, 2, 3], hasNext: false }); | |
| 319 | + }); | |
| 320 | + | |
| 321 | + it("numbers pages from one", () => { | |
| 322 | + expect(pageNumber(0, 25)).toBe(1); | |
| 323 | + expect(pageNumber(25, 25)).toBe(2); | |
| 324 | + expect(pageNumber(75, 25)).toBe(4); | |
| 325 | + }); | |
| 326 | +}); | |
| @@ -0,0 +1,283 @@ | ||
| 1 | +import type { HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared"; | |
| 2 | + | |
| 3 | +// --------------------------------------------------------------------------- | |
| 4 | +// Pure display logic for the admin Users section. | |
| 5 | +// | |
| 6 | +// Everything here is a plain function over wire data so it can be tested | |
| 7 | +// without mounting the console (the package's testing doctrine: extract the | |
| 8 | +// logic, test the logic). The components below it only arrange the results. | |
| 9 | +// --------------------------------------------------------------------------- | |
| 10 | + | |
| 11 | +/** The connection shape the admin plane returns (`AdminUserConnection`), typed | |
| 12 | + * structurally so these helpers don't depend on the schema module. */ | |
| 13 | +export interface AdminConnectionRow { | |
| 14 | + readonly owner: Owner; | |
| 15 | + readonly integration: IntegrationSlug; | |
| 16 | + readonly name: string; | |
| 17 | + readonly oauthScope: string | null; | |
| 18 | + readonly lastHealth: { readonly status: HealthStatus } | null; | |
| 19 | +} | |
| 20 | + | |
| 21 | +// ── Last seen ─────────────────────────────────────────────────────────────── | |
| 22 | + | |
| 23 | +const MINUTE = 60_000; | |
| 24 | +const HOUR = 60 * MINUTE; | |
| 25 | +const DAY = 24 * HOUR; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * Relative "last seen", phrased no more precisely than the field deserves. | |
| 29 | + * | |
| 30 | + * The writer throttles `lastSeenAt` to roughly an hour, so a sub-hour delta is | |
| 31 | + * NOT evidence the user was here five minutes ago — it only means the sighting | |
| 32 | + * landed within the last throttle window. Everything under an hour therefore | |
| 33 | + * collapses to one honest bucket instead of counting minutes, and `null` (never | |
| 34 | + * seen on a request — a user earns a row at connection-create, before any | |
| 35 | + * sighting) reads as "Never", not "unknown". | |
| 36 | + */ | |
| 37 | +export const formatLastSeen = (lastSeenAt: number | null, now: number = Date.now()): string => { | |
| 38 | + if (lastSeenAt === null) return "Never"; | |
| 39 | + const delta = now - lastSeenAt; | |
| 40 | + if (delta < HOUR) return "Within the hour"; | |
| 41 | + if (delta < DAY) { | |
| 42 | + const hours = Math.floor(delta / HOUR); | |
| 43 | + return hours === 1 ? "About 1 hour ago" : `About ${hours} hours ago`; | |
| 44 | + } | |
| 45 | + const days = Math.floor(delta / DAY); | |
| 46 | + if (days === 1) return "About 1 day ago"; | |
| 47 | + if (days < 30) return `About ${days} days ago`; | |
| 48 | + return new Intl.DateTimeFormat(undefined, { | |
| 49 | + month: "short", | |
| 50 | + day: "numeric", | |
| 51 | + year: "numeric", | |
| 52 | + }).format(new Date(lastSeenAt)); | |
| 53 | +}; | |
| 54 | + | |
| 55 | +/** The tooltip behind {@link formatLastSeen}: the exact recorded instant plus | |
| 56 | + * the coarseness caveat, so an operator reading "Within the hour" can see what | |
| 57 | + * the row actually stores. */ | |
| 58 | +export const lastSeenTitle = (lastSeenAt: number | null): string => | |
| 59 | + lastSeenAt === null | |
| 60 | + ? "Never seen on a request" | |
| 61 | + : `Recorded ${new Date(lastSeenAt).toLocaleString()} — updated at most once an hour`; | |
| 62 | + | |
| 63 | +/** Absolute date, for the created column. */ | |
| 64 | +export const formatAdminDate = (epochMs: number): string => | |
| 65 | + new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format( | |
| 66 | + new Date(epochMs), | |
| 67 | + ); | |
| 68 | + | |
| 69 | +// ── Identity ──────────────────────────────────────────────────────────────── | |
| 70 | + | |
| 71 | +/** | |
| 72 | + * How a user's opaque `externalId` is shown. | |
| 73 | + * | |
| 74 | + * The id is opaque by contract — nothing may parse it — so it is rendered whole, | |
| 75 | + * in mono, and only visually shortened when it is long enough to break the | |
| 76 | + * column. The full value stays available via the title attribute and the copy | |
| 77 | + * button. `"local"` is a host sentinel for the single-player subject and reads | |
| 78 | + * better spelled out. | |
| 79 | + */ | |
| 80 | +export const LOCAL_SUBJECT_ID = "local"; | |
| 81 | + | |
| 82 | +export const isLocalSubject = (externalId: string): boolean => externalId === LOCAL_SUBJECT_ID; | |
| 83 | + | |
| 84 | +/** Visual truncation only — never a parse. Keeps both ends so two ids that | |
| 85 | + * differ in the middle stay distinguishable. */ | |
| 86 | +export const shortenExternalId = (externalId: string, max = 28): string => { | |
| 87 | + if (externalId.length <= max) return externalId; | |
| 88 | + const head = Math.ceil((max - 1) / 2); | |
| 89 | + const tail = Math.floor((max - 1) / 2); | |
| 90 | + return `${externalId.slice(0, head)}…${externalId.slice(externalId.length - tail)}`; | |
| 91 | +}; | |
| 92 | + | |
| 93 | +/** The identity half of a user row, as the admin plane reports it. Both fields | |
| 94 | + * are independently nullable: the host resolved one, the other, or neither. */ | |
| 95 | +export interface AdminUserIdentityRow { | |
| 96 | + readonly externalId: string; | |
| 97 | + readonly email?: string | null; | |
| 98 | + readonly displayName?: string | null; | |
| 99 | +} | |
| 100 | + | |
| 101 | +/** | |
| 102 | + * How one user is titled and subtitled. | |
| 103 | + * | |
| 104 | + * The primary line is whatever an operator would actually recognize — the email | |
| 105 | + * first, since that is what they invite, search and support against; the | |
| 106 | + * display name only when there is no email; and the id itself when the host | |
| 107 | + * resolved neither. The secondary line carries the `externalId` whenever the | |
| 108 | + * primary line is NOT already the id, so the opaque principal is always one | |
| 109 | + * glance away without ever being the headline for a user who has a name. | |
| 110 | + * | |
| 111 | + * When identity is absent the result is exactly the pre-identity rendering — one | |
| 112 | + * mono id line and no subtitle — so an unresolvable row (a member who left the | |
| 113 | + * org while their connections remain, a host sentinel like "local", a directory | |
| 114 | + * outage) degrades to what this table always showed rather than to a gap. | |
| 115 | + * | |
| 116 | + * `primaryIsId` is what tells the component which line gets mono/metadata | |
| 117 | + * styling, so the caller never has to re-derive the same decision. | |
| 118 | + */ | |
| 119 | +export interface AdminUserTitle { | |
| 120 | + readonly primary: string; | |
| 121 | + readonly secondary: string | null; | |
| 122 | + readonly primaryIsId: boolean; | |
| 123 | +} | |
| 124 | + | |
| 125 | +export const adminUserTitle = (user: AdminUserIdentityRow): AdminUserTitle => { | |
| 126 | + const email = user.email?.trim(); | |
| 127 | + const displayName = user.displayName?.trim(); | |
| 128 | + const idLabel = isLocalSubject(user.externalId) ? LOCAL_SUBJECT_ID : user.externalId; | |
| 129 | + const named = email || displayName; | |
| 130 | + if (!named) return { primary: idLabel, secondary: null, primaryIsId: true }; | |
| 131 | + return { primary: named, secondary: idLabel, primaryIsId: false }; | |
| 132 | +}; | |
| 133 | + | |
| 134 | +/** The email an operator can copy off a row, or `null` when the host resolved | |
| 135 | + * none. Deliberately NOT the display name: a name is not an address, and a | |
| 136 | + * copy button that yields something unusable is worse than no button. */ | |
| 137 | +export const adminUserCopyableEmail = (user: AdminUserIdentityRow): string | null => { | |
| 138 | + const email = user.email?.trim(); | |
| 139 | + return email ? email : null; | |
| 140 | +}; | |
| 141 | + | |
| 142 | +// ── Connections ───────────────────────────────────────────────────────────── | |
| 143 | + | |
| 144 | +/** The health status a row displays. A connection that was never probed carries | |
| 145 | + * no verdict, which is `unknown` in the shared vocabulary. */ | |
| 146 | +export const connectionHealthStatus = (connection: AdminConnectionRow): HealthStatus => | |
| 147 | + connection.lastHealth?.status ?? "unknown"; | |
| 148 | + | |
| 149 | +/** The catalog row this view needs: the slug it marks, plus the `kind` that | |
| 150 | + * says whether connecting is even a thing one can do to it. Structural so | |
| 151 | + * these helpers stay independent of the catalog schema module. */ | |
| 152 | +export interface AdminCatalogRow { | |
| 153 | + readonly slug: IntegrationSlug; | |
| 154 | + readonly kind: string; | |
| 155 | +} | |
| 156 | + | |
| 157 | +/** The built-in integration's reserved slug. `kind` is the general signal, but | |
| 158 | + * the product page pins the slug too, so this mirrors it exactly. */ | |
| 159 | +const BUILT_IN_INTEGRATION_SLUG = "executor"; | |
| 160 | + | |
| 161 | +/** | |
| 162 | + * Whether an integration has a connect flow at all. | |
| 163 | + * | |
| 164 | + * Deliberately NOT "declares no auth methods": an openapi/mcp/graphql | |
| 165 | + * integration can carry `authMethods: []` and still be connectable, because its | |
| 166 | + * accounts panel passes `createCustomMethod` and the user defines the method | |
| 167 | + * themselves (`accounts-section.tsx`: `canAddConnection = methods.length > 0 || | |
| 168 | + * createCustomMethod !== undefined`). Empty auth methods therefore means "no | |
| 169 | + * method declared yet", not "cannot be connected". | |
| 170 | + * | |
| 171 | + * The real gate is the one the product's integration detail page applies before | |
| 172 | + * any of that — `integration-detail.tsx`: `isBuiltInIntegration = namespace === | |
| 173 | + * "executor" || integrationData?.kind === "built-in"`, which removes the whole | |
| 174 | + * Accounts tab rather than merely disabling a button. A built-in integration is | |
| 175 | + * a plugin-declared static source with a synthetic connection and no | |
| 176 | + * credential: there is nothing to connect and no flow to send anyone to. | |
| 177 | + */ | |
| 178 | +export const isConnectableIntegration = (row: AdminCatalogRow): boolean => | |
| 179 | + row.kind !== "built-in" && String(row.slug) !== BUILT_IN_INTEGRATION_SLUG; | |
| 180 | + | |
| 181 | +/** | |
| 182 | + * The available-vs-connected view for one user: every *connectable* integration | |
| 183 | + * in the tenant's catalog, marked with whether this user has connected it. | |
| 184 | + * | |
| 185 | + * "Available" is the catalog, so an integration nobody has connected still gets | |
| 186 | + * a (greyed) slot — that absence is the point of the view. Sorted connected | |
| 187 | + * first so a dense grid leads with signal, then by slug for a stable order | |
| 188 | + * across users. | |
| 189 | + * | |
| 190 | + * Non-connectable integrations are dropped entirely — from the slots, from the | |
| 191 | + * counts, and from the connect links. Listing the built-in under "not | |
| 192 | + * connected" would invite an admin to send a link to a flow that doesn't exist. | |
| 193 | + * The one exception is data honesty: if a user somehow HOLDS a connection on | |
| 194 | + * one, it is reported as connected rather than hidden. | |
| 195 | + * | |
| 196 | + * Org-owned connections are deliberately excluded from the API's per-user | |
| 197 | + * results, so anything appearing here is genuinely this user's own. | |
| 198 | + */ | |
| 199 | +export interface IntegrationConnectionState { | |
| 200 | + readonly integration: IntegrationSlug; | |
| 201 | + readonly connected: boolean; | |
| 202 | + readonly connections: readonly AdminConnectionRow[]; | |
| 203 | +} | |
| 204 | + | |
| 205 | +export const integrationConnectionStates = ( | |
| 206 | + catalog: readonly AdminCatalogRow[], | |
| 207 | + connections: readonly AdminConnectionRow[], | |
| 208 | +): readonly IntegrationConnectionState[] => { | |
| 209 | + const byIntegration = new Map<IntegrationSlug, AdminConnectionRow[]>(); | |
| 210 | + for (const connection of connections) { | |
| 211 | + const existing = byIntegration.get(connection.integration); | |
| 212 | + if (existing) existing.push(connection); | |
| 213 | + else byIntegration.set(connection.integration, [connection]); | |
| 214 | + } | |
| 215 | + // A user can hold a connection on an integration the catalog read didn't | |
| 216 | + // return (a catalog the viewer can't see, or one removed since). Showing it | |
| 217 | + // as connected is more honest than dropping the user's own credential — and | |
| 218 | + // an unknown slug is assumed connectable, since only the catalog can say | |
| 219 | + // otherwise. | |
| 220 | + const slugs = new Set<IntegrationSlug>([ | |
| 221 | + ...catalog.filter(isConnectableIntegration).map((row) => row.slug), | |
| 222 | + ...byIntegration.keys(), | |
| 223 | + ]); | |
| 224 | + return [...slugs] | |
| 225 | + .map((integration) => ({ | |
| 226 | + integration, | |
| 227 | + connected: byIntegration.has(integration), | |
| 228 | + connections: byIntegration.get(integration) ?? [], | |
| 229 | + })) | |
| 230 | + .sort((a, b) => { | |
| 231 | + if (a.connected !== b.connected) return a.connected ? -1 : 1; | |
| 232 | + return a.integration.localeCompare(b.integration); | |
| 233 | + }); | |
| 234 | +}; | |
| 235 | + | |
| 236 | +// ── Connect links ─────────────────────────────────────────────────────────── | |
| 237 | + | |
| 238 | +/** | |
| 239 | + * The connect deep link for an integration, as an absolute URL for THIS | |
| 240 | + * deployment — something an admin can paste to a user who needs to connect. | |
| 241 | + * | |
| 242 | + * The org segment is load-bearing and must be the SENDING admin's org. A | |
| 243 | + * recipient who belongs to several orgs would otherwise land in whichever one | |
| 244 | + * their session defaults to and connect the credential in the wrong workspace — | |
| 245 | + * silently, since the flow itself would succeed. The admin page is org-scoped, | |
| 246 | + * so the correct org is known at the call site and is carried in the link | |
| 247 | + * rather than left to the recipient's session to guess. | |
| 248 | + * | |
| 249 | + * The slug is optional because the route's `{-$orgSlug}` segment is: a host that | |
| 250 | + * renders no segment yields the bare `/connect/<slug>` form, which is the whole | |
| 251 | + * route there. (Both cloud and self-host do canonicalize onto a slug in | |
| 252 | + * practice — self-host onto `default` — so the prefixed form is the common | |
| 253 | + * case.) It is rendered as a copyable URL rather than an in-console link | |
| 254 | + * because it targets the recipient's session, not the admin's. | |
| 255 | + */ | |
| 256 | +export const connectLinkUrl = ( | |
| 257 | + integration: IntegrationSlug, | |
| 258 | + origin: string, | |
| 259 | + orgSlug?: string | undefined, | |
| 260 | +): string => { | |
| 261 | + const base = origin.replace(/\/+$/, ""); | |
| 262 | + const org = orgSlug?.trim(); | |
| 263 | + return org ? `${base}/${org}/connect/${integration}` : `${base}/connect/${integration}`; | |
| 264 | +}; | |
| 265 | + | |
| 266 | +// ── Paging ────────────────────────────────────────────────────────────────── | |
| 267 | + | |
| 268 | +/** | |
| 269 | + * Split an over-fetched page into the rows to render plus whether another page | |
| 270 | + * exists. The contract returns no total count, so "is there a next page" is | |
| 271 | + * answered by asking for one row more than the page holds. | |
| 272 | + */ | |
| 273 | +export const splitPage = <A>( | |
| 274 | + rows: readonly A[], | |
| 275 | + pageSize: number, | |
| 276 | +): { readonly rows: readonly A[]; readonly hasNext: boolean } => ({ | |
| 277 | + rows: rows.slice(0, pageSize), | |
| 278 | + hasNext: rows.length > pageSize, | |
| 279 | +}); | |
| 280 | + | |
| 281 | +/** The 1-based page number an offset lands on, for the mono pager label. */ | |
| 282 | +export const pageNumber = (offset: number, pageSize: number): number => | |
| 283 | + Math.floor(offset / pageSize) + 1; | |
| @@ -0,0 +1,53 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 3 | + | |
| 4 | +import { orgMembersAtom } from "../api/account-atoms"; | |
| 5 | +import { isTenantAdminMember, type TenantMemberRow } from "../lib/admin-access"; | |
| 6 | +import type { ShellNavItem } from "./shell"; | |
| 7 | + | |
| 8 | +// --------------------------------------------------------------------------- | |
| 9 | +// Admin-only nav items. | |
| 10 | +// | |
| 11 | +// The console has no role on the session (`/account/me` returns a user and an | |
| 12 | +// organization, no role), so the current member's role is read off the member | |
| 13 | +// list — the same derivation cloud's org page already uses for its danger zone. | |
| 14 | +// Hosts pass their admin-only entries through this hook, which drops them for | |
| 15 | +// anyone the list doesn't show as an active admin/owner. | |
| 16 | +// | |
| 17 | +// This hides a section; it does not protect one. The page behind each item | |
| 18 | +// renders a denied state off the server's own 401/403, which is the gate that | |
| 19 | +// matters. Failing closed here is deliberate: while the member list is loading | |
| 20 | +// or unavailable, showing nothing is better than flashing a section that will | |
| 21 | +// refuse. | |
| 22 | +// | |
| 23 | +// That fail-closed default is also what makes this hook SSR-safe. `orgMembersAtom` | |
| 24 | +// carries an explicit server value (see `api/account-atoms`), so on the server it | |
| 25 | +// reads as initial without evaluating — the role is simply UNKNOWN during SSR, | |
| 26 | +// which lands on `false` here and resolves client-side once the list arrives. | |
| 27 | +// The hook must never suspend or throw: it runs above the routed outlet, so | |
| 28 | +// anything it raises takes down the whole server-rendered document. | |
| 29 | +// --------------------------------------------------------------------------- | |
| 30 | + | |
| 31 | +/** Whether the current member reads as an admin/owner of this tenant. */ | |
| 32 | +export const useIsTenantAdmin = (): boolean => { | |
| 33 | + const members = useAtomValue(orgMembersAtom); | |
| 34 | + return AsyncResult.match(members, { | |
| 35 | + onInitial: () => false, | |
| 36 | + onFailure: () => false, | |
| 37 | + onSuccess: ({ value }) => isTenantAdminMember(value.members as readonly TenantMemberRow[]), | |
| 38 | + }); | |
| 39 | +}; | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Append admin-only nav items to a host's nav, for admins only. | |
| 43 | + * | |
| 44 | + * Takes both lists so hosts keep one nav definition and the ordering stays | |
| 45 | + * theirs — the admin entries land wherever the host places the call. | |
| 46 | + */ | |
| 47 | +export const useAdminNavItems = ( | |
| 48 | + base: ReadonlyArray<ShellNavItem>, | |
| 49 | + adminOnly: ReadonlyArray<ShellNavItem>, | |
| 50 | +): ReadonlyArray<ShellNavItem> => { | |
| 51 | + const isAdmin = useIsTenantAdmin(); | |
| 52 | + return isAdmin ? [...base, ...adminOnly] : base; | |
| 53 | +}; | |
| @@ -0,0 +1,692 @@ | ||
| 1 | +import { useState } from "react"; | |
| 2 | +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | |
| 3 | +import { useParams } from "@tanstack/react-router"; | |
| 4 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 5 | +import * as Cause from "effect/Cause"; | |
| 6 | +import * as Option from "effect/Option"; | |
| 7 | +import * as Predicate from "effect/Predicate"; | |
| 8 | +import type { HealthStatus, Integration, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 9 | +import { useIntegrationPlugins } from "@executor-js/sdk/client"; | |
| 10 | + | |
| 11 | +import { | |
| 12 | + ADMIN_USERS_PAGE_SIZE, | |
| 13 | + adminUserConnectionsAtom, | |
| 14 | + adminUsersWithConnectionsAtom, | |
| 15 | +} from "../api/admin-atoms"; | |
| 16 | +import { integrationsOptimisticAtom } from "../api/atoms"; | |
| 17 | +import { ownerLabel } from "../api/owner-display"; | |
| 18 | +import { Button } from "../components/button"; | |
| 19 | +import { CopyButton } from "../components/copy-button"; | |
| 20 | +import { ErrorState } from "../components/error-state"; | |
| 21 | +import { | |
| 22 | + IntegrationFavicon, | |
| 23 | + integrationInferredUrl, | |
| 24 | + integrationPresetIconUrl, | |
| 25 | +} from "../components/integration-favicon"; | |
| 26 | +import { PageContainer, PageHeader } from "../components/page"; | |
| 27 | +import { | |
| 28 | + Sheet, | |
| 29 | + SheetContent, | |
| 30 | + SheetDescription, | |
| 31 | + SheetHeader, | |
| 32 | + SheetTitle, | |
| 33 | +} from "../components/sheet"; | |
| 34 | +import { Skeleton } from "../components/skeleton"; | |
| 35 | +import { | |
| 36 | + adminUserCopyableEmail, | |
| 37 | + adminUserTitle, | |
| 38 | + connectionHealthStatus, | |
| 39 | + connectLinkUrl, | |
| 40 | + formatAdminDate, | |
| 41 | + formatLastSeen, | |
| 42 | + integrationConnectionStates, | |
| 43 | + lastSeenTitle, | |
| 44 | + pageNumber, | |
| 45 | + shortenExternalId, | |
| 46 | + splitPage, | |
| 47 | + type AdminCatalogRow, | |
| 48 | + type AdminConnectionRow, | |
| 49 | + type AdminUserIdentityRow, | |
| 50 | +} from "../lib/admin-users-display"; | |
| 51 | +import { | |
| 52 | + HEALTH_INDICATOR_COLOR, | |
| 53 | + HEALTH_STATUS_LABEL, | |
| 54 | + HEALTH_TEXT_CLASS, | |
| 55 | +} from "../lib/health-display"; | |
| 56 | +import { isAsyncResultLoading } from "../lib/async-result"; | |
| 57 | +import { useExecutorDocumentTitle } from "../lib/document-title"; | |
| 58 | + | |
| 59 | +// --------------------------------------------------------------------------- | |
| 60 | +// Admin · Users — the tenant-wide operator view. | |
| 61 | +// | |
| 62 | +// The product plane answers "what can I reach"; this page answers the owner's | |
| 63 | +// question: who are my users, and what has each of them connected. It reads the | |
| 64 | +// admin plane only (`/admin/users*`), which is read-only by construction, so | |
| 65 | +// there is nothing to mutate here — every affordance is a read or a copy. | |
| 66 | +// | |
| 67 | +// ACCESS: the server is the gate. A plain member's request comes back 403 (401 | |
| 68 | +// with no session), and the page renders an explicit denial rather than an | |
| 69 | +// empty table, so "you may not see this" never reads as "there is nobody here". | |
| 70 | +// The nav item is separately hidden from non-admins (see lib/admin-access), but | |
| 71 | +// that is convenience — this is the surface that actually refuses. | |
| 72 | +// | |
| 73 | +// The row is a button rather than a link: a user has no page of its own, and a | |
| 74 | +// detail sheet keeps the list's paging position while an operator scans several | |
| 75 | +// users in a row. | |
| 76 | +// --------------------------------------------------------------------------- | |
| 77 | + | |
| 78 | +type AdminUserRow = { | |
| 79 | + readonly externalId: string; | |
| 80 | + readonly createdAt: number; | |
| 81 | + readonly lastSeenAt: number | null; | |
| 82 | + readonly status: string | null; | |
| 83 | + /** Joined server-side from the host's member directory. Null whenever the | |
| 84 | + * host couldn't resolve the id — see `adminUserTitle` for what that renders | |
| 85 | + * as. */ | |
| 86 | + readonly email: string | null; | |
| 87 | + readonly displayName: string | null; | |
| 88 | + readonly connections: readonly AdminConnectionRow[]; | |
| 89 | +}; | |
| 90 | + | |
| 91 | +/** 401/403 from the admin plane. Both mean "not an operator here" and get the | |
| 92 | + * same denial: the distinction (no session vs wrong role) is the server's, and | |
| 93 | + * a signed-in member reading "unauthorized" would only confuse. */ | |
| 94 | +const isAccessDenied = (cause: Cause.Cause<unknown>): boolean => | |
| 95 | + Option.match(Cause.findErrorOption(cause), { | |
| 96 | + onNone: () => false, | |
| 97 | + onSome: (error) => | |
| 98 | + Predicate.isTagged(error, "AdminUsersForbidden") || | |
| 99 | + Predicate.isTagged(error, "AdminUsersUnauthorized"), | |
| 100 | + }); | |
| 101 | + | |
| 102 | +// ── Access denied ─────────────────────────────────────────────────────────── | |
| 103 | + | |
| 104 | +function AccessDenied() { | |
| 105 | + return ( | |
| 106 | + <div className="rounded-lg border border-border bg-card p-8"> | |
| 107 | + <p className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | |
| 108 | + Admin only | |
| 109 | + </p> | |
| 110 | + <h2 className="mt-2 text-base font-semibold text-foreground"> | |
| 111 | + You don't have access to this workspace'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've connected requires an admin role. Ask an admin of | |
| 115 | + this workspace if you need it. | |
| 116 | + </p> | |
| 117 | + </div> | |
| 118 | + ); | |
| 119 | +} | |
| 120 | + | |
| 121 | +// ── Health ────────────────────────────────────────────────────────────────── | |
| 122 | + | |
| 123 | +/** The shared health vocabulary, rendered from a stored verdict. | |
| 124 | + * | |
| 125 | + * Deliberately NOT `IntegrationHealthSummary`: that component probes upstream | |
| 126 | + * on mount through the product plane, which is both the wrong plane here (an | |
| 127 | + * admin is not the credential's owner) and the wrong behavior (opening an | |
| 128 | + * admin view must not fire traffic at every user's provider). This reads the | |
| 129 | + * `lastHealth` the admin payload already carries. */ | |
| 130 | +function HealthIndicator(props: { readonly status: HealthStatus }) { | |
| 131 | + const label = HEALTH_STATUS_LABEL[props.status]; | |
| 132 | + return ( | |
| 133 | + <span className="flex shrink-0 items-center gap-1.5" title={`Status: ${label}`}> | |
| 134 | + <span | |
| 135 | + className={`font-mono text-[11px] font-medium uppercase tracking-[0.08em] ${HEALTH_TEXT_CLASS[props.status]}`} | |
| 136 | + > | |
| 137 | + {label} | |
| 138 | + </span> | |
| 139 | + <span | |
| 140 | + aria-label={`Status: ${label}`} | |
| 141 | + className={`size-2 rounded-full ${HEALTH_INDICATOR_COLOR[props.status].dot}`} | |
| 142 | + /> | |
| 143 | + </span> | |
| 144 | + ); | |
| 145 | +} | |
| 146 | + | |
| 147 | +// ── Integration icons ─────────────────────────────────────────────────────── | |
| 148 | + | |
| 149 | +/** The catalog joined into a favicon lookup. The admin payload carries only a | |
| 150 | + * slug per connection (no kind, no display URL — the platform view hands out | |
| 151 | + * nothing it doesn't have to), so brand marks come from the tenant's own | |
| 152 | + * integration catalog, which the admin can read on the product plane. */ | |
| 153 | +type IconLookup = (integration: IntegrationSlug) => { | |
| 154 | + readonly icon: string | null; | |
| 155 | + readonly url: string | undefined; | |
| 156 | + readonly name: string; | |
| 157 | +}; | |
| 158 | + | |
| 159 | +const useIntegrationIcons = (): IconLookup => { | |
| 160 | + const catalog = useAtomValue(integrationsOptimisticAtom); | |
| 161 | + const integrationPlugins = useIntegrationPlugins(); | |
| 162 | + const integrations = Option.getOrElse( | |
| 163 | + AsyncResult.value(catalog), | |
| 164 | + (): readonly Integration[] => [], | |
| 165 | + ); | |
| 166 | + | |
| 167 | + return (integration) => { | |
| 168 | + const slug = String(integration); | |
| 169 | + const found = integrations.find((row) => String(row.slug) === slug); | |
| 170 | + const name = found?.name || slug; | |
| 171 | + return { | |
| 172 | + icon: found | |
| 173 | + ? integrationPresetIconUrl( | |
| 174 | + { id: slug, kind: found.kind, name, url: found.displayUrl }, | |
| 175 | + integrationPlugins, | |
| 176 | + ) | |
| 177 | + : null, | |
| 178 | + url: found?.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined, | |
| 179 | + name, | |
| 180 | + }; | |
| 181 | + }; | |
| 182 | +}; | |
| 183 | + | |
| 184 | +/** The catalog rows this view needs — slug plus kind, since `kind` is what says | |
| 185 | + * whether an integration can be connected at all (see | |
| 186 | + * `isConnectableIntegration`). Empty while the catalog loads, which degrades to | |
| 187 | + * "connected only" rather than blocking the users table on a second request. */ | |
| 188 | +const useCatalogRows = (): readonly AdminCatalogRow[] => { | |
| 189 | + const catalog = useAtomValue(integrationsOptimisticAtom); | |
| 190 | + return Option.match(AsyncResult.value(catalog), { | |
| 191 | + onNone: (): readonly AdminCatalogRow[] => [], | |
| 192 | + onSome: (integrations: readonly Integration[]) => | |
| 193 | + integrations.map((row) => ({ slug: row.slug, kind: row.kind })), | |
| 194 | + }); | |
| 195 | +}; | |
| 196 | + | |
| 197 | +/** | |
| 198 | + * The connection summary: one slot per integration in the catalog, lit when | |
| 199 | + * this user has connected it and greyed when they haven't. | |
| 200 | + * | |
| 201 | + * Showing the not-connected slots is the point — the grid answers "what is | |
| 202 | + * available and what has this person actually set up", which a list of only | |
| 203 | + * their connections cannot. | |
| 204 | + */ | |
| 205 | +function ConnectionGrid(props: { | |
| 206 | + readonly catalog: readonly AdminCatalogRow[]; | |
| 207 | + readonly connections: readonly AdminConnectionRow[]; | |
| 208 | + readonly icons: IconLookup; | |
| 209 | +}) { | |
| 210 | + const states = integrationConnectionStates(props.catalog, props.connections); | |
| 211 | + if (states.length === 0) { | |
| 212 | + return <span className="font-mono text-[11px] text-muted-foreground">No integrations</span>; | |
| 213 | + } | |
| 214 | + | |
| 215 | + const connectedCount = states.filter((state) => state.connected).length; | |
| 216 | + | |
| 217 | + return ( | |
| 218 | + <div className="flex min-w-0 items-center gap-1.5"> | |
| 219 | + <div className="flex min-w-0 flex-wrap items-center gap-1"> | |
| 220 | + {states.slice(0, 8).map((state) => { | |
| 221 | + const { icon, url, name } = props.icons(state.integration); | |
| 222 | + return ( | |
| 223 | + <span | |
| 224 | + key={state.integration} | |
| 225 | + // State on data-* (the repo convention) so the connected/not- | |
| 226 | + // connected distinction is readable without parsing classes. | |
| 227 | + data-slot="admin-user-integration" | |
| 228 | + data-integration={String(state.integration)} | |
| 229 | + data-connected={state.connected ? "true" : "false"} | |
| 230 | + title={`${name} — ${state.connected ? "connected" : "not connected"}`} | |
| 231 | + className={ | |
| 232 | + state.connected | |
| 233 | + ? "flex size-5 items-center justify-center" | |
| 234 | + : "flex size-5 items-center justify-center opacity-25 grayscale" | |
| 235 | + } | |
| 236 | + > | |
| 237 | + <IntegrationFavicon | |
| 238 | + icon={icon} | |
| 239 | + integrationId={String(state.integration)} | |
| 240 | + url={url} | |
| 241 | + size={16} | |
| 242 | + /> | |
| 243 | + </span> | |
| 244 | + ); | |
| 245 | + })} | |
| 246 | + </div> | |
| 247 | + <span | |
| 248 | + data-slot="admin-user-connection-count" | |
| 249 | + className="shrink-0 font-mono text-[11px] text-muted-foreground" | |
| 250 | + title={`${connectedCount} of ${states.length} integrations connected`} | |
| 251 | + > | |
| 252 | + {connectedCount}/{states.length} | |
| 253 | + </span> | |
| 254 | + </div> | |
| 255 | + ); | |
| 256 | +} | |
| 257 | + | |
| 258 | +// ── Identity ──────────────────────────────────────────────────────────────── | |
| 259 | + | |
| 260 | +/** | |
| 261 | + * The user cell: who this is, then the opaque id underneath. | |
| 262 | + * | |
| 263 | + * The host joins email/name onto each row server-side, so the headline is | |
| 264 | + * something an operator recognizes and the `externalId` becomes the supporting | |
| 265 | + * line — still mono, still carrying the full value in its title, still under the | |
| 266 | + * same `data-slot` it always had, because the id is what identifies a row to | |
| 267 | + * anything reading this table. When the host resolved no identity the cell | |
| 268 | + * collapses back to exactly the single mono id line this table has always shown. | |
| 269 | + * | |
| 270 | + * NO copy button lives here: the row itself is a `<button>` (a user has no page | |
| 271 | + * of its own, so the row opens a detail sheet), and a button inside a button is | |
| 272 | + * invalid markup that swallows its own clicks. The copy affordances live one | |
| 273 | + * click away in the sheet header, on the same values. | |
| 274 | + */ | |
| 275 | +function UserIdentityCell(props: { readonly user: AdminUserIdentityRow }) { | |
| 276 | + const title = adminUserTitle(props.user); | |
| 277 | + | |
| 278 | + if (title.primaryIsId) { | |
| 279 | + return ( | |
| 280 | + <span | |
| 281 | + data-slot="admin-user-id" | |
| 282 | + className="min-w-0 truncate font-mono text-sm text-foreground" | |
| 283 | + title={props.user.externalId} | |
| 284 | + > | |
| 285 | + {shortenExternalId(title.primary)} | |
| 286 | + </span> | |
| 287 | + ); | |
| 288 | + } | |
| 289 | + | |
| 290 | + return ( | |
| 291 | + <span className="flex min-w-0 flex-col gap-0.5"> | |
| 292 | + <span | |
| 293 | + data-slot="admin-user-name" | |
| 294 | + className="truncate text-sm text-foreground" | |
| 295 | + title={title.primary} | |
| 296 | + > | |
| 297 | + {title.primary} | |
| 298 | + </span> | |
| 299 | + <span | |
| 300 | + data-slot="admin-user-id" | |
| 301 | + className="truncate font-mono text-[11px] text-muted-foreground" | |
| 302 | + title={props.user.externalId} | |
| 303 | + > | |
| 304 | + {shortenExternalId(props.user.externalId)} | |
| 305 | + </span> | |
| 306 | + </span> | |
| 307 | + ); | |
| 308 | +} | |
| 309 | + | |
| 310 | +/** | |
| 311 | + * The sheet header: the same title treatment as the row, plus the copy | |
| 312 | + * affordances the row can't carry. | |
| 313 | + * | |
| 314 | + * Two separate buttons rather than one, because they yield different things and | |
| 315 | + * an operator wants a specific one: the email is what they paste into a mail | |
| 316 | + * client or a support ticket, and the `externalId` is what they paste into a log | |
| 317 | + * search or an API call. A single "copy" would force them to guess which. | |
| 318 | + * | |
| 319 | + * The email button is absent — not disabled — when the host resolved no email, | |
| 320 | + * since there is nothing to put on the clipboard. The id button is always | |
| 321 | + * present, whether the id is the headline (no identity resolved) or the | |
| 322 | + * supporting line: every user has an id by definition, and it is the value that | |
| 323 | + * identifies them to logs and to the API. | |
| 324 | + */ | |
| 325 | +function UserDetailTitle(props: { readonly user: AdminUserRow }) { | |
| 326 | + const title = adminUserTitle(props.user); | |
| 327 | + const email = adminUserCopyableEmail(props.user); | |
| 328 | + | |
| 329 | + return ( | |
| 330 | + <div className="flex min-w-0 flex-col gap-1"> | |
| 331 | + <div className="flex min-w-0 items-center gap-1"> | |
| 332 | + <SheetTitle | |
| 333 | + data-slot="admin-user-detail-title" | |
| 334 | + className={ | |
| 335 | + title.primaryIsId ? "truncate font-mono text-sm" : "truncate text-sm font-semibold" | |
| 336 | + } | |
| 337 | + title={title.primary} | |
| 338 | + > | |
| 339 | + {title.primary} | |
| 340 | + </SheetTitle> | |
| 341 | + {email ? ( | |
| 342 | + <CopyButton value={email} label="Copy email" /> | |
| 343 | + ) : ( | |
| 344 | + title.primaryIsId && <CopyButton value={props.user.externalId} label="Copy ID" /> | |
| 345 | + )} | |
| 346 | + </div> | |
| 347 | + {title.secondary !== null && ( | |
| 348 | + <div className="flex min-w-0 items-center gap-1"> | |
| 349 | + <span | |
| 350 | + data-slot="admin-user-detail-id" | |
| 351 | + className="truncate font-mono text-[11px] text-muted-foreground" | |
| 352 | + title={props.user.externalId} | |
| 353 | + > | |
| 354 | + {props.user.externalId} | |
| 355 | + </span> | |
| 356 | + <CopyButton value={props.user.externalId} label="Copy ID" /> | |
| 357 | + </div> | |
| 358 | + )} | |
| 359 | + </div> | |
| 360 | + ); | |
| 361 | +} | |
| 362 | + | |
| 363 | +// ── Detail sheet ──────────────────────────────────────────────────────────── | |
| 364 | + | |
| 365 | +function UserDetail(props: { | |
| 366 | + readonly user: AdminUserRow; | |
| 367 | + readonly catalog: readonly AdminCatalogRow[]; | |
| 368 | + readonly icons: IconLookup; | |
| 369 | + readonly orgSlug: string | undefined; | |
| 370 | +}) { | |
| 371 | + const result = useAtomValue(adminUserConnectionsAtom(props.user.externalId)); | |
| 372 | + const refresh = useAtomRefresh(adminUserConnectionsAtom(props.user.externalId)); | |
| 373 | + // The connect link targets the recipient's own session, so it is built for | |
| 374 | + // this deployment's origin and copied out — never navigated to from here. It | |
| 375 | + // carries THIS admin's org so a multi-org recipient connects in the workspace | |
| 376 | + // the link came from, not whichever one their session defaults to. | |
| 377 | + const origin = globalThis.window?.location?.origin ?? ""; | |
| 378 | + | |
| 379 | + if (isAsyncResultLoading(result)) { | |
| 380 | + return ( | |
| 381 | + <div className="flex flex-col gap-2 p-6"> | |
| 382 | + {[0, 1, 2].map((row) => ( | |
| 383 | + <Skeleton key={row} className="h-12" /> | |
| 384 | + ))} | |
| 385 | + </div> | |
| 386 | + ); | |
| 387 | + } | |
| 388 | + | |
| 389 | + return AsyncResult.match(result, { | |
| 390 | + onInitial: () => ( | |
| 391 | + <div className="flex flex-col gap-2 p-6"> | |
| 392 | + {[0, 1, 2].map((row) => ( | |
| 393 | + <Skeleton key={row} className="h-12" /> | |
| 394 | + ))} | |
| 395 | + </div> | |
| 396 | + ), | |
| 397 | + onFailure: (failure) => | |
| 398 | + isAccessDenied(failure.cause) ? ( | |
| 399 | + <div className="p-6"> | |
| 400 | + <AccessDenied /> | |
| 401 | + </div> | |
| 402 | + ) : ( | |
| 403 | + <div className="p-6"> | |
| 404 | + <ErrorState message="Couldn't load this user's connections" onRetry={refresh} /> | |
| 405 | + </div> | |
| 406 | + ), | |
| 407 | + onSuccess: ({ value }) => { | |
| 408 | + const states = integrationConnectionStates(props.catalog, value.connections); | |
| 409 | + const connected = states.filter((state) => state.connected); | |
| 410 | + const available = states.filter((state) => !state.connected); | |
| 411 | + | |
| 412 | + return ( | |
| 413 | + <div className="flex flex-col gap-8 overflow-y-auto p-6"> | |
| 414 | + <section> | |
| 415 | + <h3 className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | |
| 416 | + Connected · {connected.length} | |
| 417 | + </h3> | |
| 418 | + {connected.length === 0 ? ( | |
| 419 | + <p className="mt-3 text-sm leading-6 text-muted-foreground"> | |
| 420 | + This user hasn't connected anything yet. Send them a connect link below to get | |
| 421 | + started. | |
| 422 | + </p> | |
| 423 | + ) : ( | |
| 424 | + <div className="mt-3 overflow-hidden rounded-lg border border-border bg-card"> | |
| 425 | + {connected.flatMap((state) => | |
| 426 | + state.connections.map((connection) => { | |
| 427 | + const { icon, url, name } = props.icons(connection.integration); | |
| 428 | + return ( | |
| 429 | + <div | |
| 430 | + key={`${connection.integration}/${connection.owner}/${connection.name}`} | |
| 431 | + className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-b-0" | |
| 432 | + > | |
| 433 | + <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center"> | |
| 434 | + <IntegrationFavicon | |
| 435 | + icon={icon} | |
| 436 | + integrationId={String(connection.integration)} | |
| 437 | + url={url} | |
| 438 | + size={16} | |
| 439 | + /> | |
| 440 | + </span> | |
| 441 | + <div className="min-w-0 flex-1"> | |
| 442 | + <div className="flex items-center gap-2"> | |
| 443 | + <p className="truncate text-sm font-medium text-foreground">{name}</p> | |
| 444 | + <span className="shrink-0 font-mono text-[11px] text-muted-foreground"> | |
| 445 | + {ownerLabel(connection.owner)} | |
| 446 | + </span> | |
| 447 | + </div> | |
| 448 | + <p className="mt-1 truncate font-mono text-xs text-muted-foreground"> | |
| 449 | + {connection.name} | |
| 450 | + </p> | |
| 451 | + {connection.oauthScope ? ( | |
| 452 | + <p | |
| 453 | + className="mt-1.5 break-words font-mono text-[11px] leading-5 text-muted-foreground" | |
| 454 | + title={connection.oauthScope} | |
| 455 | + > | |
| 456 | + {connection.oauthScope} | |
| 457 | + </p> | |
| 458 | + ) : ( | |
| 459 | + <p className="mt-1.5 font-mono text-[11px] text-muted-foreground"> | |
| 460 | + No OAuth scope — static credential | |
| 461 | + </p> | |
| 462 | + )} | |
| 463 | + </div> | |
| 464 | + <HealthIndicator status={connectionHealthStatus(connection)} /> | |
| 465 | + </div> | |
| 466 | + ); | |
| 467 | + }), | |
| 468 | + )} | |
| 469 | + </div> | |
| 470 | + )} | |
| 471 | + </section> | |
| 472 | + | |
| 473 | + <section> | |
| 474 | + <h3 className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | |
| 475 | + Not connected · {available.length} | |
| 476 | + </h3> | |
| 477 | + <p className="mt-2 text-sm leading-6 text-muted-foreground"> | |
| 478 | + Copy a link and send it to this user — it opens the connect flow in their own session. | |
| 479 | + </p> | |
| 480 | + {available.length === 0 ? ( | |
| 481 | + <p className="mt-3 text-sm leading-6 text-muted-foreground"> | |
| 482 | + This user has connected every integration in the catalog. | |
| 483 | + </p> | |
| 484 | + ) : ( | |
| 485 | + <div className="mt-3 overflow-hidden rounded-lg border border-border bg-card"> | |
| 486 | + {available.map((state) => { | |
| 487 | + const { icon, url, name } = props.icons(state.integration); | |
| 488 | + const link = connectLinkUrl(state.integration, origin, props.orgSlug); | |
| 489 | + return ( | |
| 490 | + <div | |
| 491 | + key={state.integration} | |
| 492 | + className="flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0" | |
| 493 | + > | |
| 494 | + <span className="flex size-5 shrink-0 items-center justify-center opacity-40 grayscale"> | |
| 495 | + <IntegrationFavicon | |
| 496 | + icon={icon} | |
| 497 | + integrationId={String(state.integration)} | |
| 498 | + url={url} | |
| 499 | + size={16} | |
| 500 | + /> | |
| 501 | + </span> | |
| 502 | + <div className="min-w-0 flex-1"> | |
| 503 | + <p className="truncate text-sm text-foreground">{name}</p> | |
| 504 | + <p | |
| 505 | + data-slot="admin-user-connect-link" | |
| 506 | + className="truncate font-mono text-[11px] text-muted-foreground" | |
| 507 | + > | |
| 508 | + {link} | |
| 509 | + </p> | |
| 510 | + </div> | |
| 511 | + <CopyButton value={link} label="Copy link" /> | |
| 512 | + </div> | |
| 513 | + ); | |
| 514 | + })} | |
| 515 | + </div> | |
| 516 | + )} | |
| 517 | + </section> | |
| 518 | + </div> | |
| 519 | + ); | |
| 520 | + }, | |
| 521 | + }); | |
| 522 | +} | |
| 523 | + | |
| 524 | +// ── Page ──────────────────────────────────────────────────────────────────── | |
| 525 | + | |
| 526 | +export function AdminUsersPage() { | |
| 527 | + useExecutorDocumentTitle("Users"); | |
| 528 | + const [offset, setOffset] = useState(0); | |
| 529 | + const [selected, setSelected] = useState<AdminUserRow | null>(null); | |
| 530 | + | |
| 531 | + const page = { limit: ADMIN_USERS_PAGE_SIZE, offset }; | |
| 532 | + const result = useAtomValue(adminUsersWithConnectionsAtom(page)); | |
| 533 | + const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page)); | |
| 534 | + const catalog = useCatalogRows(); | |
| 535 | + const icons = useIntegrationIcons(); | |
| 536 | + // The route is `/{-$orgSlug}/users`: the segment is OPTIONAL, so this reads | |
| 537 | + // `undefined` on any host that renders no segment and the link falls back to | |
| 538 | + // the bare form. Both cloud and self-host do canonicalize onto a slug (self- | |
| 539 | + // host onto `default`), so in practice the prefixed form is what ships — but | |
| 540 | + // the optional param is the contract, not an assumption about the host. | |
| 541 | + const { orgSlug } = useParams({ strict: false }) as { orgSlug?: string }; | |
| 542 | + | |
| 543 | + const header = ( | |
| 544 | + <PageHeader | |
| 545 | + title="Users" | |
| 546 | + description="Everyone who has used this workspace, and what each of them has connected." | |
| 547 | + /> | |
| 548 | + ); | |
| 549 | + | |
| 550 | + const loading = ( | |
| 551 | + <div className="flex flex-col gap-2"> | |
| 552 | + {[0, 1, 2, 3].map((row) => ( | |
| 553 | + <Skeleton key={row} className="h-14" /> | |
| 554 | + ))} | |
| 555 | + </div> | |
| 556 | + ); | |
| 557 | + | |
| 558 | + return ( | |
| 559 | + <PageContainer> | |
| 560 | + {header} | |
| 561 | + | |
| 562 | + {isAsyncResultLoading(result) | |
| 563 | + ? loading | |
| 564 | + : AsyncResult.match(result, { | |
| 565 | + onInitial: () => loading, | |
| 566 | + onFailure: (failure) => | |
| 567 | + isAccessDenied(failure.cause) ? ( | |
| 568 | + <AccessDenied /> | |
| 569 | + ) : ( | |
| 570 | + <ErrorState message="Couldn't load users" onRetry={refresh} /> | |
| 571 | + ), | |
| 572 | + onSuccess: ({ value }) => { | |
| 573 | + const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); | |
| 574 | + | |
| 575 | + if (rows.length === 0) { | |
| 576 | + return ( | |
| 577 | + <div className="rounded-lg border border-dashed border-border bg-card p-8"> | |
| 578 | + <h2 className="text-base font-semibold text-foreground"> | |
| 579 | + {offset === 0 ? "No users yet" : "No users on this page"} | |
| 580 | + </h2> | |
| 581 | + <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground"> | |
| 582 | + {offset === 0 | |
| 583 | + ? "A user appears here the first time they reach this workspace or connect an account." | |
| 584 | + : "Go back a page to see this workspace's users."} | |
| 585 | + </p> | |
| 586 | + {offset > 0 && ( | |
| 587 | + <Button | |
| 588 | + variant="outline" | |
| 589 | + size="sm" | |
| 590 | + className="mt-4" | |
| 591 | + onClick={() => setOffset(Math.max(0, offset - ADMIN_USERS_PAGE_SIZE))} | |
| 592 | + > | |
| 593 | + Previous | |
| 594 | + </Button> | |
| 595 | + )} | |
| 596 | + </div> | |
| 597 | + ); | |
| 598 | + } | |
| 599 | + | |
| 600 | + return ( | |
| 601 | + <> | |
| 602 | + <div | |
| 603 | + className="overflow-hidden rounded-lg border border-border bg-card" | |
| 604 | + data-slot="admin-users-table" | |
| 605 | + > | |
| 606 | + <div className="grid grid-cols-[1fr_auto] gap-4 border-b border-border px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground md:grid-cols-[1.6fr_0.8fr_0.9fr_1.1fr]"> | |
| 607 | + <span>User</span> | |
| 608 | + <span className="hidden md:block">Created</span> | |
| 609 | + <span className="hidden md:block">Last seen</span> | |
| 610 | + <span className="text-right md:text-left">Connections</span> | |
| 611 | + </div> | |
| 612 | + {rows.map((user: AdminUserRow) => ( | |
| 613 | + // oxlint-disable-next-line react/forbid-elements | |
| 614 | + <button | |
| 615 | + key={user.externalId} | |
| 616 | + type="button" | |
| 617 | + onClick={() => setSelected(user)} | |
| 618 | + data-slot="admin-user-row" | |
| 619 | + className="grid w-full grid-cols-[1fr_auto] items-center gap-4 border-b border-border px-4 py-4 text-left transition-colors last:border-b-0 hover:bg-accent md:grid-cols-[1.6fr_0.8fr_0.9fr_1.1fr]" | |
| 620 | + > | |
| 621 | + <UserIdentityCell user={user} /> | |
| 622 | + <span className="hidden font-mono text-xs text-muted-foreground md:block"> | |
| 623 | + {formatAdminDate(user.createdAt)} | |
| 624 | + </span> | |
| 625 | + <span | |
| 626 | + className="hidden font-mono text-xs text-muted-foreground md:block" | |
| 627 | + title={lastSeenTitle(user.lastSeenAt)} | |
| 628 | + > | |
| 629 | + {formatLastSeen(user.lastSeenAt)} | |
| 630 | + </span> | |
| 631 | + <span className="justify-self-end md:justify-self-start"> | |
| 632 | + <ConnectionGrid | |
| 633 | + catalog={catalog} | |
| 634 | + connections={user.connections} | |
| 635 | + icons={icons} | |
| 636 | + /> | |
| 637 | + </span> | |
| 638 | + </button> | |
| 639 | + ))} | |
| 640 | + </div> | |
| 641 | + | |
| 642 | + {(hasNext || offset > 0) && ( | |
| 643 | + <div className="mt-4 flex items-center justify-between"> | |
| 644 | + <span className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground"> | |
| 645 | + Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)} | |
| 646 | + </span> | |
| 647 | + <div className="flex items-center gap-2"> | |
| 648 | + <Button | |
| 649 | + variant="outline" | |
| 650 | + size="sm" | |
| 651 | + disabled={offset === 0} | |
| 652 | + onClick={() => setOffset(Math.max(0, offset - ADMIN_USERS_PAGE_SIZE))} | |
| 653 | + > | |
| 654 | + Previous | |
| 655 | + </Button> | |
| 656 | + <Button | |
| 657 | + variant="outline" | |
| 658 | + size="sm" | |
| 659 | + disabled={!hasNext} | |
| 660 | + onClick={() => setOffset(offset + ADMIN_USERS_PAGE_SIZE)} | |
| 661 | + > | |
| 662 | + Next | |
| 663 | + </Button> | |
| 664 | + </div> | |
| 665 | + </div> | |
| 666 | + )} | |
| 667 | + </> | |
| 668 | + ); | |
| 669 | + }, | |
| 670 | + })} | |
| 671 | + | |
| 672 | + <Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}> | |
| 673 | + <SheetContent className="w-full gap-0 p-0 sm:max-w-xl"> | |
| 674 | + {selected && ( | |
| 675 | + <> | |
| 676 | + <SheetHeader className="border-b border-border"> | |
| 677 | + <UserDetailTitle user={selected} /> | |
| 678 | + <SheetDescription> | |
| 679 | + Created {formatAdminDate(selected.createdAt)} · last seen{" "} | |
| 680 | + <span title={lastSeenTitle(selected.lastSeenAt)}> | |
| 681 | + {formatLastSeen(selected.lastSeenAt).toLowerCase()} | |
| 682 | + </span> | |
| 683 | + </SheetDescription> | |
| 684 | + </SheetHeader> | |
| 685 | + <UserDetail user={selected} catalog={catalog} icons={icons} orgSlug={orgSlug} /> | |
| 686 | + </> | |
| 687 | + )} | |
| 688 | + </SheetContent> | |
| 689 | + </Sheet> | |
| 690 | + </PageContainer> | |
| 691 | + ); | |
| 692 | +} | |
| @@ -0,0 +1,109 @@ | ||
| 1 | +import { useEffect } from "react"; | |
| 2 | +import { Link, useNavigate } from "@tanstack/react-router"; | |
| 3 | +import { useAtomValue, useAtomRefresh } from "@effect/atom-react"; | |
| 4 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 5 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 6 | + | |
| 7 | +import { integrationAtom, integrationsOptimisticAtom } from "../api/atoms"; | |
| 8 | +import { ErrorState } from "../components/error-state"; | |
| 9 | +import { Skeleton } from "../components/skeleton"; | |
| 10 | +import { isAsyncResultLoading } from "../lib/async-result"; | |
| 11 | +import { useExecutorDocumentTitle } from "../lib/document-title"; | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | +// `/connect/<integration slug>` — the stable, shareable deep link that drops a | |
| 15 | +// person straight into the connect flow for ONE integration ("connect your | |
| 16 | +// Linear") instead of the dashboard. | |
| 17 | +// | |
| 18 | +// This page owns no connect UI of its own. It resolves the slug against the | |
| 19 | +// tenant's catalog and forwards into the integration detail page's existing | |
| 20 | +// account handoff (`?addAccount=1`), which is the same surface the detail | |
| 21 | +// page's own "Add connection" button opens — multi-method tab strips, OAuth | |
| 22 | +// popups, existing-connection lists and "add another account" all come for | |
| 23 | +// free, and stay correct as that flow evolves. | |
| 24 | +// | |
| 25 | +// Deliberately keyed on the INTEGRATION SLUG, not a plugin id: the slug is the | |
| 26 | +// catalog identity a customer can write down, whereas plugin routes are on | |
| 27 | +// their way out. The redirect is `replace: true` so the deep link does not | |
| 28 | +// linger in history and Back leaves the connect flow rather than bouncing. | |
| 29 | +// --------------------------------------------------------------------------- | |
| 30 | + | |
| 31 | +export function ConnectIntegrationPage(props: { readonly integrationSlug: string }) { | |
| 32 | + const { integrationSlug } = props; | |
| 33 | + const slug = IntegrationSlug.make(integrationSlug); | |
| 34 | + const integration = useAtomValue(integrationAtom(slug)); | |
| 35 | + const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); | |
| 36 | + const navigate = useNavigate(); | |
| 37 | + | |
| 38 | + useExecutorDocumentTitle("Connect"); | |
| 39 | + | |
| 40 | + // The catalog read is a client-side find over the integrations list, so an | |
| 41 | + // unknown slug resolves to Success(null) rather than a failure — the missing | |
| 42 | + // case is a not-found state, and only a genuine fetch failure is an error. | |
| 43 | + const resolved = AsyncResult.isSuccess(integration) ? integration.value : null; | |
| 44 | + const loading = isAsyncResultLoading(integration); | |
| 45 | + const failed = AsyncResult.isFailure(integration); | |
| 46 | + const missing = !loading && !failed && resolved === null; | |
| 47 | + | |
| 48 | + useEffect(() => { | |
| 49 | + if (resolved === null) return; | |
| 50 | + void navigate({ | |
| 51 | + to: "/{-$orgSlug}/integrations/$namespace", | |
| 52 | + params: { namespace: String(resolved.slug) }, | |
| 53 | + // The NUMBER 1, so the router serializes the canonical `?addAccount=1` | |
| 54 | + // that agent-generated handoff links already use (a string would be | |
| 55 | + // quoted as `%221%22`). | |
| 56 | + search: { tab: "accounts", addAccount: 1 }, | |
| 57 | + replace: true, | |
| 58 | + }); | |
| 59 | + }, [resolved, navigate]); | |
| 60 | + | |
| 61 | + if (failed) { | |
| 62 | + return ( | |
| 63 | + <ConnectStateFrame> | |
| 64 | + <ErrorState message="Failed to load integrations" onRetry={refreshIntegrations} /> | |
| 65 | + </ConnectStateFrame> | |
| 66 | + ); | |
| 67 | + } | |
| 68 | + | |
| 69 | + if (missing) { | |
| 70 | + return ( | |
| 71 | + <ConnectStateFrame> | |
| 72 | + <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> | |
| 73 | + <p className="mb-1 text-sm font-medium text-foreground/70"> | |
| 74 | + Unknown integration: {integrationSlug} | |
| 75 | + </p> | |
| 76 | + <p className="mb-5 text-xs text-muted-foreground"> | |
| 77 | + This integration is not in your workspace. | |
| 78 | + </p> | |
| 79 | + <Link | |
| 80 | + to="/{-$orgSlug}" | |
| 81 | + className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" | |
| 82 | + > | |
| 83 | + Back to integrations | |
| 84 | + </Link> | |
| 85 | + </div> | |
| 86 | + </ConnectStateFrame> | |
| 87 | + ); | |
| 88 | + } | |
| 89 | + | |
| 90 | + // Loading, or resolved and mid-redirect: the same quiet placeholder, so the | |
| 91 | + // hand-off never flashes an empty frame between catalog load and navigation. | |
| 92 | + return ( | |
| 93 | + <ConnectStateFrame> | |
| 94 | + <div className="space-y-3"> | |
| 95 | + <Skeleton className="h-8 w-56" /> | |
| 96 | + <Skeleton className="h-4 w-80" /> | |
| 97 | + <Skeleton className="h-32 w-full" /> | |
| 98 | + </div> | |
| 99 | + </ConnectStateFrame> | |
| 100 | + ); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function ConnectStateFrame(props: { readonly children: React.ReactNode }) { | |
| 104 | + return ( | |
| 105 | + <div className="relative min-h-0 flex-1 overflow-y-auto"> | |
| 106 | + <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">{props.children}</div> | |
| 107 | + </div> | |
| 108 | + ); | |
| 109 | +} | |
| @@ -60,6 +60,10 @@ type ToolRow = { · 59 unmodified lines | ||
| 60 | 60 | export function IntegrationDetailPage(props: { |
| 61 | 61 | namespace: string; |
| 62 | 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 | 68 | const { namespace } = props; |
| 65 | 69 | const slug = IntegrationSlug.make(namespace); |
| @@ -122,9 +126,11 @@ export function IntegrationDetailPage(props: { · 56 unmodified lines | ||
| 122 | 126 | const canRefresh = integrationData?.canRefresh ?? false; |
| 123 | 127 | const canRemove = integrationData?.canRemove ?? false; |
| 124 | 128 | const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => { |
| 125 | − if (locationSearch.length === 0) return null; | |
| 126 | 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 | 134 | const owner = search.get("owner"); |
| 129 | 135 | const template = search.get("template"); |
| 130 | 136 | const label = search.get("label"); |
| @@ -149,13 +155,13 @@ export function IntegrationDetailPage(props: { · 18 unmodified lines | ||
| 149 | 155 | }; |
| 150 | 156 | })(); |
| 151 | 157 | return { |
| 152 | − key: locationSearch, | |
| 158 | + key: `${String(props.addAccount)}:${locationSearch}`, | |
| 153 | 159 | ...(owner === "org" || owner === "user" ? { owner } : {}), |
| 154 | 160 | ...(template != null && template.length > 0 ? { template } : {}), |
| 155 | 161 | ...(label != null && label.length > 0 ? { label } : {}), |
| 156 | 162 | ...(oauthClient !== undefined ? { oauthClient } : {}), |
| 157 | 163 | }; |
| 158 | − }, [locationSearch]); | |
| 164 | + }, [locationSearch, props.addAccount]); | |
| 159 | 165 | const accountHandoff = manualAccountHandoff ?? urlAccountHandoff; |
| 160 | 166 | |
| 161 | 167 | useEffect(() => { |
| @@ -0,0 +1,121 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + createMemoryHistory, | |
| 4 | + createRootRoute, | |
| 5 | + createRoute, | |
| 6 | + createRouter, | |
| 7 | +} from "@tanstack/react-router"; | |
| 8 | + | |
| 9 | +import { CONSOLE_ROUTE_PATHS } from "../console-routes"; | |
| 10 | +import { isAddAccountRequested } from "./integrations.$namespace"; | |
| 11 | + | |
| 12 | +// Routing semantics of the `/connect/<integration slug>` deep link — the URL a | |
| 13 | +// customer hands to an end user ("connect your Linear"). The page component is | |
| 14 | +// exercised end-to-end in e2e/scenarios/connect-deep-link.test.ts; this locks | |
| 15 | +// the URL contract itself: which shapes match, what the params resolve to, and | |
| 16 | +// that the org-slug prefix round-trips. | |
| 17 | + | |
| 18 | +type RouteMatch = { readonly routeId: string; readonly params: Record<string, string> }; | |
| 19 | +type SpikeRouter = { | |
| 20 | + matchRoutes(pathname: string): ReadonlyArray<RouteMatch>; | |
| 21 | + buildLocation(options: unknown): { readonly href: string }; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const CONNECT_ROUTE_ID = "/{-$orgSlug}/connect/$integrationSlug"; | |
| 25 | + | |
| 26 | +const make = (): SpikeRouter => { | |
| 27 | + const rootRoute = createRootRoute(); | |
| 28 | + const orgScope = createRoute({ getParentRoute: () => rootRoute, path: "/{-$orgSlug}" }); | |
| 29 | + const indexRoute = createRoute({ getParentRoute: () => orgScope, path: "/" }); | |
| 30 | + const connectRoute = createRoute({ | |
| 31 | + getParentRoute: () => orgScope, | |
| 32 | + path: "/connect/$integrationSlug", | |
| 33 | + }); | |
| 34 | + const nsRoute = createRoute({ | |
| 35 | + getParentRoute: () => orgScope, | |
| 36 | + path: "/integrations/$namespace", | |
| 37 | + }); | |
| 38 | + const routeTree = rootRoute.addChildren([ | |
| 39 | + orgScope.addChildren([indexRoute, connectRoute, nsRoute]), | |
| 40 | + ]); | |
| 41 | + // A test-local tree is not the package's registered console tree, so widen | |
| 42 | + // away the global Register types — this asserts runtime semantics. | |
| 43 | + // oxlint-disable-next-line executor/no-double-cast -- boundary: a test-local route tree must escape the package-global router Register types | |
| 44 | + return createRouter({ | |
| 45 | + routeTree, | |
| 46 | + history: createMemoryHistory({ initialEntries: ["/"] }), | |
| 47 | + }) as unknown as SpikeRouter; | |
| 48 | +}; | |
| 49 | + | |
| 50 | +describe("connect deep link", () => { | |
| 51 | + it("is part of the shared console route contract", () => { | |
| 52 | + // Every host mounts the contract, so the deep link exists on cloud, | |
| 53 | + // self-host, cloudflare and local alike. | |
| 54 | + expect(CONSOLE_ROUTE_PATHS).toContain("/connect/$integrationSlug"); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it("matches the bare link a customer would generate", () => { | |
| 58 | + const matches = make().matchRoutes("/connect/linear"); | |
| 59 | + const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID); | |
| 60 | + expect(match?.params).toEqual({ integrationSlug: "linear" }); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("matches the org-prefixed form without eating the slug", () => { | |
| 64 | + const matches = make().matchRoutes("/acme/connect/linear"); | |
| 65 | + const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID); | |
| 66 | + expect(match?.params).toEqual({ orgSlug: "acme", integrationSlug: "linear" }); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("keeps slugs that look like other console words intact", () => { | |
| 70 | + // The integration slug is a free-form catalog id; "tools" or "integrations" | |
| 71 | + // as a slug must still land on connect, not on those pages. | |
| 72 | + for (const slug of ["tools", "integrations", "connect"]) { | |
| 73 | + const match = make() | |
| 74 | + .matchRoutes(`/connect/${slug}`) | |
| 75 | + .find((m) => m.routeId === CONNECT_ROUTE_ID); | |
| 76 | + expect(match?.params).toEqual({ integrationSlug: slug }); | |
| 77 | + } | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("builds the link with and without an org slug", () => { | |
| 81 | + const router = make(); | |
| 82 | + expect( | |
| 83 | + router.buildLocation({ to: CONNECT_ROUTE_ID, params: { integrationSlug: "linear" } }).href, | |
| 84 | + ).toBe("/connect/linear"); | |
| 85 | + expect( | |
| 86 | + router.buildLocation({ | |
| 87 | + to: CONNECT_ROUTE_ID, | |
| 88 | + params: { orgSlug: "acme", integrationSlug: "linear" }, | |
| 89 | + }).href, | |
| 90 | + ).toBe("/acme/connect/linear"); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("forwards into the integration's existing connect flow", () => { | |
| 94 | + // The page redirects here rather than rendering its own connect UI; the | |
| 95 | + // handoff is carried as validated search so it survives client navigation. | |
| 96 | + // The number 1 keeps the canonical `?addAccount=1` that agent-generated | |
| 97 | + // handoff links already use — a string would serialize as `%221%22`. | |
| 98 | + const href = make().buildLocation({ | |
| 99 | + to: "/{-$orgSlug}/integrations/$namespace", | |
| 100 | + params: { namespace: "linear" }, | |
| 101 | + search: { tab: "accounts", addAccount: 1 }, | |
| 102 | + }).href; | |
| 103 | + expect(href).toBe("/integrations/linear?tab=accounts&addAccount=1"); | |
| 104 | + }); | |
| 105 | +}); | |
| 106 | + | |
| 107 | +describe("addAccount flag", () => { | |
| 108 | + it("accepts every spelling the two link sources produce", () => { | |
| 109 | + // A full page load parses `?addAccount=1` to the number; a client-side | |
| 110 | + // navigation may carry the string. Both mean "open the connect flow". | |
| 111 | + expect(isAddAccountRequested(1)).toBe(true); | |
| 112 | + expect(isAddAccountRequested("1")).toBe(true); | |
| 113 | + expect(isAddAccountRequested(true)).toBe(true); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it("ignores anything else instead of failing the page", () => { | |
| 117 | + for (const value of [undefined, null, 0, "0", "", "banana", {}]) { | |
| 118 | + expect(isAddAccountRequested(value)).toBe(false); | |
| 119 | + } | |
| 120 | + }); | |
| 121 | +}); | |
| @@ -0,0 +1,10 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | + | |
| 3 | +import { ConnectIntegrationPage } from "../pages/connect-integration"; | |
| 4 | + | |
| 5 | +export const Route = createFileRoute("/{-$orgSlug}/connect/$integrationSlug")({ | |
| 6 | + component: () => { | |
| 7 | + const { integrationSlug } = Route.useParams(); | |
| 8 | + return <ConnectIntegrationPage integrationSlug={integrationSlug} />; | |
| 9 | + }, | |
| 10 | +}); | |
| @@ -4,21 +4,40 @@ import { createFileRoute } from "@tanstack/react-router"; · 3 unmodified lines | ||
| 4 | 4 | import { IntegrationDetailPage } from "../pages/integration-detail"; |
| 5 | 5 | import 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. | |
| 7 | 20 | const SearchParams = Schema.toStandardSchemaV1( |
| 8 | 21 | Schema.Struct({ |
| 9 | 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. */ | |
| 28 | +export const isAddAccountRequested = (value: unknown): boolean => | |
| 29 | + value === 1 || value === "1" || value === true; | |
| 30 | + | |
| 13 | 31 | export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({ |
| 14 | 32 | validateSearch: SearchParams, |
| 15 | 33 | component: () => { |
| 16 | 34 | const { namespace } = Route.useParams(); |
| 17 | − const { tab } = Route.useSearch(); | |
| 35 | + const { tab, addAccount } = Route.useSearch(); | |
| 18 | 36 | return ( |
| 19 | 37 | <IntegrationDetailPage |
| 20 | 38 | namespace={namespace} |
| 21 | 39 | tab={tab as IntegrationDetailSearchTab | undefined} |
| 40 | + addAccount={isAddAccountRequested(addAccount)} | |
| 22 | 41 | /> |
| 23 | 42 | ); |
| 24 | 43 | }, |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | import { Route as rootRouteImport } from './__root' |
| 12 | 12 | import { Route as DotIndexRouteImport } from './index' |
| 13 | +import { Route as DotUsersRouteImport } from './users' | |
| 13 | 14 | import { Route as DotToolsRouteImport } from './tools' |
| 14 | 15 | import { Route as DotToolkitsRouteImport } from './toolkits' |
| 15 | 16 | import { Route as DotSecretsRouteImport } from './secrets' |
| @@ -17,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 1 unmodified line | ||
| 17 | 18 | import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' |
| 18 | 19 | import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' |
| 19 | 20 | import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' |
| 21 | +import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug' | |
| 20 | 22 | import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$' |
| 21 | 23 | import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey' |
| 22 | 24 | |
| @@ -25,6 +27,11 @@ const DotIndexRoute = DotIndexRouteImport.update({ · 2 unmodified lines | ||
| 25 | 27 | path: '/{-$orgSlug}/', |
| 26 | 28 | getParentRoute: () => rootRouteImport, |
| 27 | 29 | } as any) |
| 30 | +const DotUsersRoute = DotUsersRouteImport.update({ | |
| 31 | + id: '/{-$orgSlug}/users', | |
| 32 | + path: '/{-$orgSlug}/users', | |
| 33 | + getParentRoute: () => rootRouteImport, | |
| 34 | +} as any) | |
| 28 | 35 | const DotToolsRoute = DotToolsRouteImport.update({ |
| 29 | 36 | id: '/{-$orgSlug}/tools', |
| 30 | 37 | path: '/{-$orgSlug}/tools', |
| @@ -62,6 +69,12 @@ const DotIntegrationsDotnamespaceRoute = · 31 unmodified lines | ||
| 62 | 69 | path: '/{-$orgSlug}/integrations/$namespace', |
| 63 | 70 | getParentRoute: () => rootRouteImport, |
| 64 | 71 | } as any) |
| 72 | +const DotConnectDotintegrationSlugRoute = | |
| 73 | + DotConnectDotintegrationSlugRouteImport.update({ | |
| 74 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 75 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 76 | + getParentRoute: () => rootRouteImport, | |
| 77 | + } as any) | |
| 65 | 78 | const DotPluginsDotpluginIdDotsplatRoute = |
| 66 | 79 | DotPluginsDotpluginIdDotsplatRouteImport.update({ |
| 67 | 80 | id: '/{-$orgSlug}/plugins/$pluginId/$', |
| @@ -80,7 +93,9 @@ export interface FileRoutesByFullPath { · 12 unmodified lines | ||
| 80 | 93 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 81 | 94 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 82 | 95 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 96 | + '/{-$orgSlug}/users': typeof DotUsersRoute | |
| 83 | 97 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 98 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | |
| 84 | 99 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 85 | 100 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 86 | 101 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| @@ -92,7 +107,9 @@ export interface FileRoutesByTo { · 5 unmodified lines | ||
| 92 | 107 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 93 | 108 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 94 | 109 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 110 | + '/{-$orgSlug}/users': typeof DotUsersRoute | |
| 95 | 111 | '/{-$orgSlug}': typeof DotIndexRoute |
| 112 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | |
| 96 | 113 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 97 | 114 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 98 | 115 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| @@ -105,7 +122,9 @@ export interface FileRoutesById { · 6 unmodified lines | ||
| 105 | 122 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 106 | 123 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 107 | 124 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 125 | + '/{-$orgSlug}/users': typeof DotUsersRoute | |
| 108 | 126 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 127 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | |
| 109 | 128 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 110 | 129 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 111 | 130 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| @@ -119,7 +138,9 @@ export interface FileRouteTypes { · 7 unmodified lines | ||
| 119 | 138 | | '/{-$orgSlug}/secrets' |
| 120 | 139 | | '/{-$orgSlug}/toolkits' |
| 121 | 140 | | '/{-$orgSlug}/tools' |
| 141 | + | '/{-$orgSlug}/users' | |
| 122 | 142 | | '/{-$orgSlug}/' |
| 143 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 123 | 144 | | '/{-$orgSlug}/integrations/$namespace' |
| 124 | 145 | | '/{-$orgSlug}/resume/$executionId' |
| 125 | 146 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -131,7 +152,9 @@ export interface FileRouteTypes { · 5 unmodified lines | ||
| 131 | 152 | | '/{-$orgSlug}/secrets' |
| 132 | 153 | | '/{-$orgSlug}/toolkits' |
| 133 | 154 | | '/{-$orgSlug}/tools' |
| 155 | + | '/{-$orgSlug}/users' | |
| 134 | 156 | | '/{-$orgSlug}' |
| 157 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 135 | 158 | | '/{-$orgSlug}/integrations/$namespace' |
| 136 | 159 | | '/{-$orgSlug}/resume/$executionId' |
| 137 | 160 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -143,7 +166,9 @@ export interface FileRouteTypes { · 5 unmodified lines | ||
| 143 | 166 | | '/{-$orgSlug}/secrets' |
| 144 | 167 | | '/{-$orgSlug}/toolkits' |
| 145 | 168 | | '/{-$orgSlug}/tools' |
| 169 | + | '/{-$orgSlug}/users' | |
| 146 | 170 | | '/{-$orgSlug}/' |
| 171 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 147 | 172 | | '/{-$orgSlug}/integrations/$namespace' |
| 148 | 173 | | '/{-$orgSlug}/resume/$executionId' |
| 149 | 174 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -156,7 +181,9 @@ export interface RootRouteChildren { · 6 unmodified lines | ||
| 156 | 181 | DotSecretsRoute: typeof DotSecretsRoute |
| 157 | 182 | DotToolkitsRoute: typeof DotToolkitsRouteWithChildren |
| 158 | 183 | DotToolsRoute: typeof DotToolsRoute |
| 184 | + DotUsersRoute: typeof DotUsersRoute | |
| 159 | 185 | DotIndexRoute: typeof DotIndexRoute |
| 186 | + DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute | |
| 160 | 187 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute |
| 161 | 188 | DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute |
| 162 | 189 | DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute |
| @@ -172,6 +199,13 @@ declare module '@tanstack/react-router' { · 9 unmodified lines | ||
| 172 | 199 | preLoaderRoute: typeof DotIndexRouteImport |
| 173 | 200 | 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 | 209 | '/{-$orgSlug}/tools': { |
| 176 | 210 | id: '/{-$orgSlug}/tools' |
| 177 | 211 | path: '/{-$orgSlug}/tools' |
| @@ -221,6 +255,13 @@ declare module '@tanstack/react-router' { · 43 unmodified lines | ||
| 221 | 255 | preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport |
| 222 | 256 | 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 | 265 | '/{-$orgSlug}/plugins/$pluginId/$': { |
| 225 | 266 | id: '/{-$orgSlug}/plugins/$pluginId/$' |
| 226 | 267 | path: '/{-$orgSlug}/plugins/$pluginId/$' |
| @@ -255,7 +296,9 @@ const rootRouteChildren: RootRouteChildren = { · 28 unmodified lines | ||
| 255 | 296 | DotSecretsRoute: DotSecretsRoute, |
| 256 | 297 | DotToolkitsRoute: DotToolkitsRouteWithChildren, |
| 257 | 298 | DotToolsRoute: DotToolsRoute, |
| 299 | + DotUsersRoute: DotUsersRoute, | |
| 258 | 300 | DotIndexRoute: DotIndexRoute, |
| 301 | + DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, | |
| 259 | 302 | DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, |
| 260 | 303 | DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute, |
| 261 | 304 | DotIntegrationsDotaddDotpluginKeyRoute: |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | + | |
| 3 | +import { AdminUsersPage } from "../pages/admin-users"; | |
| 4 | + | |
| 5 | +export const Route = createFileRoute("/{-$orgSlug}/users")({ | |
| 6 | + component: () => <AdminUsersPage />, | |
| 7 | +}); | |
| @@ -0,0 +1,138 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | +/** | |
| 3 | + * Asserts that every committed `routeTree.gen.ts` matches what its generator | |
| 4 | + * produces from the current route contract. | |
| 5 | + * | |
| 6 | + * The route trees are GENERATED (`bun run routes:gen` in each package, from | |
| 7 | + * `tsr.routes.ts` / `consoleRoutes()`) but COMMITTED, because the build and the | |
| 8 | + * type-level `Link to=...` checking both read them off disk. That makes them a | |
| 9 | + * classic drift surface: change the route contract, forget to regenerate, and | |
| 10 | + * the committed tree still compiles — the routes it lacks simply don't exist at | |
| 11 | + * runtime. | |
| 12 | + * | |
| 13 | + * Nothing else catches it. The generated files open with a suppression pragma | |
| 14 | + * that opts them out of type checking entirely, so `typecheck` cannot see a | |
| 15 | + * missing route; lint doesn't read them semantically; and unit tests import | |
| 16 | + * the very tree they are drifting against. This bug class has already escaped | |
| 17 | + * once and shipped a live 404 for a route that was in the contract but not in | |
| 18 | + * the committed tree. | |
| 19 | + * | |
| 20 | + * So: regenerate every tree into place, diff against git, and restore. The | |
| 21 | + * generators are pure functions of the checked-in route config, so a clean | |
| 22 | + * checkout is a no-op and any diff is real drift. | |
| 23 | + * | |
| 24 | + * Usage: | |
| 25 | + * bun run scripts/check-route-trees.ts | |
| 26 | + */ | |
| 27 | +import { spawnSync } from "node:child_process"; | |
| 28 | +import { readFileSync, writeFileSync } from "node:fs"; | |
| 29 | +import { resolve } from "node:path"; | |
| 30 | + | |
| 31 | +const repoRoot = resolve(import.meta.dir, ".."); | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * Every package with a committed route tree, as (workspace dir, tree path) | |
| 35 | + * pairs. Each dir has a `routes:gen` script; the tree is the file it writes. | |
| 36 | + */ | |
| 37 | +const TREES = [ | |
| 38 | + { dir: "packages/react", tree: "packages/react/src/routes/routeTree.gen.ts" }, | |
| 39 | + { dir: "packages/app", tree: "packages/app/src/routeTree.gen.ts" }, | |
| 40 | + { dir: "apps/cloud", tree: "apps/cloud/src/routeTree.gen.ts" }, | |
| 41 | + { dir: "apps/host-selfhost", tree: "apps/host-selfhost/web/routeTree.gen.ts" }, | |
| 42 | + { dir: "apps/host-cloudflare", tree: "apps/host-cloudflare/web/routeTree.gen.ts" }, | |
| 43 | +] as const; | |
| 44 | + | |
| 45 | +const run = (command: string, args: readonly string[], cwd: string) => | |
| 46 | + spawnSync(command, [...args], { cwd, encoding: "utf8" }); | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * Compare on ROUTE CONTENT, not byte-for-byte. | |
| 50 | + * | |
| 51 | + * Two generators write these files: each package's `routes:gen` script, and the | |
| 52 | + * TanStack vite plugin during `dev`. They agree on every route, import, and | |
| 53 | + * type — they disagree only on blank lines in the trailing `declare module` | |
| 54 | + * block. Comparing raw bytes would therefore fail on any checkout where a dev | |
| 55 | + * server had run, which is a false alarm about formatting, not the drift this | |
| 56 | + * guards (a route present in the contract and missing from the tree). Blank | |
| 57 | + * lines cannot encode a route, so dropping them loses no signal. | |
| 58 | + */ | |
| 59 | +const routeContent = (source: string): string => | |
| 60 | + source | |
| 61 | + .split("\n") | |
| 62 | + .filter((line) => line.trim() !== "") | |
| 63 | + .join("\n"); | |
| 64 | + | |
| 65 | +// Snapshot the committed content so a failed run leaves the checkout exactly as | |
| 66 | +// it found it — this script must be safe to run on a dirty working tree. | |
| 67 | +const before = new Map<string, string>(); | |
| 68 | +for (const { tree } of TREES) { | |
| 69 | + before.set(tree, readFileSync(resolve(repoRoot, tree), "utf8")); | |
| 70 | +} | |
| 71 | + | |
| 72 | +const restore = () => { | |
| 73 | + for (const [tree, content] of before) writeFileSync(resolve(repoRoot, tree), content); | |
| 74 | +}; | |
| 75 | + | |
| 76 | +/** The lines each side has and the other does not — enough to name the route | |
| 77 | + * that drifted, without pulling in a diff library for a CI guard. */ | |
| 78 | +const lineDiff = (committed: string, regenerated: string): string => { | |
| 79 | + const committedLines = new Set(committed.split("\n")); | |
| 80 | + const regeneratedLines = new Set(regenerated.split("\n")); | |
| 81 | + const missing = [...regeneratedLines].filter((line) => !committedLines.has(line)); | |
| 82 | + const extra = [...committedLines].filter((line) => !regeneratedLines.has(line)); | |
| 83 | + const show = (label: string, lines: readonly string[]) => | |
| 84 | + lines.length === 0 | |
| 85 | + ? [] | |
| 86 | + : [` ${label}:`, ...lines.slice(0, 20).map((line) => ` ${line}`)]; | |
| 87 | + return [ | |
| 88 | + ...show("in the regenerated tree but NOT committed (missing routes)", missing), | |
| 89 | + ...show("committed but NOT regenerated (stale routes)", extra), | |
| 90 | + ].join("\n"); | |
| 91 | +}; | |
| 92 | + | |
| 93 | +type Failure = { readonly tree: string; readonly detail: string }; | |
| 94 | +const failures: Failure[] = []; | |
| 95 | + | |
| 96 | +for (const { dir, tree } of TREES) { | |
| 97 | + const generated = run("bun", ["run", "routes:gen"], resolve(repoRoot, dir)); | |
| 98 | + if (generated.status !== 0) { | |
| 99 | + failures.push({ | |
| 100 | + tree, | |
| 101 | + detail: `\`bun run routes:gen\` failed in ${dir}:\n${generated.stderr || generated.stdout}`, | |
| 102 | + }); | |
| 103 | + continue; | |
| 104 | + } | |
| 105 | + const after = readFileSync(resolve(repoRoot, tree), "utf8"); | |
| 106 | + const committed = routeContent(before.get(tree) ?? ""); | |
| 107 | + const regenerated = routeContent(after); | |
| 108 | + if (regenerated !== committed) { | |
| 109 | + // Show the actual drift rather than just naming the file: what changed is | |
| 110 | + // usually the whole answer (a route added to the contract, or removed). | |
| 111 | + // Diffed against the SNAPSHOT rather than via `git diff`, so this reports | |
| 112 | + // correctly even when the committed file was already dirty. | |
| 113 | + failures.push({ | |
| 114 | + tree, | |
| 115 | + detail: `regenerating produced a different tree:\n${lineDiff(committed, regenerated)}`, | |
| 116 | + }); | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +restore(); | |
| 121 | + | |
| 122 | +if (failures.length > 0) { | |
| 123 | + const lines = failures.map((f) => ` - ${f.tree}: ${f.detail}`).join("\n\n"); | |
| 124 | + console.error( | |
| 125 | + `\nRoute-tree check FAILED (${failures.length} tree(s) out of date):\n${lines}\n\n` + | |
| 126 | + "Cause: a committed routeTree.gen.ts does not match what its generator\n" + | |
| 127 | + "produces from the current route contract — the contract changed and the\n" + | |
| 128 | + "tree was not regenerated. These files opt out of type checking, so the\n" + | |
| 129 | + "typecheck job cannot catch this; the app just serves a 404 for the\n" + | |
| 130 | + "missing route.\n\n" + | |
| 131 | + "Fix: regenerate and commit the trees:\n" + | |
| 132 | + TREES.map(({ dir }) => ` bun run --cwd ${dir} routes:gen`).join("\n") + | |
| 133 | + "\n", | |
| 134 | + ); | |
| 135 | + process.exit(1); | |
| 136 | +} | |
| 137 | + | |
| 138 | +console.log(`Route-tree check passed: ${TREES.length} generated tree(s) match their contract.`); | |