* 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
Showing the changes to scripts (1 of 98 files). Show all
Unified · Split
| @@ -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.`); | ||