diff --git a/PLAN.md b/PLAN.md index 4f8aa533..7dc792fd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,6 +4,9 @@ This file is the single source of truth for autonomous maintenance. Read by Clau ## Targeted Security Follow-up +- [x] #389: persist verified user/org identity bindings, backfill recognized existing tenants without moving data, resolve dashboard tenant before reads/writes, and restore explicit Personal selection. Shared/unknown legacy ownership requires operator audit; see `docs/knowledge/organization-identity.md`. +- #389 verification: API 525/525, dashboard 12/12 + build, API typecheck, changed-file Biome, SDK examples, TS SDK 17 passed/2 skipped, Python 55 passed/2 skipped. PR CI must pass; do not merge or deploy. + - [x] #436 Part 1: preserve global domain uniqueness, return neutral cross-project errors, atomically reclaim never-verified pending/failed claims after 7 days, and document the one-owner rule with regression coverage. - [x] #436 Part 2: verify existing JSON-path hardening from #441; no implementation changes. - PR review/green CI required before merge; do not merge this follow-up or release-please #442 as part of this task. diff --git a/bun.lock b/bun.lock index 82985788..1fb9a9d9 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "packages/dashboard": { "name": "dashboard", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@agentstate/shared": "workspace:*", "@base-ui/react": "^1.7.0", @@ -64,13 +64,14 @@ "@types/react": "^19", "@types/react-dom": "^19", "astro": "^7.0.0", + "happy-dom": "^20.14.5", "tailwindcss": "^4", "typescript": "^5", }, }, "packages/mcp": { "name": "@agentstate/mcp", - "version": "0.1.4", + "version": "0.2.0", "bin": { "agentstate-mcp": "./dist/index.js", }, @@ -707,6 +708,10 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], @@ -797,6 +802,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], @@ -961,7 +968,7 @@ "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1089,6 +1096,8 @@ "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -1839,6 +1848,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], @@ -1999,6 +2010,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], diff --git a/docs/INDEX.md b/docs/INDEX.md index 065a461a..e9934d78 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -27,6 +27,7 @@ | [ClickHouse Monitoring](integrations/clickhouse-monitoring.md) | Use AgentState as the conversation-history backend for the clickhouse-monitoring dashboard | | [Environment Variables](environment-variables.md) | Env vars and Cloudflare bindings | | [Data Handling & Ownership](data-handling.md) | What data is stored, export, deletion controls, retention, and self-hosting | +| [Organization Identity](knowledge/organization-identity.md) | Stable tenant bindings, Personal workspaces, migration audit, and operator-only legacy recovery | | [Core Memory](knowledge/core-memory.md) | Durable maintenance notes for future agents | | [Workers Cache](knowledge/workers-cache.md) | Cloudflare Workers Cache: what's enabled, which public endpoints are cached, why authed routes are not | | [Recipe: Leases](recipes/leases.md) | Distributed locking — coordinate N agents with exactly-one-writer semantics | diff --git a/docs/knowledge/core-memory.md b/docs/knowledge/core-memory.md index 894d6af6..ed688e88 100644 --- a/docs/knowledge/core-memory.md +++ b/docs/knowledge/core-memory.md @@ -17,6 +17,8 @@ Durable notes for recurring maintenance. Keep this file small and update it inst ## Review Memory +- Dashboard tenancy resolves verified Clerk user/organization principals through persisted `organization_identities` to stable internal IDs. Never authorize by independently rebuilding `clerk_org_id` strings or auto-share Personal projects on organization attach. Keep Personal selectable; audit unbound legacy rows and require verified operator ownership before recovery. See [Organization Identity](organization-identity.md). + - Historical API-doc review notes from March 2026 were folded into the live docs. Keep API endpoint coverage current in `docs/api-reference.md`, `docs/sdk.md`, and `docs/integration.md`. - Historical test-coverage notes were stale after the test suite expanded. Use current `packages/api/test/` coverage and CI output as the source of truth before adding tests. - Recent state-platform maintenance should cover sparse `/api/v1/states/query` filters. Tag and JSON-path queries must keep scanning past nonmatching rows instead of stopping at the first capped candidate page. diff --git a/docs/knowledge/organization-identity.md b/docs/knowledge/organization-identity.md new file mode 100644 index 00000000..92f70be8 --- /dev/null +++ b/docs/knowledge/organization-identity.md @@ -0,0 +1,98 @@ +# Organization identity and recovery + +## Identity contract + +Dashboard authentication resolves a verified Clerk principal once to a persisted +`organization_identities` binding. A principal is either a Clerk user (Personal) +or the active Clerk organization. The binding points to `organizations.id`, which +is the stable tenant ID used by project creation, reads, and authorization. +`organizations.clerk_org_id` remains a compatibility/display field, not an +independently re-derived authorization key. + +The first authenticated request establishes a new principal's tenant, even when +it is a read. Creation is atomic and conflict-safe. Existing bindings are reused; +organization name sync cannot change identity. JWT organization claims are +normalized across Clerk versions; malformed or conflicting claims are rejected, +not interpreted as Personal. + +Personal and team workspaces remain separate. The workspace selector always +includes **Personal** and never auto-activates a membership. Switching back to +Personal clears the Clerk active organization and restores access to the same +personal projects. Joining a team does not share personal projects with its +members. Identical project slugs in different workspaces are valid. + +## Migration + +The identity migration binds recognized `org_` and +`personal:user_` values to their **existing** internal organization +IDs. It does not move projects, rewrite keys, or delete rows. Descendant data and +API-key access remain unchanged. + +Shared `default`, malformed values, and unknown historical formats have no +provable owner in the schema. They remain unbound. An exact legacy row encountered +without a binding causes `IDENTITY_CONFLICT`, rather than adopting it or returning +a newly created empty tenant. An unrecognized legacy row with a different value +cannot be attributed to a login automatically: deployment must include the audit +below. New identity conventions require an explicit migration, not a fallback +change in session verification. + +## Deployment audit (read-only) + +Run after applying migrations, before accepting the deployment as complete: + +```sql +SELECT o.id, o.clerk_org_id, o.name, COUNT(p.id) AS project_count +FROM organizations o +LEFT JOIN organization_identities i ON i.organization_id = o.id +LEFT JOIN projects p ON p.org_id = o.id +WHERE i.organization_id IS NULL +GROUP BY o.id, o.clerk_org_id, o.name; +``` + +Every returned production row needs an operator disposition. Do not treat an +empty dashboard as evidence that data was deleted. Do not print query results +containing tenant information into public CI logs or issue comments. + +## Operator-only recovery + +There is intentionally no browser endpoint for claiming an unbound organization. +The historical shared `default` tenant may contain data from multiple people; +neither a current session, an organization name, nor membership proves ownership. + +1. Back up the database and record the existing organization ID and project IDs. +2. Establish ownership independently using trustworthy historical records and + Clerk administration. If ownership is mixed or cannot be proven, stop. A + reviewed per-project recovery is necessary; never assign the shared row to the + next person who signs in. +3. Audit both sides using the **verified** principal kind and Clerk subject: + + ```sql + SELECT * FROM organization_identities + WHERE organization_id = :existing_organization_id + OR (principal_kind = :verified_kind AND clerk_subject = :verified_subject); + ``` + +4. Only if the row is unbound **and** the destination principal is unbound, insert + the explicitly reviewed mapping. Use bound SQL parameters with an operator + database client; the names below are placeholders, not values to paste: + + ```sql + INSERT INTO organization_identities + (principal_kind, clerk_subject, organization_id) + VALUES (:verified_kind, :verified_subject, :existing_organization_id); + ``` + + Unique constraints refuse competing principal/tenant mappings. Never use + `REPLACE`, delete a conflicting binding, or overwrite a live destination. If + the destination already has a tenant, stop for a separate reviewed merge that + checks project slug collisions and retains data ownership. Re-running a repair + should first confirm the exact mapping already exists, then make no change. +5. Re-run the audit. Verify project IDs and API-key behavior are unchanged, the + verified owner can read the data, and an unrelated session cannot. Retain a + private audit record of the approved mapping and verification. + +For rollback, stop dashboard writes and restore the known-good backup or revert +only a newly inserted mapping after verifying it is still exactly the reviewed +mapping and no new activity depends on it. Do not delete organizations or project +children. Do not roll back application code to string-derived authorization +without checking every compatibility field against its binding first. diff --git a/packages/api/drizzle/0012_mixed_monster_badoon.sql b/packages/api/drizzle/0012_mixed_monster_badoon.sql new file mode 100644 index 00000000..9583347a --- /dev/null +++ b/packages/api/drizzle/0012_mixed_monster_badoon.sql @@ -0,0 +1,24 @@ +CREATE TABLE `organization_identities` ( + `principal_kind` text NOT NULL, + `clerk_subject` text NOT NULL, + `organization_id` text NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "organization_identities_kind_check" CHECK("organization_identities"."principal_kind" IN ('user', 'organization')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `organization_identities_principal_idx` ON `organization_identities` (`principal_kind`,`clerk_subject`);--> statement-breakpoint +CREATE UNIQUE INDEX `organization_identities_organization_idx` ON `organization_identities` (`organization_id`); +--> statement-breakpoint +-- Bind only recognizable historical identities, preserving internal tenant IDs. +-- Shared default and unknown formats require verified operator recovery. +INSERT INTO organization_identities (principal_kind, clerk_subject, organization_id) +SELECT 'user', substr(clerk_org_id, 10), id FROM organizations +WHERE clerk_org_id GLOB 'personal:user_*' + AND length(substr(clerk_org_id, 15)) > 0 + AND substr(clerk_org_id, 15) NOT GLOB '*[^A-Za-z0-9]*'; +--> statement-breakpoint +INSERT INTO organization_identities (principal_kind, clerk_subject, organization_id) +SELECT 'organization', clerk_org_id, id FROM organizations +WHERE clerk_org_id GLOB 'org_*' + AND length(substr(clerk_org_id, 5)) > 0 + AND substr(clerk_org_id, 5) NOT GLOB '*[^A-Za-z0-9]*'; diff --git a/packages/api/drizzle/meta/0012_snapshot.json b/packages/api/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..e9763727 --- /dev/null +++ b/packages/api/drizzle/meta/0012_snapshot.json @@ -0,0 +1,2353 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "66acd192-0d3c-46c7-a95f-b86c3243f460", + "prevId": "b28b33af-3f81-41e3-8c2e-dc7ffa6910a4", + "tables": { + "agent_states": { + "name": "agent_states", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "latest_sequence": { + "name": "latest_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_states_project_id_state_key_idx": { + "name": "agent_states_project_id_state_key_idx", + "columns": [ + "project_id", + "state_key" + ], + "isUnique": true + }, + "agent_states_project_id_agent_id_idx": { + "name": "agent_states_project_id_agent_id_idx", + "columns": [ + "project_id", + "agent_id" + ], + "isUnique": false + }, + "agent_states_project_id_updated_at_idx": { + "name": "agent_states_project_id_updated_at_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "agent_states_project_id_latest_sequence_idx": { + "name": "agent_states_project_id_latest_sequence_idx", + "columns": [ + "project_id", + "latest_sequence" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_states_project_id_projects_id_fk": { + "name": "agent_states_project_id_projects_id_fk", + "tableFrom": "agent_states", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + }, + "api_keys_project_id_idx": { + "name": "api_keys_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_project_id_projects_id_fk": { + "name": "api_keys_project_id_projects_id_fk", + "tableFrom": "api_keys", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "capability_tokens": { + "name": "capability_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "capability_tokens_key_hash_idx": { + "name": "capability_tokens_key_hash_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + }, + "capability_tokens_project_id_idx": { + "name": "capability_tokens_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "capability_tokens_project_id_projects_id_fk": { + "name": "capability_tokens_project_id_projects_id_fk", + "tableFrom": "capability_tokens", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claim_evidence": { + "name": "claim_evidence", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_id": { + "name": "claim_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "json_path": { + "name": "json_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_value": { + "name": "expected_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "claim_evidence_project_id_claim_id_idx": { + "name": "claim_evidence_project_id_claim_id_idx", + "columns": [ + "project_id", + "claim_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "claim_evidence_project_id_projects_id_fk": { + "name": "claim_evidence_project_id_projects_id_fk", + "tableFrom": "claim_evidence", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "claim_evidence_claim_id_claims_id_fk": { + "name": "claim_evidence_claim_id_claims_id_fk", + "tableFrom": "claim_evidence", + "tableTo": "claims", + "columnsFrom": [ + "claim_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claim_verification_runs": { + "name": "claim_verification_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_id": { + "name": "claim_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "claim_verification_runs_project_id_claim_id_idx": { + "name": "claim_verification_runs_project_id_claim_id_idx", + "columns": [ + "project_id", + "claim_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "claim_verification_runs_project_id_projects_id_fk": { + "name": "claim_verification_runs_project_id_projects_id_fk", + "tableFrom": "claim_verification_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "claim_verification_runs_claim_id_claims_id_fk": { + "name": "claim_verification_runs_claim_id_claims_id_fk", + "tableFrom": "claim_verification_runs", + "tableTo": "claims", + "columnsFrom": [ + "claim_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claims": { + "name": "claims", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statement": { + "name": "statement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "claims_project_id_created_at_idx": { + "name": "claims_project_id_created_at_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + }, + "claims_project_id_subject_idx": { + "name": "claims_project_id_subject_idx", + "columns": [ + "project_id", + "subject_type", + "subject_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "claims_project_id_projects_id_fk": { + "name": "claims_project_id_projects_id_fk", + "tableFrom": "claims", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversation_tags": { + "name": "conversation_tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversation_tags_conversation_id_tag_idx": { + "name": "conversation_tags_conversation_id_tag_idx", + "columns": [ + "conversation_id", + "tag" + ], + "isUnique": true + }, + "conversation_tags_tag_idx": { + "name": "conversation_tags_tag_idx", + "columns": [ + "tag" + ], + "isUnique": false + }, + "conversation_tags_tag_conversation_id_idx": { + "name": "conversation_tags_tag_conversation_id_idx", + "columns": [ + "tag", + "conversation_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "conversation_tags_conversation_id_conversations_id_fk": { + "name": "conversation_tags_conversation_id_conversations_id_fk", + "tableFrom": "conversation_tags", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "conversations_project_id_created_at_idx": { + "name": "conversations_project_id_created_at_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + }, + "conversations_project_id_external_id_idx": { + "name": "conversations_project_id_external_id_idx", + "columns": [ + "project_id", + "external_id" + ], + "isUnique": false + }, + "conversations_project_id_updated_at_idx": { + "name": "conversations_project_id_updated_at_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "conversations_project_id_external_id_unique_idx": { + "name": "conversations_project_id_external_id_unique_idx", + "columns": [ + "project_id", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_domains": { + "name": "custom_domains", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verification_status": { + "name": "verification_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssl_enabled": { + "name": "ssl_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "custom_domains_domain_unique": { + "name": "custom_domains_domain_unique", + "columns": [ + "domain" + ], + "isUnique": true + }, + "custom_domains_project_id_idx": { + "name": "custom_domains_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "custom_domains_verification_status_idx": { + "name": "custom_domains_verification_status_idx", + "columns": [ + "verification_status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "custom_domains_project_id_projects_id_fk": { + "name": "custom_domains_project_id_projects_id_fk", + "tableFrom": "custom_domains", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idempotency_keys": { + "name": "idempotency_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idempotency_keys_project_id_key_idx": { + "name": "idempotency_keys_project_id_key_idx", + "columns": [ + "project_id", + "key" + ], + "isUnique": true + }, + "idempotency_keys_project_id_created_at_idx": { + "name": "idempotency_keys_project_id_created_at_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "idempotency_keys_project_id_projects_id_fk": { + "name": "idempotency_keys_project_id_projects_id_fk", + "tableFrom": "idempotency_keys", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "observation_type": { + "name": "observation_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + "conversation_id" + ], + "isUnique": false + }, + "messages_conversation_id_created_at_idx": { + "name": "messages_conversation_id_created_at_idx", + "columns": [ + "conversation_id", + "created_at" + ], + "isUnique": false + }, + "messages_parent_message_id_idx": { + "name": "messages_parent_message_id_idx", + "columns": [ + "parent_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_authorization_codes": { + "name": "oauth_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'S256'" + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_authorization_codes_code_hash_idx": { + "name": "oauth_authorization_codes_code_hash_idx", + "columns": [ + "code_hash" + ], + "isUnique": true + }, + "oauth_authorization_codes_expires_at_idx": { + "name": "oauth_authorization_codes_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_authorization_codes_project_id_projects_id_fk": { + "name": "oauth_authorization_codes_project_id_projects_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_clients": { + "name": "oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_secret_hash": { + "name": "client_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_types": { + "name": "grant_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"authorization_code\",\"refresh_token\"]'" + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_clients_created_at_idx": { + "name": "oauth_clients_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_id": { + "name": "access_token_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_refresh_tokens_token_hash_idx": { + "name": "oauth_refresh_tokens_token_hash_idx", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "oauth_refresh_tokens_client_id_idx": { + "name": "oauth_refresh_tokens_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_refresh_tokens_project_id_projects_id_fk": { + "name": "oauth_refresh_tokens_project_id_projects_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_identities": { + "name": "organization_identities", + "columns": { + "principal_kind": { + "name": "principal_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clerk_subject": { + "name": "clerk_subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_identities_principal_idx": { + "name": "organization_identities_principal_idx", + "columns": [ + "principal_kind", + "clerk_subject" + ], + "isUnique": true + }, + "organization_identities_organization_idx": { + "name": "organization_identities_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organization_identities_organization_id_organizations_id_fk": { + "name": "organization_identities_organization_id_organizations_id_fk", + "tableFrom": "organization_identities", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "organization_identities_kind_check": { + "name": "organization_identities_kind_check", + "value": "\"organization_identities\".\"principal_kind\" IN ('user', 'organization')" + } + } + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_days": { + "name": "retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_org_id_slug_idx": { + "name": "projects_org_id_slug_idx", + "columns": [ + "org_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "projects_org_id_organizations_id_fk": { + "name": "projects_org_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "projects_retention_days_range_check": { + "name": "projects_retention_days_range_check", + "value": "\"projects\".\"retention_days\" IS NULL OR (\"projects\".\"retention_days\" BETWEEN 1 AND 3650)" + } + } + }, + "rate_limits": { + "name": "rate_limits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_hash": { + "name": "api_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "window_start": { + "name": "window_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rate_limits_key_window_idx": { + "name": "rate_limits_key_window_idx", + "columns": [ + "api_key_hash", + "window_start" + ], + "isUnique": true + }, + "rate_limits_window_start_idx": { + "name": "rate_limits_window_start_idx", + "columns": [ + "window_start" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "state_events": { + "name": "state_events", + "columns": { + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "state_events_id_idx": { + "name": "state_events_id_idx", + "columns": [ + "id" + ], + "isUnique": true + }, + "state_events_project_id_sequence_idx": { + "name": "state_events_project_id_sequence_idx", + "columns": [ + "project_id", + "sequence" + ], + "isUnique": false + }, + "state_events_project_id_state_key_idx": { + "name": "state_events_project_id_state_key_idx", + "columns": [ + "project_id", + "state_key" + ], + "isUnique": false + }, + "state_events_project_id_created_at_idx": { + "name": "state_events_project_id_created_at_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "state_events_project_id_projects_id_fk": { + "name": "state_events_project_id_projects_id_fk", + "tableFrom": "state_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "state_leases": { + "name": "state_leases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fencing_token": { + "name": "fencing_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "renewed_at": { + "name": "renewed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at": { + "name": "released_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "state_leases_project_id_state_key_idx": { + "name": "state_leases_project_id_state_key_idx", + "columns": [ + "project_id", + "state_key" + ], + "isUnique": false + }, + "state_leases_project_id_expires_at_idx": { + "name": "state_leases_project_id_expires_at_idx", + "columns": [ + "project_id", + "expires_at" + ], + "isUnique": false + }, + "state_leases_active_unique_idx": { + "name": "state_leases_active_unique_idx", + "columns": [ + "project_id", + "state_key" + ], + "isUnique": true, + "where": "\"state_leases\".\"released_at\" IS NULL" + } + }, + "foreignKeys": { + "state_leases_project_id_projects_id_fk": { + "name": "state_leases_project_id_projects_id_fk", + "tableFrom": "state_leases", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "state_snapshots": { + "name": "state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "state_snapshots_project_id_state_key_sequence_idx": { + "name": "state_snapshots_project_id_state_key_sequence_idx", + "columns": [ + "project_id", + "state_key", + "sequence" + ], + "isUnique": true + }, + "state_snapshots_project_id_sequence_idx": { + "name": "state_snapshots_project_id_sequence_idx", + "columns": [ + "project_id", + "sequence" + ], + "isUnique": false + } + }, + "foreignKeys": { + "state_snapshots_project_id_projects_id_fk": { + "name": "state_snapshots_project_id_projects_id_fk", + "tableFrom": "state_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "state_tags": { + "name": "state_tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "state_tags_project_id_state_key_tag_idx": { + "name": "state_tags_project_id_state_key_tag_idx", + "columns": [ + "project_id", + "state_key", + "tag" + ], + "isUnique": true + }, + "state_tags_project_id_tag_idx": { + "name": "state_tags_project_id_tag_idx", + "columns": [ + "project_id", + "tag" + ], + "isUnique": false + } + }, + "foreignKeys": { + "state_tags_project_id_projects_id_fk": { + "name": "state_tags_project_id_projects_id_fk", + "tableFrom": "state_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_project_id_idx": { + "name": "webhooks_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "webhooks_project_id_active_idx": { + "name": "webhooks_project_id_active_idx", + "columns": [ + "project_id", + "active" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_project_id_projects_id_fk": { + "name": "webhooks_project_id_projects_id_fk", + "tableFrom": "webhooks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index 864e48ff..e8343199 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1784250132977, "tag": "0011_overrated_cerebro", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1789609136998, + "tag": "0012_mixed_monster_badoon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 79aa3402..b4694eea 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -16,6 +16,29 @@ export const organizations = sqliteTable("organizations", { createdAt: integer("created_at").notNull(), }); +// Durable authorization bindings. The compatibility clerk_org_id is not an auth key. +export const organizationIdentities = sqliteTable( + "organization_identities", + { + principalKind: text("principal_kind", { enum: ["user", "organization"] }).notNull(), + clerkSubject: text("clerk_subject").notNull(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id), + }, + (table) => [ + uniqueIndex("organization_identities_principal_idx").on( + table.principalKind, + table.clerkSubject, + ), + uniqueIndex("organization_identities_organization_idx").on(table.organizationId), + check( + "organization_identities_kind_check", + sql`${table.principalKind} IN ('user', 'organization')`, + ), + ], +); + // --------------------------------------------------------------------------- // projects // --------------------------------------------------------------------------- @@ -644,6 +667,7 @@ export const oauthRefreshTokens = sqliteTable( // --------------------------------------------------------------------------- export type Organization = InferSelectModel; +export type OrganizationIdentity = InferSelectModel; export type Project = InferSelectModel; export type ApiKey = InferSelectModel; export type Conversation = InferSelectModel; diff --git a/packages/api/src/lib/clerk-session.ts b/packages/api/src/lib/clerk-session.ts index e0dbcf19..5d2ea7a9 100644 --- a/packages/api/src/lib/clerk-session.ts +++ b/packages/api/src/lib/clerk-session.ts @@ -1,19 +1,6 @@ import { verifyToken } from "@clerk/backend"; import type { Bindings } from "../types"; -/** - * Clerk session JWT claim shape (subset used by the dashboard). - * See {@link https://clerk.com/docs/backend-requests/handling-manual-jwt}. - */ -export interface ClerkSessionClaims { - /** User id (Clerk `sub` claim). */ - sub?: string; - /** Active organization id (Clerk `o_id` claim) — present when an org is active. */ - o_id?: string; - /** Legacy/fallback claim name for the active org id. */ - org_id?: string; -} - /** * Origins permitted as JWT `azp` (authorized party). Clerk recommends passing * `authorizedParties` to defend against the subdomain-cookie-leaking attack. @@ -24,25 +11,48 @@ export const AUTHORIZED_PARTIES = [ "http://127.0.0.1:3000", ]; +export type DashboardPrincipal = { kind: "user" | "organization"; subject: string }; + export interface VerifiedSession { clerkUserId: string; orgId: string; + principal: DashboardPrincipal; +} + +/** Normalize verified claims. Malformed active organizations must never become personal. */ +export function normalizeSessionClaims(claims: Record): VerifiedSession { + if (typeof claims.sub !== "string" || !claims.sub.trim() || claims.sub !== claims.sub.trim()) { + throw new Error("token missing valid sub claim"); + } + const values: unknown[] = []; + for (const key of ["o_id", "org_id"]) { + if (Object.hasOwn(claims, key)) values.push(claims[key]); + } + if (Object.hasOwn(claims, "o")) { + if (!claims.o || typeof claims.o !== "object" || Array.isArray(claims.o)) { + throw new Error("malformed active organization"); + } + values.push((claims.o as Record).id); + } + if ( + values.some((v) => typeof v !== "string" || !v.trim() || v !== v.trim()) || + new Set(values).size > 1 + ) + throw new Error("invalid active organization"); + const activeOrg = values[0] as string | undefined; + // Reserved legacy/personal namespaces cannot be used as organization subjects. + if (activeOrg === "default" || activeOrg?.startsWith("personal:")) { + throw new Error("invalid active organization"); + } + return { + clerkUserId: claims.sub, + orgId: activeOrg ?? `personal:${claims.sub}`, + principal: activeOrg + ? { kind: "organization", subject: activeOrg } + : { kind: "user", subject: claims.sub }, + }; } -/** - * Verify a Clerk session token and return the verified claims. - * - * Returns the user id (`sub`) and active org id (`o_id`, falling back to - * `org_id`). When the session has NO active Clerk organization, the org id is - * derived per-user as `personal:${clerkUserId}` so that each personal account - * maps to its OWN internal organization. This avoids collapsing every org-less - * user into a single shared sentinel org (which would allow cross-tenant reads - * between unrelated personal accounts). Throws on any verification failure so - * callers can translate to a single 401. - * - * This is a thin seam over `@clerk/backend`'s `verifyToken`, extracted so it - * can be replaced wholesale in tests (see `lib/clerk-session.ts` mock). - */ export async function verifyDashboardSession( token: string, env: Pick, @@ -64,15 +74,5 @@ export async function verifyDashboardSession( throw new Error("token verification failed"); } - const claims = result as unknown as ClerkSessionClaims; - const clerkUserId = claims.sub; - if (!clerkUserId) { - throw new Error("token missing sub claim"); - } - - return { - clerkUserId, - // Per-user discriminator when no active Clerk org — never a shared default. - orgId: claims.o_id ?? claims.org_id ?? `personal:${clerkUserId}`, - }; + return normalizeSessionClaims(result as unknown as Record); } diff --git a/packages/api/src/middleware/clerk-dashboard-auth.ts b/packages/api/src/middleware/clerk-dashboard-auth.ts index 510ac090..90accff4 100644 --- a/packages/api/src/middleware/clerk-dashboard-auth.ts +++ b/packages/api/src/middleware/clerk-dashboard-auth.ts @@ -1,6 +1,7 @@ import { createMiddleware } from "hono/factory"; -import { verifyDashboardSession } from "../lib/clerk-session"; +import { type VerifiedSession, verifyDashboardSession } from "../lib/clerk-session"; import { errorResponse } from "../lib/helpers"; +import { IdentityConflictError, resolveTenant } from "../services/organizations"; import type { Bindings, Variables } from "../types"; /** @@ -64,7 +65,7 @@ export const clerkDashboardAuth = createMiddleware<{ Bindings: Bindings; Variabl return errorResponse(c, "UNAUTHORIZED", "Authentication required", 401); } - let session: { clerkUserId: string; orgId: string }; + let session: VerifiedSession; try { session = await verifyDashboardSession(token, c.env); } catch { @@ -72,6 +73,14 @@ export const clerkDashboardAuth = createMiddleware<{ Bindings: Bindings; Variabl return errorResponse(c, "UNAUTHORIZED", "Authentication required", 401); } + try { + c.set("tenantId", await resolveTenant(c.get("d1Db"), session.principal)); + } catch (error) { + if (error instanceof IdentityConflictError) { + return errorResponse(c, "IDENTITY_CONFLICT", error.message, 409); + } + throw error; + } c.set("clerkUserId", session.clerkUserId); c.set("orgId", session.orgId); diff --git a/packages/api/src/routes/analytics.ts b/packages/api/src/routes/analytics.ts index 6cebf447..10e1b32c 100644 --- a/packages/api/src/routes/analytics.ts +++ b/packages/api/src/routes/analytics.ts @@ -1,6 +1,6 @@ import { and, eq, gte, sql } from "drizzle-orm"; import { Hono } from "hono"; -import { conversations, messages, organizations, projects } from "../db/schema"; +import { conversations, messages, projects } from "../db/schema"; import { analyticsCacheKey } from "../lib/analytics-cache"; import { MS_PER_DAY } from "../lib/config"; import { errorResponse } from "../lib/helpers"; @@ -36,20 +36,13 @@ app.get("/:id/analytics", async (c) => { const db = c.get("db"); const projectId = c.req.param("id"); - // Resolve the session Clerk org id to the internal org id, then verify the - // project belongs to that org. - const clerkOrgId = c.get("orgId"); - const [org] = await db - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.clerkOrgId, clerkOrgId ?? "")) - .limit(1); + const tenantId = c.get("tenantId"); const [project] = await db .select({ orgId: projects.orgId }) .from(projects) .where(eq(projects.id, projectId)) .limit(1); - if (!project || !org || project.orgId !== org.id) { + if (!project || !tenantId || project.orgId !== tenantId) { return errorResponse(c, "NOT_FOUND", "Project not found", 404); } diff --git a/packages/api/src/routes/domains.ts b/packages/api/src/routes/domains.ts index ecf7d448..9d524be8 100644 --- a/packages/api/src/routes/domains.ts +++ b/packages/api/src/routes/domains.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; -import { organizations, projects } from "../db/schema"; +import { projects } from "../db/schema"; import type { AppContext } from "../lib/helpers"; import { errorResponse, parseJsonBody, validationError } from "../lib/helpers"; import { @@ -30,26 +30,13 @@ function handleDomainError(c: AppContext, e: unknown) { throw e; } -/** Resolve the session's Clerk org id to the internal org id. */ -async function resolveSessionOrgId(c: AppContext): Promise { - const db = c.get("db"); - const clerkOrgId = c.get("orgId"); - if (!clerkOrgId) return null; - const [org] = await db - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.clerkOrgId, clerkOrgId)) - .limit(1); - return org?.id ?? null; -} - /** * Verify the requested project belongs to the authenticated Clerk org. * Resolves the session Clerk org id to the internal org id before comparing. */ async function authorizeProjectOrg(c: AppContext, projectId: string): Promise { const db = c.get("db"); - const sessionInternalOrgId = await resolveSessionOrgId(c); + const sessionInternalOrgId = c.get("tenantId"); const [project] = await db .select({ orgId: projects.orgId }) .from(projects) @@ -119,7 +106,12 @@ router.post("/:projectId/domains", async (c) => { return errorResponse(c, "DOMAIN_EXISTS", "Domain already exists", 409); } if (e instanceof Error && e.message === "DOMAIN_UNAVAILABLE") { - return errorResponse(c, "DOMAIN_UNAVAILABLE", "Domain cannot be added. Please try again later.", 409); + return errorResponse( + c, + "DOMAIN_UNAVAILABLE", + "Domain cannot be added. Please try again later.", + 409, + ); } throw e; } diff --git a/packages/api/src/routes/oauth/index.ts b/packages/api/src/routes/oauth/index.ts index 094eff5a..494bb59b 100644 --- a/packages/api/src/routes/oauth/index.ts +++ b/packages/api/src/routes/oauth/index.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { organizations, projects } from "../../db/schema"; +import { projects } from "../../db/schema"; import { type AppContext, errorResponse } from "../../lib/helpers"; import { isGrantableScope, WILDCARD_SCOPE } from "../../lib/scopes"; import { clerkDashboardAuth } from "../../middleware/clerk-dashboard-auth"; @@ -276,29 +276,21 @@ app.post("/authorize/decision", clerkDashboardAuth, async (c) => { return errorResponse(c, "INVALID_SCOPE", "One or more requested scopes are not grantable", 400); } - // Verify the project belongs to the authenticated Clerk org. - const clerkOrgId = c.get("orgId"); + const tenantId = c.get("tenantId"); const clerkUserId = c.get("clerkUserId") ?? null; - if (!clerkOrgId) { - return errorResponse(c, "FORBIDDEN", "No active organization", 403); - } - const [[org], [project]] = await Promise.all([ - db - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.clerkOrgId, clerkOrgId)) - .limit(1), - db.select({ orgId: projects.orgId }).from(projects).where(eq(projects.id, projectId)).limit(1), - ]); - if (!org || !project || project.orgId !== org.id) { - // 404 (not 403) avoids leaking the existence of other orgs' projects. + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + if (!tenantId || !project || project.orgId !== tenantId) { return errorResponse(c, "NOT_FOUND", "Project not found", 404); } const code = await createAuthorizationCode(db, { clientId, projectId, - orgId: org.id, + orgId: tenantId, userId: clerkUserId, scopes, redirectUri, diff --git a/packages/api/src/routes/organizations/index.ts b/packages/api/src/routes/organizations/index.ts index 03192851..290c029f 100644 --- a/packages/api/src/routes/organizations/index.ts +++ b/packages/api/src/routes/organizations/index.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { z } from "zod"; import { errorResponse, parseJsonBody, validationError } from "../../lib/helpers"; -import { getOrganizationByClerkId, syncOrganization } from "../../services/organizations"; +import { getOrganizationById, syncOrganization } from "../../services/organizations"; import type { Bindings, Variables } from "../../types"; const router = new Hono<{ Bindings: Bindings; Variables: Variables }>(); @@ -31,8 +31,8 @@ const SyncOrgSchema = z.object({ */ router.post("/sync", async (c) => { // JWT org is required — never trust a client-supplied clerk_org_id. - const clerkOrgId = c.get("orgId"); - if (!clerkOrgId) { + const tenantId = c.get("tenantId"); + if (!tenantId) { return errorResponse(c, "UNAUTHORIZED", "Organization is required", 401); } @@ -45,12 +45,9 @@ router.post("/sync", async (c) => { } const db = c.get("db"); - const org = await syncOrganization(db, { - clerk_org_id: clerkOrgId, - name: parsed.data.name, - }); + const org = await syncOrganization(db, tenantId, parsed.data.name); - return c.json(org, org.created_at === org.updated_at ? 201 : 200); + return c.json(org, 200); }); // --------------------------------------------------------------------------- @@ -68,7 +65,9 @@ router.get("/:clerkOrgId", async (c) => { } const db = c.get("db"); - const org = await getOrganizationByClerkId(db, clerkOrgId); + const tenantId = c.get("tenantId"); + if (!tenantId) return errorResponse(c, "UNAUTHORIZED", "Missing tenant context", 401); + const org = await getOrganizationById(db, tenantId); if (!org) { return errorResponse(c, "NOT_FOUND", "Organization not found", 404); diff --git a/packages/api/src/routes/project-traces.ts b/packages/api/src/routes/project-traces.ts index a5fcf4c0..df2f2277 100644 --- a/packages/api/src/routes/project-traces.ts +++ b/packages/api/src/routes/project-traces.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { conversations, organizations, projects } from "../db/schema"; +import { conversations, projects } from "../db/schema"; import { errorResponse, parseLimitParam, parseOrderParam } from "../lib/helpers"; import * as tracesService from "../services/traces"; import type { Bindings, Variables } from "../types"; @@ -19,20 +19,13 @@ app.get("/:id/traces", async (c) => { const db = c.get("db"); const projectId = c.req.param("id"); - // Resolve the session Clerk org id to the internal org id, then verify the - // project belongs to that org (mirrors analytics.ts pattern). - const clerkOrgId = c.get("orgId"); - const [org] = await db - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.clerkOrgId, clerkOrgId ?? "")) - .limit(1); + const tenantId = c.get("tenantId"); const [project] = await db .select({ orgId: projects.orgId }) .from(projects) .where(eq(projects.id, projectId)) .limit(1); - if (!project || !org || project.orgId !== org.id) { + if (!project || !tenantId || project.orgId !== tenantId) { return errorResponse(c, "NOT_FOUND", "Project not found", 404); } @@ -66,20 +59,13 @@ app.get("/:id/traces/:traceId", async (c) => { const projectId = c.req.param("id"); const traceId = c.req.param("traceId"); - // Resolve the session Clerk org id to the internal org id, then verify the - // project belongs to that org (mirrors analytics.ts pattern). - const clerkOrgId = c.get("orgId"); - const [org] = await db - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.clerkOrgId, clerkOrgId ?? "")) - .limit(1); + const tenantId = c.get("tenantId"); const [project] = await db .select({ orgId: projects.orgId }) .from(projects) .where(eq(projects.id, projectId)) .limit(1); - if (!project || !org || project.orgId !== org.id) { + if (!project || !tenantId || project.orgId !== tenantId) { return errorResponse(c, "NOT_FOUND", "Project not found", 404); } diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index 073cca4f..30462807 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { organizations, projects } from "../db/schema"; +import { projects } from "../db/schema"; import { type AppContext, errorResponse, @@ -34,19 +34,6 @@ const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); // before comparing, so the check is correct across the two namespaces. // --------------------------------------------------------------------------- -/** Resolve the session's Clerk org id to the internal org id. */ -async function resolveSessionOrgId(c: AppContext): Promise { - const db = c.get("db"); - const clerkOrgId = c.get("orgId"); - if (!clerkOrgId) return null; - const [org] = await db - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.clerkOrgId, clerkOrgId)) - .limit(1); - return org?.id ?? null; -} - /** * Verify a project belongs to the authenticated Clerk org. * Returns null (authorized) or a 404 response. 404 (not 403) avoids leaking @@ -54,7 +41,7 @@ async function resolveSessionOrgId(c: AppContext): Promise { */ async function authorizeProjectOrg(c: AppContext, projectId: string): Promise { const db = c.get("db"); - const sessionInternalOrgId = await resolveSessionOrgId(c); + const sessionInternalOrgId = c.get("tenantId"); const [project] = await db .select({ orgId: projects.orgId }) .from(projects) @@ -86,7 +73,7 @@ app.post("/", projectCreationRateLimit, async (c) => { // if it somehow didn't, refuse rather than fall back to a shared org (#277). const name = parsed.data.name; const slug = parsed.data.slug; - const sessionOrgId = c.get("orgId"); + const sessionOrgId = c.get("tenantId"); if (!sessionOrgId) { return errorResponse(c, "UNAUTHORIZED", "Missing organization context", 401); } @@ -110,7 +97,7 @@ app.get("/", async (c) => { const db = c.get("db"); // org_id is taken from the verified Clerk session, NOT the query string. // Refuse rather than fall back to a shared org when it is missing (#277). - const sessionOrgId = c.get("orgId"); + const sessionOrgId = c.get("tenantId"); if (!sessionOrgId) { return errorResponse(c, "UNAUTHORIZED", "Missing organization context", 401); } @@ -126,9 +113,10 @@ app.get("/", async (c) => { app.get("/by-slug/:slug", async (c) => { const db = c.get("db"); const slug = c.req.param("slug"); - const sessionInternalOrgId = await resolveSessionOrgId(c); + const sessionInternalOrgId = c.get("tenantId"); - const project = await getProjectBySlug(db, slug); + if (!sessionInternalOrgId) return errorResponse(c, "UNAUTHORIZED", "Missing tenant context", 401); + const project = await getProjectBySlug(db, slug, sessionInternalOrgId); if (!project || !sessionInternalOrgId || project.org_id !== sessionInternalOrgId) { return errorResponse(c, "NOT_FOUND", "Project not found", 404); @@ -186,7 +174,7 @@ app.get("/:id/conversations/:convId/messages", async (c) => { app.get("/:id", async (c) => { const db = c.get("db"); const projectId = c.req.param("id"); - const sessionInternalOrgId = await resolveSessionOrgId(c); + const sessionInternalOrgId = c.get("tenantId"); const project = await getProjectById(db, projectId); diff --git a/packages/api/src/services/organizations.ts b/packages/api/src/services/organizations.ts index f439aee9..5f80e8d0 100644 --- a/packages/api/src/services/organizations.ts +++ b/packages/api/src/services/organizations.ts @@ -1,16 +1,9 @@ -// --------------------------------------------------------------------------- -// Organizations service — Business logic for Clerk organization sync -// --------------------------------------------------------------------------- - import { eq } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import { organizations } from "../db/schema"; +import type { DashboardPrincipal } from "../lib/clerk-session"; import { generateId } from "../lib/id"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - export interface Organization { id: string; clerk_org_id: string; @@ -19,104 +12,93 @@ export interface Organization { updated_at: number | null; } -export interface SyncOrganizationInput { - clerk_org_id: string; - name: string; -} - -// --------------------------------------------------------------------------- -// Organization Sync -// --------------------------------------------------------------------------- - -/** - * Sync a Clerk organization to the local database. - * If the organization exists, updates the name if changed. - * If not, creates a new organization record. - * - * Callers must pass the verified session org id (`c.get("orgId")`), never a - * client-supplied `clerk_org_id`. - * - * @param db - Database instance - * @param input - Clerk organization data (session-bound) - * @returns Organization record with timestamps - */ -export async function syncOrganization( - db: DrizzleD1Database, - input: SyncOrganizationInput, -): Promise { - const { clerk_org_id, name } = input; - const now = Date.now(); - - // Check if org already exists - const existing = await db - .select() - .from(organizations) - .where(eq(organizations.clerkOrgId, clerk_org_id)) - .get(); - - if (existing) { - // Update name if it has changed - if (existing.name !== name) { - await db.update(organizations).set({ name }).where(eq(organizations.id, existing.id)); - } - - return { - id: existing.id, - clerk_org_id: existing.clerkOrgId, - name: existing.name, - created_at: existing.createdAt, - updated_at: now, - }; +export class IdentityConflictError extends Error { + constructor() { + super("Workspace identity requires verified recovery"); } +} - // Create new org - const orgId = generateId(); - await db.insert(organizations).values({ - id: orgId, - clerkOrgId: clerk_org_id, - name, - createdAt: now, - }); +/** Resolve only persisted bindings; compatibility fields never grant access. */ +export async function resolveTenant( + db: D1Database, + principal: DashboardPrincipal, +): Promise { + const lookup = () => + db + .prepare(`SELECT i.organization_id AS id FROM organization_identities i + JOIN organizations o ON o.id = i.organization_id + WHERE i.principal_kind = ? AND i.clerk_subject = ?`) + .bind(principal.kind, principal.subject) + .first<{ id: string }>(); + const existing = await lookup(); + if (existing) return existing.id; - return { - id: orgId, - clerk_org_id, - name, - created_at: now, - updated_at: now, - }; + const id = generateId(); + const compatibilityId = + principal.kind === "user" ? `personal:${principal.subject}` : principal.subject; + // D1 batch is transactional. Only the request that inserts this candidate can + // bind it. A concurrent winner is re-read below; an unbound legacy match is + // quarantined, never adopted or replaced. No network calls or data transfers. + await db.batch([ + db + .prepare(`INSERT INTO organizations (id, clerk_org_id, name, created_at) + SELECT ?, ?, ?, ? WHERE NOT EXISTS ( + SELECT 1 FROM organizations WHERE clerk_org_id = ? + ) AND NOT EXISTS ( + SELECT 1 FROM organization_identities WHERE principal_kind = ? AND clerk_subject = ? + )`) + .bind( + id, + compatibilityId, + principal.kind === "user" ? "Personal" : compatibilityId, + Date.now(), + compatibilityId, + principal.kind, + principal.subject, + ), + db + .prepare(`INSERT INTO organization_identities (principal_kind, clerk_subject, organization_id) + SELECT ?, ?, id FROM organizations WHERE id = ?`) + .bind(principal.kind, principal.subject, id), + ]); + const resolved = await lookup(); + if (!resolved) throw new IdentityConflictError(); + return resolved.id; } -// --------------------------------------------------------------------------- -// Organization Lookup -// --------------------------------------------------------------------------- - -/** - * Get an organization by Clerk org ID. - * - * @param db - Database instance - * @param clerkOrgId - Clerk organization ID - * @returns Organization record or null if not found - */ -export async function getOrganizationByClerkId( +/** Sync display name only, on the tenant already resolved by authentication. */ +export async function syncOrganization( db: DrizzleD1Database, - clerkOrgId: string, -): Promise { - const org = await db - .select() - .from(organizations) - .where(eq(organizations.clerkOrgId, clerkOrgId)) - .get(); - - if (!org) { - return null; - } - + tenantId: string, + name: string, +): Promise { + const [org] = await db + .update(organizations) + .set({ name }) + .where(eq(organizations.id, tenantId)) + .returning(); + if (!org) throw new IdentityConflictError(); return { id: org.id, clerk_org_id: org.clerkOrgId, name: org.name, created_at: org.createdAt, - updated_at: null, + updated_at: Date.now(), }; } + +export async function getOrganizationById( + db: DrizzleD1Database, + tenantId: string, +): Promise { + const org = await db.select().from(organizations).where(eq(organizations.id, tenantId)).get(); + return org + ? { + id: org.id, + clerk_org_id: org.clerkOrgId, + name: org.name, + created_at: org.createdAt, + updated_at: null, + } + : null; +} diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 62c0568f..c02e7913 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -18,7 +18,6 @@ import { messages, oauthAuthorizationCodes, oauthRefreshTokens, - organizations, projects, rateLimits, stateEvents, @@ -107,82 +106,20 @@ export interface CreateProjectResult { }; } -// --------------------------------------------------------------------------- -// Organization Management -// --------------------------------------------------------------------------- - -/** - * Get or create an organization by Clerk org ID. - */ -export async function getOrCreateOrg( - db: DrizzleD1Database, - clerkOrgId: string, -): Promise<{ id: string; clerkOrgId: string; name: string; createdAt: number }> { - const existing = await db - .select() - .from(organizations) - .where(eq(organizations.clerkOrgId, clerkOrgId)) - .get(); - - if (existing) { - return existing; - } - - const orgId = generateId(); - const now = Date.now(); - const orgName = clerkOrgId; - - await db.insert(organizations).values({ - id: orgId, - clerkOrgId, - name: orgName, - createdAt: now, - }); - - return { id: orgId, clerkOrgId, name: orgName, createdAt: now }; -} - -/** - * Get organization by Clerk org ID. - */ -export async function getOrgByClerkId( - db: DrizzleD1Database, - clerkOrgId: string, -): Promise<{ id: string; clerkOrgId: string; name: string; createdAt: number } | null> { - return ( - (await db.select().from(organizations).where(eq(organizations.clerkOrgId, clerkOrgId)).get()) ?? - null - ); -} - -// --------------------------------------------------------------------------- -// Project CRUD Operations -// --------------------------------------------------------------------------- - -/** - * Create a new project with a default API key. - * - * `clerkOrgId` is required at the type level: callers MUST pass the org id - * resolved from the verified Clerk session (see `verifyDashboardSession`, - * which derives `personal:` for org-less sessions). A silent - * fallback to a shared "default" org previously let any caller that omitted - * the org id resurrect the #254 cross-tenant leak — omitting it is now a - * compile-time error (#277). - */ +/** Create a project in the resolved internal tenant. */ export async function createProject( db: DrizzleD1Database, name: string, slug: string, - clerkOrgId: string, + tenantId: string, ): Promise { - const org = await getOrCreateOrg(db, clerkOrgId); const now = Date.now(); // Check slug uniqueness within the org const existing = await db .select() .from(projects) - .where(and(eq(projects.orgId, org.id), eq(projects.slug, slug))) + .where(and(eq(projects.orgId, tenantId), eq(projects.slug, slug))) .get(); if (existing) { @@ -193,7 +130,7 @@ export async function createProject( const projectId = generateId(); await db.insert(projects).values({ id: projectId, - orgId: org.id, + orgId: tenantId, name, slug, createdAt: now, @@ -206,7 +143,7 @@ export async function createProject( return { project: { id: projectId, - org_id: org.id, + org_id: tenantId, name, slug, created_at: now, @@ -225,34 +162,12 @@ export async function createProject( /** * List projects for an organization with active key counts. * - * `clerkOrgId` is required at the type level — see `createProject` (#277). + * The tenant is resolved once by authentication. */ export async function listProjects( db: DrizzleD1Database, - clerkOrgId: string, + tenantId: string, ): Promise { - const org = await getOrgByClerkId(db, clerkOrgId); - - if (!org) { - // Org rows are created lazily (see getOrCreateOrg), so a missing row is - // legitimate for an org that has not created its first project yet — this - // must stay a 200 with an empty list, not an error (#388). - // - // It is ALSO how orphaned tenants look: #276 changed org-id derivation and - // silently stranded every project written under the old scheme. The two are - // indistinguishable from the response, so log the id shape to tell them - // apart. The value is a Clerk org id or `personal:`, not a secret. - console.warn( - JSON.stringify({ - event: "org_not_found", - clerk_org_id: clerkOrgId, - kind: clerkOrgId.startsWith("personal:") ? "personal" : "clerk_org", - msg: "Session org resolved to no organization row; returning empty project list.", - }), - ); - return []; - } - const baseRows = await db .select({ id: projects.id, @@ -262,7 +177,7 @@ export async function listProjects( created_at: projects.createdAt, }) .from(projects) - .where(eq(projects.orgId, org.id)); + .where(eq(projects.orgId, tenantId)); if (baseRows.length === 0) { return []; @@ -365,8 +280,13 @@ export async function getProjectById( export async function getProjectBySlug( db: DrizzleD1Database, slug: string, + tenantId: string, ): Promise { - const project = await db.select().from(projects).where(eq(projects.slug, slug)).get(); + const project = await db + .select() + .from(projects) + .where(and(eq(projects.slug, slug), eq(projects.orgId, tenantId))) + .get(); if (!project) { return null; diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index ef60ba33..551a27f7 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -33,6 +33,8 @@ export type Variables = { capabilityScopes: string[]; /** Clerk active organization id (o_id claim) — set by clerkDashboardAuth on dashboard-management routes. */ orgId?: string; + /** Stable internal organization ID resolved from the persisted principal binding. */ + tenantId?: string; /** Clerk user id (sub claim) — set by clerkDashboardAuth on dashboard-management routes. */ clerkUserId?: string; }; diff --git a/packages/api/test/clerk-jwt.ts b/packages/api/test/clerk-jwt.ts index cb8350a2..3dfc10ad 100644 --- a/packages/api/test/clerk-jwt.ts +++ b/packages/api/test/clerk-jwt.ts @@ -40,6 +40,8 @@ export interface TestSessionOptions { azp?: string; /** Seconds until expiry. Defaults to 3600. */ expiresInSec?: number; + /** Override signed claims to exercise Clerk version and malformed-claim cases. */ + claims?: Record; } /** @@ -70,6 +72,8 @@ export async function signTestSessionToken(opts: TestSessionOptions = {}): Promi payload.o_role = "org:admin"; } + Object.assign(payload, opts.claims); + const encHeader = base64Url(strToBytes(JSON.stringify(header))); const encPayload = base64Url(strToBytes(JSON.stringify(payload))); const signingInput = `${encHeader}.${encPayload}`; diff --git a/packages/api/test/domains.test.ts b/packages/api/test/domains.test.ts index dd33d1be..7de5ae40 100644 --- a/packages/api/test/domains.test.ts +++ b/packages/api/test/domains.test.ts @@ -843,6 +843,9 @@ async function insertOtherOrgProject(label: string): Promise { const orgRow = await env.DB.prepare("SELECT id FROM organizations WHERE clerk_org_id = ?") .bind(OTHER_ORG_ID) .first<{ id: string }>(); + await env.DB.prepare( + "INSERT OR IGNORE INTO organization_identities (principal_kind, clerk_subject, organization_id) VALUES ('organization', ?, ?)", + ).bind(OTHER_ORG_ID, orgRow?.id).run(); const projectId = `proj_other_${label}_${now}`; await env.DB.prepare( "INSERT INTO projects (id, org_id, name, slug, created_at) VALUES (?, ?, ?, ?, ?)", diff --git a/packages/api/test/organization-identity.test.ts b/packages/api/test/organization-identity.test.ts new file mode 100644 index 00000000..e81b1639 --- /dev/null +++ b/packages/api/test/organization-identity.test.ts @@ -0,0 +1,180 @@ +import { env, SELF } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { sessionCookie, signTestSessionToken } from "./clerk-jwt"; +import { applyIdentityMigration, applyMigrations, seedProject } from "./setup"; + +async function headers(userId: string, claims: Record = {}) { + return { + Cookie: sessionCookie(await signTestSessionToken({ userId, noOrg: true, claims })), + "Content-Type": "application/json", + }; +} +async function list(h: Record) { + const response = await SELF.fetch("http://localhost/api/v1/projects", { headers: h }); + expect(response.status).toBe(200); + return response.json<{ data: { id: string; org_id: string }[] }>(); +} +async function create(h: Record, slug: string) { + const response = await SELF.fetch("http://localhost/api/v1/projects", { + method: "POST", + headers: h, + body: JSON.stringify({ name: slug, slug }), + }); + expect(response.status).toBe(201); + return response.json<{ project: { id: string; org_id: string }; api_key: { key: string } }>(); +} + +describe("persisted organization identity", () => { + beforeEach(async () => { + await applyMigrations(); + await seedProject(); + }); + + it("provisions once on concurrent first reads, without needing a project or sync write", async () => { + const h = await headers("user_FirstRead"); + await Promise.all(Array.from({ length: 5 }, () => list(h))); + const bindings = await env.DB.prepare( + "SELECT organization_id FROM organization_identities WHERE clerk_subject = ?", + ) + .bind("user_FirstRead") + .all(); + expect(bindings.results).toHaveLength(1); + const orgs = await env.DB.prepare("SELECT id FROM organizations WHERE clerk_org_id = ?") + .bind("personal:user_FirstRead") + .all(); + expect(orgs.results).toHaveLength(1); + }); + + it("keeps Personal projects across org attach and return, without sharing them", async () => { + const personal = await headers("user_Owner"); + const team = await headers("user_Owner", { o: { id: "org_Team" } }); + const other = await headers("user_Member", { org_id: "org_Team" }); + const a = await create(personal, "same-slug"); + expect((await list(team)).data).toEqual([]); + const b = await create(team, "same-slug"); + expect(b.project.org_id).not.toBe(a.project.org_id); + expect((await list(other)).data.map((p) => p.id)).toEqual([b.project.id]); + expect((await list(personal)).data.map((p) => p.id)).toEqual([a.project.id]); + for (const [h, id] of [ + [personal, a.project.id], + [team, b.project.id], + ] as const) { + const res = await SELF.fetch("http://localhost/api/v1/projects/by-slug/same-slug", { + headers: h, + }); + expect(res.status).toBe(200); + expect((await res.json<{ id: string }>()).id).toBe(id); + } + const denied = await SELF.fetch(`http://localhost/api/v1/projects/${a.project.id}`, { + headers: other, + }); + expect(denied.status).toBe(404); + }); + + it("uses a persisted binding even after compatibility identity drift", async () => { + const h = await headers("user_Stable"); + const { project } = await create(h, "stable"); + await env.DB.prepare( + "UPDATE organizations SET clerk_org_id = 'historical-display-value' WHERE id = ?", + ) + .bind(project.org_id) + .run(); + expect((await list(h)).data.map((p) => p.id)).toEqual([project.id]); + const next = await create(h, "still-stable"); + expect(next.project.org_id).toBe(project.org_id); + for (const suffix of ["analytics", "domains", "traces"]) { + const response = await SELF.fetch( + `http://localhost/api/v1/projects/${project.id}/${suffix}`, + { headers: h }, + ); + expect(response.status).toBe(200); + } + const sync = await SELF.fetch("http://localhost/api/v1/organizations/sync", { + method: "POST", + headers: h, + body: JSON.stringify({ name: "Updated" }), + }); + expect(sync.status).toBe(200); + expect(await sync.json<{ id: string; name: string }>()).toMatchObject({ + id: project.org_id, + name: "Updated", + }); + }); + + it.each([ + { o: {} }, + { o: null }, + { o: { id: "" } }, + { o_id: 123 }, + { o: { id: "org_A" }, org_id: "org_B" }, + { o_id: "default" }, + { o_id: "personal:user_Victim" }, + ])("rejects malformed/conflicting signed claims without provisioning: %j", async (claims) => { + const response = await SELF.fetch("http://localhost/api/v1/projects", { + headers: await headers("user_Invalid", claims), + }); + expect(response.status).toBe(401); + expect( + await env.DB.prepare( + "SELECT * FROM organization_identities WHERE clerk_subject = 'user_Invalid'", + ).first(), + ).toBeNull(); + }); + + it("reports an unbound exact legacy match instead of silently replacing or adopting it", async () => { + await env.DB.prepare( + "INSERT INTO organizations VALUES ('legacy', 'personal:user_Legacy', 'Legacy', 1)", + ).run(); + const response = await SELF.fetch("http://localhost/api/v1/projects", { + headers: await headers("user_Legacy"), + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ error: { code: "IDENTITY_CONFLICT" } }); + expect( + await env.DB.prepare( + "SELECT * FROM organization_identities WHERE organization_id = 'legacy'", + ).first(), + ).toBeNull(); + }); + + it("migrates existing personal and team rows in place, quarantining ambiguous legacy rows", async () => { + // Recreate the pre-migration state, then execute the actual committed SQL. + await env.DB.prepare("DROP TABLE organization_identities").run(); + for (const [id, external] of [ + ["personal-old", "personal:user_Migrated"], + ["team-old", "org_Migrated"], + ["shared-old", "default"], + ["unknown-old", "personal:"], + ["malformed-old", "org_bad-id"], + ]) { + await env.DB.prepare("INSERT INTO organizations VALUES (?, ?, 'Old', 1)") + .bind(id, external) + .run(); + await env.DB.prepare( + "INSERT INTO projects (id, org_id, name, slug, created_at) VALUES (?, ?, 'Old', ?, 1)", + ) + .bind(`project-${id}`, id, `slug-${id}`) + .run(); + } + await applyIdentityMigration(); + const personal = await headers("user_Migrated"); + expect((await list(personal)).data).toEqual([ + expect.objectContaining({ id: "project-personal-old", org_id: "personal-old" }), + ]); + const team = await headers("user_Migrated", { o: { id: "org_Migrated" } }); + expect((await list(team)).data).toEqual([ + expect.objectContaining({ id: "project-team-old", org_id: "team-old" }), + ]); + expect((await list(await headers("user_Unrelated"))).data).toEqual([]); + for (const id of ["shared-old", "unknown-old", "malformed-old"]) { + expect( + await env.DB.prepare("SELECT * FROM organization_identities WHERE organization_id = ?") + .bind(id) + .first(), + ).toBeNull(); + expect( + await env.DB.prepare("SELECT id FROM projects WHERE org_id = ?").bind(id).first(), + ).not.toBeNull(); + } + }); +}); diff --git a/packages/api/test/setup.ts b/packages/api/test/setup.ts index c0b84e2e..d5dfcc22 100644 --- a/packages/api/test/setup.ts +++ b/packages/api/test/setup.ts @@ -1,4 +1,11 @@ import { env } from "cloudflare:test"; +import identityMigration from "../drizzle/0012_mixed_monster_badoon.sql?raw"; + +export async function applyIdentityMigration(): Promise { + for (const sql of identityMigration.split("--> statement-breakpoint")) { + if (sql.trim()) await env.DB.prepare(sql).run(); + } +} // Individual DDL statements extracted from drizzle/0000_natural_hydra.sql. // We run them one by one using prepare().run() to avoid multi-statement issues. @@ -327,6 +334,10 @@ export async function applyMigrations(): Promise { for (const stmt of DDL_STATEMENTS) { await env.DB.prepare(stmt).run(); } + const identities = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'organization_identities'", + ).first(); + if (!identities) await applyIdentityMigration(); } export async function seedProject(): Promise { @@ -355,6 +366,7 @@ export async function seedProject(): Promise { "conversations", "api_keys", "projects", + "organization_identities", "organizations", "rate_limits", ]) { @@ -367,6 +379,12 @@ export async function seedProject(): Promise { .bind(TEST_ORG_ID, "clerk_test_org_001", "Test Org", now) .run(); + await env.DB.prepare( + "INSERT INTO organization_identities (principal_kind, clerk_subject, organization_id) VALUES ('organization', ?, ?)", + ) + .bind("clerk_test_org_001", TEST_ORG_ID) + .run(); + await env.DB.prepare( `INSERT OR IGNORE INTO projects (id, org_id, name, slug, created_at) VALUES (?, ?, ?, ?, ?)`, ) diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 3e39748c..d8240198 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -42,6 +42,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "astro": "^7.0.0", + "happy-dom": "^20.14.5", "tailwindcss": "^4", "typescript": "^5" }, diff --git a/packages/dashboard/src/components/app-shell.tsx b/packages/dashboard/src/components/app-shell.tsx index 5109688b..222841fa 100644 --- a/packages/dashboard/src/components/app-shell.tsx +++ b/packages/dashboard/src/components/app-shell.tsx @@ -1,10 +1,9 @@ "use client"; -import { SignIn, UserButton, useAuth, useOrganization, useUser } from "@clerk/react"; +import { SignIn, UserButton, useAuth, useUser } from "@clerk/react"; import { ArrowUpRight, BookOpen, - Buildings, CaretDown, ChartLine, ChatCircle, @@ -22,11 +21,11 @@ import { } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; import { useTheme } from "next-themes"; -import { type ReactNode, useEffect, useId, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { LogoMark } from "@/components/logo-mark"; import { ProjectScopeProvider, useProjectScope } from "@/components/project-scope"; -import { useOrganizationsList } from "@/hooks/_use-organizations-list"; +import { WorkspaceSwitcher } from "@/components/workspace-switcher"; import { SESSION_EXPIRED_EVENT } from "@/lib/api"; import { TUI_HOST } from "@/lib/site"; @@ -257,192 +256,6 @@ function SidebarInner({ ); } -/** - * Pick which Clerk membership to auto-activate when the session has no active - * org. Prefer a membership named "Default Organization" (historical prod name), - * else the first membership. Stable ids only — no array identity in deps. - */ -function pickAutoActivateOrgId( - orgs: { id: string; name: string }[], -): string | null { - if (orgs.length === 0) return null; - const preferred = orgs.find( - (o) => o.name.trim().toLowerCase() === "default organization", - ); - return preferred?.id ?? orgs[0].id; -} - -/** - * SidebarOrgScope — the active-organization switcher above the project scope. - * - * The org id is load-bearing for every project-scoped read (the API derives it - * from the session's `o_id` claim), so it needs to be both visible and - * selectable — an org mismatch otherwise presents as an empty account with no - * way to diagnose or correct it (#387). - * - * Switching orgs mints a new session token with a different `o_id`. Rather than - * refetch each project-scoped cache by hand, reload once so every consumer - * re-reads under the new org. - */ -function SidebarOrgScope() { - const { organizations, isLoaded, setActive } = useOrganizationsList(); - const { isLoaded: isActiveOrgLoaded, organization: activeOrg } = useOrganization(); - const [switching, setSwitching] = useState(false); - // The shell mounts this twice (desktop sidebar + mobile drawer), so a literal - // id would collide and both labels would resolve to the first select. - const selectId = useId(); - - // Prefer Default Organization when present; otherwise first membership. - // Stable primitives only — Clerk membership arrays change identity every render. - const membershipKey = organizations.map((o) => `${o.id}\0${o.name}`).join("\n"); - const autoOrgId = pickAutoActivateOrgId(organizations); - const autoOrgName = - organizations.find((o) => o.id === autoOrgId)?.name ?? autoOrgId ?? ""; - - // Clerk sessions start on the personal account (no `o_id` claim) even for a - // user who belongs to orgs. AgentState scopes ALL data to orgs, so that state - // renders an empty dashboard while projects sit under the real clerk_org_id - // (#387/#389). Activate a membership when none is active so the user lands on - // their data instead of an empty personal scope. - useEffect(() => { - // Wait for BOTH hooks: `useOrganization` reports `activeOrg === undefined` - // while loading and `null` only once loaded with no active org. Gating on - // its own `isLoaded` avoids firing during that window and reloading a user - // who actually has an active org. - if (!isLoaded || !isActiveOrgLoaded || !setActive) return; - if (activeOrg || !autoOrgId) return; - - // Guard against reload loops: only one auto-activate attempt per target org - // per tab session. Do NOT use a permanent boolean — that blocked retries - // forever after a failed sole-org activation (#391 guard). - const GUARD = `agentstate:auto-activated-org:${autoOrgId}`; - try { - // Clear the legacy boolean guard from #391 so stuck tabs can recover. - sessionStorage.removeItem("agentstate:auto-activated-org"); - if (sessionStorage.getItem(GUARD) === "1") return; - sessionStorage.setItem(GUARD, "1"); - } catch { - // Storage unavailable — proceed relying on the post-reload activeOrg check. - } - - void setActive({ organization: autoOrgId }) - .then(async () => { - // Best-effort: ensure local organizations row exists for this Clerk org - // (name sync). Ignore failures — listProjects only needs clerk_org_id. - try { - await fetch("/api/v1/organizations/sync", { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clerk_org_id: autoOrgId, - name: autoOrgName || autoOrgId, - }), - }); - } catch { - // non-fatal - } - window.location.reload(); - }) - .catch(() => { - // Activation failed: clear the guard so a later attempt can retry, and - // do not reload since nothing changed. - try { - sessionStorage.removeItem(GUARD); - } catch { - // ignore — nothing to clean up if storage is unavailable - } - }); - // membershipKey captures org list changes without array identity churn. - }, [ - isLoaded, - isActiveOrgLoaded, - setActive, - activeOrg, - autoOrgId, - autoOrgName, - membershipKey, - ]); - - if (!isLoaded) { - return ( -
- - ); - } - - if (organizations.length === 0) { - return ( - - ); - } - - const handleChange = async (orgId: string) => { - if (!setActive || orgId === activeOrg?.id) return; - setSwitching(true); - try { - await setActive({ organization: orgId }); - window.location.reload(); - } catch { - setSwitching(false); - toast.error("Could not switch organization. Please try again."); - } - }; - - return ( -
- - {!activeOrg && ( -

- No organization selected — projects are hidden until you pick one. -

- )} -
- - - -
-
- ); -} - /** * SidebarProjectScope — the active-project switcher under the logo. Reads/writes * the shared ProjectScope so it controls every project-scoped page at once. @@ -534,7 +347,7 @@ export function AppShell({ children }: { children: ReactNode }) { ~/{TUI_HOST}
- + @@ -572,7 +385,7 @@ export function AppShell({ children }: { children: ReactNode }) { - + setMenuOpen(false)} /> diff --git a/packages/dashboard/src/components/workspace-switcher.tsx b/packages/dashboard/src/components/workspace-switcher.tsx new file mode 100644 index 00000000..f284281d --- /dev/null +++ b/packages/dashboard/src/components/workspace-switcher.tsx @@ -0,0 +1,90 @@ +import { useOrganization } from "@clerk/react"; +import { Buildings, CaretDown } from "@phosphor-icons/react"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { useOrganizationsList } from "@/hooks/_use-organizations-list"; + +/** + * WorkspaceSwitcher — the active-organization switcher above the project scope. + * + * The org id is load-bearing for every project-scoped read (the API derives it + * from the verified session), so it needs to be both visible and + * selectable — an org mismatch otherwise presents as an empty account with no + * way to diagnose or correct it (#387). + * + * Switching orgs mints a new session token with a different `o_id`. Rather than + * refetch each project-scoped cache by hand, reload once so every consumer + * re-reads under the new org. + */ +export function WorkspaceSwitcher() { + const { organizations, isLoaded, setActive } = useOrganizationsList(); + const { isLoaded: isActiveOrgLoaded, organization: activeOrg } = useOrganization(); + const [switching, setSwitching] = useState(false); + // The shell mounts this twice (desktop sidebar + mobile drawer), so a literal + // id would collide and both labels would resolve to the first select. + const selectId = useId(); + + // Personal is a real workspace. Never auto-select a membership: doing so + // hides personal projects and makes an explicit return to Personal impossible. + if (!isLoaded || !isActiveOrgLoaded) { + return ( +
+ + ); + } + + const handleChange = async (orgId: string) => { + if (!setActive || orgId === (activeOrg?.id ?? "")) return; + setSwitching(true); + try { + await setActive({ organization: orgId || null }); + window.location.reload(); + } catch { + setSwitching(false); + toast.error("Could not switch organization. Please try again."); + } + }; + + return ( +
+ + {!activeOrg && ( +

+ Personal projects are private to you. Select an organization to view its projects. +

+ )} +
+ + + +
+
+ ); +} + diff --git a/packages/dashboard/src/lib/workspace-switcher.test.tsx b/packages/dashboard/src/lib/workspace-switcher.test.tsx new file mode 100644 index 00000000..a88dad93 --- /dev/null +++ b/packages/dashboard/src/lib/workspace-switcher.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +const setActive = mock(async (_input: { organization: string | null }) => {}); +let activeOrg: { id: string; name: string } | null = null; +let memberships = [ + { id: "org_first", name: "First team" }, + { id: "org_default", name: "Default Organization" }, +]; + +mock.module("@clerk/react", () => ({ + useOrganization: () => ({ isLoaded: true, organization: activeOrg }), +})); +mock.module("@/hooks/_use-organizations-list", () => ({ + useOrganizationsList: () => ({ organizations: memberships, isLoaded: true, setActive }), +})); + +const { WorkspaceSwitcher } = await import("../components/workspace-switcher"); +let window: Window; +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + window = new Window({ url: "http://localhost:4321/dashboard/" }); + Object.assign(globalThis, { + window, + document: window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + setActive.mockClear(); + activeOrg = null; + memberships = [ + { id: "org_first", name: "First team" }, + { id: "org_default", name: "Default Organization" }, + ]; +}); + +afterEach(async () => { + await act(async () => root.unmount()); + window.happyDOM.abort(); +}); + +describe("workspace selection", () => { + it("preserves Personal with multiple memberships instead of auto-activating a team", async () => { + await act(async () => root.render()); + const select = container.querySelector("select")!; + expect(select.value).toBe(""); + expect(select.options[0].text).toBe("Personal"); + expect(select.options.length).toBe(3); + expect(setActive).not.toHaveBeenCalled(); + }); + + it("switches a team to Personal through the rendered control and preserves it on remount", async () => { + activeOrg = memberships[0]; + await act(async () => root.render()); + const select = container.querySelector("select")!; + expect(select.value).toBe("org_first"); + + await act(async () => { + select.value = ""; + select.dispatchEvent(new window.Event("change", { bubbles: true })); + }); + expect(setActive).toHaveBeenCalledTimes(1); + expect(setActive).toHaveBeenCalledWith({ organization: null }); + + activeOrg = null; + await act(async () => root.render()); + expect(container.querySelector("select")!.value).toBe(""); + expect(setActive).toHaveBeenCalledTimes(1); + }); + + it("shows Personal even with no organization memberships", async () => { + memberships = []; + await act(async () => root.render()); + expect(container.querySelector("select")!.options[0].text).toBe("Personal"); + expect(setActive).not.toHaveBeenCalled(); + }); +});