History

Add admin Users console page (#1469)

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

Rhys Sullivan committed 3 months ago

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

Showing the changes to scripts (1 of 98 files). Show all

Unified · Split

scripts/check-route-trees.ts+138−0
@@ -0,0 +1,138 @@
1#!/usr/bin/env bun
2/**
3 * Asserts that every committed `routeTree.gen.ts` matches what its generator
4 * produces from the current route contract.
5 *
6 * The route trees are GENERATED (`bun run routes:gen` in each package, from
7 * `tsr.routes.ts` / `consoleRoutes()`) but COMMITTED, because the build and the
8 * type-level `Link to=...` checking both read them off disk. That makes them a
9 * classic drift surface: change the route contract, forget to regenerate, and
10 * the committed tree still compiles — the routes it lacks simply don't exist at
11 * runtime.
12 *
13 * Nothing else catches it. The generated files open with a suppression pragma
14 * that opts them out of type checking entirely, so `typecheck` cannot see a
15 * missing route; lint doesn't read them semantically; and unit tests import
16 * the very tree they are drifting against. This bug class has already escaped
17 * once and shipped a live 404 for a route that was in the contract but not in
18 * the committed tree.
19 *
20 * So: regenerate every tree into place, diff against git, and restore. The
21 * generators are pure functions of the checked-in route config, so a clean
22 * checkout is a no-op and any diff is real drift.
23 *
24 * Usage:
25 * bun run scripts/check-route-trees.ts
26 */
27import { spawnSync } from "node:child_process";
28import { readFileSync, writeFileSync } from "node:fs";
29import { resolve } from "node:path";
30
31const repoRoot = resolve(import.meta.dir, "..");
32
33/**
34 * Every package with a committed route tree, as (workspace dir, tree path)
35 * pairs. Each dir has a `routes:gen` script; the tree is the file it writes.
36 */
37const TREES = [
38 { dir: "packages/react", tree: "packages/react/src/routes/routeTree.gen.ts" },
39 { dir: "packages/app", tree: "packages/app/src/routeTree.gen.ts" },
40 { dir: "apps/cloud", tree: "apps/cloud/src/routeTree.gen.ts" },
41 { dir: "apps/host-selfhost", tree: "apps/host-selfhost/web/routeTree.gen.ts" },
42 { dir: "apps/host-cloudflare", tree: "apps/host-cloudflare/web/routeTree.gen.ts" },
43] as const;
44
45const run = (command: string, args: readonly string[], cwd: string) =>
46 spawnSync(command, [...args], { cwd, encoding: "utf8" });
47
48/**
49 * Compare on ROUTE CONTENT, not byte-for-byte.
50 *
51 * Two generators write these files: each package's `routes:gen` script, and the
52 * TanStack vite plugin during `dev`. They agree on every route, import, and
53 * type — they disagree only on blank lines in the trailing `declare module`
54 * block. Comparing raw bytes would therefore fail on any checkout where a dev
55 * server had run, which is a false alarm about formatting, not the drift this
56 * guards (a route present in the contract and missing from the tree). Blank
57 * lines cannot encode a route, so dropping them loses no signal.
58 */
59const routeContent = (source: string): string =>
60 source
61 .split("\n")
62 .filter((line) => line.trim() !== "")
63 .join("\n");
64
65// Snapshot the committed content so a failed run leaves the checkout exactly as
66// it found it — this script must be safe to run on a dirty working tree.
67const before = new Map<string, string>();
68for (const { tree } of TREES) {
69 before.set(tree, readFileSync(resolve(repoRoot, tree), "utf8"));
70}
71
72const restore = () => {
73 for (const [tree, content] of before) writeFileSync(resolve(repoRoot, tree), content);
74};
75
76/** The lines each side has and the other does not — enough to name the route
77 * that drifted, without pulling in a diff library for a CI guard. */
78const lineDiff = (committed: string, regenerated: string): string => {
79 const committedLines = new Set(committed.split("\n"));
80 const regeneratedLines = new Set(regenerated.split("\n"));
81 const missing = [...regeneratedLines].filter((line) => !committedLines.has(line));
82 const extra = [...committedLines].filter((line) => !regeneratedLines.has(line));
83 const show = (label: string, lines: readonly string[]) =>
84 lines.length === 0
85 ? []
86 : [` ${label}:`, ...lines.slice(0, 20).map((line) => ` ${line}`)];
87 return [
88 ...show("in the regenerated tree but NOT committed (missing routes)", missing),
89 ...show("committed but NOT regenerated (stale routes)", extra),
90 ].join("\n");
91};
92
93type Failure = { readonly tree: string; readonly detail: string };
94const failures: Failure[] = [];
95
96for (const { dir, tree } of TREES) {
97 const generated = run("bun", ["run", "routes:gen"], resolve(repoRoot, dir));
98 if (generated.status !== 0) {
99 failures.push({
100 tree,
101 detail: `\`bun run routes:gen\` failed in ${dir}:\n${generated.stderr || generated.stdout}`,
102 });
103 continue;
104 }
105 const after = readFileSync(resolve(repoRoot, tree), "utf8");
106 const committed = routeContent(before.get(tree) ?? "");
107 const regenerated = routeContent(after);
108 if (regenerated !== committed) {
109 // Show the actual drift rather than just naming the file: what changed is
110 // usually the whole answer (a route added to the contract, or removed).
111 // Diffed against the SNAPSHOT rather than via `git diff`, so this reports
112 // correctly even when the committed file was already dirty.
113 failures.push({
114 tree,
115 detail: `regenerating produced a different tree:\n${lineDiff(committed, regenerated)}`,
116 });
117 }
118}
119
120restore();
121
122if (failures.length > 0) {
123 const lines = failures.map((f) => ` - ${f.tree}: ${f.detail}`).join("\n\n");
124 console.error(
125 `\nRoute-tree check FAILED (${failures.length} tree(s) out of date):\n${lines}\n\n` +
126 "Cause: a committed routeTree.gen.ts does not match what its generator\n" +
127 "produces from the current route contract — the contract changed and the\n" +
128 "tree was not regenerated. These files opt out of type checking, so the\n" +
129 "typecheck job cannot catch this; the app just serves a 404 for the\n" +
130 "missing route.\n\n" +
131 "Fix: regenerate and commit the trees:\n" +
132 TREES.map(({ dir }) => ` bun run --cwd ${dir} routes:gen`).join("\n") +
133 "\n",
134 );
135 process.exit(1);
136}
137
138console.log(`Route-tree check passed: ${TREES.length} generated tree(s) match their contract.`);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

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

Repository code

Go to file
t