From c0027dbe5758b1eda1745264aabb3b19af8a737f Mon Sep 17 00:00:00 2001 From: duyetbot <101855044+duyetbot@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:01:59 +0700 Subject: [PATCH 1/2] fix(api): persist tenant identity bindings and migrate existing organizations Resolve verified Clerk principals once before dashboard reads and writes. Preserve stable tenant IDs, reject ambiguous legacy matches, and support nested session organization claims. Add signed-session and migration regression coverage for #389. Co-Authored-By: Duyet Le Co-Authored-By: duyetbot --- PLAN.md | 3 + docs/INDEX.md | 1 + docs/knowledge/core-memory.md | 2 + docs/knowledge/organization-identity.md | 98 + .../api/drizzle/0012_mixed_monster_badoon.sql | 24 + packages/api/drizzle/meta/0012_snapshot.json | 2353 +++++++++++++++++ packages/api/drizzle/meta/_journal.json | 7 + packages/api/src/db/schema.ts | 24 + packages/api/src/lib/clerk-session.ts | 76 +- .../src/middleware/clerk-dashboard-auth.ts | 13 +- packages/api/src/routes/analytics.ts | 13 +- packages/api/src/routes/domains.ts | 24 +- packages/api/src/routes/oauth/index.ts | 26 +- .../api/src/routes/organizations/index.ts | 17 +- packages/api/src/routes/project-traces.ts | 24 +- packages/api/src/routes/projects.ts | 28 +- packages/api/src/services/organizations.ts | 174 +- packages/api/src/services/projects.ts | 108 +- packages/api/src/types.ts | 2 + packages/api/test/clerk-jwt.ts | 4 + packages/api/test/domains.test.ts | 3 + .../api/test/organization-identity.test.ts | 180 ++ packages/api/test/setup.ts | 18 + 23 files changed, 2901 insertions(+), 321 deletions(-) create mode 100644 docs/knowledge/organization-identity.md create mode 100644 packages/api/drizzle/0012_mixed_monster_badoon.sql create mode 100644 packages/api/drizzle/meta/0012_snapshot.json create mode 100644 packages/api/test/organization-identity.test.ts 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/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 (?, ?, ?, ?, ?)`, ) From 755c27e54cb79ce9591c30828a45fe41d7b240d2 Mon Sep 17 00:00:00 2001 From: duyetbot <101855044+duyetbot@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:02:06 +0700 Subject: [PATCH 2/2] fix(dashboard): keep Personal workspace explicitly selectable Remove automatic membership selection, preserve separate personal and team scopes, and test switching to Personal through the rendered control. Co-Authored-By: Duyet Le Co-Authored-By: duyetbot --- bun.lock | 19 +- packages/dashboard/package.json | 1 + .../dashboard/src/components/app-shell.tsx | 197 +----------------- .../src/components/workspace-switcher.tsx | 90 ++++++++ .../src/lib/workspace-switcher.test.tsx | 83 ++++++++ 5 files changed, 195 insertions(+), 195 deletions(-) create mode 100644 packages/dashboard/src/components/workspace-switcher.tsx create mode 100644 packages/dashboard/src/lib/workspace-switcher.test.tsx 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/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(); + }); +});