diff --git a/.env.example b/.env.example index b77c668a3..e9fbd4f76 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ -# Thunderbolt Cloud URL (optional, defaults to http://localhost:8000) +# Thunderbolt Cloud URL — the default-server URL the boot decision tree fetches +# /v1/config from. Optional; defaults to http://localhost:8000. VITE_THUNDERBOLT_CLOUD_URL="http://localhost:8000/v1" +# Boot decision flags. v1 production: standalone off + default URL set + user-added off +# (forced-server). The mode picker UI ships in a later PR; v1 production never reaches it. +# VITE_STANDALONE_MODE_ENABLED="false" +# VITE_ALLOW_USER_ADDED_SERVERS="false" + # Bypass waitlist routes for UI development (set to "true" to skip waitlist) # Note: This only bypasses frontend routing, not backend auth # VITE_BYPASS_WAITLIST="true" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d43e50112..da7494d94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,6 +222,13 @@ jobs: cd backend bun install --frozen-lockfile + # `shared/workspaces.ts` imports `uuid`. TS bundler-mode resolution from + # shared/ walks up to root `node_modules/` (never reaches backend/), so + # the backend type-check fails with "Cannot find module 'uuid'" unless + # the root deps are installed too. + - name: Install root dependencies (needed for shared/ type-check) + run: bun install --frozen-lockfile + - name: Type check backend run: | cd backend diff --git a/backend/.env.example b/backend/.env.example index 5b342cc17..0f0de1890 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,11 +1,19 @@ # Backend dev environment variables. # Copy to .env: cp .env.example .env -# Then run `make doctor` — it generates BETTER_AUTH_SECRET for you. +# Then run `make doctor` — it generates BETTER_AUTH_SECRET and SERVER_ID for you. # # REQUIRED for the app to start: # - BETTER_AUTH_SECRET (auto-generated by `make doctor`) +# - SERVER_ID (auto-generated by `make doctor`) # - At least one AI provider key (ANTHROPIC_API_KEY / FIREWORKS_API_KEY / MISTRAL_API_KEY / THUNDERBOLT_INFERENCE_API_KEY) +# === Required: deployment identity === +# Stable per-deployment UUID returned by GET /v1/config. The frontend uses this to +# namespace its trust-domain registry (auth token, device ID, encryption keys, DB filename +# are all keyed by it), so changing it in place is a hard reset for connected clients. +# Generate with `uuidgen` or run `make doctor`. +SERVER_ID= + # === Required: auth signing secret === # Used to sign bearer tokens and session cookies. # Generate with `openssl rand -base64 32` or run `make doctor`. @@ -46,7 +54,15 @@ POWERSYNC_TOKEN_EXPIRY_SECONDS=3600 AUTH_MODE=consumer # Anonymous-session overlay. When false, the anonymous() plugin is NOT registered # and /v1/api/auth/sign-in/anonymous returns 404. Frontend mirror: VITE_AUTH_ENABLE_ANONYMOUS. +# Surfaced to the UI via GET /v1/config as `allowAnonUsers`. AUTH_ALLOW_ANONYMOUS=false + +# === Workspace creation policy === +# Surfaced to the UI via GET /v1/config; enforced by the PowerSync upload-handler factory +# (workspaces table). Both default to false to match v1 production posture +# (central-admin only); relax per deployment. +ALLOW_WORKSPACE_CREATION_BY_ANON=false +ALLOW_WORKSPACE_CREATION_BY_MEMBERS=false # Backend's public URL used for OIDC redirect URIs (must match what's registered with your IdP) BETTER_AUTH_URL=http://localhost:8000 TRUSTED_ORIGINS=http://localhost:1420,http://localhost:8180 diff --git a/backend/bun.lock b/backend/bun.lock index ba4073c33..a092d1052 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -33,6 +33,7 @@ "rate-limiter-flexible": "^11.0.1", "react-email": "^6.0.5", "resend": "^6.5.2", + "uuid": "^14.0.0", "zod": "^4.3.6", }, "devDependencies": { @@ -1146,7 +1147,7 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1202,6 +1203,8 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@langchain/core/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "@langchain/langgraph/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], "@langchain/langgraph-checkpoint/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], @@ -1326,6 +1329,8 @@ "@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], + "@posthog/ai/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -1354,6 +1359,8 @@ "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "langchain/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "langsmith/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], "nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], diff --git a/backend/drizzle/0021_brave_quasar.sql b/backend/drizzle/0021_brave_quasar.sql new file mode 100644 index 000000000..0c9300602 --- /dev/null +++ b/backend/drizzle/0021_brave_quasar.sql @@ -0,0 +1,285 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at http://mozilla.org/MPL/2.0/. + +-- Workspaces v1: foundation + data layer + models.api_key. Consolidated into a +-- single migration so the BE schema lands atomically — splitting it lets a +-- partial apply leave the DB with workspace_id NOT NULL columns but no +-- workspaces rows to FK to, or with workspaces tables but no backfilled data. +-- +-- Phase 1 — Workspace identity: +-- create the workspace identity tables and materialize one Personal Workspace +-- + admin membership per existing user. Must run before phase 2 so the +-- workspaces row exists when phase 2's UPDATE backfills workspace_id from +-- user_id (computed via uuid_generate_v5). +-- +-- Phase 2 — Per-user data tables get workspace_id: +-- Backfill instead of truncate so existing rows survive the cutover. Sync-rule +-- updates invalidate every connected client's checkpoint on first PowerSync +-- restart with the new schema — if BE returned empty buckets at that point, +-- every client (migrated or stale) would reconcile to "your bucket is empty" +-- and wipe its local copies. Backfilling keeps BE-side data continuous so +-- both shapes of clients see no interruption. +-- +-- ORDER IS LOAD-BEARING: the backfill UPDATEs run BEFORE we drop the old +-- (id, user_id) composite PKs. The powersync.* tables are published for +-- logical replication and Postgres refuses UPDATEs on a published table that +-- has no REPLICA IDENTITY — dropping the PK first leaves the table with no +-- identity → `cannot update table … because it does not have a replica +-- identity and publishes updates`. So: add the column, populate it, THEN +-- swap PKs and FKs. +-- +-- Phase 3 — models.api_key: +-- reintroduces the column dropped in THU-505; carried inside this migration +-- so the workspaces cutover ships the full v1 schema in one transaction. +-- +-- Personal workspace ids are derived deterministically from user.id via +-- uuid_generate_v5 over the namespace defined in shared/workspaces.ts: +-- computePersonalWorkspaceId(userId) = +-- uuid_generate_v5(NAMESPACE, 'personal:' || userId) +-- computePersonalAdminMembershipId(userId) = +-- uuid_generate_v5(NAMESPACE, 'personal-admin:' || userId) +-- The same constants are reused by the FE bootstrap and the BE upload +-- handler — multi-device upserts collapse to the same row id. + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp";--> statement-breakpoint + +CREATE TABLE "powersync"."workspaces" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text, + "icon" text, + "is_personal" boolean DEFAULT false NOT NULL, + "owner_user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "powersync"."workspace_memberships" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text NOT NULL, + "user_name" text, + "user_email" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "powersync"."workspace_pending_memberships" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "email" text NOT NULL, + "role" text NOT NULL, + "invited_by_user_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "powersync"."workspace_permissions" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "permission_key" text NOT NULL, + "required_role" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "powersync"."workspaces" ADD CONSTRAINT "workspaces_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_memberships" ADD CONSTRAINT "workspace_memberships_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_memberships" ADD CONSTRAINT "workspace_memberships_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_pending_memberships" ADD CONSTRAINT "workspace_pending_memberships_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_pending_memberships" ADD CONSTRAINT "workspace_pending_memberships_invited_by_user_id_user_id_fk" FOREIGN KEY ("invited_by_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_permissions" ADD CONSTRAINT "workspace_permissions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspaces_personal_per_owner" ON "powersync"."workspaces" USING btree ("owner_user_id") WHERE "powersync"."workspaces"."is_personal" = true;--> statement-breakpoint +CREATE INDEX "idx_workspaces_owner_user_id" ON "powersync"."workspaces" USING btree ("owner_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspaces_slug" ON "powersync"."workspaces" USING btree ("slug") WHERE "powersync"."workspaces"."slug" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspace_memberships_workspace_user" ON "powersync"."workspace_memberships" USING btree ("workspace_id","user_id");--> statement-breakpoint +CREATE INDEX "idx_workspace_memberships_user" ON "powersync"."workspace_memberships" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_workspace_memberships_workspace" ON "powersync"."workspace_memberships" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspace_pending_memberships_workspace_email" ON "powersync"."workspace_pending_memberships" USING btree ("workspace_id","email");--> statement-breakpoint +CREATE INDEX "idx_workspace_pending_memberships_email" ON "powersync"."workspace_pending_memberships" USING btree ("email");--> statement-breakpoint +CREATE INDEX "idx_workspace_pending_memberships_workspace" ON "powersync"."workspace_pending_memberships" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspace_permissions_workspace_key" ON "powersync"."workspace_permissions" USING btree ("workspace_id","permission_key");--> statement-breakpoint +CREATE INDEX "idx_workspace_permissions_workspace" ON "powersync"."workspace_permissions" USING btree ("workspace_id");--> statement-breakpoint +-- Backfill: one Personal Workspace per existing user. +INSERT INTO "powersync"."workspaces" ("id", "name", "is_personal", "owner_user_id", "created_at", "updated_at") +SELECT + uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || u.id)::text, + 'Default', + true, + u.id, + now(), + now() +FROM "public"."user" u; +--> statement-breakpoint +-- Backfill: admin membership tying each user to their Personal Workspace. +-- user_name / user_email are denormalized from `auth.user` so the Members page +-- can render display info without a synced `users` projection. +INSERT INTO "powersync"."workspace_memberships" ("id", "workspace_id", "user_id", "role", "user_name", "user_email", "created_at") +SELECT + uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal-admin:' || u.id)::text, + uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || u.id)::text, + u.id, + 'admin', + u.name, + u.email, + now() +FROM "public"."user" u; +--> statement-breakpoint + +-- Tripwire: every per-user table backfills `workspace_id` from `user_id`, and +-- `uuid_generate_v5(NS, 'personal:' || NULL)` returns NULL — which would later +-- fail the `SET NOT NULL` flip below with a vague constraint-violation error +-- after most of the migration had already run. The pre-migration schema put +-- user_id NOT NULL on every row (composite PK on resource tables, FK-NOT-NULL +-- on chat tables), so any NULL here means a data invariant has already drifted +-- somewhere upstream. Abort loudly before we touch anything. +DO $$ +DECLARE + null_count bigint; +BEGIN + SELECT + (SELECT count(*) FROM "powersync"."agents" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."chat_messages" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."chat_threads" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."model_profiles" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."models" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."modes" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."prompts" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."skills" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."tasks" WHERE "user_id" IS NULL) + + (SELECT count(*) FROM "powersync"."triggers" WHERE "user_id" IS NULL) + INTO null_count; + IF null_count > 0 THEN + RAISE EXCEPTION 'pre-Workspaces backfill aborted: % rows have NULL user_id across powersync.* tables — workspace_id cannot be derived', null_count; + END IF; +END $$;--> statement-breakpoint + +-- ADD workspace_id columns as NULLABLE first so the UPDATE backfill can run +-- against existing rows. SET NOT NULL flips after the backfill below. +ALTER TABLE "powersync"."agents" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."chat_messages" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."chat_threads" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."tasks" ADD COLUMN "workspace_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ADD COLUMN "workspace_id" text;--> statement-breakpoint + +-- Scope columns default to 'workspace' so existing rows opt in implicitly; +-- the new 'user' scope (THU-603) is only ever set on rows authored after +-- workspaces v1 lands. +ALTER TABLE "powersync"."agents" ADD COLUMN "scope" text DEFAULT 'workspace' NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD COLUMN "scope" text DEFAULT 'workspace' NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD COLUMN "scope" text DEFAULT 'workspace' NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD COLUMN "scope" text DEFAULT 'workspace' NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD COLUMN "scope" text DEFAULT 'workspace' NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD COLUMN "scope" text DEFAULT 'workspace' NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ADD COLUMN "scope" text DEFAULT 'workspace' NOT NULL;--> statement-breakpoint + +-- Backfill workspace_id from each row's user_id. Runs while the old +-- (id, user_id) composite PKs are still in place — those PKs are the tables' +-- replica identities, and PG requires one for UPDATEs on published tables. +-- Every row pre-migration had user_id NOT NULL (composite PK guaranteed it +-- for tasks/models/modes/etc.; chat tables were FK-NOT-NULL on user_id), so +-- every row gets stamped with its author's Personal Workspace id (computed +-- via uuid_generate_v5 with the same namespace as shared/workspaces.ts → +-- computePersonalWorkspaceId). +UPDATE "powersync"."agents" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."chat_messages" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."chat_threads" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."model_profiles" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."models" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."modes" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."prompts" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."skills" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."tasks" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint +UPDATE "powersync"."triggers" SET "workspace_id" = uuid_generate_v5('e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b'::uuid, 'personal:' || "user_id")::text;--> statement-breakpoint + +-- Flip workspace_id to NOT NULL now that every row is populated. +ALTER TABLE "powersync"."agents" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."chat_messages" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."chat_threads" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."models" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."tasks" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint + +-- Drop old user_id FKs + (id, user_id) composite PKs so we can relax user_id +-- NOT NULL on resource tables and re-shape the PK around workspace_id. +ALTER TABLE "powersync"."agents" DROP CONSTRAINT "agents_user_id_user_id_fk";--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" DROP CONSTRAINT "model_profiles_user_id_user_id_fk";--> statement-breakpoint +ALTER TABLE "powersync"."models" DROP CONSTRAINT "models_user_id_user_id_fk";--> statement-breakpoint +ALTER TABLE "powersync"."modes" DROP CONSTRAINT "modes_user_id_user_id_fk";--> statement-breakpoint +ALTER TABLE "powersync"."prompts" DROP CONSTRAINT "prompts_user_id_user_id_fk";--> statement-breakpoint +ALTER TABLE "powersync"."skills" DROP CONSTRAINT "skills_user_id_user_id_fk";--> statement-breakpoint +ALTER TABLE "powersync"."triggers" DROP CONSTRAINT "triggers_user_id_user_id_fk";--> statement-breakpoint +ALTER TABLE "powersync"."agents" DROP CONSTRAINT "agents_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" DROP CONSTRAINT "model_profiles_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."models" DROP CONSTRAINT "models_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."modes" DROP CONSTRAINT "modes_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."prompts" DROP CONSTRAINT "prompts_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."skills" DROP CONSTRAINT "skills_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."tasks" DROP CONSTRAINT "tasks_id_user_id_pk";--> statement-breakpoint + +-- Resource tables relax user_id NOT NULL so shared-workspace rows can have a +-- null owner after the original author deletes their account (FK switches to +-- ON DELETE set null below). User-private tables (chat_threads, chat_messages, +-- tasks) keep user_id NOT NULL — those rows are useless without their author. +ALTER TABLE "powersync"."agents" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."models" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint + +-- New composite PKs (id, workspace_id) — same id may repeat across workspaces +-- (e.g. seeded defaults), so workspace_id has to be part of the key. +ALTER TABLE "powersync"."agents" ADD CONSTRAINT "agents_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD CONSTRAINT "model_profiles_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD CONSTRAINT "models_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD CONSTRAINT "modes_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD CONSTRAINT "prompts_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD CONSTRAINT "skills_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."tasks" ADD CONSTRAINT "tasks_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint + +-- Workspace FKs on every workspace-scoped table. +ALTER TABLE "powersync"."agents" ADD CONSTRAINT "agents_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."chat_messages" ADD CONSTRAINT "chat_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."chat_threads" ADD CONSTRAINT "chat_threads_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD CONSTRAINT "model_profiles_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD CONSTRAINT "models_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD CONSTRAINT "modes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD CONSTRAINT "prompts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD CONSTRAINT "skills_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."tasks" ADD CONSTRAINT "tasks_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ADD CONSTRAINT "triggers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint + +-- Re-add user_id FKs. Resource tables move to ON DELETE set null so a shared +-- workspace's resources survive an author leaving / deleting their account. +ALTER TABLE "powersync"."agents" ADD CONSTRAINT "agents_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD CONSTRAINT "model_profiles_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD CONSTRAINT "models_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD CONSTRAINT "modes_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD CONSTRAINT "prompts_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD CONSTRAINT "skills_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ADD CONSTRAINT "triggers_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint + +-- workspace_id indexes on every workspace-scoped table. +CREATE INDEX "idx_agents_workspace_id" ON "powersync"."agents" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_chat_messages_workspace_id" ON "powersync"."chat_messages" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_chat_threads_workspace_id" ON "powersync"."chat_threads" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_model_profiles_workspace_id" ON "powersync"."model_profiles" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_models_workspace_id" ON "powersync"."models" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_modes_workspace_id" ON "powersync"."modes" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_prompts_workspace_id" ON "powersync"."prompts" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_skills_workspace_id" ON "powersync"."skills" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_tasks_workspace_id" ON "powersync"."tasks" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_triggers_workspace_id" ON "powersync"."triggers" USING btree ("workspace_id");--> statement-breakpoint + +-- THU-579: reintroduce models.api_key (dropped in THU-505). Carried inside +-- the workspaces cutover so v1 lands as a single schema swap. +ALTER TABLE "powersync"."models" ADD COLUMN "api_key" text; diff --git a/backend/drizzle/meta/0021_snapshot.json b/backend/drizzle/meta/0021_snapshot.json new file mode 100644 index 000000000..59f2bd2c7 --- /dev/null +++ b/backend/drizzle/meta/0021_snapshot.json @@ -0,0 +1,3086 @@ +{ + "id": "14599017-cbd2-4fcd-b942-6f44a6413608", + "prevId": "f5c953ed-42f2-4ee1-b614-2ef307e1cde0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_deviceId_idx": { + "name": "session_deviceId_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "waitlist_status_idx": { + "name": "waitlist_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_batch_id_idx": { + "name": "waitlist_batch_id_idx", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.agents": { + "name": "agents", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + } + }, + "indexes": { + "idx_agents_user_id": { + "name": "idx_agents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agents_workspace_id": { + "name": "idx_agents_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_user_id_user_id_fk": { + "name": "agents_user_id_user_id_fk", + "tableFrom": "agents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_workspace_id_workspaces_id_fk": { + "name": "agents_workspace_id_workspaces_id_fk", + "tableFrom": "agents", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_id_workspace_id_pk": { + "name": "agents_id_workspace_id_pk", + "columns": [ + "id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_messages": { + "name": "chat_messages", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_thread_id": { + "name": "chat_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache": { + "name": "cache", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_messages_user_id": { + "name": "idx_chat_messages_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_messages_workspace_id": { + "name": "idx_chat_messages_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_workspace_id_workspaces_id_fk": { + "name": "chat_messages_workspace_id_workspaces_id_fk", + "tableFrom": "chat_messages", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_threads": { + "name": "chat_threads", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "was_triggered_by_automation": { + "name": "was_triggered_by_automation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "context_size": { + "name": "context_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acp_session_id": { + "name": "acp_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_threads_user_id": { + "name": "idx_chat_threads_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_workspace_id": { + "name": "idx_chat_threads_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.devices": { + "name": "devices", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trusted": { + "name": "trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "approval_pending": { + "name": "approval_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mlkem_public_key": { + "name": "mlkem_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_devices_user_id": { + "name": "idx_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_user_id_fk": { + "name": "devices_user_id_user_id_fk", + "tableFrom": "devices", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.model_profiles": { + "name": "model_profiles", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "max_steps": { + "name": "max_steps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "nudge_threshold": { + "name": "nudge_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_system_message_mode_developer": { + "name": "use_system_message_mode_developer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "tools_override": { + "name": "tools_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_previews_override": { + "name": "link_previews_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_mode_addendum": { + "name": "chat_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_mode_addendum": { + "name": "search_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "research_mode_addendum": { + "name": "research_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "citation_reinforcement_enabled": { + "name": "citation_reinforcement_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "citation_reinforcement_prompt": { + "name": "citation_reinforcement_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_final_step": { + "name": "nudge_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_preventive": { + "name": "nudge_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_retry": { + "name": "nudge_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_final_step": { + "name": "nudge_search_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_preventive": { + "name": "nudge_search_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_retry": { + "name": "nudge_search_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_options": { + "name": "provider_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + } + }, + "indexes": { + "idx_model_profiles_user_id": { + "name": "idx_model_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_profiles_workspace_id": { + "name": "idx_model_profiles_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_profiles_user_id_user_id_fk": { + "name": "model_profiles_user_id_user_id_fk", + "tableFrom": "model_profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "model_profiles_workspace_id_workspaces_id_fk": { + "name": "model_profiles_workspace_id_workspaces_id_fk", + "tableFrom": "model_profiles", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_profiles_id_workspace_id_pk": { + "name": "model_profiles_id_workspace_id_pk", + "columns": [ + "id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.models": { + "name": "models", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "tool_usage": { + "name": "tool_usage", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_confidential": { + "name": "is_confidential", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "start_with_reasoning": { + "name": "start_with_reasoning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "supports_parallel_tool_calls": { + "name": "supports_parallel_tool_calls", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + } + }, + "indexes": { + "idx_models_user_id": { + "name": "idx_models_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_models_workspace_id": { + "name": "idx_models_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "models_user_id_user_id_fk": { + "name": "models_user_id_user_id_fk", + "tableFrom": "models", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "models_workspace_id_workspaces_id_fk": { + "name": "models_workspace_id_workspaces_id_fk", + "tableFrom": "models", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "models_id_workspace_id_pk": { + "name": "models_id_workspace_id_pk", + "columns": [ + "id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.modes": { + "name": "modes", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + } + }, + "indexes": { + "idx_modes_user_id": { + "name": "idx_modes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_modes_workspace_id": { + "name": "idx_modes_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "modes_user_id_user_id_fk": { + "name": "modes_user_id_user_id_fk", + "tableFrom": "modes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "modes_workspace_id_workspaces_id_fk": { + "name": "modes_workspace_id_workspaces_id_fk", + "tableFrom": "modes", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "modes_id_workspace_id_pk": { + "name": "modes_id_workspace_id_pk", + "columns": [ + "id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.prompts": { + "name": "prompts", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + } + }, + "indexes": { + "idx_prompts_user_id": { + "name": "idx_prompts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prompts_workspace_id": { + "name": "idx_prompts_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prompts_user_id_user_id_fk": { + "name": "prompts_user_id_user_id_fk", + "tableFrom": "prompts", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "prompts_workspace_id_workspaces_id_fk": { + "name": "prompts_workspace_id_workspaces_id_fk", + "tableFrom": "prompts", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "prompts_id_workspace_id_pk": { + "name": "prompts_id_workspace_id_pk", + "columns": [ + "id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.settings": { + "name": "settings", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_settings_user_id": { + "name": "idx_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_id_user_id_pk": { + "name": "settings_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.skills": { + "name": "skills", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + } + }, + "indexes": { + "idx_skills_user_id": { + "name": "idx_skills_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_skills_workspace_id": { + "name": "idx_skills_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_user_id_user_id_fk": { + "name": "skills_user_id_user_id_fk", + "tableFrom": "skills", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "skills_workspace_id_workspaces_id_fk": { + "name": "skills_workspace_id_workspaces_id_fk", + "tableFrom": "skills", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skills_id_workspace_id_pk": { + "name": "skills_id_workspace_id_pk", + "columns": [ + "id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.tasks": { + "name": "tasks", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item": { + "name": "item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_complete": { + "name": "is_complete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_tasks_user_id": { + "name": "idx_tasks_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tasks_workspace_id": { + "name": "idx_tasks_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_user_id_user_id_fk": { + "name": "tasks_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_workspace_id_workspaces_id_fk": { + "name": "tasks_workspace_id_workspaces_id_fk", + "tableFrom": "tasks", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tasks_id_workspace_id_pk": { + "name": "tasks_id_workspace_id_pk", + "columns": [ + "id", + "workspace_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.triggers": { + "name": "triggers", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_time": { + "name": "trigger_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + } + }, + "indexes": { + "idx_triggers_user_id": { + "name": "idx_triggers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_triggers_workspace_id": { + "name": "idx_triggers_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "triggers_user_id_user_id_fk": { + "name": "triggers_user_id_user_id_fk", + "tableFrom": "triggers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "triggers_workspace_id_workspaces_id_fk": { + "name": "triggers_workspace_id_workspaces_id_fk", + "tableFrom": "triggers", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.workspace_memberships": { + "name": "workspace_memberships", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_memberships_workspace_user": { + "name": "idx_workspace_memberships_workspace_user", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_memberships_user": { + "name": "idx_workspace_memberships_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_memberships_workspace": { + "name": "idx_workspace_memberships_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_memberships_workspace_id_workspaces_id_fk": { + "name": "workspace_memberships_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_memberships", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_memberships_user_id_user_id_fk": { + "name": "workspace_memberships_user_id_user_id_fk", + "tableFrom": "workspace_memberships", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.workspace_pending_memberships": { + "name": "workspace_pending_memberships", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_pending_memberships_workspace_email": { + "name": "idx_workspace_pending_memberships_workspace_email", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_pending_memberships_email": { + "name": "idx_workspace_pending_memberships_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_pending_memberships_workspace": { + "name": "idx_workspace_pending_memberships_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_pending_memberships_workspace_id_workspaces_id_fk": { + "name": "workspace_pending_memberships_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_pending_memberships", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_pending_memberships_invited_by_user_id_user_id_fk": { + "name": "workspace_pending_memberships_invited_by_user_id_user_id_fk", + "tableFrom": "workspace_pending_memberships", + "tableTo": "user", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.workspace_permissions": { + "name": "workspace_permissions", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "required_role": { + "name": "required_role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_workspace_permissions_workspace_key": { + "name": "idx_workspace_permissions_workspace_key", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_permissions_workspace": { + "name": "idx_workspace_permissions_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_permissions_workspace_id_workspaces_id_fk": { + "name": "workspace_permissions_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_permissions", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.workspaces": { + "name": "workspaces", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_personal": { + "name": "is_personal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspaces_personal_per_owner": { + "name": "idx_workspaces_personal_per_owner", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"powersync\".\"workspaces\".\"is_personal\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_owner_user_id": { + "name": "idx_workspaces_owner_user_id", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"powersync\".\"workspaces\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_user_id_user_id_fk": { + "name": "workspaces_owner_user_id_user_id_fk", + "tableFrom": "workspaces", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expire": { + "name": "expire", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rate_limits_expire_idx": { + "name": "rate_limits_expire_idx", + "columns": [ + { + "expression": "expire", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encryption_metadata": { + "name": "encryption_metadata", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canary_iv": { + "name": "canary_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_ctext": { + "name": "canary_ctext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_secret_hash": { + "name": "canary_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encryption_metadata_user_id_user_id_fk": { + "name": "encryption_metadata_user_id_user_id_fk", + "tableFrom": "encryption_metadata", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.envelopes": { + "name": "envelopes", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapped_ck": { + "name": "wrapped_ck", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_envelopes_user_id": { + "name": "idx_envelopes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "envelopes_device_id_devices_id_fk": { + "name": "envelopes_device_id_devices_id_fk", + "tableFrom": "envelopes", + "tableTo": "devices", + "schemaTo": "powersync", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "envelopes_user_id_user_id_fk": { + "name": "envelopes_user_id_user_id_fk", + "tableFrom": "envelopes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_challenge": { + "name": "otp_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_token": { + "name": "challenge_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_challenge_email_unique": { + "name": "otp_challenge_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 16a6dae4a..cdde6a5e0 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1781546434494, "tag": "0020_conscious_silverclaw", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1782408136496, + "tag": "0021_brave_quasar", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 7af588edb..5545f5cfe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,7 @@ "rate-limiter-flexible": "^11.0.1", "react-email": "^6.0.5", "resend": "^6.5.2", + "uuid": "^14.0.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/backend/src/api/account.test.ts b/backend/src/api/account.test.ts index a672c5173..a2c7f7f8b 100644 --- a/backend/src/api/account.test.ts +++ b/backend/src/api/account.test.ts @@ -6,6 +6,7 @@ import { createAuth } from '@/auth/auth' import { session as sessionTable, user } from '@/db/auth-schema' import { encryptionMetadataTable, envelopesTable } from '@/db/encryption-schema' import { chatThreadsTable, devicesTable, settingsTable, tasksTable } from '@/db/schema' +import { workspacesTable } from '@/db/powersync-schema' import { hashCanarySecret } from '@/lib/canary' import { createTestDb } from '@/test-utils/db' import { createHmac } from 'crypto' @@ -423,15 +424,19 @@ describe('Account API', () => { lastSeen: now, createdAt: now, }) + const workspaceId = '00000000-0000-0000-0000-000000000000' + await db.insert(workspacesTable).values({ id: workspaceId, name: 'Test Workspace' }) await db.insert(tasksTable).values({ id: 'task-cascade-1', item: 'Task', userId, + workspaceId, }) await db.insert(chatThreadsTable).values({ id: 'thread-cascade-1', title: 'Thread', userId, + workspaceId, }) const response = await app.handle( diff --git a/backend/src/api/config.test.ts b/backend/src/api/config.test.ts index 5236636d0..41b9eba24 100644 --- a/backend/src/api/config.test.ts +++ b/backend/src/api/config.test.ts @@ -15,6 +15,12 @@ const fetchConfig = async (settings: Parameters[0]) = describe('Config Routes', () => { describe('GET /config', () => { + it('returns the configured serverId', async () => { + const serverId = '11111111-2222-3333-4444-555555555555' + const { body } = await fetchConfig(createTestSettings({ serverId })) + expect(body.serverId).toBe(serverId) + }) + it('reflects e2eeEnabled', async () => { const disabled = await fetchConfig(createTestSettings({ e2eeEnabled: false })) expect(disabled.body.e2eeEnabled).toBe(false) @@ -23,6 +29,25 @@ describe('Config Routes', () => { expect(enabled.body.e2eeEnabled).toBe(true) }) + it('exposes allowAnonUsers from the authAllowAnonymous setting', async () => { + const off = await fetchConfig(createTestSettings({ authAllowAnonymous: false })) + expect(off.body.allowAnonUsers).toBe(false) + + const on = await fetchConfig(createTestSettings({ authAllowAnonymous: true })) + expect(on.body.allowAnonUsers).toBe(true) + }) + + it('exposes workspace creation policy flags', async () => { + const { body } = await fetchConfig( + createTestSettings({ + allowWorkspaceCreationByAnon: true, + allowWorkspaceCreationByMembers: true, + }), + ) + expect(body.allowWorkspaceCreationByAnon).toBe(true) + expect(body.allowWorkspaceCreationByMembers).toBe(true) + }) + it('exposes builtInAgentEnabled: true by default and false when disabled', async () => { const onByDefault = await fetchConfig(createTestSettings()) expect(onByDefault.body.builtInAgentEnabled).toBe(true) @@ -49,6 +74,22 @@ describe('Config Routes', () => { expect(body.minAppVersion).toBe('0.2.0') }) + it('exposes allowUserScopedResources', async () => { + const on = await fetchConfig(createTestSettings({ allowUserScopedResources: true })) + expect(on.body.allowUserScopedResources).toBe(true) + + const off = await fetchConfig(createTestSettings({ allowUserScopedResources: false })) + expect(off.body.allowUserScopedResources).toBe(false) + }) + + it('exposes allowWorkspacePermissionsUi (defaults false unless explicitly enabled)', async () => { + const off = await fetchConfig(createTestSettings()) + expect(off.body.allowWorkspacePermissionsUi).toBe(false) + + const on = await fetchConfig(createTestSettings({ allowWorkspacePermissionsUi: true })) + expect(on.body.allowWorkspacePermissionsUi).toBe(true) + }) + it('does not require authentication', async () => { const { status } = await fetchConfig(createTestSettings()) expect(status).toBe(200) diff --git a/backend/src/api/config.ts b/backend/src/api/config.ts index 45029b23b..dba6d3592 100644 --- a/backend/src/api/config.ts +++ b/backend/src/api/config.ts @@ -13,7 +13,13 @@ import { Elysia } from 'elysia' */ export const createConfigRoutes = (settings: Settings) => new Elysia({ prefix: '/config' }).onError(safeErrorHandler).get('/', () => ({ + serverId: settings.serverId, e2eeEnabled: settings.e2eeEnabled, + allowAnonUsers: settings.authAllowAnonymous, + allowWorkspaceCreationByAnon: settings.allowWorkspaceCreationByAnon, + allowWorkspaceCreationByMembers: settings.allowWorkspaceCreationByMembers, + allowUserScopedResources: settings.allowUserScopedResources, + allowWorkspacePermissionsUi: settings.allowWorkspacePermissionsUi, // Inverted so the env reads as an opt-in switch ("disable") while the wire // contract reads as a positive capability ("enabled"). builtInAgentEnabled: !settings.disableBuiltInAgent, diff --git a/backend/src/api/powersync.test.ts b/backend/src/api/powersync.test.ts index 6b78615c1..c2904ae00 100644 --- a/backend/src/api/powersync.test.ts +++ b/backend/src/api/powersync.test.ts @@ -5,11 +5,19 @@ import type { Settings } from '@/config/settings' import { createBetterAuthPlugin } from '@/auth/elysia-plugin' import { session as sessionTable, user as userTable } from '@/db/auth-schema' -import { devicesTable, modelsTable, promptsTable, settingsTable } from '@/db/schema' +import { + devicesTable, + modelsTable, + promptsTable, + settingsTable, + workspaceMembershipsTable, + workspacesTable, +} from '@/db/schema' import { createTestDb } from '@/test-utils/db' import { createHmac } from 'crypto' import { eq } from 'drizzle-orm' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import { v7 as uuidv7 } from 'uuid' import { clearSettingsCache } from '@/config/settings' import { Elysia } from 'elysia' import { createPowerSyncRoutes } from './powersync' @@ -24,6 +32,11 @@ const signToken = (token: string): string => { } const powersyncSettings: Settings = { + serverId: 'd70c9c16-8665-4eb8-afb3-0d9f0214e4f8', + allowWorkspaceCreationByAnon: false, + allowWorkspaceCreationByMembers: false, + allowUserScopedResources: true, + allowWorkspacePermissionsUi: false, fireworksApiKey: '', mistralApiKey: '', anthropicApiKey: '', @@ -81,8 +94,13 @@ describe('PowerSync API', () => { let app: Elysia let db: Awaited>['db'] let cleanup: () => Promise + // Unique per test: the new upload handler uses database.transaction() which + // commits the outer BEGIN/ROLLBACK test transaction in PGlite, so we can't + // rely on rollback isolation. Unique device IDs prevent key collisions. + let testDeviceId: string beforeEach(async () => { + testDeviceId = uuidv7() const testEnv = await createTestDb() db = testEnv.db cleanup = testEnv.cleanup @@ -96,7 +114,7 @@ describe('PowerSync API', () => { } }) - const uploadHeaders = (bearer: string, deviceId = 'test-device-id') => ({ + const uploadHeaders = (bearer: string, deviceId = testDeviceId) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${signToken(bearer)}`, 'X-Device-ID': deviceId, @@ -1067,7 +1085,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -1101,7 +1119,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -1150,7 +1168,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -1249,7 +1267,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'patch_setting', @@ -1301,7 +1319,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'empty_patch_setting', value: 'unchanged', @@ -1357,7 +1375,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'stripped_patch_setting', value: 'unchanged', @@ -1409,7 +1427,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) // No settings row exists for 'nonexistent_key' const response = await app.handle( @@ -1428,12 +1446,13 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('ROW_NOT_FOUND') }) - it('returns 400 when PATCH targets record belonging to another user', async () => { + it('returns 200 with ROW_NOT_FOUND rejection when PATCH targets record belonging to another user', async () => { const userA = 'user-patch-owner' const userB = 'user-patch-attacker' const now = new Date() @@ -1498,9 +1517,10 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('ROW_NOT_FOUND') const rows = await db.select().from(settingsTable).where(eq(settingsTable.key, 'owner_only_setting')) expect(rows).toHaveLength(1) @@ -1529,7 +1549,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'patch_owned', value: 'initial', @@ -1581,13 +1601,27 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) + // Workspace + membership required by migration 0020 FK + workspace-scoped handler. + await db.insert(workspacesTable).values({ + id: '00000000-0000-0000-0000-000000000000', + name: 'test-workspace', + isPersonal: false, + ownerUserId: userId, + }) + await db.insert(workspaceMembershipsTable).values({ + id: 'membership-patch-deleted-at', + workspaceId: '00000000-0000-0000-0000-000000000000', + userId, + role: 'admin', + }) await db.insert(promptsTable).values({ id: 'prompt-to-soft-delete', title: 'My Prompt', prompt: 'Hello', modelId: 'gpt-4', userId, + workspaceId: '00000000-0000-0000-0000-000000000000', }) const deletedAtIso = '2026-02-18T16:41:12.428Z' @@ -1639,7 +1673,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'to_delete', @@ -1662,7 +1696,7 @@ describe('PowerSync API', () => { expect(rows).toHaveLength(0) }) - it('returns 400 when DELETE targets non-existent record', async () => { + it('returns 200 with ROW_NOT_FOUND rejection when DELETE targets non-existent record', async () => { const userId = 'user-delete-nonexistent' const now = new Date() const expiresAt = new Date(now.getTime() + 3600 * 1000) @@ -1683,7 +1717,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) // No settings row exists for 'nonexistent_to_delete' const response = await app.handle( @@ -1695,12 +1729,13 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('ROW_NOT_FOUND') }) - it('returns 400 when DELETE targets record belonging to another user', async () => { + it('returns 200 with ROW_NOT_FOUND rejection when DELETE targets record belonging to another user', async () => { const userA = 'user-delete-owner' const userB = 'user-delete-attacker' const now = new Date() @@ -1758,9 +1793,10 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('ROW_NOT_FOUND') const rows = await db.select().from(settingsTable).where(eq(settingsTable.key, 'owner_only_to_delete')) expect(rows).toHaveLength(1) @@ -1794,7 +1830,7 @@ describe('PowerSync API', () => { const response = await app.handle( new Request('http://localhost/powersync/upload', { method: 'PUT', - headers: uploadHeaders('bearer-patch-device-app-version'), + headers: uploadHeaders('bearer-patch-device-app-version', 'test-device-id'), body: JSON.stringify({ operations: [ { @@ -1836,7 +1872,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) // Insert a second device that the attacker will try to delete via PowerSync await db.insert(devicesTable).values({ @@ -1857,9 +1893,10 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('DELETE_NOT_ALLOWED') // Device must still exist const devices = await db.select().from(devicesTable).where(eq(devicesTable.id, deviceId)) @@ -1889,7 +1926,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -2084,7 +2121,7 @@ describe('PowerSync API', () => { expect(themeRows.find((r) => r.userId === userB)?.value).toBe('system') }) - it('returns 400 when an operation fails (invalid table, empty payload, etc.)', async () => { + it('returns 200 with UNKNOWN_TABLE rejection for an unrecognised table name', async () => { const userId = 'user-upload-fail' const now = new Date() const expiresAt = new Date(now.getTime() + 3600 * 1000) @@ -2106,7 +2143,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -2124,13 +2161,15 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const data = (await response.json()) as { error: string; code: string; table: string; id: string; op: string } - expect(data.error).toBe('Upload operation failed') - expect(data.code).toBe('UPLOAD_OPERATION_FAILED') - expect(data.table).toBe('nonexistent_table') - expect(data.id).toBe('some_id') - expect(data.op).toBe('PUT') + expect(response.status).toBe(200) + const data = (await response.json()) as { + rejected: Array<{ code: string; table: string; id: string; op: string }> + } + expect(data.rejected).toHaveLength(1) + expect(data.rejected[0]?.code).toBe('UNKNOWN_TABLE') + expect(data.rejected[0]?.table).toBe('nonexistent_table') + expect(data.rejected[0]?.id).toBe('some_id') + expect(data.rejected[0]?.op).toBe('PUT') }) it('returns 200 with empty operations array', async () => { @@ -2155,7 +2194,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -2890,7 +2929,9 @@ describe('PowerSync API — anonymous sync guard', () => { ) expect(response.status).toBe(200) const data = await response.json() - expect(data).toEqual({ success: true }) + // Response now includes `rejected: []` alongside `success`; use toMatchObject + // so adding fields to the response shape doesn't break this regression guard. + expect(data).toMatchObject({ success: true }) }) }) diff --git a/backend/src/api/powersync.ts b/backend/src/api/powersync.ts index 0fc994222..1a107fc6b 100644 --- a/backend/src/api/powersync.ts +++ b/backend/src/api/powersync.ts @@ -5,7 +5,8 @@ import type { Auth } from '@/auth/elysia-plugin' import type { Settings } from '@/config/settings' import { isOriginAllowed } from '@/config/settings' -import { applyOperation, getActiveSessionByToken, getDeviceById, getUserById, upsertDevice } from '@/dal' +import { getActiveSessionByToken, getDeviceById, getUserById, upsertDevice } from '@/dal' +import { applyUploadBatch, type UploadOp } from '@/powersync/upload-handlers' import type { db as DbType } from '@/db/client' import { verifySignedBearerToken } from '@/auth/bearer-token' import type { User } from '@shared/types/auth' @@ -260,26 +261,39 @@ export const createPowerSyncRoutes = (auth: Auth, settings: Settings, database: return validation.body } - const operations = body.operations - - // Process operations sequentially to maintain order. - // If any operation fails, return 4xx so the client does not call transaction.complete() - // and PowerSync will retry the batch. - for (const op of operations) { - const ok = await applyOperation(database, op, user.id) - if (!ok) { - set.status = 400 - return { - error: 'Upload operation failed', - code: 'UPLOAD_OPERATION_FAILED', - table: op.type, - id: op.id, - op: op.op, - } + // Dispatch the batch through the per-table upload handler factory. Each op + // runs in its own savepoint inside one outer transaction (see + // `applyUploadBatch`). Permanent rejections accumulate and return 200 so + // PowerSync clears the queue; any transient failure rolls back the whole + // batch and returns 503 so PowerSync retries. + // Elysia validates `type` as a string at the protocol boundary; the dispatcher + // narrows it via the handlers registry (returns `UNKNOWN_TABLE` permanent reject + // for names not in `PowerSyncTableName`). + const result = await applyUploadBatch(database, body.operations as UploadOp[], { + userId: user.id, + settings, + }) + + if (!result.ok) { + set.status = 503 + return { + error: 'Upload batch transient failure', + code: result.code, + table: result.op?.type, + id: result.op?.id, + op: result.op?.op, } } - return { success: true } + return { + success: true, + rejected: result.rejected.map(({ op, code }) => ({ + table: op.type, + id: op.id, + op: op.op, + code, + })), + } }, { body: t.Object({ diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 154b742c5..d2796d645 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -4,6 +4,7 @@ import { approveWaitlistEntry, + promotePendingMemberships, getOrCreateOtpChallenge, createWaitlistEntry, deleteOtpChallengesForEmail, @@ -11,6 +12,7 @@ import { getUserByEmail, getWaitlistByEmail, markUserNotNew, + syncMembershipDisplayInfo, validateOtpChallenge, } from '@/dal' import type { db as DbType } from '@/db/client' @@ -208,6 +210,28 @@ export const createAuth = (database: typeof DbType, emailDeps: AuthEmailDeps = { before: async (userData) => ({ data: { ...userData, email: normalizeEmail(userData.email) }, }), + // Promote any pending memberships invited by email. The personal workspace + // itself is FE-created (uploaded via PowerSync with a deterministic id), so + // this hook only handles the cross-workspace promotion flow — a brand-new + // user isn't a member of anything yet, so no FE client can see (let alone + // act on) the invite at signup time. Skipped for anonymous users — anon + // never receives invites. + after: async (createdUser) => { + const isAnonymous = (createdUser as { isAnonymous?: boolean }).isAnonymous === true + if (isAnonymous) { + return + } + await promotePendingMemberships(database, createdUser.id, createdUser.email, createdUser.name) + }, + }, + // Mirror name/email changes onto every membership row so co-members see + // updated display info on the next sync round-trip. The `workspace_memberships` + // table denormalizes these fields because PowerSync sync rules can't follow + // `user_id` across buckets — without this hook, edits would go stale. + update: { + after: async (updatedUser) => { + await syncMembershipDisplayInfo(database, updatedUser.id, updatedUser.name, updatedUser.email) + }, }, }, }, diff --git a/backend/src/config/settings.test.ts b/backend/src/config/settings.test.ts index e4d83967f..a40020ed5 100644 --- a/backend/src/config/settings.test.ts +++ b/backend/src/config/settings.test.ts @@ -51,6 +51,33 @@ describe('Config Settings', () => { }) }) + describe('SERVER_ID validation', () => { + const savedServerId = process.env.SERVER_ID + + afterEach(() => { + if (savedServerId !== undefined) { + process.env.SERVER_ID = savedServerId + } else { + delete process.env.SERVER_ID + } + clearSettingsCache() + }) + + it('surfaces a setup-pointing error when SERVER_ID is unset', () => { + delete process.env.SERVER_ID + clearSettingsCache() + + expect(() => getSettings()).toThrow(/SERVER_ID env var must be set.*make doctor/) + }) + + it('surfaces a UUID-format error when SERVER_ID is set but not a UUID', () => { + process.env.SERVER_ID = 'not-a-uuid' + clearSettingsCache() + + expect(() => getSettings()).toThrow(/SERVER_ID must be a valid UUID/) + }) + }) + describe('CORS default security', () => { const corsEnvKeys = ['CORS_ORIGINS'] as const diff --git a/backend/src/config/settings.ts b/backend/src/config/settings.ts index cff4914a3..df62a084a 100644 --- a/backend/src/config/settings.ts +++ b/backend/src/config/settings.ts @@ -9,6 +9,20 @@ import { z } from 'zod' */ const settingsSchema = z .object({ + // Stable per-deployment UUID. Returned by GET /v1/config and used by the frontend to + // key the trust-domain registry (auth token, device ID, encryption keys, DB filename + // are all namespaced by this). No default — TS-enforced to prevent ID duplication + // across deployments. `make doctor` auto-generates one for local dev. + // + // Custom messages mirror the BETTER_AUTH_SECRET ergonomics: a raw `Expected + // string, received undefined` ZodError doesn't tell a fresh dev what to do. + serverId: z + .string({ + error: + 'SERVER_ID env var must be set to a stable per-deployment UUID. Run `make doctor` to auto-generate one for local dev.', + }) + .uuid({ error: 'SERVER_ID must be a valid UUID.' }), + // API Keys fireworksApiKey: z.string().default(''), mistralApiKey: z.string().default(''), @@ -33,7 +47,23 @@ const settingsSchema = z // Anonymous-session overlay — opt-in. When false, the anonymous() Better Auth plugin // is NOT registered so /v1/api/auth/sign-in/anonymous returns 404. Defense-in-depth // against a malicious client bypassing the frontend gate via direct curl. + // Surfaced to the UI via GET /config as `allowAnonUsers`. authAllowAnonymous: z.boolean().default(false), + // Workspace creation policy flags. Surfaced via GET /config; enforced by the + // PowerSync upload-handler factory (workspaces table). Both default to false to + // match v1 production posture (central-admin only); relax per deployment. + allowWorkspaceCreationByAnon: z.boolean().default(false), + allowWorkspaceCreationByMembers: z.boolean().default(false), + // THU-603: per-row scope on the 8 workspace-shared resource tables. When false, + // PowerSync upload handlers reject `scope = 'user'` PUTs (USER_SCOPE_DISABLED) + // and the UI hides the scope picker. Defaults true so the feature is opt-out: + // deployments that want strict workspace-shared semantics set this to false. + allowUserScopedResources: z.boolean().default(true), + // Gate the workspace Permissions settings page + sidebar entry + members-page + // link. Defaults false: the feature ships hidden and must be explicitly opted + // into per deployment. Surfaced via GET /config; the FE hides route, menu, + // and members-page link when false so direct URL nav also 404s. + allowWorkspacePermissionsUi: z.boolean().default(false), oidcClientId: z.string().default(''), oidcClientSecret: z.string().default(''), oidcIssuer: z.string().default(''), @@ -154,6 +184,7 @@ export type Settings = z.infer const parseSettings = (): Settings => { const isDevelopment = process.env.NODE_ENV === 'development' const env = { + serverId: process.env.SERVER_ID, fireworksApiKey: process.env.FIREWORKS_API_KEY || '', mistralApiKey: process.env.MISTRAL_API_KEY || '', anthropicApiKey: process.env.ANTHROPIC_API_KEY || '', @@ -167,6 +198,10 @@ const parseSettings = (): Settings => { microsoftClientSecret: process.env.MICROSOFT_CLIENT_SECRET || '', authMode: (process.env.AUTH_MODE || 'consumer').toLowerCase(), authAllowAnonymous: process.env.AUTH_ALLOW_ANONYMOUS === 'true', + allowWorkspaceCreationByAnon: process.env.ALLOW_WORKSPACE_CREATION_BY_ANON === 'true', + allowWorkspaceCreationByMembers: process.env.ALLOW_WORKSPACE_CREATION_BY_MEMBERS === 'true', + allowUserScopedResources: process.env.ALLOW_USER_SCOPED_RESOURCES !== 'false', + allowWorkspacePermissionsUi: process.env.ALLOW_WORKSPACE_PERMISSIONS_UI === 'true', oidcClientId: process.env.OIDC_CLIENT_ID || '', oidcClientSecret: process.env.OIDC_CLIENT_SECRET || '', oidcIssuer: process.env.OIDC_ISSUER || '', diff --git a/backend/src/dal/index.ts b/backend/src/dal/index.ts index d333f47e1..9e118d9da 100644 --- a/backend/src/dal/index.ts +++ b/backend/src/dal/index.ts @@ -22,8 +22,33 @@ export { getActiveSessionByToken, linkSessionToDevice, revokeDeviceSessions } fr // Waitlist export { getWaitlistByEmail, createWaitlistEntry, approveWaitlistEntry } from './waitlist' -// PowerSync -export { applyOperation } from './powersync' +// Workspaces +export { + promotePendingMemberships, + countWorkspaceAdmins, + countWorkspaceMemberships, + deleteMembership, + deletePendingMembership, + deleteWorkspacePermission, + getMembershipById, + getPendingMembershipById, + getWorkspaceById, + getWorkspacePermissionById, + insertPersonalWorkspaceIfMissing, + isAdminOfAnyWorkspace, + isPersonalWorkspace, + isWorkspaceAdmin, + isWorkspaceMember, + syncMembershipDisplayInfo, + updateMembership, + updatePendingMembership, + updateWorkspace, + updateWorkspacePermission, + upsertMembership, + upsertPendingMembership, + upsertWorkspace, + upsertWorkspacePermission, +} from './workspaces' // OTP Challenge (session binding) export { diff --git a/backend/src/dal/powersync.ts b/backend/src/dal/powersync.ts deleted file mode 100644 index a106825fd..000000000 --- a/backend/src/dal/powersync.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import type { db as DbType } from '@/db/client' -import { - powersyncConflictTarget, - powersyncDbNameToSchemaKey, - powersyncPkColumn, - powersyncTablesByName, -} from '@/db/powersync-schema' -import { type PowerSyncTableName, powersyncTableNames } from '@shared/powersync-tables' -import { and, eq } from 'drizzle-orm' -import type { AnyPgTable } from 'drizzle-orm/pg-core' - -const validTables = new Set(powersyncTableNames) - -/** DB column names that clients cannot set via PowerSync upload (server-managed fields). */ -const uploadDenyColumns: Partial> = { - devices: ['revoked_at', 'trusted', 'public_key', 'mlkem_public_key', 'approval_pending', 'app_version'], -} - -/** Tables that cannot be deleted via PowerSync upload — must use dedicated API endpoints. */ -const uploadDenyDelete = new Set(['devices']) - -type PowerSyncOperation = { - op: 'PUT' | 'PATCH' | 'DELETE' - type: string - id: string - data?: Record -} - -/** DB column names that use Drizzle timestamp(); JSON sends them as ISO strings, so we convert to Date. */ -const timestampDbColumns = new Set(['deleted_at', 'last_seen', 'created_at', 'revoked_at', 'updated_at']) - -/** - * Convert payload with DB column names to schema keys and filter to valid columns only. - * Timestamp columns arrive as ISO strings from JSON; convert to Date for Drizzle. - */ -const toSchemaRecord = ( - dbRecord: Record, - validDbNames: Set, - dbNameToKey: Record, -): Record => { - const out: Record = {} - for (const [dbName, value] of Object.entries(dbRecord)) { - if (!validDbNames.has(dbName)) { - continue - } - const schemaKey = dbNameToKey[dbName] - if (schemaKey && value !== undefined) { - let mapped = value - if (timestampDbColumns.has(dbName) && typeof value === 'string') { - const d = new Date(value) - mapped = Number.isNaN(d.getTime()) ? value : d - } - out[schemaKey] = mapped - } - } - return out -} - -/** - * Apply a single PowerSync operation using Drizzle's query builder (parameterized, no raw SQL). - * The user_id is always set to the authenticated user to ensure data isolation. - */ -export const applyOperation = async ( - database: typeof DbType, - op: PowerSyncOperation, - userId: string, -): Promise => { - if (!validTables.has(op.type)) { - return false - } - - const tableName = op.type as PowerSyncTableName - const table = powersyncTablesByName[tableName] - const dbNameToKey = powersyncDbNameToSchemaKey[tableName] - const pkColumn = powersyncPkColumn[tableName] - const conflictTarget = powersyncConflictTarget[tableName] - if (!table || !dbNameToKey || !pkColumn || !conflictTarget) { - return false - } - - const validDbNames = new Set(Object.keys(dbNameToKey)) - const tableWithUserId = table as AnyPgTable & { userId: typeof table.userId } - - switch (op.op) { - case 'PUT': { - const payload = { ...(op.data ?? {}) } as Record - delete payload.id - delete payload.user_id - for (const col of uploadDenyColumns[tableName] ?? []) { - delete payload[col] - } - const rawData: Record = { ...payload, id: op.id, user_id: userId } - const schemaValues = toSchemaRecord(rawData, validDbNames, dbNameToKey) - if (Object.keys(schemaValues).length === 0) { - return false - } - - const updateSet = { ...schemaValues } - delete updateSet.id - delete updateSet.key - delete updateSet.userId - - const insertQuery = database.insert(table).values(schemaValues as never) - if (Object.keys(updateSet).length > 0) { - await insertQuery.onConflictDoUpdate({ - target: conflictTarget, - set: updateSet as never, - setWhere: eq(tableWithUserId.userId, userId), - }) - } else { - await insertQuery.onConflictDoNothing({ target: conflictTarget }) - } - return true - } - case 'PATCH': { - if (!op.data || Object.keys(op.data).length === 0) { - return true - } - const patchPayload = { ...op.data } as Record - delete patchPayload.id - delete patchPayload.user_id - for (const col of uploadDenyColumns[tableName] ?? []) { - delete patchPayload[col] - } - const schemaPatch = toSchemaRecord(patchPayload, validDbNames, dbNameToKey) - // Empty patch after stripping server-managed and unknown columns is a - // harmless no-op (e.g. a buggy client that only sent `{ user_id: null }`). - // Accept it so the client's CRUD queue can drain instead of looping on a - // 400 — refusing it would block every subsequent upload behind a write - // that has nothing to apply anyway. - if (Object.keys(schemaPatch).length === 0) { - return true - } - - const patched = await database - .update(table) - .set(schemaPatch as never) - .where(and(eq(pkColumn, op.id), eq(tableWithUserId.userId, userId))) - .returning() - - return patched.length > 0 - } - case 'DELETE': { - if (uploadDenyDelete.has(tableName)) { - return false - } - - const deleted = await database - .delete(table) - .where(and(eq(pkColumn, op.id), eq(tableWithUserId.userId, userId))) - .returning() - - return deleted.length > 0 - } - } - return false -} diff --git a/backend/src/dal/users.test.ts b/backend/src/dal/users.test.ts index 87af40b94..e75b1c649 100644 --- a/backend/src/dal/users.test.ts +++ b/backend/src/dal/users.test.ts @@ -41,7 +41,7 @@ describe('users DAL', () => { it('returns user when found', async () => { await insertUser('u1', 'u1@test.com') const result = await getUserById(db, 'u1') - expect(result).toEqual({ id: 'u1', isAnonymous: false }) + expect(result).toEqual({ id: 'u1', isAnonymous: false, name: 'Test User', email: 'u1@test.com' }) }) it('returns null when not found', async () => { @@ -54,7 +54,7 @@ describe('users DAL', () => { it('returns user when found', async () => { await insertUser('u2', 'u2@test.com') const result = await getUserByEmail(db, 'u2@test.com') - expect(result).toEqual({ id: 'u2' }) + expect(result).toEqual({ id: 'u2', name: 'Test User', email: 'u2@test.com' }) }) it('returns null when not found', async () => { diff --git a/backend/src/dal/users.ts b/backend/src/dal/users.ts index 0026da0ca..2551d3670 100644 --- a/backend/src/dal/users.ts +++ b/backend/src/dal/users.ts @@ -9,7 +9,7 @@ import { eq } from 'drizzle-orm' /** Get a user by ID. Returns null if not found. */ export const getUserById = async (database: typeof DbType, id: string) => database - .select({ id: user.id, isAnonymous: user.isAnonymous }) + .select({ id: user.id, isAnonymous: user.isAnonymous, name: user.name, email: user.email }) .from(user) .where(eq(user.id, id)) .limit(1) @@ -18,7 +18,7 @@ export const getUserById = async (database: typeof DbType, id: string) => /** Get a user by email. Returns null if not found. */ export const getUserByEmail = async (database: typeof DbType, email: string) => database - .select({ id: user.id }) + .select({ id: user.id, name: user.name, email: user.email }) .from(user) .where(eq(user.email, email)) .limit(1) diff --git a/backend/src/dal/workspaces.test.ts b/backend/src/dal/workspaces.test.ts new file mode 100644 index 000000000..b6bd3f979 --- /dev/null +++ b/backend/src/dal/workspaces.test.ts @@ -0,0 +1,258 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { user } from '@/db/auth-schema' +import { workspaceMembershipsTable, workspacePendingMembershipsTable, workspacesTable } from '@/db/powersync-schema' +import { createTestDb } from '@/test-utils/db' +import { and, eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { v7 as uuidv7 } from 'uuid' +import { promotePendingMemberships, syncMembershipDisplayInfo } from './workspaces' + +describe('promotePendingMemberships', () => { + let db: Awaited>['db'] + let cleanup: () => Promise + + const insertUser = async (id: string, email: string) => { + const now = new Date() + await db.insert(user).values({ + id, + name: 'Test User', + email, + emailVerified: true, + isNew: true, + createdAt: now, + updatedAt: now, + }) + } + + const insertSharedWorkspace = async (id: string): Promise => { + await db.insert(workspacesTable).values({ + id, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + } + + beforeEach(async () => { + const testEnv = await createTestDb() + db = testEnv.db + cleanup = testEnv.cleanup + }) + + afterEach(async () => { + await cleanup() + }) + + it('is a no-op for users with no matching pending memberships', async () => { + await insertUser('u1', 'u1@test.com') + await promotePendingMemberships(db, 'u1', 'u1@test.com', 'Test User') + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.userId, 'u1')) + expect(memberships).toHaveLength(0) + }) + + it('promotes pending memberships matching the user email into membership rows', async () => { + await insertUser('admin1', 'admin1@test.com') + const sharedWorkspaceId = uuidv7() + await insertSharedWorkspace(sharedWorkspaceId) + + await db.insert(workspacePendingMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedWorkspaceId, + email: 'newcomer@test.com', + role: 'member', + invitedByUserId: 'admin1', + }) + + await insertUser('newcomer', 'newcomer@test.com') + await promotePendingMemberships(db, 'newcomer', 'newcomer@test.com', 'Test User') + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.userId, 'newcomer')) + expect(memberships).toHaveLength(1) + expect(memberships[0].workspaceId).toBe(sharedWorkspaceId) + expect(memberships[0].role).toBe('member') + + const remainingPending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.email, 'newcomer@test.com')) + expect(remainingPending).toHaveLength(0) + }) + + it('normalizes the email before matching pending memberships', async () => { + await insertUser('admin2', 'admin2@test.com') + const sharedWorkspaceId = uuidv7() + await insertSharedWorkspace(sharedWorkspaceId) + + await db.insert(workspacePendingMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedWorkspaceId, + email: 'mixedcase@test.com', + role: 'admin', + invitedByUserId: 'admin2', + }) + + await insertUser('mixed', 'mixedcase@test.com') + // Mixed-case input — `promotePendingMemberships` normalizes before matching. + await promotePendingMemberships(db, 'mixed', 'MixedCase@TEST.com', 'Test User') + + const promoted = await db + .select() + .from(workspaceMembershipsTable) + .where( + and( + eq(workspaceMembershipsTable.userId, 'mixed'), + eq(workspaceMembershipsTable.workspaceId, sharedWorkspaceId), + ), + ) + expect(promoted).toHaveLength(1) + expect(promoted[0].role).toBe('admin') + }) + + it('promotes multiple pending invites for the same email atomically', async () => { + await insertUser('admin3', 'admin3@test.com') + const ws1 = uuidv7() + const ws2 = uuidv7() + await insertSharedWorkspace(ws1) + await insertSharedWorkspace(ws2) + + await db.insert(workspacePendingMembershipsTable).values([ + { + id: uuidv7(), + workspaceId: ws1, + email: 'multi@test.com', + role: 'member', + invitedByUserId: 'admin3', + }, + { + id: uuidv7(), + workspaceId: ws2, + email: 'multi@test.com', + role: 'admin', + invitedByUserId: 'admin3', + }, + ]) + + await insertUser('multi', 'multi@test.com') + await promotePendingMemberships(db, 'multi', 'multi@test.com', 'Test User') + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.userId, 'multi')) + expect(memberships).toHaveLength(2) + + const remainingPending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.email, 'multi@test.com')) + expect(remainingPending).toHaveLength(0) + }) +}) + +describe('syncMembershipDisplayInfo', () => { + let db: Awaited>['db'] + let cleanup: () => Promise + + const insertUser = async (id: string, email: string, name = 'Original Name') => { + const now = new Date() + await db.insert(user).values({ + id, + name, + email, + emailVerified: true, + isNew: true, + createdAt: now, + updatedAt: now, + }) + } + + const insertSharedWorkspace = async (id: string): Promise => { + await db.insert(workspacesTable).values({ id, name: 'Shared', isPersonal: false, ownerUserId: null }) + } + + beforeEach(async () => { + const testEnv = await createTestDb() + db = testEnv.db + cleanup = testEnv.cleanup + }) + + afterEach(async () => { + await cleanup() + }) + + it('updates user_name and user_email on every membership row for the user', async () => { + await insertUser('alice', 'old@test.com', 'Old Name') + const ws1 = uuidv7() + const ws2 = uuidv7() + await insertSharedWorkspace(ws1) + await insertSharedWorkspace(ws2) + + await db.insert(workspaceMembershipsTable).values([ + { + id: uuidv7(), + workspaceId: ws1, + userId: 'alice', + role: 'admin', + userName: 'Old Name', + userEmail: 'old@test.com', + }, + { + id: uuidv7(), + workspaceId: ws2, + userId: 'alice', + role: 'member', + userName: 'Old Name', + userEmail: 'old@test.com', + }, + ]) + + await syncMembershipDisplayInfo(db, 'alice', 'New Name', 'new@test.com') + + const rows = await db.select().from(workspaceMembershipsTable).where(eq(workspaceMembershipsTable.userId, 'alice')) + expect(rows).toHaveLength(2) + for (const row of rows) { + expect(row.userName).toBe('New Name') + expect(row.userEmail).toBe('new@test.com') + } + }) + + it('leaves rows for other users untouched', async () => { + await insertUser('alice', 'alice@test.com', 'Alice') + await insertUser('bob', 'bob@test.com', 'Bob') + const ws = uuidv7() + await insertSharedWorkspace(ws) + + await db.insert(workspaceMembershipsTable).values([ + { id: uuidv7(), workspaceId: ws, userId: 'alice', role: 'admin', userName: 'Alice', userEmail: 'alice@test.com' }, + { id: uuidv7(), workspaceId: ws, userId: 'bob', role: 'member', userName: 'Bob', userEmail: 'bob@test.com' }, + ]) + + await syncMembershipDisplayInfo(db, 'alice', 'Alice Updated', 'alice-new@test.com') + + const bobRow = await db + .select() + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.workspaceId, ws), eq(workspaceMembershipsTable.userId, 'bob'))) + expect(bobRow[0].userName).toBe('Bob') + expect(bobRow[0].userEmail).toBe('bob@test.com') + }) + + it('is a no-op when the user has no memberships', async () => { + await insertUser('lonely', 'lonely@test.com', 'Lonely') + + await syncMembershipDisplayInfo(db, 'lonely', 'New', 'new@test.com') + + const rows = await db.select().from(workspaceMembershipsTable).where(eq(workspaceMembershipsTable.userId, 'lonely')) + expect(rows).toHaveLength(0) + }) +}) diff --git a/backend/src/dal/workspaces.ts b/backend/src/dal/workspaces.ts new file mode 100644 index 000000000..3510aa72d --- /dev/null +++ b/backend/src/dal/workspaces.ts @@ -0,0 +1,620 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { db as DbType } from '@/db/client' +import { + workspaceMembershipsTable, + workspacePendingMembershipsTable, + workspacePermissionsTable, + workspacesTable, +} from '@/db/powersync-schema' +import { normalizeEmail } from '@/lib/email' +import { and, count, eq, ne } from 'drizzle-orm' +import { v7 as uuidv7 } from 'uuid' + +/** + * Promotes any pending memberships matching this user's email into real + * membership rows, atomically with the user creation. + * + * Called from the Better Auth post-user-create hook. The personal workspace + * itself is FE-created (uploaded via PowerSync with a deterministic id from + * `shared/workspaces.ts`) — the BE no longer creates one here. Pending + * promotion stays server-side because a brand-new user isn't a member of any + * workspace yet, so no FE client can see (or act on) the pending invite at + * signup time. + * + * Skipped for anonymous users — anon never receives pending invites. + */ +export const promotePendingMemberships = async ( + database: typeof DbType, + userId: string, + email: string, + name: string, +): Promise => { + const normalizedEmail = normalizeEmail(email) + + await database.transaction(async (tx) => { + const txDb = tx as unknown as typeof database + + const pending = await txDb + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.email, normalizedEmail)) + + if (pending.length === 0) { + return + } + + await txDb + .insert(workspaceMembershipsTable) + .values( + pending.map((row) => ({ + id: uuidv7(), + workspaceId: row.workspaceId, + userId, + role: row.role, + userName: name, + userEmail: normalizedEmail, + })), + ) + .onConflictDoNothing({ + target: [workspaceMembershipsTable.workspaceId, workspaceMembershipsTable.userId], + }) + + await txDb + .delete(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.email, normalizedEmail)) + }) +} + +export type WorkspaceRow = { + id: string + isPersonal: boolean + ownerUserId: string | null +} + +export type MembershipRow = { + id: string + workspaceId: string + userId: string + role: 'admin' | 'member' +} + +export type PendingRow = { + id: string + workspaceId: string +} + +export type PermissionRow = { + id: string + workspaceId: string +} + +/** Fetches the workspace row for membership/personal checks. Returns `null` if missing. */ +export const getWorkspaceById = async (database: typeof DbType, workspaceId: string): Promise => { + const rows = await database + .select({ + id: workspacesTable.id, + isPersonal: workspacesTable.isPersonal, + ownerUserId: workspacesTable.ownerUserId, + }) + .from(workspacesTable) + .where(eq(workspacesTable.id, workspaceId)) + .limit(1) + return rows[0] ?? null +} + +export const isPersonalWorkspace = async (database: typeof DbType, workspaceId: string): Promise => { + const row = await getWorkspaceById(database, workspaceId) + return row?.isPersonal === true +} + +export const isWorkspaceMember = async ( + database: typeof DbType, + workspaceId: string, + userId: string, +): Promise => { + const rows = await database + .select({ id: workspaceMembershipsTable.id }) + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.workspaceId, workspaceId), eq(workspaceMembershipsTable.userId, userId))) + .limit(1) + return rows.length > 0 +} + +export const isWorkspaceAdmin = async ( + database: typeof DbType, + workspaceId: string, + userId: string, +): Promise => { + const rows = await database + .select({ id: workspaceMembershipsTable.id }) + .from(workspaceMembershipsTable) + .where( + and( + eq(workspaceMembershipsTable.workspaceId, workspaceId), + eq(workspaceMembershipsTable.userId, userId), + eq(workspaceMembershipsTable.role, 'admin'), + ), + ) + .limit(1) + return rows.length > 0 +} + +/** + * Returns the user's `role` ('admin' | 'member') for the workspace, or `null` + * if they have no membership row. Used by upload handlers to evaluate + * `workspace_permissions.required_role` against the caller's actual role. + */ +export const getUserRoleInWorkspace = async ( + database: typeof DbType, + workspaceId: string, + userId: string, +): Promise => { + const rows = await database + .select({ role: workspaceMembershipsTable.role }) + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.workspaceId, workspaceId), eq(workspaceMembershipsTable.userId, userId))) + .limit(1) + const row = rows[0] + if (!row) { + return null + } + return row.role as Role +} + +/** + * Reads `workspace_permissions.required_role` for `(workspaceId, permissionKey)`. + * Returns `null` when no row exists yet; callers default to `'admin'` + * (Decision 11 — the safe default for any new key). + */ +export const getRequiredRoleForPermission = async ( + database: typeof DbType, + workspaceId: string, + permissionKey: WorkspacePermissionKey, +): Promise => { + const rows = await database + .select({ requiredRole: workspacePermissionsTable.requiredRole }) + .from(workspacePermissionsTable) + .where( + and( + eq(workspacePermissionsTable.workspaceId, workspaceId), + eq(workspacePermissionsTable.permissionKey, permissionKey), + ), + ) + .limit(1) + const row = rows[0] + if (!row) { + return null + } + return row.requiredRole as Role +} + +/** True when the user is an admin of any workspace (used to gate shared-workspace creation). */ +export const isAdminOfAnyWorkspace = async (database: typeof DbType, userId: string): Promise => { + const rows = await database + .select({ id: workspaceMembershipsTable.id }) + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.userId, userId), eq(workspaceMembershipsTable.role, 'admin'))) + .limit(1) + return rows.length > 0 +} + +/** + * Counts admin memberships in a workspace, optionally excluding one membership id. + * Used for last-admin protection: callers count after the delete inside the same tx + * and reject when the result would be zero. + */ +/** + * Counts memberships in a workspace. Used by the bootstrap-admin exception in + * the membership upload handler: it allows a single admin self-claim for a + * personal workspace only when the workspace currently has zero memberships. + */ +export const countWorkspaceMemberships = async (database: typeof DbType, workspaceId: string): Promise => { + const rows = await database + .select({ value: count() }) + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, workspaceId)) + return Number(rows[0]?.value ?? 0) +} + +export const countWorkspaceAdmins = async ( + database: typeof DbType, + workspaceId: string, + excludeMembershipId?: string, +): Promise => { + const baseFilter = and( + eq(workspaceMembershipsTable.workspaceId, workspaceId), + eq(workspaceMembershipsTable.role, 'admin'), + ) + const where = excludeMembershipId + ? and(baseFilter, ne(workspaceMembershipsTable.id, excludeMembershipId)) + : baseFilter + const rows = await database.select({ value: count() }).from(workspaceMembershipsTable).where(where) + return Number(rows[0]?.value ?? 0) +} + +export const getMembershipById = async ( + database: typeof DbType, + membershipId: string, +): Promise => { + const rows = await database + .select({ + id: workspaceMembershipsTable.id, + workspaceId: workspaceMembershipsTable.workspaceId, + userId: workspaceMembershipsTable.userId, + role: workspaceMembershipsTable.role, + }) + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.id, membershipId)) + .limit(1) + return rows[0] ?? null +} + +/** + * Look up a membership by `(workspace_id, user_id)` — the unique constraint + * that `upsertMembership` collides on. Upload-handler validation uses this to + * detect when a PUT would effectively change an existing role (treated as a + * PATCH for auth purposes). + */ +export const getMembershipByWorkspaceAndUser = async ( + database: typeof DbType, + workspaceId: string, + userId: string, +): Promise => { + const rows = await database + .select({ + id: workspaceMembershipsTable.id, + workspaceId: workspaceMembershipsTable.workspaceId, + userId: workspaceMembershipsTable.userId, + role: workspaceMembershipsTable.role, + }) + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.workspaceId, workspaceId), eq(workspaceMembershipsTable.userId, userId))) + .limit(1) + return rows[0] ?? null +} + +export const getPendingMembershipById = async (database: typeof DbType, id: string): Promise => { + const rows = await database + .select({ + id: workspacePendingMembershipsTable.id, + workspaceId: workspacePendingMembershipsTable.workspaceId, + }) + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, id)) + .limit(1) + return rows[0] ?? null +} + +export const getWorkspacePermissionById = async ( + database: typeof DbType, + id: string, +): Promise => { + const rows = await database + .select({ + id: workspacePermissionsTable.id, + workspaceId: workspacePermissionsTable.workspaceId, + }) + .from(workspacePermissionsTable) + .where(eq(workspacePermissionsTable.id, id)) + .limit(1) + return rows[0] ?? null +} + +import type { WorkspacePermissionKey } from '@shared/workspaces' + +export type Role = 'admin' | 'member' +export type { WorkspacePermissionKey } + +export type UpsertWorkspaceInput = { + id: string + name: string + isPersonal: boolean + /** Required when `isPersonal` is `true`; null/omitted for shared. */ + ownerUserId?: string | null + /** Optional slug. Shared-only; personal workspaces never carry one. */ + slug?: string | null + /** Optional icon (emoji or base64 image). Either workspace kind may set it. */ + icon?: string | null +} + +/** + * Upserts a shared workspace row. Conflict target is the PK; on conflict the + * mutable fields are refreshed and `updated_at` bumped — covers the admin- + * rename-via-PUT path even though FE renames now flow through PATCH. + * + * Use `insertPersonalWorkspaceIfMissing` for personal workspaces instead — the + * "do nothing on conflict" semantics avoid clobbering a user rename when a + * second device runs its idempotent bootstrap PUT. + */ +export const upsertWorkspace = async (database: typeof DbType, input: UpsertWorkspaceInput): Promise => { + await database + .insert(workspacesTable) + .values({ + id: input.id, + name: input.name, + slug: input.slug ?? null, + icon: input.icon ?? null, + isPersonal: input.isPersonal, + ownerUserId: input.ownerUserId ?? null, + }) + .onConflictDoUpdate({ + target: workspacesTable.id, + set: { + name: input.name, + ...(input.slug !== undefined ? { slug: input.slug } : {}), + ...(input.icon !== undefined ? { icon: input.icon } : {}), + updatedAt: new Date(), + }, + }) +} + +/** + * Insert a personal workspace row if no row with this id exists. Multi-device + * safe: device A creates and renames the workspace; device B running its own + * `ensurePersonalWorkspace` bootstrap re-uploads the canonical PUT with the + * default name. `ON CONFLICT DO NOTHING` preserves the renamed name on the BE. + * + * `slug` is intentionally absent — personal workspaces don't appear in URLs + * (see THU-551 URL deviation) so the column stays null. `icon` is optional and + * persisted on first insert only. + */ +export const insertPersonalWorkspaceIfMissing = async ( + database: typeof DbType, + input: { id: string; name: string; ownerUserId: string; icon?: string | null }, +): Promise => { + await database + .insert(workspacesTable) + .values({ + id: input.id, + name: input.name, + icon: input.icon ?? null, + isPersonal: true, + ownerUserId: input.ownerUserId, + }) + .onConflictDoNothing({ target: workspacesTable.id }) +} + +/** + * Updates a workspace's mutable fields. The upload handler is the only caller + * and gates writes on admin-of-the-workspace — this just persists the patch. + * + * Returns the affected row count so callers can map 0 → ROW_NOT_FOUND. + */ +export const updateWorkspace = async ( + database: typeof DbType, + id: string, + patch: { name?: string; slug?: string | null; icon?: string | null }, +): Promise => { + const setClause: Record = { updatedAt: new Date() } + if (patch.name !== undefined) { + setClause.name = patch.name + } + if (patch.slug !== undefined) { + setClause.slug = patch.slug + } + if (patch.icon !== undefined) { + setClause.icon = patch.icon + } + const rows = await database.update(workspacesTable).set(setClause).where(eq(workspacesTable.id, id)).returning() + return rows.length +} + +export type MembershipInput = { + id: string + workspaceId: string + userId: string + role: Role + /** Denormalized from `auth.user`. Synced down so the Members page can render + * display info without a `users` projection table (PowerSync sync rules + * can't follow `user_id` across buckets). */ + userName?: string | null + userEmail?: string | null +} + +/** + * Upserts a workspace membership. Conflict target is the natural key + * `(workspace_id, user_id)`; on conflict the role is refreshed. Display info + * (`user_name`, `user_email`) is refreshed too so a stale denormalized row + * heals the next time the upload handler runs against it. + */ +export const upsertMembership = async (database: typeof DbType, input: MembershipInput): Promise => { + await database + .insert(workspaceMembershipsTable) + .values(input) + .onConflictDoUpdate({ + target: [workspaceMembershipsTable.workspaceId, workspaceMembershipsTable.userId], + set: { + role: input.role, + ...(input.userName !== undefined ? { userName: input.userName } : {}), + ...(input.userEmail !== undefined ? { userEmail: input.userEmail } : {}), + }, + }) +} + +/** + * Insert a membership row only if no row with the same `(workspace_id, user_id)` + * already exists. Used by the promote-on-insert path in the pending-membership + * upload handler: an invite for an email that already belongs to a member must + * not overwrite that member's existing role (otherwise an invite for an admin's + * own email would downgrade them to whatever role the invite carried). Mirrors + * the `promotePendingMemberships` DO-NOTHING semantics for the signup path. + */ +export const insertMembershipIfMissing = async (database: typeof DbType, input: MembershipInput): Promise => { + await database + .insert(workspaceMembershipsTable) + .values(input) + .onConflictDoNothing({ + target: [workspaceMembershipsTable.workspaceId, workspaceMembershipsTable.userId], + }) +} + +/** + * Mirrors a user's current display info onto every one of their membership rows. + * Called from the Better Auth `update.after` hook so name/email changes propagate + * to co-members on the next sync round-trip. Idempotent — safe to call on every + * user update regardless of whether name/email actually changed. + */ +export const syncMembershipDisplayInfo = async ( + database: typeof DbType, + userId: string, + name: string, + email: string, +): Promise => { + await database + .update(workspaceMembershipsTable) + .set({ userName: name, userEmail: email }) + .where(eq(workspaceMembershipsTable.userId, userId)) +} + +export const updateMembership = async ( + database: typeof DbType, + id: string, + patch: { role?: Role }, +): Promise => { + if (patch.role === undefined) { + return 0 + } + const rows = await database + .update(workspaceMembershipsTable) + .set({ role: patch.role }) + .where(eq(workspaceMembershipsTable.id, id)) + .returning() + return rows.length +} + +export const deleteMembership = async (database: typeof DbType, id: string): Promise => { + const rows = await database.delete(workspaceMembershipsTable).where(eq(workspaceMembershipsTable.id, id)).returning() + return rows.length +} + +export type PendingMembershipInput = { + id: string + workspaceId: string + email: string + role: Role + invitedByUserId: string +} + +/** + * Upserts a pending membership row. Email is normalized server-side so case / + * whitespace variants land on the same record as the Better Auth `before` hook's + * normalized `user.email`. Conflict target is `(workspace_id, email)`; on conflict + * the role and inviter are refreshed. + */ +export const upsertPendingMembership = async ( + database: typeof DbType, + input: PendingMembershipInput, +): Promise => { + const email = normalizeEmail(input.email) + await database + .insert(workspacePendingMembershipsTable) + .values({ ...input, email }) + .onConflictDoUpdate({ + target: [workspacePendingMembershipsTable.workspaceId, workspacePendingMembershipsTable.email], + set: { role: input.role, invitedByUserId: input.invitedByUserId }, + }) +} + +export const updatePendingMembership = async ( + database: typeof DbType, + id: string, + patch: { email?: string; role?: Role; invitedByUserId?: string }, +): Promise => { + const setClause: Record = {} + if (patch.email !== undefined) { + setClause.email = normalizeEmail(patch.email) + } + if (patch.role !== undefined) { + setClause.role = patch.role + } + if (patch.invitedByUserId !== undefined) { + setClause.invitedByUserId = patch.invitedByUserId + } + if (Object.keys(setClause).length === 0) { + return 0 + } + const rows = await database + .update(workspacePendingMembershipsTable) + .set(setClause) + .where(eq(workspacePendingMembershipsTable.id, id)) + .returning() + return rows.length +} + +export const deletePendingMembership = async (database: typeof DbType, id: string): Promise => { + const rows = await database + .delete(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, id)) + .returning() + return rows.length +} + +/** + * Deletes the pending row for `(workspace_id, email)`. Used by the + * promote-on-insert path in the upload handler: when `upsertPendingMembership` + * conflicts on the `(workspace_id, email)` unique constraint, Postgres keeps + * the existing row's id, so a delete keyed on the upload's `op.id` would no-op + * and leave a stale pending invite behind for someone who is now a real + * member. Email is normalized to match `upsertPendingMembership`'s storage. + */ +export const deletePendingMembershipByWorkspaceAndEmail = async ( + database: typeof DbType, + workspaceId: string, + email: string, +): Promise => { + const normalizedEmail = normalizeEmail(email) + const rows = await database + .delete(workspacePendingMembershipsTable) + .where( + and( + eq(workspacePendingMembershipsTable.workspaceId, workspaceId), + eq(workspacePendingMembershipsTable.email, normalizedEmail), + ), + ) + .returning() + return rows.length +} + +export type WorkspacePermissionInput = { + id: string + workspaceId: string + permissionKey: WorkspacePermissionKey + requiredRole: Role +} + +export const upsertWorkspacePermission = async ( + database: typeof DbType, + input: WorkspacePermissionInput, +): Promise => { + await database + .insert(workspacePermissionsTable) + .values(input) + .onConflictDoUpdate({ + target: [workspacePermissionsTable.workspaceId, workspacePermissionsTable.permissionKey], + set: { requiredRole: input.requiredRole }, + }) +} + +export const updateWorkspacePermission = async ( + database: typeof DbType, + id: string, + patch: { requiredRole?: Role }, +): Promise => { + if (patch.requiredRole === undefined) { + return 0 + } + const rows = await database + .update(workspacePermissionsTable) + .set({ requiredRole: patch.requiredRole }) + .where(eq(workspacePermissionsTable.id, id)) + .returning() + return rows.length +} + +export const deleteWorkspacePermission = async (database: typeof DbType, id: string): Promise => { + const rows = await database.delete(workspacePermissionsTable).where(eq(workspacePermissionsTable.id, id)).returning() + return rows.length +} diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 27d1f6789..f24070cb5 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { PGlite } from '@electric-sql/pglite' +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp' import { drizzle as drizzlePglite } from 'drizzle-orm/pglite' import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator' import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js' @@ -26,11 +27,38 @@ const postgresUrl = isPglite ? null : process.env.DATABASE_URL || (isDevelopment ? 'postgresql://postgres:postgres@localhost:5433/postgres' : '') -if (isPglite && process.env.DATABASE_URL) { - mkdirSync(resolve(process.env.DATABASE_URL), { recursive: true }) +// When DRIVER=pglite, `DATABASE_URL` is treated as a *data-directory path* +// (`.env.example` documents `.pglite/data`). The default dev / e2e `.env` +// ships `postgresql://...` for the postgres driver, though, and inherits +// into pglite-mode runs (bun test, playwright web-server, manual `bun run +// src/index.ts` with mixed env). `new PGlite('postgresql://...')` then +// treats the connection string as a path and bootstraps a real Postgres +// data dir into `backend/postgresql:/postgres:postgres@localhost:.../...`. +// Detect the schema and treat connection-string values as "no path given" +// (i.e. in-memory PGlite). +const isPostgresConnectionUrl = (url: string | undefined): boolean => + typeof url === 'string' && /^(?:postgres|postgresql):\/\//.test(url) + +const pgliteDataDir = + isPglite && process.env.DATABASE_URL && !isPostgresConnectionUrl(process.env.DATABASE_URL) + ? process.env.DATABASE_URL + : undefined + +if (pgliteDataDir) { + mkdirSync(resolve(pgliteDataDir), { recursive: true }) } -const pgliteClient = isPglite ? new PGlite(process.env.DATABASE_URL) : null // undefined = in-memory +// `uuid_ossp` is bundled with PGlite as an opt-in contrib extension but isn't +// auto-loaded — the workspaces foundation migration uses `uuid_generate_v5` +// for deterministic personal-workspace ids and would otherwise fail with +// `extension "uuid-ossp" is not available`. The bun-test path (test-utils/ +// db.ts) registers the same extension; this keeps prod / e2e parity. +const pgliteOptions = { extensions: { uuid_ossp } } as const +const pgliteClient = isPglite + ? pgliteDataDir + ? new PGlite(pgliteDataDir, pgliteOptions) + : new PGlite(pgliteOptions) // no dataDir → in-memory + : null const pgliteDb = pgliteClient ? drizzlePglite({ client: pgliteClient, schema }) : null diff --git a/backend/src/db/powersync-schema.ts b/backend/src/db/powersync-schema.ts index 3f4fc6e04..a86eb76c8 100644 --- a/backend/src/db/powersync-schema.ts +++ b/backend/src/db/powersync-schema.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { PowerSyncTableName } from '@shared/powersync-tables' +import { workspacePermissionKeys, workspacePermissionRoles } from '@shared/workspaces' import { type AnyPgColumn, type AnyPgTable, @@ -14,8 +15,9 @@ import { real, text, timestamp, + uniqueIndex, } from 'drizzle-orm/pg-core' -import { getTableColumns } from 'drizzle-orm' +import { getTableColumns, sql } from 'drizzle-orm' import { user } from './auth-schema' /** @@ -25,6 +27,122 @@ import { user } from './auth-schema' const powersyncSchema = pgSchema('powersync') +/** + * Workspace entity. Every real user gets one personal workspace (`is_personal = true`) + * created by the Better Auth post-create hook; shared workspaces are created later via + * PowerSync upload from the FE (gated by `allowWorkspaceCreationBy*` flags). + * + * `owner_user_id` defines who a personal workspace belongs to (NOT an access-control + * "owner" role — roles live in `workspace_memberships`). The partial unique index + * enforces "one personal workspace per user". + */ +export const workspacesTable = powersyncSchema.table( + 'workspaces', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug'), + icon: text('icon'), + isPersonal: boolean('is_personal').notNull().default(false), + ownerUserId: text('owner_user_id').references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('idx_workspaces_personal_per_owner') + .on(table.ownerUserId) + .where(sql`${table.isPersonal} = true`), + index('idx_workspaces_owner_user_id').on(table.ownerUserId), + uniqueIndex('idx_workspaces_slug') + .on(table.slug) + .where(sql`${table.slug} IS NOT NULL`), + ], +) + +/** + * Workspace membership and role assignment. The natural key per spec §3.7 is + * `(workspace_id, user_id)`; PowerSync requires a single `id` column for row tracking, + * so the natural key is enforced as a unique constraint instead of a composite PK. + * + * Roles are `admin` | `member` only (Decision 9 — no `owner`). Last-admin protection + * lives in the upload handler factory. + * + * `user_name` / `user_email` are denormalized from `auth.user` so the Members + * page can render display info without a synced `users` table — PowerSync sync + * rules can't follow a `user_id` foreign key across buckets. The upload handler + * fills them at insert time; the Better Auth `after('updateUser')` hook keeps + * them in step when a user later edits their name or email. + */ +export const workspaceMembershipsTable = powersyncSchema.table( + 'workspace_memberships', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + role: text('role', { enum: ['admin', 'member'] }).notNull(), + userName: text('user_name'), + userEmail: text('user_email'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('idx_workspace_memberships_workspace_user').on(table.workspaceId, table.userId), + index('idx_workspace_memberships_user').on(table.userId), + index('idx_workspace_memberships_workspace').on(table.workspaceId), + ], +) + +/** + * Pending memberships: admin invites an email that doesn't yet have an account. + * On signup, the Better Auth post-create hook promotes any matching rows into + * `workspace_memberships` and deletes them here. Emails are stored normalized + * (lower-cased + trimmed) to match the `before` hook's normalization of `user.email`. + */ +export const workspacePendingMembershipsTable = powersyncSchema.table( + 'workspace_pending_memberships', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + role: text('role', { enum: ['admin', 'member'] }).notNull(), + invitedByUserId: text('invited_by_user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('idx_workspace_pending_memberships_workspace_email').on(table.workspaceId, table.email), + index('idx_workspace_pending_memberships_email').on(table.email), + index('idx_workspace_pending_memberships_workspace').on(table.workspaceId), + ], +) + +/** + * Per-workspace permission policy (Decision 10). The enum lists every + * configurable action the workspace exposes; the source of truth lives in + * `shared/workspaces.ts` so FE/BE schemas + types stay in lockstep. + */ +export const workspacePermissionsTable = powersyncSchema.table( + 'workspace_permissions', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + permissionKey: text('permission_key', { enum: [...workspacePermissionKeys] }).notNull(), + requiredRole: text('required_role', { enum: [...workspacePermissionRoles] }).notNull(), + }, + (table) => [ + uniqueIndex('idx_workspace_permissions_workspace_key').on(table.workspaceId, table.permissionKey), + index('idx_workspace_permissions_workspace').on(table.workspaceId), + ], +) + export const settingsTable = powersyncSchema.table( 'settings', { @@ -53,11 +171,19 @@ export const chatThreadsTable = powersyncSchema.table( acpSessionId: text('acp_session_id'), agentId: text('agent_id'), deletedAt: timestamp('deleted_at'), + // User-private within a workspace — the row's author is the only valid reader, + // so deleting the user cascades the row away (no other member can ever see it). userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [index('idx_chat_threads_user_id').on(table.userId)], + (table) => [ + index('idx_chat_threads_user_id').on(table.userId), + index('idx_chat_threads_workspace_id').on(table.workspaceId), + ], ) export const chatMessagesTable = powersyncSchema.table( @@ -76,8 +202,14 @@ export const chatMessagesTable = powersyncSchema.table( userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [index('idx_chat_messages_user_id').on(table.userId)], + (table) => [ + index('idx_chat_messages_user_id').on(table.userId), + index('idx_chat_messages_workspace_id').on(table.workspaceId), + ], ) export const tasksTable = powersyncSchema.table( @@ -92,8 +224,16 @@ export const tasksTable = powersyncSchema.table( userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_tasks_user_id').on(table.userId)], + (table) => [ + // Composite PK on (id, workspace_id) lets default-data rows repeat across workspaces. + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_tasks_user_id').on(table.userId), + index('idx_tasks_workspace_id').on(table.workspaceId), + ], ) export const modelsTable = powersyncSchema.table( @@ -117,11 +257,20 @@ export const modelsTable = powersyncSchema.table( defaultHash: text('default_hash'), vendor: text('vendor'), description: text('description'), - userId: text('user_id') + apiKey: text('api_key'), + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_models_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_models_user_id').on(table.userId), + index('idx_models_workspace_id').on(table.workspaceId), + ], ) export const promptsTable = powersyncSchema.table( @@ -133,11 +282,19 @@ export const promptsTable = powersyncSchema.table( modelId: text('model_id'), deletedAt: timestamp('deleted_at'), defaultHash: text('default_hash'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_prompts_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_prompts_user_id').on(table.userId), + index('idx_prompts_workspace_id').on(table.workspaceId), + ], ) export const skillsTable = powersyncSchema.table( @@ -151,11 +308,19 @@ export const skillsTable = powersyncSchema.table( pinnedOrder: integer('pinned_order'), deletedAt: timestamp('deleted_at'), defaultHash: text('default_hash'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_skills_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_skills_user_id').on(table.userId), + index('idx_skills_workspace_id').on(table.workspaceId), + ], ) export const triggersTable = powersyncSchema.table( @@ -167,11 +332,15 @@ export const triggersTable = powersyncSchema.table( promptId: text('prompt_id'), isEnabled: integer('is_enabled').default(1), deletedAt: timestamp('deleted_at'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, - (table) => [index('idx_triggers_user_id').on(table.userId)], + (table) => [index('idx_triggers_user_id').on(table.userId), index('idx_triggers_workspace_id').on(table.workspaceId)], ) export const modesTable = powersyncSchema.table( @@ -186,11 +355,19 @@ export const modesTable = powersyncSchema.table( order: integer('order').default(0), defaultHash: text('default_hash'), deletedAt: timestamp('deleted_at'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_modes_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_modes_user_id').on(table.userId), + index('idx_modes_workspace_id').on(table.workspaceId), + ], ) export const modelProfilesTable = powersyncSchema.table( @@ -218,11 +395,19 @@ export const modelProfilesTable = powersyncSchema.table( providerOptions: text('provider_options'), defaultHash: text('default_hash'), deletedAt: timestamp('deleted_at'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_model_profiles_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_model_profiles_user_id').on(table.userId), + index('idx_model_profiles_workspace_id').on(table.workspaceId), + ], ) /** Synced via PowerSync. Device list, status, and public key for encryption. */ @@ -246,14 +431,20 @@ export const devicesTable = powersyncSchema.table( (table) => [index('idx_devices_user_id').on(table.userId)], ) -/** Synced via PowerSync. User-created ACP agents only. System agents are not rows. */ +/** + * Synced via PowerSync. User-created ACP agents only. System agents are not rows. + * Workspace-scoped, shared with all members. External service connection + * configs belong to a workspace, not a single user — the addendum predates + * this table being added in THU-547. + */ export const agentsTable = powersyncSchema.table( 'agents', { id: text('id').notNull(), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), name: text('name').notNull(), type: text('type', { enum: ['remote-acp', 'managed-acp'] }).notNull(), transport: text('transport', { enum: ['websocket'] }).notNull(), @@ -262,8 +453,15 @@ export const agentsTable = powersyncSchema.table( icon: text('icon'), enabled: integer('enabled').default(1).notNull(), deletedAt: timestamp('deleted_at'), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_agents_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_agents_user_id').on(table.userId), + index('idx_agents_workspace_id').on(table.workspaceId), + ], ) /** @@ -283,6 +481,10 @@ export const powersyncTablesByName = { model_profiles: modelProfilesTable, devices: devicesTable, agents: agentsTable, + workspaces: workspacesTable, + workspace_memberships: workspaceMembershipsTable, + workspace_pending_memberships: workspacePendingMembershipsTable, + workspace_permissions: workspacePermissionsTable, } satisfies Record /** @@ -312,6 +514,10 @@ export const powersyncPkColumn: Record = { model_profiles: modelProfilesTable.id, devices: devicesTable.id, agents: agentsTable.id, + workspaces: workspacesTable.id, + workspace_memberships: workspaceMembershipsTable.id, + workspace_pending_memberships: workspacePendingMembershipsTable.id, + workspace_permissions: workspacePermissionsTable.id, } /** @@ -323,13 +529,17 @@ export const powersyncConflictTarget: Record settings: [settingsTable.key, settingsTable.userId], chat_threads: [chatThreadsTable.id], chat_messages: [chatMessagesTable.id], - tasks: [tasksTable.id, tasksTable.userId], - models: [modelsTable.id, modelsTable.userId], - prompts: [promptsTable.id, promptsTable.userId], - skills: [skillsTable.id, skillsTable.userId], + tasks: [tasksTable.id, tasksTable.workspaceId], + models: [modelsTable.id, modelsTable.workspaceId], + prompts: [promptsTable.id, promptsTable.workspaceId], + skills: [skillsTable.id, skillsTable.workspaceId], triggers: [triggersTable.id], - modes: [modesTable.id, modesTable.userId], - model_profiles: [modelProfilesTable.id, modelProfilesTable.userId], + modes: [modesTable.id, modesTable.workspaceId], + model_profiles: [modelProfilesTable.id, modelProfilesTable.workspaceId], devices: [devicesTable.id], - agents: [agentsTable.id, agentsTable.userId], + agents: [agentsTable.id, agentsTable.workspaceId], + workspaces: [workspacesTable.id], + workspace_memberships: [workspaceMembershipsTable.id], + workspace_pending_memberships: [workspacePendingMembershipsTable.id], + workspace_permissions: [workspacePermissionsTable.id], } diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 82c5ba810..fdba49d95 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -8,3 +8,13 @@ * - Trims whitespace */ export const normalizeEmail = (email: string) => email.toLowerCase().trim() + +/** + * Format check (same regex as the FE's `isValidEmailFormat`) — guards + * upload-handler inserts against junk strings before they hit the DB. Run + * against the normalized form so case/whitespace don't sneak past. + */ +const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/ + +export const isValidEmailFormat = (email: string): boolean => emailRegex.test(normalizeEmail(email)) diff --git a/backend/src/powersync/upload-handlers/helpers.ts b/backend/src/powersync/upload-handlers/helpers.ts new file mode 100644 index 000000000..42f44bb9b --- /dev/null +++ b/backend/src/powersync/upload-handlers/helpers.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getRequiredRoleForPermission, getUserRoleInWorkspace } from '@/dal/workspaces' +import { permissionAllows, type WorkspacePermissionKey } from '@shared/workspaces' +import type { HandlerResult, UploadTx } from './types' + +/** Column names Drizzle declares as `timestamp(...)`; JSON sends them as ISO strings. */ +const timestampDbColumns = new Set(['deleted_at', 'last_seen', 'created_at', 'revoked_at', 'updated_at']) + +/** + * Map a `{ db_column_name: value }` payload from PowerSync into a Drizzle-ready + * `{ schemaKey: value }` shape, dropping unknown columns and converting ISO date + * strings on `timestamp` columns into `Date` instances. + */ +export const toSchemaRecord = ( + dbRecord: Record, + validDbNames: Set, + dbNameToKey: Record, +): Record => { + const out: Record = {} + for (const [dbName, value] of Object.entries(dbRecord)) { + if (!validDbNames.has(dbName)) { + continue + } + const schemaKey = dbNameToKey[dbName] + if (schemaKey && value !== undefined) { + let mapped = value + if (timestampDbColumns.has(dbName) && typeof value === 'string') { + const d = new Date(value) + mapped = Number.isNaN(d.getTime()) ? value : d + } + out[schemaKey] = mapped + } + } + return out +} + +/** Shorthand result constructors so handler bodies stay terse. */ +export const allow = (): HandlerResult => ({ kind: 'apply' }) +export const reject = (rejectionClass: 'permanent' | 'transient', code: string): HandlerResult => ({ + kind: 'reject', + class: rejectionClass, + code, +}) + +/** + * Resolves the caller's role + the configured permission's required role and + * returns whether the op is allowed. Defaults `required_role` to `'admin'` + * when no `workspace_permissions` row exists for the key (Decision 11) so an + * unconfigured workspace stays admin-only. Shared by every handler that gates + * writes on `workspace_permissions` — keep the lookup in one place so the + * default-to-admin policy can't drift between tables. + */ +export const callerSatisfiesPermission = async ( + tx: UploadTx, + workspaceId: string, + userId: string, + permissionKey: WorkspacePermissionKey, +): Promise => { + const required = (await getRequiredRoleForPermission(tx, workspaceId, permissionKey)) ?? 'admin' + const userRole = await getUserRoleInWorkspace(tx, workspaceId, userId) + return permissionAllows(userRole, required) +} diff --git a/backend/src/powersync/upload-handlers/index.ts b/backend/src/powersync/upload-handlers/index.ts new file mode 100644 index 000000000..9aeffdd75 --- /dev/null +++ b/backend/src/powersync/upload-handlers/index.ts @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export { applyUploadBatch, handlers, type BatchResult } from './registry' +export type { HandlerResult, UploadCtx, UploadHandler, UploadOp, UploadTx } from './types' +export { UploadRejection } from './types' diff --git a/backend/src/powersync/upload-handlers/registry.test.ts b/backend/src/powersync/upload-handlers/registry.test.ts new file mode 100644 index 000000000..f8284fabe --- /dev/null +++ b/backend/src/powersync/upload-handlers/registry.test.ts @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { powersyncTableNames } from '@shared/powersync-tables' +import { describe, expect, it } from 'bun:test' +import { handlers } from './registry' + +/** + * Schema-drift test (addendum §3.9): the upload handler registry must cover every + * synced table. The `Record` constraint already + * catches this at compile time — this runtime assertion is a defensive check that + * the registry never gets accidentally narrowed (e.g. via casts). + */ +describe('upload handler registry', () => { + it('has a handler for every synced table', () => { + const missing = powersyncTableNames.filter((name) => !(name in handlers)) + expect(missing).toEqual([]) + }) + + it('does not expose handlers for unknown table names', () => { + const extra = Object.keys(handlers).filter((name) => !powersyncTableNames.includes(name as never)) + expect(extra).toEqual([]) + }) + + it('every handler exposes validate and apply functions', () => { + for (const name of powersyncTableNames) { + const handler = handlers[name] + expect(typeof handler.validate).toBe('function') + expect(typeof handler.apply).toBe('function') + } + }) +}) diff --git a/backend/src/powersync/upload-handlers/registry.ts b/backend/src/powersync/upload-handlers/registry.ts new file mode 100644 index 000000000..dd302c3a7 --- /dev/null +++ b/backend/src/powersync/upload-handlers/registry.ts @@ -0,0 +1,172 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { db as DbType } from '@/db/client' +import { type PowerSyncTableName } from '@shared/powersync-tables' +import { createUserScopedHandler } from './user-scoped' +import { createWorkspaceScopedHandler } from './workspace-scoped' +import { UploadRejection, type RejectedOp, type UploadCtx, type UploadHandler, type UploadOp } from './types' +import { workspacesHandler } from './workspaces' +import { workspaceMembershipsHandler } from './workspace-memberships' +import { workspacePendingMembershipsHandler } from './workspace-pending-memberships' +import { workspacePermissionsHandler } from './workspace-permissions' + +/** + * Per-table upload handler registry. The `Record` shape + * is the schema-drift pin (addendum §3.9): adding a new synced table to + * `shared/powersync-tables.ts` without a matching handler here fails `tsc`. + * + * The user-scoped factory covers tables whose rows are owned by a single user; + * the workspace tables have bespoke handlers because their permission model is + * row-relational rather than row-owned. + */ +export const handlers: Record = { + // Account-level (user-scoped, not workspace-scoped). + settings: createUserScopedHandler({ tableName: 'settings' }), + // Devices are partially writable: server-managed columns are stripped, DELETE + // goes through the dedicated revoke API (`/api/account/devices/:id`). + devices: createUserScopedHandler({ + tableName: 'devices', + denyColumns: ['revoked_at', 'trusted', 'public_key', 'mlkem_public_key', 'approval_pending', 'app_version'], + denyDelete: true, + }), + + // Workspace-scoped, user-private (only the row's author may read/write). + chat_threads: createWorkspaceScopedHandler({ tableName: 'chat_threads', userPrivate: true }), + chat_messages: createWorkspaceScopedHandler({ tableName: 'chat_messages', userPrivate: true }), + tasks: createWorkspaceScopedHandler({ tableName: 'tasks', userPrivate: true }), + + // Workspace-scoped, shared by default. `scopeAware: true` opts the table into + // THU-603's per-row visibility: `scope = 'workspace'` rows behave as today, + // `scope = 'user'` rows are user-private within the workspace (only the row + // owner may read or write). + models: createWorkspaceScopedHandler({ + tableName: 'models', + userPrivate: false, + scopeAware: true, + addPermissionKey: 'add_models', + removePermissionKey: 'remove_models', + softDeleteColumn: 'deleted_at', + }), + prompts: createWorkspaceScopedHandler({ tableName: 'prompts', userPrivate: false, scopeAware: true }), + skills: createWorkspaceScopedHandler({ + tableName: 'skills', + userPrivate: false, + scopeAware: true, + addPermissionKey: 'add_skills', + removePermissionKey: 'remove_skills', + softDeleteColumn: 'deleted_at', + }), + triggers: createWorkspaceScopedHandler({ tableName: 'triggers', userPrivate: false, scopeAware: true }), + modes: createWorkspaceScopedHandler({ tableName: 'modes', userPrivate: false, scopeAware: true }), + model_profiles: createWorkspaceScopedHandler({ tableName: 'model_profiles', userPrivate: false, scopeAware: true }), + agents: createWorkspaceScopedHandler({ + tableName: 'agents', + userPrivate: false, + scopeAware: true, + addPermissionKey: 'add_agents', + removePermissionKey: 'remove_agents', + softDeleteColumn: 'deleted_at', + }), + + // Workspace registry tables — bespoke handlers (commit 2). + workspaces: workspacesHandler, + workspace_memberships: workspaceMembershipsHandler, + workspace_pending_memberships: workspacePendingMembershipsHandler, + workspace_permissions: workspacePermissionsHandler, +} + +export type BatchResult = + /** + * The batch completed without any transient failures. Applied ops are committed; + * `rejected` lists every op that was permanently rejected (its savepoint rolled + * back individually so it never landed in the DB). Empty list = full success. + */ + | { ok: true; rejected: RejectedOp[] } + /** + * At least one op (or the outer transaction itself) hit a transient failure; + * the entire batch rolled back. The caller maps this to a 5xx so PowerSync + * retries the batch. + */ + | { ok: false; code: string; op?: UploadOp } + +/** + * Runs the upload batch with per-op savepoints inside a single outer transaction. + * + * - Permanent rejection → that op's savepoint is rolled back; the op is added to + * the `rejected` list and the loop continues. Earlier applied ops remain visible + * to later ops (last-admin protection still observes cumulative state). + * - Transient rejection (or any non-`UploadRejection` throw) → bubble out of the + * outer transaction, rolling everything back. Caller returns 5xx so PowerSync + * retries the batch. + * + * Per the addendum's failure classification (§3.9): permanent = ack-reject and + * discard, transient = retry. The all-or-nothing tx around the batch keeps + * cross-op invariants atomic; per-op savepoints let permanent rejections coexist + * with applied ops in the same response. + */ +export const applyUploadBatch = async ( + database: typeof DbType, + operations: UploadOp[], + ctx: UploadCtx, +): Promise => { + const rejected: RejectedOp[] = [] + // Mutated inside the async tx callback — TS won't narrow across that boundary, + // so the outer catch reads these fields directly. + let transientOp: UploadOp | null = null + let transientCode: string | null = null + + const recordPermanent = (op: UploadOp, code: string): void => { + rejected.push({ op, code }) + } + + try { + await database.transaction(async (outerTx) => { + const outerDb = outerTx as unknown as typeof database + + for (const op of operations) { + const handler = handlers[op.type] + if (!handler) { + recordPermanent(op, 'UNKNOWN_TABLE') + continue + } + + try { + // Nested transaction uses a Postgres savepoint; rolling back the inner + // throw doesn't abort the outer tx. See Drizzle's transaction docs. + await outerDb.transaction(async (innerTx) => { + const innerDb = innerTx as unknown as typeof database + const result = await handler.validate(op, ctx, innerDb) + if (result.kind === 'reject') { + throw new UploadRejection(result.class, result.code) + } + await handler.apply(op, ctx, innerDb) + }) + } catch (err) { + if (err instanceof UploadRejection && err.rejectionClass === 'permanent') { + recordPermanent(op, err.code) + continue + } + // Either a transient UploadRejection or an uncategorized error (raw DB + // failure, deadlock, etc.) — abort the whole batch so PowerSync retries. + transientOp = op + transientCode = + err instanceof UploadRejection ? err.code : err instanceof Error ? err.message : 'UNKNOWN_ERROR' + throw err + } + } + }) + } catch (err) { + if (transientOp && transientCode) { + return { ok: false, code: transientCode, op: transientOp } + } + // Outer tx itself failed (commit error, connection drop, etc.) — surface as transient. + return { + ok: false, + code: err instanceof Error ? err.message : 'UNKNOWN_ERROR', + } + } + + return { ok: true, rejected } +} diff --git a/backend/src/powersync/upload-handlers/types.ts b/backend/src/powersync/upload-handlers/types.ts new file mode 100644 index 000000000..1d309bcb3 --- /dev/null +++ b/backend/src/powersync/upload-handlers/types.ts @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { Settings } from '@/config/settings' +import type { db as DbType } from '@/db/client' +import type { PowerSyncTableName } from '@shared/powersync-tables' + +export type UploadOp = { + op: 'PUT' | 'PATCH' | 'DELETE' + type: PowerSyncTableName + id: string + data?: Record +} + +export type UploadCtx = { + userId: string + settings: Settings +} + +export type HandlerResult = { kind: 'apply' } | { kind: 'reject'; class: 'permanent' | 'transient'; code: string } + +/** + * Drizzle's transaction callback receives an opaque `tx` object; the rest of the + * codebase casts it to `typeof db` so all the usual query builders compose. We + * follow the same idiom — see `backend/src/api/account.ts` / `encryption.ts`. + */ +export type UploadTx = typeof DbType + +export type UploadHandler = { + /** + * Pre-write policy check. Pure read or constant-time logic where possible — + * anything that needs the row state (last-admin protection, etc.) should run + * inside `apply` so it shares the same transactional snapshot as the write. + */ + validate: (op: UploadOp, ctx: UploadCtx, tx: UploadTx) => Promise + /** + * Performs the write. Throw `UploadRejection` to abort the batch with a + * permanent or transient error; any other throw is treated as transient + * (DB-level error) by the dispatcher. + */ + apply: (op: UploadOp, ctx: UploadCtx, tx: UploadTx) => Promise +} + +/** + * Thrown by handlers to abort the upload batch with a structured rejection. + * Distinct from generic `Error` so the dispatcher can classify the failure + * without inspecting the message. + */ +export class UploadRejection extends Error { + constructor( + public readonly rejectionClass: 'permanent' | 'transient', + public readonly code: string, + ) { + super(`upload ${rejectionClass}: ${code}`) + this.name = 'UploadRejection' + } +} + +/** A permanently rejected op accumulated during batch dispatch. */ +export type RejectedOp = { + op: UploadOp + code: string +} diff --git a/backend/src/powersync/upload-handlers/user-scoped.ts b/backend/src/powersync/upload-handlers/user-scoped.ts new file mode 100644 index 000000000..423806570 --- /dev/null +++ b/backend/src/powersync/upload-handlers/user-scoped.ts @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + powersyncConflictTarget, + powersyncDbNameToSchemaKey, + powersyncPkColumn, + powersyncTablesByName, +} from '@/db/powersync-schema' +import type { PowerSyncTableName } from '@shared/powersync-tables' +import { and, eq } from 'drizzle-orm' +import type { AnyPgColumn, AnyPgTable } from 'drizzle-orm/pg-core' +import { allow, reject, toSchemaRecord } from './helpers' +import { UploadRejection, type UploadHandler } from './types' + +export type UserScopedConfig = { + tableName: PowerSyncTableName + /** Columns the client may not set; stripped from PUT/PATCH payloads. */ + denyColumns?: readonly string[] + /** When true, DELETE ops are permanently rejected. */ + denyDelete?: boolean +} + +/** + * Builds an `UploadHandler` for a table whose rows are owned by a single user + * (the row's `user_id` column always equals the authenticated user). Covers + * every pre-workspace synced table; the workspace tables get their own handlers. + * + * Behavior matches the pre-factory `applyOperation` so this commit is a pure + * refactor for the existing tables. Workspace-id scoping is layered in commit 3. + */ +export const createUserScopedHandler = (cfg: UserScopedConfig): UploadHandler => { + const { tableName, denyColumns = [], denyDelete = false } = cfg + + return { + validate: async (op) => { + if (op.op === 'DELETE' && denyDelete) { + return reject('permanent', 'DELETE_NOT_ALLOWED') + } + return allow() + }, + apply: async (op, ctx, tx) => { + const table = powersyncTablesByName[tableName] + const dbNameToKey = powersyncDbNameToSchemaKey[tableName] + const pkColumn = powersyncPkColumn[tableName] + const conflictTarget = powersyncConflictTarget[tableName] + + const validDbNames = new Set(Object.keys(dbNameToKey)) + // All user-scoped tables carry a `user_id` column for ownership isolation. + const tableWithUserId = table as AnyPgTable & { userId: AnyPgColumn } + + switch (op.op) { + case 'PUT': { + const payload = { ...(op.data ?? {}) } as Record + delete payload.id + delete payload.user_id + for (const col of denyColumns) { + delete payload[col] + } + const rawData: Record = { ...payload, id: op.id, user_id: ctx.userId } + const schemaValues = toSchemaRecord(rawData, validDbNames, dbNameToKey) + if (Object.keys(schemaValues).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + const updateSet = { ...schemaValues } + delete updateSet.id + delete updateSet.key + delete updateSet.userId + + const insertQuery = tx.insert(table).values(schemaValues as never) + if (Object.keys(updateSet).length > 0) { + await insertQuery.onConflictDoUpdate({ + target: conflictTarget, + set: updateSet as never, + setWhere: eq(tableWithUserId.userId, ctx.userId), + }) + } else { + await insertQuery.onConflictDoNothing({ target: conflictTarget }) + } + return + } + case 'PATCH': { + if (!op.data || Object.keys(op.data).length === 0) { + return + } + const patchPayload = { ...op.data } as Record + delete patchPayload.id + delete patchPayload.user_id + for (const col of denyColumns) { + delete patchPayload[col] + } + const schemaPatch = toSchemaRecord(patchPayload, validDbNames, dbNameToKey) + if (Object.keys(schemaPatch).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + const patched = await tx + .update(table) + .set(schemaPatch as never) + .where(and(eq(pkColumn, op.id), eq(tableWithUserId.userId, ctx.userId))) + .returning() + + if (patched.length === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const deleted = await tx + .delete(table) + .where(and(eq(pkColumn, op.id), eq(tableWithUserId.userId, ctx.userId))) + .returning() + + if (deleted.length === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, + } +} diff --git a/backend/src/powersync/upload-handlers/workspace-handlers.test.ts b/backend/src/powersync/upload-handlers/workspace-handlers.test.ts new file mode 100644 index 000000000..ea234ac76 --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-handlers.test.ts @@ -0,0 +1,2566 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { user } from '@/db/auth-schema' +import { + agentsTable, + modelsTable, + modesTable, + skillsTable, + tasksTable, + workspaceMembershipsTable, + workspacePendingMembershipsTable, + workspacePermissionsTable, + workspacesTable, +} from '@/db/powersync-schema' +import { createTestDb } from '@/test-utils/db' +import { createTestSettings } from '@/test-utils/settings' +import { computePersonalAdminMembershipId, computePersonalWorkspaceId } from '@shared/workspaces' +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { v7 as uuidv7 } from 'uuid' +import { applyUploadBatch } from './registry' +import type { UploadCtx, UploadOp } from './types' + +describe('workspace upload handlers', () => { + let db: Awaited>['db'] + let cleanup: () => Promise + + const insertUser = async (id: string, email: string) => { + const now = new Date() + await db.insert(user).values({ + id, + name: 'Test User', + email, + emailVerified: true, + isNew: true, + createdAt: now, + updatedAt: now, + }) + } + + const ctxFor = (userId: string, overrides: Partial = {}): UploadCtx => ({ + userId, + settings: createTestSettings(), + ...overrides, + }) + + /** + * Drives a personal-workspace bootstrap through the real upload handler — the + * same code path the FE exercises on first sign-in. Verifies the canonical + * acceptance path and gives later tests a personal-workspace fixture to + * exercise the immutability rules against. + */ + const bootstrapPersonalViaUpload = async (userId: string): Promise => { + const workspaceId = computePersonalWorkspaceId(userId) + const membershipId = computePersonalAdminMembershipId(userId) + const ops: UploadOp[] = [ + { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: userId, name: 'Personal' }, + }, + { + op: 'PUT', + type: 'workspace_memberships', + id: membershipId, + data: { workspace_id: workspaceId, user_id: userId, role: 'admin' }, + }, + ] + const result = await applyUploadBatch(db, ops, ctxFor(userId)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + return workspaceId + } + + const expectPermanentReject = (result: Awaited>, code: string): void => { + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(1) + expect(result.rejected[0].code).toBe(code) + } + } + + beforeEach(async () => { + const env = await createTestDb() + db = env.db + cleanup = env.cleanup + }) + + afterEach(async () => { + await cleanup() + }) + + describe('workspaces — personal', () => { + it('accepts a personal workspace PUT with canonical id and matching owner', async () => { + await insertUser('owner1', 'owner1@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner1') + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored).toHaveLength(1) + expect(stored[0].isPersonal).toBe(true) + expect(stored[0].ownerUserId).toBe('owner1') + expect(stored[0].name).toBe('Personal') + }) + + it('rejects a personal workspace PUT with non-canonical id', async () => { + await insertUser('owner2', 'owner2@test.com') + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { is_personal: true, owner_user_id: 'owner2', name: 'Personal' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('owner2')) + expectPermanentReject(result, 'PERSONAL_WORKSPACE_ID_NOT_CANONICAL') + }) + + it('rejects a personal workspace PUT claiming someone else as owner', async () => { + await insertUser('attacker', 'attacker@test.com') + // Attacker tries to upload a personal workspace under victim's canonical id. + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: computePersonalWorkspaceId('victim'), + data: { is_personal: true, owner_user_id: 'victim', name: 'Personal' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('attacker')) + // Canonical-id check fires first since the attacker's userId doesn't hash + // to the same workspace id; the owner-mismatch branch covers the case + // where someone supplies a matching id but a different owner field. + expectPermanentReject(result, 'PERSONAL_WORKSPACE_ID_NOT_CANONICAL') + }) + + it('is idempotent on re-upload — multiple devices uploading the same row', async () => { + await insertUser('mdev', 'mdev@test.com') + const workspaceId = await bootstrapPersonalViaUpload('mdev') + + // Simulate device B uploading the same canonical row. + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'mdev', name: 'Personal' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('mdev')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored).toHaveLength(1) + }) + + it('allows PATCH-rename on a personal workspace by its owner-admin', async () => { + await insertUser('owner3', 'owner3@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner3') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { name: 'Home base' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('owner3')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].name).toBe('Home base') + }) + + it('rejects PATCH on a personal workspace from a non-owner', async () => { + await insertUser('owner3b', 'owner3b@test.com') + await insertUser('attacker3', 'attacker3@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner3b') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { name: 'Hijacked' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('attacker3')) + expectPermanentReject(result, 'NOT_WORKSPACE_ADMIN') + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].name).toBe('Personal') + }) + + it('preserves a prior rename when a second device re-uploads the bootstrap PUT', async () => { + await insertUser('mdev2', 'mdev2@test.com') + const workspaceId = await bootstrapPersonalViaUpload('mdev2') + + // User renames on device A. + const renameOp: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { name: 'Renamed' }, + } + const renameResult = await applyUploadBatch(db, [renameOp], ctxFor('mdev2')) + expect(renameResult.ok).toBe(true) + + // Device B's idempotent bootstrap re-uploads the canonical PUT with the + // default name — must not clobber the rename. + const reBootstrap: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'mdev2', name: 'Default' }, + } + const reResult = await applyUploadBatch(db, [reBootstrap], ctxFor('mdev2')) + expect(reResult.ok).toBe(true) + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].name).toBe('Renamed') + }) + + it('rejects DELETE on workspaces (v1 deferred)', async () => { + await insertUser('owner4', 'owner4@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner4') + + const op: UploadOp = { op: 'DELETE', type: 'workspaces', id: workspaceId } + const result = await applyUploadBatch(db, [op], ctxFor('owner4')) + expectPermanentReject(result, 'WORKSPACE_DELETE_DISABLED') + }) + }) + + describe('workspaces — shared', () => { + it('rejects member-initiated creation when the policy flag is off', async () => { + await insertUser('member', 'member@test.com') + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { name: 'Forbidden' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('member')) + expectPermanentReject(result, 'WORKSPACE_CREATION_DISABLED') + }) + + it('allows member-initiated creation when the policy flag is on', async () => { + await insertUser('member2', 'm2@test.com') + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { name: 'Shared with everyone' }, + } + const result = await applyUploadBatch( + db, + [op], + ctxFor('member2', { + settings: createTestSettings({ allowWorkspaceCreationByMembers: true }), + }), + ) + expect(result.ok).toBe(true) + + const inserted = await db.select().from(workspacesTable).where(eq(workspacesTable.id, op.id)) + expect(inserted).toHaveLength(1) + expect(inserted[0].name).toBe('Shared with everyone') + expect(inserted[0].isPersonal).toBe(false) + }) + + it('lets a user who admins their own personal workspace create shared workspaces with the flag off', async () => { + await insertUser('admin', 'a@test.com') + await bootstrapPersonalViaUpload('admin') + + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { name: 'Admin-created' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin')) + expect(result.ok).toBe(true) + }) + + it('rejects updates by non-admins of a shared workspace', async () => { + await insertUser('a1', 'a1@test.com') + await insertUser('b1', 'b1@test.com') + await bootstrapPersonalViaUpload('a1') + + const create: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { name: 'Original' }, + } + const createResult = await applyUploadBatch(db, [create], ctxFor('a1')) + expect(createResult.ok).toBe(true) + + const rename: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: create.id, + data: { name: 'Hijacked' }, + } + const renameResult = await applyUploadBatch(db, [rename], ctxFor('b1')) + expectPermanentReject(renameResult, 'NOT_WORKSPACE_ADMIN') + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, create.id)) + expect(stored[0].name).toBe('Original') + }) + }) + + describe('workspaces — slug + icon', () => { + const createSharedAs = async (userId: string, name = 'Acme'): Promise => { + const id = uuidv7() + await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id, data: { name } }], + ctxFor(userId, { settings: createTestSettings({ allowWorkspaceCreationByMembers: true }) }), + ) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId: id, userId, role: 'admin' }) + return id + } + + it('PATCH applies slug + icon on a shared workspace by its admin', async () => { + await insertUser('slugadmin', 'slugadmin@test.com') + const workspaceId = await createSharedAs('slugadmin', 'Original') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { slug: 'engineering', icon: '🛠️' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('slugadmin')) + expect(result.ok).toBe(true) + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].slug).toBe('engineering') + expect(stored[0].icon).toBe('🛠️') + }) + + it('PATCH rejects slug on a personal workspace', async () => { + await insertUser('personal_slug', 'personal_slug@test.com') + const workspaceId = await bootstrapPersonalViaUpload('personal_slug') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { slug: 'nope' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('personal_slug')) + expectPermanentReject(result, 'PERSONAL_WORKSPACE_SLUG_FORBIDDEN') + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].slug).toBeNull() + }) + + it('PATCH allows icon-only update on a personal workspace', async () => { + await insertUser('personal_icon', 'personal_icon@test.com') + const workspaceId = await bootstrapPersonalViaUpload('personal_icon') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { icon: '🏠' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('personal_icon')) + expect(result.ok).toBe(true) + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].icon).toBe('🏠') + expect(stored[0].slug).toBeNull() + }) + + it('PUT does not clear server-side slug/icon when payload omits them', async () => { + await insertUser('keepfields', 'keepfields@test.com') + const workspaceId = await createSharedAs('keepfields', 'Initial') + + // Set slug + icon via PATCH. + const patchResult = await applyUploadBatch( + db, + [{ op: 'PATCH', type: 'workspaces', id: workspaceId, data: { slug: 'kept-slug', icon: '🎯' } }], + ctxFor('keepfields'), + ) + expect(patchResult.ok).toBe(true) + + // Now PUT with only `name` — slug + icon must survive. + const putResult = await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id: workspaceId, data: { name: 'Renamed' } }], + ctxFor('keepfields'), + ) + expect(putResult.ok).toBe(true) + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].name).toBe('Renamed') + expect(stored[0].slug).toBe('kept-slug') + expect(stored[0].icon).toBe('🎯') + }) + + it('PUT rejects shared workspace with a slug already taken', async () => { + await insertUser('first', 'first@test.com') + await insertUser('second', 'second@test.com') + await bootstrapPersonalViaUpload('first') + await bootstrapPersonalViaUpload('second') + + const firstId = uuidv7() + const firstResult = await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id: firstId, data: { name: 'First', slug: 'shared-slug' } }], + ctxFor('first'), + ) + expect(firstResult.ok).toBe(true) + + const secondId = uuidv7() + const secondResult = await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id: secondId, data: { name: 'Second', slug: 'shared-slug' } }], + ctxFor('second'), + ) + expectPermanentReject(secondResult, 'WORKSPACE_SLUG_TAKEN') + }) + }) + + describe('workspace_memberships', () => { + it('accepts the bootstrap admin membership for own personal workspace', async () => { + await insertUser('owner5', 'owner5@test.com') + // The bootstrapPersonalViaUpload helper exercises this in one batch; here + // we split it into two batches to verify the membership exception fires + // even when uploaded separately. + const workspaceId = computePersonalWorkspaceId('owner5') + const wsOp: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'owner5', name: 'Personal' }, + } + const wsResult = await applyUploadBatch(db, [wsOp], ctxFor('owner5')) + expect(wsResult.ok).toBe(true) + + const memOp: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: computePersonalAdminMembershipId('owner5'), + data: { workspace_id: workspaceId, user_id: 'owner5', role: 'admin' }, + } + const memResult = await applyUploadBatch(db, [memOp], ctxFor('owner5')) + expect(memResult.ok).toBe(true) + + const stored = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, workspaceId)) + expect(stored).toHaveLength(1) + expect(stored[0].role).toBe('admin') + // Display info is denormalized from auth.user by the upload handler so the + // FE Members page can render without a synced users projection. + expect(stored[0].userName).toBe('Test User') + expect(stored[0].userEmail).toBe('owner5@test.com') + }) + + it('rejects an admin-role membership PUT from invite_users-only caller (escalation guard)', async () => { + // Direct `workspace_memberships` PUT with `role: 'admin'` must require + // `change_roles` in addition to `invite_users` — same shape as the + // pending-membership escalation guard. + await insertUser('admin-ws', 'admin-ws@test.com') + await bootstrapPersonalViaUpload('admin-ws') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ + id: sharedId, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-ws', + role: 'admin', + }) + const memberId = 'ws-member-invite-only' + await insertUser(memberId, 'ws-member-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + const newUserId = 'new-admin-target' + await insertUser(newUserId, 'new-admin-target@test.com') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, user_id: newUserId, role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('rejects PUT that demotes an existing admin without change_roles', async () => { + // `upsertMembership` does ON CONFLICT DO UPDATE SET role on + // `(workspace_id, user_id)`. A caller with `invite_users` alone could + // otherwise PUT `role: 'member'` at an existing admin's pair and + // demote them without `change_roles` — same effective action as a + // PATCH demote, which DOES require `change_roles`. + await insertUser('demote-admin', 'demote-admin@test.com') + await bootstrapPersonalViaUpload('demote-admin') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ + id: sharedId, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'demote-admin', + role: 'admin', + }) + const targetAdminId = 'demote-target-admin' + await insertUser(targetAdminId, 'demote-target-admin@test.com') + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: targetAdminId, + role: 'admin', + }) + const memberId = 'demote-actor-invite-only' + await insertUser(memberId, 'demote-actor-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + // PUT with a fresh op.id (so the lookup must hit by `(workspace_id, user_id)`) + // and the demoted role on the existing admin's pair. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, user_id: targetAdminId, role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + + // Existing admin row is untouched. + const stored = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.id, targetMembershipId)) + expect(stored[0].role).toBe('admin') + }) + + it('rejects PUT that would demote the last admin (last-admin protection)', async () => { + // PUT's apply path upserts via `ON CONFLICT DO UPDATE SET role`. Without + // a last-admin guard in apply, a caller satisfying `change_roles` could + // demote the workspace's only admin to member by PUT — leaving zero + // admins, while PATCH/DELETE would reject with LAST_ADMIN_PROTECTED. + await insertUser('lone-admin', 'lone-admin@test.com') + await bootstrapPersonalViaUpload('lone-admin') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ + id: sharedId, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + const adminMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: adminMembershipId, + workspaceId: sharedId, + userId: 'lone-admin', + role: 'admin', + }) + const memberId = 'member-with-cr-demote' + await insertUser(memberId, 'member-with-cr-demote@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + // Grant both keys so PUT validate passes — the test exercises the apply + // layer's last-admin guard. + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'change_roles', + requiredRole: 'member', + }) + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, user_id: 'lone-admin', role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'LAST_ADMIN_PROTECTED') + + // The admin row is unchanged. + const stored = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.id, adminMembershipId)) + expect(stored[0].role).toBe('admin') + }) + + it('rejects a second membership write to a personal workspace (immutable)', async () => { + await insertUser('owner6', 'owner6@test.com') + await insertUser('victim', 'victim@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner6') + + // owner6 tries to add another member to their personal workspace. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: workspaceId, user_id: 'victim', role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('owner6')) + expectPermanentReject(result, 'PERSONAL_WORKSPACE_IMMUTABLE') + }) + + it('rejects bootstrap admin membership for another user', async () => { + await insertUser('badguy', 'badguy@test.com') + await insertUser('target', 'target@test.com') + const targetWorkspaceId = await bootstrapPersonalViaUpload('target') + + // badguy tries to claim admin in target's personal workspace. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: targetWorkspaceId, user_id: 'badguy', role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('badguy')) + // Personal-workspace immutability kicks in. + expectPermanentReject(result, 'PERSONAL_WORKSPACE_IMMUTABLE') + }) + + // Rollout case (THU-622): Drizzle 0020 backfilled the personal workspace + + // admin membership for every pre-existing user. On first sign-in to the + // workspaces build, `ensurePersonalWorkspace` FE-creates the same row at + // the canonical id and PowerSync queues an upload. The handler treats it + // as an idempotent re-claim of the canonical admin row rather than + // rejecting as PERSONAL_WORKSPACE_IMMUTABLE — otherwise PowerSync's + // permanent-reject path reverts the local oplog write and `WorkspaceGate` + // briefly closes (loading flicker) before sync downloads the BE row back. + it('accepts a re-bootstrap of the canonical admin row (idempotent re-claim)', async () => { + await insertUser('rebootstrap1', 'rebootstrap1@test.com') + const workspaceId = await bootstrapPersonalViaUpload('rebootstrap1') + const adminMembershipId = computePersonalAdminMembershipId('rebootstrap1') + + // Same canonical id, same workspace, same user, same admin role — + // re-uploaded by the FE on a fresh-install device. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: adminMembershipId, + data: { workspace_id: workspaceId, user_id: 'rebootstrap1', role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('rebootstrap1')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + // Only one membership remains — upsert applied, no duplicate row. + const rows = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, workspaceId)) + expect(rows).toHaveLength(1) + expect(rows[0].id).toBe(adminMembershipId) + expect(rows[0].role).toBe('admin') + }) + + it('rejects a re-bootstrap attempting to demote the canonical admin to member', async () => { + await insertUser('rebootstrap2', 'rebootstrap2@test.com') + const workspaceId = await bootstrapPersonalViaUpload('rebootstrap2') + const adminMembershipId = computePersonalAdminMembershipId('rebootstrap2') + + // Canonical id + canonical workspace + canonical user, but role flipped + // to 'member'. Bootstrap requires role === 'admin', so this falls through + // to the immutable rejection rather than silently demoting. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: adminMembershipId, + data: { workspace_id: workspaceId, user_id: 'rebootstrap2', role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('rebootstrap2')) + expectPermanentReject(result, 'PERSONAL_WORKSPACE_IMMUTABLE') + }) + + it('protects the last admin of a shared workspace from deletion', async () => { + await insertUser('a4', 'a4@test.com') + await bootstrapPersonalViaUpload('a4') + + const sharedId = uuidv7() + await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id: sharedId, data: { name: 'Shared' } }], + ctxFor('a4'), + ) + + // Seed an admin membership directly so we can exercise last-admin protection; + // the creator-admin flow lands later when shared-workspace creation also + // creates the admin membership. + const a4MembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: a4MembershipId, + workspaceId: sharedId, + userId: 'a4', + role: 'admin', + }) + + const op: UploadOp = { + op: 'DELETE', + type: 'workspace_memberships', + id: a4MembershipId, + } + const result = await applyUploadBatch(db, [op], ctxFor('a4')) + expectPermanentReject(result, 'LAST_ADMIN_PROTECTED') + + const stillThere = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.id, a4MembershipId)) + expect(stillThere).toHaveLength(1) + }) + + it('PATCH role: allowed when caller has change_roles=member', async () => { + await insertUser('admin-cr-ok', 'admin-cr-ok@test.com') + await insertUser('target-cr-ok', 'target-cr-ok@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'Shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-cr-ok', + role: 'admin', + }) + const memberId = 'member-with-cr' + await insertUser(memberId, 'member-with-cr@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: 'target-cr-ok', + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'change_roles', + requiredRole: 'member', + }) + + const op: UploadOp = { + op: 'PATCH', + type: 'workspace_memberships', + id: targetMembershipId, + data: { role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expect(result.ok).toBe(true) + }) + + it('PATCH role: rejected when caller lacks change_roles', async () => { + await insertUser('admin-cr-no', 'admin-cr-no@test.com') + await insertUser('target-cr-no', 'target-cr-no@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'Shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-cr-no', + role: 'admin', + }) + const memberId = 'member-no-cr' + await insertUser(memberId, 'member-no-cr@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: 'target-cr-no', + role: 'member', + }) + // No workspace_permissions row → change_roles defaults to admin. + + const op: UploadOp = { + op: 'PATCH', + type: 'workspace_memberships', + id: targetMembershipId, + data: { role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('DELETE: allowed when caller has remove_users=member', async () => { + await insertUser('admin-ru-ok', 'admin-ru-ok@test.com') + await insertUser('target-ru-ok', 'target-ru-ok@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'Shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-ru-ok', + role: 'admin', + }) + const memberId = 'member-with-ru' + await insertUser(memberId, 'member-with-ru@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: 'target-ru-ok', + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'remove_users', + requiredRole: 'member', + }) + + const op: UploadOp = { + op: 'DELETE', + type: 'workspace_memberships', + id: targetMembershipId, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expect(result.ok).toBe(true) + }) + + it('DELETE: rejected when caller lacks remove_users', async () => { + await insertUser('admin-ru-no', 'admin-ru-no@test.com') + await insertUser('target-ru-no', 'target-ru-no@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'Shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-ru-no', + role: 'admin', + }) + const memberId = 'member-no-ru' + await insertUser(memberId, 'member-no-ru@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: 'target-ru-no', + role: 'member', + }) + + const op: UploadOp = { + op: 'DELETE', + type: 'workspace_memberships', + id: targetMembershipId, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('allows a PUT adding another user when e2eeEnabled is true', async () => { + // Shared-workspace collaborative resources travel plaintext under the + // temporary per-workspace E2EE scope, so cross-user memberships are no + // longer rejected on E2EE-enabled servers. When workspace-aware E2EE + // lands (THU-593), reinstate a rejection. + await insertUser('admin-e1', 'admin-e1@test.com') + await insertUser('invitee-e1', 'invitee-e1@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'E2EE shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-e1', + role: 'admin', + }) + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, user_id: 'invitee-e1', role: 'member' }, + } + const result = await applyUploadBatch( + db, + [op], + ctxFor('admin-e1', { settings: createTestSettings({ e2eeEnabled: true }) }), + ) + expect(result.ok).toBe(true) + }) + + it('still allows the self-bootstrap PUT when e2eeEnabled is true', async () => { + await insertUser('owner-e1', 'owner-e1@test.com') + const workspaceId = computePersonalWorkspaceId('owner-e1') + const ops: UploadOp[] = [ + { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'owner-e1', name: 'Personal' }, + }, + { + op: 'PUT', + type: 'workspace_memberships', + id: computePersonalAdminMembershipId('owner-e1'), + data: { workspace_id: workspaceId, user_id: 'owner-e1', role: 'admin' }, + }, + ] + const result = await applyUploadBatch( + db, + ops, + ctxFor('owner-e1', { settings: createTestSettings({ e2eeEnabled: true }) }), + ) + expect(result.ok).toBe(true) + }) + }) + + describe('workspace_pending_memberships', () => { + const seedSharedAsAdmin = async (adminUserId: string): Promise => { + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ + id: sharedId, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: adminUserId, + role: 'admin', + }) + return sharedId + } + + it('allows a member to invite when invite_users is granted to member role', async () => { + await insertUser('a-perm', 'a-perm@test.com') + await insertUser('b-perm', 'b-perm@test.com') + await bootstrapPersonalViaUpload('a-perm') + const sharedId = await seedSharedAsAdmin('a-perm') + + // b-perm becomes a member of the shared workspace. + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'b-perm', + role: 'member', + }) + + // Workspace grants `invite_users` to the `member` role. + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + // b-perm (member) sends a pending invite — should succeed. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'newcomer@test.com', + role: 'member', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('b-perm')) + expect(result.ok).toBe(true) + }) + + it('rejects an admin-role pending invite from invite_users-only caller (escalation guard)', async () => { + // `invite_users` alone must not be enough to invite `role: 'admin'` — + // otherwise the signup-promote path would mint a new admin and bypass + // the `change_roles` gate that protects existing-member promotions. + await insertUser('inviter-only', 'inviter-only@test.com') + await bootstrapPersonalViaUpload('inviter-only') + const sharedId = await seedSharedAsAdmin('inviter-only') + const memberId = 'member-with-invite-only' + await insertUser(memberId, 'member-with-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, email: 'pending-admin@test.com', role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('rejects PATCH that promotes a pending invite to admin without change_roles', async () => { + // PATCH-to-admin must hit the same escalation guard as PUT-to-admin — + // otherwise an inviter could quietly elevate a pending invite they + // shouldn't be able to promote. + await insertUser('patch-inviter', 'patch-inviter@test.com') + await bootstrapPersonalViaUpload('patch-inviter') + const sharedId = await seedSharedAsAdmin('patch-inviter') + const memberId = 'patch-member-invite-only' + await insertUser(memberId, 'patch-member-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + // Seed a pending invite at role=member, then try to PATCH to admin. + const pendingId = uuidv7() + await db.insert(workspacePendingMembershipsTable).values({ + id: pendingId, + workspaceId: sharedId, + email: 'pending-target@test.com', + role: 'member', + invitedByUserId: 'patch-inviter', + }) + + const op: UploadOp = { + op: 'PATCH', + type: 'workspace_pending_memberships', + id: pendingId, + data: { role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('allows PATCH demoting a pending admin invite to member from invite_users-only caller', async () => { + // The escalation guard is intentionally one-way: promotions to admin + // require `change_roles`, but demotions stay gated on `invite_users` + // alone. Demoting a pending admin invite is tampering, not escalation, + // and matching the broader "any role change" rule from the memberships + // handler would cost an extra DB read for a non-security concern. + await insertUser('demote-inviter', 'demote-inviter@test.com') + await bootstrapPersonalViaUpload('demote-inviter') + const sharedId = await seedSharedAsAdmin('demote-inviter') + const memberId = 'demote-member-invite-only' + await insertUser(memberId, 'demote-member-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + const pendingId = uuidv7() + await db.insert(workspacePendingMembershipsTable).values({ + id: pendingId, + workspaceId: sharedId, + email: 'pending-admin-to-demote@test.com', + role: 'admin', + invitedByUserId: 'demote-inviter', + }) + + const op: UploadOp = { + op: 'PATCH', + type: 'workspace_pending_memberships', + id: pendingId, + data: { role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expect(result.ok).toBe(true) + }) + + it('allows an admin-role pending invite when caller has BOTH invite_users and change_roles', async () => { + await insertUser('inviter-both', 'inviter-both@test.com') + await bootstrapPersonalViaUpload('inviter-both') + const sharedId = await seedSharedAsAdmin('inviter-both') + const memberId = 'member-with-both' + await insertUser(memberId, 'member-with-both@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + for (const key of ['invite_users', 'change_roles'] as const) { + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: key, + requiredRole: 'member', + }) + } + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, email: 'pending-admin-allowed@test.com', role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expect(result.ok).toBe(true) + }) + + it('rejects writes by non-admins of the target workspace', async () => { + await insertUser('a5', 'a5@test.com') + await insertUser('b5', 'b5@test.com') + await bootstrapPersonalViaUpload('a5') + const sharedId = await seedSharedAsAdmin('a5') + + // b5 (no membership) tries to invite an email. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'invitee@test.com', + role: 'member', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('b5')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('normalizes email on insert', async () => { + await insertUser('admin5', 'admin5@test.com') + await bootstrapPersonalViaUpload('admin5') + const sharedId = await seedSharedAsAdmin('admin5') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: ' MixedCase@TEST.com ', + role: 'member', + invited_by_user_id: 'admin5', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin5')) + expect(result.ok).toBe(true) + + const stored = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(stored[0].email).toBe('mixedcase@test.com') + }) + + it('promotes to membership + deletes pending row when invited email matches an existing user', async () => { + await insertUser('admin6', 'admin6@test.com') + await insertUser('invitee1', 'invitee1@test.com') + await bootstrapPersonalViaUpload('admin6') + const sharedId = await seedSharedAsAdmin('admin6') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'invitee1@test.com', + role: 'member', + invited_by_user_id: 'admin6', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin6')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const pending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(pending).toHaveLength(0) + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, sharedId)) + // admin6 (from seedSharedAsAdmin) + invitee1 (promoted) + expect(memberships).toHaveLength(2) + const invitee = memberships.find((m) => m.userId === 'invitee1') + expect(invitee).toBeDefined() + expect(invitee?.role).toBe('member') + // Promotion carries the matched user's display info onto the membership row. + expect(invitee?.userName).toBe('Test User') + expect(invitee?.userEmail).toBe('invitee1@test.com') + }) + + it('promotes via normalized email match (case + whitespace)', async () => { + await insertUser('admin7', 'admin7@test.com') + await insertUser('invitee2', 'invitee2@test.com') + await bootstrapPersonalViaUpload('admin7') + const sharedId = await seedSharedAsAdmin('admin7') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: ' Invitee2@TEST.com ', + role: 'admin', + invited_by_user_id: 'admin7', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin7')) + expect(result.ok).toBe(true) + + const pending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(pending).toHaveLength(0) + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, sharedId)) + const invitee = memberships.find((m) => m.userId === 'invitee2') + expect(invitee?.role).toBe('admin') + }) + + it('deletes the actual pending row on promote-on-insert even after unique-key conflict', async () => { + await insertUser('admin10', 'admin10@test.com') + await insertUser('invitee3', 'invitee3@test.com') + await bootstrapPersonalViaUpload('admin10') + const sharedId = await seedSharedAsAdmin('admin10') + + // Seed an existing pending row with id=X. + const originalPendingId = uuidv7() + await db.insert(workspacePendingMembershipsTable).values({ + id: originalPendingId, + workspaceId: sharedId, + email: 'invitee3@test.com', + role: 'admin', + invitedByUserId: 'admin10', + }) + + // Now upload a NEW pending op (id=Y) for the same workspace+email. + const newOpId = uuidv7() + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: newOpId, + data: { + workspace_id: sharedId, + email: 'invitee3@test.com', + role: 'member', + invited_by_user_id: 'admin10', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin10')) + expect(result.ok).toBe(true) + + // Both the id=X row (the actual one in DB) and the id=Y delete target + // must result in zero pending rows for (workspace_id, email). + const pending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.workspaceId, sharedId)) + expect(pending).toHaveLength(0) + }) + + it('preserves an existing membership role on promote-on-insert (no downgrade)', async () => { + await insertUser('admin9', 'admin9@test.com') + await insertUser('coadmin', 'coadmin@test.com') + await bootstrapPersonalViaUpload('admin9') + const sharedId = await seedSharedAsAdmin('admin9') + + // Make coadmin an admin of the same workspace directly. + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'coadmin', + role: 'admin', + userName: 'Test User', + userEmail: 'coadmin@test.com', + }) + + // Invite coadmin's email with role='member' — should NOT downgrade. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'coadmin@test.com', + role: 'member', + invited_by_user_id: 'admin9', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin9')) + expect(result.ok).toBe(true) + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, sharedId)) + const coadminRow = memberships.find((m) => m.userId === 'coadmin') + expect(coadminRow?.role).toBe('admin') + }) + + it('rejects malformed email on pending PUT with INVALID_EMAIL', async () => { + await insertUser('admin-email', 'admin-email@test.com') + await bootstrapPersonalViaUpload('admin-email') + const sharedId = await seedSharedAsAdmin('admin-email') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, email: 'not-an-email', role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin-email')) + expectPermanentReject(result, 'INVALID_EMAIL') + }) + + it('overrides client-supplied invited_by_user_id with ctx.userId', async () => { + await insertUser('inviter', 'inviter@test.com') + await bootstrapPersonalViaUpload('inviter') + const sharedId = await seedSharedAsAdmin('inviter') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'pending@test.com', + role: 'member', + // Attempt to attribute the invite to someone else. + invited_by_user_id: 'someone-else', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('inviter')) + expect(result.ok).toBe(true) + + const stored = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(stored[0]?.invitedByUserId).toBe('inviter') + }) + + it('keeps pending row when invited email does not match any user', async () => { + await insertUser('admin8', 'admin8@test.com') + await bootstrapPersonalViaUpload('admin8') + const sharedId = await seedSharedAsAdmin('admin8') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'nobody@test.com', + role: 'member', + invited_by_user_id: 'admin8', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin8')) + expect(result.ok).toBe(true) + + const pending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(pending).toHaveLength(1) + expect(pending[0].email).toBe('nobody@test.com') + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, sharedId)) + // only the seed admin — no promotion + expect(memberships).toHaveLength(1) + }) + + it('allows a pending PUT when e2eeEnabled is true', async () => { + // Pending memberships are no longer rejected under E2EE — shared-workspace + // collaborative resources travel plaintext under the temporary + // per-workspace E2EE scope. When workspace-aware E2EE lands (THU-593), + // reinstate a rejection until envelopes can reach invitees. + await insertUser('admin-e2', 'admin-e2@test.com') + const sharedId = await seedSharedAsAdmin('admin-e2') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'invitee@test.com', + role: 'member', + invited_by_user_id: 'admin-e2', + }, + } + const result = await applyUploadBatch( + db, + [op], + ctxFor('admin-e2', { settings: createTestSettings({ e2eeEnabled: true }) }), + ) + expect(result.ok).toBe(true) + }) + }) + + describe('batch accumulation', () => { + it('accepts a workspace + admin-membership batch atomically (FE first sign-in)', async () => { + await insertUser('combo', 'combo@test.com') + const workspaceId = computePersonalWorkspaceId('combo') + const membershipId = computePersonalAdminMembershipId('combo') + const ops: UploadOp[] = [ + { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'combo', name: 'Personal' }, + }, + { + op: 'PUT', + type: 'workspace_memberships', + id: membershipId, + data: { workspace_id: workspaceId, user_id: 'combo', role: 'admin' }, + }, + ] + const result = await applyUploadBatch(db, ops, ctxFor('combo')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const ws = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, workspaceId)) + expect(ws).toHaveLength(1) + expect(memberships).toHaveLength(1) + }) + + it('accumulates multiple permanent rejections in one response', async () => { + await insertUser('owner7', 'owner7@test.com') + const personalId = await bootstrapPersonalViaUpload('owner7') + + const ownerMismatch: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: personalId, + data: { is_personal: true, owner_user_id: 'somebody-else', name: 'Sneaky' }, + } + const wrongIdPersonal: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { is_personal: true, owner_user_id: 'owner7', name: 'Sneaky' }, + } + const result = await applyUploadBatch( + db, + [ownerMismatch, wrongIdPersonal], + ctxFor('owner7', { + settings: createTestSettings({ allowWorkspaceCreationByMembers: true }), + }), + ) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(2) + expect(result.rejected[0].code).toBe('PERSONAL_WORKSPACE_OWNER_MISMATCH') + expect(result.rejected[1].code).toBe('PERSONAL_WORKSPACE_ID_NOT_CANONICAL') + } + }) + }) + + describe('agents — workspace permission gating (add_agents / remove_agents)', () => { + /** + * Sets up a shared workspace with `adminId` as admin and `memberId` as member. + * Both users must have already been inserted via `insertUser`. Returns the + * workspace id so the test can target it. + */ + const seedSharedWithAdminAndMember = async (adminId: string, memberId: string): Promise => { + const workspaceId = uuidv7() + await db.insert(workspacesTable).values({ id: workspaceId, isPersonal: false, name: 'Acme' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: adminId, role: 'admin' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: memberId, role: 'member' }) + return workspaceId + } + + const setRequiredRole = async ( + workspaceId: string, + key: 'add_agents' | 'remove_agents', + requiredRole: 'admin' | 'member', + ): Promise => { + await db.insert(workspacePermissionsTable).values({ id: uuidv7(), workspaceId, permissionKey: key, requiredRole }) + } + + const agentPut = (workspaceId: string, id = uuidv7()): UploadOp => ({ + op: 'PUT', + type: 'agents', + id, + data: { + workspace_id: workspaceId, + name: 'Test agent', + type: 'remote-acp', + transport: 'websocket', + url: 'wss://example.invalid/acp', + }, + }) + + it('PUT agent: admin always succeeds (default required_role = admin)', async () => { + await insertUser('agAdmin1', 'agadmin1@test.com') + await insertUser('agMember1', 'agmember1@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin1', 'agMember1') + + const op = agentPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('agAdmin1')) + expect(result.ok).toBe(true) + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('PUT agent: member rejected when add_agents required_role = admin (default)', async () => { + await insertUser('agAdmin2', 'agadmin2@test.com') + await insertUser('agMember2', 'agmember2@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin2', 'agMember2') + + const op = agentPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('agMember2')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, op.id)) + expect(stored).toHaveLength(0) + }) + + it('PUT agent: member allowed when add_agents required_role = member', async () => { + await insertUser('agAdmin3', 'agadmin3@test.com') + await insertUser('agMember3', 'agmember3@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin3', 'agMember3') + await setRequiredRole(workspaceId, 'add_agents', 'member') + + const op = agentPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('agMember3')) + expect(result.ok).toBe(true) + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('DELETE agent: member rejected when remove_agents required_role = admin (default)', async () => { + await insertUser('agAdmin4', 'agadmin4@test.com') + await insertUser('agMember4', 'agmember4@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin4', 'agMember4') + // Admin seeds the agent so the row exists. + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin4')) + expect(putResult.ok).toBe(true) + + const deleteOp: UploadOp = { op: 'DELETE', type: 'agents', id: putOp.id } + const result = await applyUploadBatch(db, [deleteOp], ctxFor('agMember4')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, putOp.id)) + expect(stored).toHaveLength(1) + }) + + it('DELETE agent: member allowed when remove_agents required_role = member', async () => { + await insertUser('agAdmin5', 'agadmin5@test.com') + await insertUser('agMember5', 'agmember5@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin5', 'agMember5') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin5')) + expect(putResult.ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_agents', 'member') + + const deleteOp: UploadOp = { op: 'DELETE', type: 'agents', id: putOp.id } + const result = await applyUploadBatch(db, [deleteOp], ctxFor('agMember5')) + expect(result.ok).toBe(true) + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, putOp.id)) + expect(stored).toHaveLength(0) + }) + + it('add_agents = member does not unlock DELETE (remove_agents still defaults to admin)', async () => { + await insertUser('agAdmin6', 'agadmin6@test.com') + await insertUser('agMember6', 'agmember6@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin6', 'agMember6') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin6')) + expect(putResult.ok).toBe(true) + // Member can add but the remove permission is still admin. + await setRequiredRole(workspaceId, 'add_agents', 'member') + + const deleteOp: UploadOp = { op: 'DELETE', type: 'agents', id: putOp.id } + const result = await applyUploadBatch(db, [deleteOp], ctxFor('agMember6')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + // FE DAL soft-deletes via PATCH(deleted_at = now), not DELETE. The handler + // classifies that PATCH as a remove and gates it on `remove_agents`. + it('soft-delete via PATCH(deleted_at) gates on remove_agents, not add_agents', async () => { + await insertUser('agAdmin7', 'agadmin7@test.com') + await insertUser('agMember7', 'agmember7@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin7', 'agMember7') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin7')) + expect(putResult.ok).toBe(true) + // Member can edit (add) but not soft-delete (remove). + await setRequiredRole(workspaceId, 'add_agents', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'agents', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('agMember7')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('soft-delete via PATCH(deleted_at) is allowed when remove_agents = member', async () => { + await insertUser('agAdmin8', 'agadmin8@test.com') + await insertUser('agMember8', 'agmember8@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin8', 'agMember8') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin8')) + expect(putResult.ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_agents', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'agents', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('agMember8')) + expect(result.ok).toBe(true) + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, putOp.id)) + expect(stored[0].deletedAt).not.toBeNull() + }) + + // Edit PATCH (no deleted_at) still gates on add_agents, not remove_agents. + it('non-delete PATCH gates on add_agents even when remove_agents = member', async () => { + await insertUser('agAdmin9', 'agadmin9@test.com') + await insertUser('agMember9', 'agmember9@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin9', 'agMember9') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin9')) + expect(putResult.ok).toBe(true) + // Member has remove but not add; an edit (no deleted_at) should still reject. + await setRequiredRole(workspaceId, 'remove_agents', 'member') + + const editOp: UploadOp = { + op: 'PATCH', + type: 'agents', + id: putOp.id, + data: { name: 'Renamed by member' }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('agMember9')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + }) + + describe('skills — workspace permission gating (add_skills / remove_skills)', () => { + const seedSharedWithAdminAndMember = async (adminId: string, memberId: string): Promise => { + const workspaceId = uuidv7() + await db.insert(workspacesTable).values({ id: workspaceId, isPersonal: false, name: 'Acme' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: adminId, role: 'admin' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: memberId, role: 'member' }) + return workspaceId + } + + const setRequiredRole = async ( + workspaceId: string, + key: 'add_skills' | 'remove_skills', + requiredRole: 'admin' | 'member', + ): Promise => { + await db.insert(workspacePermissionsTable).values({ id: uuidv7(), workspaceId, permissionKey: key, requiredRole }) + } + + const skillPut = (workspaceId: string, id = uuidv7()): UploadOp => ({ + op: 'PUT', + type: 'skills', + id, + data: { + workspace_id: workspaceId, + name: 'Test skill', + description: 'Test', + instruction: 'Do the thing', + enabled: 1, + }, + }) + + it('PUT skill: member rejected when add_skills required_role = admin (default)', async () => { + await insertUser('skAdmin1', 'skadmin1@test.com') + await insertUser('skMember1', 'skmember1@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin1', 'skMember1') + + const op = skillPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('skMember1')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('PUT skill: member allowed when add_skills required_role = member', async () => { + await insertUser('skAdmin2', 'skadmin2@test.com') + await insertUser('skMember2', 'skmember2@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin2', 'skMember2') + await setRequiredRole(workspaceId, 'add_skills', 'member') + + const op = skillPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('skMember2')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('soft-delete via PATCH(deleted_at) gates on remove_skills, not add_skills', async () => { + await insertUser('skAdmin3', 'skadmin3@test.com') + await insertUser('skMember3', 'skmember3@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin3', 'skMember3') + const putOp = skillPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('skAdmin3'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'add_skills', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('skMember3')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('soft-delete via PATCH(deleted_at) is allowed when remove_skills = member', async () => { + await insertUser('skAdmin4', 'skadmin4@test.com') + await insertUser('skMember4', 'skmember4@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin4', 'skMember4') + const putOp = skillPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('skAdmin4'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_skills', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('skMember4')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, putOp.id)) + expect(stored[0].deletedAt).not.toBeNull() + }) + + it('edit PATCH (no deleted_at) gates on add_skills, not remove_skills', async () => { + await insertUser('skAdmin5', 'skadmin5@test.com') + await insertUser('skMember5', 'skmember5@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin5', 'skMember5') + const putOp = skillPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('skAdmin5'))).ok).toBe(true) + // Member has remove but not add — toggling `enabled` is an edit and must reject. + await setRequiredRole(workspaceId, 'remove_skills', 'member') + + const editOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { enabled: 0 }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('skMember5')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + }) + + describe('models — workspace permission gating (add_models / remove_models)', () => { + const seedSharedWithAdminAndMember = async (adminId: string, memberId: string): Promise => { + const workspaceId = uuidv7() + await db.insert(workspacesTable).values({ id: workspaceId, isPersonal: false, name: 'Acme' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: adminId, role: 'admin' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: memberId, role: 'member' }) + return workspaceId + } + + const setRequiredRole = async ( + workspaceId: string, + key: 'add_models' | 'remove_models', + requiredRole: 'admin' | 'member', + ): Promise => { + await db.insert(workspacePermissionsTable).values({ id: uuidv7(), workspaceId, permissionKey: key, requiredRole }) + } + + const modelPut = (workspaceId: string, id = uuidv7()): UploadOp => ({ + op: 'PUT', + type: 'models', + id, + data: { + workspace_id: workspaceId, + provider: 'openai', + name: 'Test model', + model: 'gpt-test', + enabled: 1, + }, + }) + + it('PUT model: member rejected when add_models required_role = admin (default)', async () => { + await insertUser('mdAdmin1', 'mdadmin1@test.com') + await insertUser('mdMember1', 'mdmember1@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin1', 'mdMember1') + + const op = modelPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('mdMember1')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('PUT model: member allowed when add_models required_role = member', async () => { + await insertUser('mdAdmin2', 'mdadmin2@test.com') + await insertUser('mdMember2', 'mdmember2@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin2', 'mdMember2') + await setRequiredRole(workspaceId, 'add_models', 'member') + + const op = modelPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('mdMember2')) + expect(result.ok).toBe(true) + const stored = await db.select().from(modelsTable).where(eq(modelsTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('soft-delete via PATCH(deleted_at) gates on remove_models, not add_models', async () => { + await insertUser('mdAdmin3', 'mdadmin3@test.com') + await insertUser('mdMember3', 'mdmember3@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin3', 'mdMember3') + const putOp = modelPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mdAdmin3'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'add_models', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'models', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('mdMember3')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('soft-delete via PATCH(deleted_at) is allowed when remove_models = member', async () => { + await insertUser('mdAdmin4', 'mdadmin4@test.com') + await insertUser('mdMember4', 'mdmember4@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin4', 'mdMember4') + const putOp = modelPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mdAdmin4'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_models', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'models', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('mdMember4')) + expect(result.ok).toBe(true) + const stored = await db.select().from(modelsTable).where(eq(modelsTable.id, putOp.id)) + expect(stored[0].deletedAt).not.toBeNull() + }) + + it('edit PATCH (toggle enabled) gates on add_models, not remove_models', async () => { + await insertUser('mdAdmin5', 'mdadmin5@test.com') + await insertUser('mdMember5', 'mdmember5@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin5', 'mdMember5') + const putOp = modelPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mdAdmin5'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_models', 'member') + + const editOp: UploadOp = { + op: 'PATCH', + type: 'models', + id: putOp.id, + data: { enabled: 0 }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('mdMember5')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + }) + + describe('scope-aware resources (THU-603)', () => { + const seedShared = async (adminId: string, memberId: string): Promise => { + const workspaceId = uuidv7() + await db.insert(workspacesTable).values({ id: workspaceId, isPersonal: false, name: 'Acme' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: adminId, role: 'admin' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: memberId, role: 'member' }) + return workspaceId + } + + const skillPut = (workspaceId: string, scope: 'workspace' | 'user' | undefined, id = uuidv7()): UploadOp => ({ + op: 'PUT', + type: 'skills', + id, + data: { + workspace_id: workspaceId, + name: 'Test skill', + description: 'Test', + instruction: 'Do the thing', + enabled: 1, + ...(scope !== undefined ? { scope } : {}), + }, + }) + + it('PUT defaults scope to workspace when payload omits it', async () => { + await insertUser('scOwner1', 'scowner1@test.com') + await insertUser('scOther1', 'scother1@test.com') + const workspaceId = await seedShared('scOwner1', 'scOther1') + + const op = skillPut(workspaceId, undefined) + const result = await applyUploadBatch(db, [op], ctxFor('scOwner1')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, op.id)) + expect(stored[0].scope).toBe('workspace') + }) + + it('PUT accepts scope=user from the row owner', async () => { + await insertUser('scOwner2', 'scowner2@test.com') + await insertUser('scOther2', 'scother2@test.com') + const workspaceId = await seedShared('scOwner2', 'scOther2') + + const op = skillPut(workspaceId, 'user') + const result = await applyUploadBatch(db, [op], ctxFor('scOwner2')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, op.id)) + expect(stored[0].scope).toBe('user') + expect(stored[0].userId).toBe('scOwner2') + }) + + it('PUT scope=user is rejected when allowUserScopedResources is false', async () => { + await insertUser('scOwner3', 'scowner3@test.com') + await insertUser('scOther3', 'scother3@test.com') + const workspaceId = await seedShared('scOwner3', 'scOther3') + + const op = skillPut(workspaceId, 'user') + const result = await applyUploadBatch( + db, + [op], + ctxFor('scOwner3', { settings: createTestSettings({ allowUserScopedResources: false }) }), + ) + expectPermanentReject(result, 'USER_SCOPE_DISABLED') + }) + + it('PUT scope=workspace is allowed even when allowUserScopedResources is false', async () => { + await insertUser('scOwner4', 'scowner4@test.com') + await insertUser('scOther4', 'scother4@test.com') + const workspaceId = await seedShared('scOwner4', 'scOther4') + + const op = skillPut(workspaceId, 'workspace') + const result = await applyUploadBatch( + db, + [op], + ctxFor('scOwner4', { settings: createTestSettings({ allowUserScopedResources: false }) }), + ) + expect(result.ok).toBe(true) + }) + + it('PATCH on a scope=user row by a non-owner is rejected with NOT_ROW_OWNER', async () => { + await insertUser('scOwner5', 'scowner5@test.com') + await insertUser('scOther5', 'scother5@test.com') + const workspaceId = await seedShared('scOwner5', 'scOther5') + const putOp = skillPut(workspaceId, 'user') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwner5'))).ok).toBe(true) + + const editOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { description: 'Stolen' }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('scOther5')) + expectPermanentReject(result, 'NOT_ROW_OWNER') + }) + + it('DELETE on a scope=user row by a non-owner is rejected with NOT_ROW_OWNER', async () => { + await insertUser('scOwner6', 'scowner6@test.com') + await insertUser('scOther6', 'scother6@test.com') + const workspaceId = await seedShared('scOwner6', 'scOther6') + const putOp = skillPut(workspaceId, 'user') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwner6'))).ok).toBe(true) + + const deleteOp: UploadOp = { op: 'DELETE', type: 'skills', id: putOp.id } + const result = await applyUploadBatch(db, [deleteOp], ctxFor('scOther6')) + expectPermanentReject(result, 'NOT_ROW_OWNER') + }) + + it('PATCH on a scope=user row by the owner is allowed', async () => { + await insertUser('scOwner7', 'scowner7@test.com') + await insertUser('scOther7', 'scother7@test.com') + const workspaceId = await seedShared('scOwner7', 'scOther7') + const putOp = skillPut(workspaceId, 'user') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwner7'))).ok).toBe(true) + + const editOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { description: 'Updated by owner' }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('scOwner7')) + expect(result.ok).toBe(true) + }) + + it("PATCH lets the row owner flip scope from 'workspace' to 'user'", async () => { + await insertUser('scOwner8', 'scowner8@test.com') + await insertUser('scOther8', 'scother8@test.com') + const workspaceId = await seedShared('scOwner8', 'scOther8') + const putOp = skillPut(workspaceId, 'workspace') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwner8'))).ok).toBe(true) + + const editOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { scope: 'user', description: 'now private' }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('scOwner8')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, putOp.id)) + expect(stored[0].scope).toBe('user') + expect(stored[0].description).toBe('now private') + }) + + it("PATCH lets the row owner flip scope from 'user' to 'workspace'", async () => { + await insertUser('scOwnerB', 'scownerB@test.com') + await insertUser('scOtherB', 'scotherB@test.com') + const workspaceId = await seedShared('scOwnerB', 'scOtherB') + const putOp = skillPut(workspaceId, 'user') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwnerB'))).ok).toBe(true) + + const editOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { scope: 'workspace' }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('scOwnerB')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, putOp.id)) + expect(stored[0].scope).toBe('workspace') + }) + + it('PATCH lets any add-permitted member flip a shared row to user-private, transferring ownership', async () => { + // The hijack risk is acceptable per product decision: anyone with + // `add_*` permission on the resource can take a workspace-shared row + // private. Flipping to `'user'` stamps the caller as the new owner so + // the row is theirs going forward. + await insertUser('scOwnerC', 'scownerC@test.com') + await insertUser('scOtherC', 'scotherC@test.com') + const workspaceId = await seedShared('scOwnerC', 'scOtherC') + // Grant the non-owner add_skills so the PATCH reaches the apply path. + await db + .insert(workspacePermissionsTable) + .values({ id: uuidv7(), workspaceId, permissionKey: 'add_skills', requiredRole: 'member' }) + const putOp = skillPut(workspaceId, 'workspace') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwnerC'))).ok).toBe(true) + + const editOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { scope: 'user', description: 'taken private' }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('scOtherC')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, putOp.id)) + expect(stored[0].scope).toBe('user') + expect(stored[0].description).toBe('taken private') + expect(stored[0].userId).toBe('scOtherC') + }) + + it('PATCH rejects an obviously-malformed scope value with INVALID_SCOPE', async () => { + await insertUser('scOwnerD', 'scownerD@test.com') + await insertUser('scOtherD', 'scotherD@test.com') + const workspaceId = await seedShared('scOwnerD', 'scOtherD') + const putOp = skillPut(workspaceId, 'workspace') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwnerD'))).ok).toBe(true) + + const editOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { scope: 'global' }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('scOwnerD')) + expectPermanentReject(result, 'INVALID_SCOPE') + }) + + it('PUT upsert by the owner preserves existing scope (cannot promote/demote)', async () => { + await insertUser('scOwner9', 'scowner9@test.com') + await insertUser('scOther9', 'scother9@test.com') + const workspaceId = await seedShared('scOwner9', 'scOther9') + const putOp = skillPut(workspaceId, 'user') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwner9'))).ok).toBe(true) + + const upsert: UploadOp = { + op: 'PUT', + type: 'skills', + id: putOp.id, + data: { + workspace_id: workspaceId, + scope: 'workspace', + name: 'Renamed', + description: 'Test', + instruction: 'Do the thing', + enabled: 1, + }, + } + const result = await applyUploadBatch(db, [upsert], ctxFor('scOwner9')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, putOp.id)) + expect(stored[0].scope).toBe('user') + expect(stored[0].name).toBe('Renamed') + }) + + it('PUT upsert against an existing scope=user row by a non-owner is rejected', async () => { + await insertUser('scOwnerA', 'scownerA@test.com') + await insertUser('scOtherA', 'scotherA@test.com') + const workspaceId = await seedShared('scOwnerA', 'scOtherA') + const putOp = skillPut(workspaceId, 'user') + expect((await applyUploadBatch(db, [putOp], ctxFor('scOwnerA'))).ok).toBe(true) + + const upsert: UploadOp = { + op: 'PUT', + type: 'skills', + id: putOp.id, + data: { + workspace_id: workspaceId, + scope: 'workspace', + name: 'Hijacked', + description: 'Test', + instruction: 'Do the thing', + enabled: 1, + }, + } + const result = await applyUploadBatch(db, [upsert], ctxFor('scOtherA')) + expectPermanentReject(result, 'NOT_ROW_OWNER') + }) + + it('PUT scope=user on agents is rejected when settings flag is off', async () => { + await insertUser('scAgentOwner', 'scagentowner@test.com') + await insertUser('scAgentOther', 'scagentother@test.com') + const workspaceId = await seedShared('scAgentOwner', 'scAgentOther') + + const op: UploadOp = { + op: 'PUT', + type: 'agents', + id: uuidv7(), + data: { + workspace_id: workspaceId, + name: 'My private agent', + type: 'remote-acp', + transport: 'websocket', + url: 'wss://example.com/agent', + enabled: 1, + scope: 'user', + }, + } + const result = await applyUploadBatch( + db, + [op], + ctxFor('scAgentOwner', { settings: createTestSettings({ allowUserScopedResources: false }) }), + ) + expectPermanentReject(result, 'USER_SCOPE_DISABLED') + }) + + it('scope=user agents accept PATCH/DELETE only from the owner', async () => { + await insertUser('scAgentOwner2', 'scagentowner2@test.com') + await insertUser('scAgentOther2', 'scagentother2@test.com') + const workspaceId = await seedShared('scAgentOwner2', 'scAgentOther2') + const agentId = uuidv7() + + const putOp: UploadOp = { + op: 'PUT', + type: 'agents', + id: agentId, + data: { + workspace_id: workspaceId, + name: 'Private', + type: 'remote-acp', + transport: 'websocket', + url: 'wss://example.com/a', + enabled: 1, + scope: 'user', + }, + } + expect((await applyUploadBatch(db, [putOp], ctxFor('scAgentOwner2'))).ok).toBe(true) + + const editByOther: UploadOp = { op: 'PATCH', type: 'agents', id: agentId, data: { name: 'Stolen' } } + expectPermanentReject(await applyUploadBatch(db, [editByOther], ctxFor('scAgentOther2')), 'NOT_ROW_OWNER') + + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, agentId)) + expect(stored[0].name).toBe('Private') + }) + + it('PUT with a malformed scope is rejected with INVALID_SCOPE', async () => { + await insertUser('scInvalid1', 'scinvalid1@test.com') + await insertUser('scInvalid1Member', 'scinvalid1m@test.com') + const workspaceId = await seedShared('scInvalid1', 'scInvalid1Member') + + // `skillPut` only emits valid scopes — hand-roll the op with a bogus value. + const op: UploadOp = { + op: 'PUT', + type: 'skills', + id: uuidv7(), + data: { + workspace_id: workspaceId, + name: 'Bogus', + description: 'Bogus', + instruction: 'Do the thing', + enabled: 1, + scope: 'totally-not-a-real-scope', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('scInvalid1')) + expectPermanentReject(result, 'INVALID_SCOPE') + }) + + it('PATCH flipping scope to user is rejected when allowUserScopedResources is false', async () => { + await insertUser('scFlip1', 'scflip1@test.com') + await insertUser('scFlip1Member', 'scflip1m@test.com') + const workspaceId = await seedShared('scFlip1', 'scFlip1Member') + // Seed a workspace-scoped row with the flag on so the create itself isn't blocked. + const putOp = skillPut(workspaceId, 'workspace') + expect((await applyUploadBatch(db, [putOp], ctxFor('scFlip1'))).ok).toBe(true) + + const flipOp: UploadOp = { op: 'PATCH', type: 'skills', id: putOp.id, data: { scope: 'user' } } + const result = await applyUploadBatch( + db, + [flipOp], + ctxFor('scFlip1', { settings: createTestSettings({ allowUserScopedResources: false }) }), + ) + expectPermanentReject(result, 'USER_SCOPE_DISABLED') + + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, putOp.id)) + expect(stored[0].scope).toBe('workspace') + }) + + it('PATCH flipping scope back to workspace is allowed even when allowUserScopedResources is false', async () => { + await insertUser('scFlip2', 'scflip2@test.com') + await insertUser('scFlip2Member', 'scflip2m@test.com') + const workspaceId = await seedShared('scFlip2', 'scFlip2Member') + const putOp = skillPut(workspaceId, 'user') + expect((await applyUploadBatch(db, [putOp], ctxFor('scFlip2'))).ok).toBe(true) + + // The flag is a kill switch for *new* user-scoping. Unwinding an existing + // user-scoped row back to workspace stays allowed so deployments that + // toggle the flag off don't strand rows in the private bucket. + const flipOp: UploadOp = { op: 'PATCH', type: 'skills', id: putOp.id, data: { scope: 'workspace' } } + const result = await applyUploadBatch( + db, + [flipOp], + ctxFor('scFlip2', { settings: createTestSettings({ allowUserScopedResources: false }) }), + ) + expect(result.ok).toBe(true) + + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, putOp.id)) + expect(stored[0].scope).toBe('workspace') + }) + + it('PATCH on a workspace-scoped row by a co-member updates non-scope fields', async () => { + // Uses `modes` rather than `skills` because skills carries an + // `add_skills` permission gate (defaults to admin-only) — we want to + // exercise the post-fix `fetchRowScope({ userId })` resolution under a + // co-member PATCH, not the permission denial path that comes before it. + await insertUser('scShare1', 'scshare1@test.com') + await insertUser('scShare1Co', 'scshare1co@test.com') + const workspaceId = await seedShared('scShare1', 'scShare1Co') + const modeId = uuidv7() + const putOp: UploadOp = { + op: 'PUT', + type: 'modes', + id: modeId, + data: { workspace_id: workspaceId, name: 'chat', label: 'Chat', scope: 'workspace' }, + } + expect((await applyUploadBatch(db, [putOp], ctxFor('scShare1'))).ok).toBe(true) + + const editOp: UploadOp = { op: 'PATCH', type: 'modes', id: modeId, data: { label: 'Co-member edit' } } + const result = await applyUploadBatch(db, [editOp], ctxFor('scShare1Co')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const stored = await db.select().from(modesTable).where(eq(modesTable.id, modeId)) + expect(stored[0].label).toBe('Co-member edit') + // Authorship is preserved on a co-member edit — the row's `user_id` + // stays as the original author, not the patcher. + expect(stored[0].userId).toBe('scShare1') + }) + }) + + describe('default-data id collisions across personal workspaces', () => { + // `defaults/tasks` (and other default tables) ship with fixed UUIDs that + // `reconcileDefaults` re-inserts into every user's personal workspace. The + // composite PK `(id, workspace_id)` permits the row to repeat per workspace + // — fetchRowScope must honor that, or the second user to sync sees their + // PUT mis-routed to the first user's row (NOT_ROW_OWNER / NOT_WORKSPACE_MEMBER). + const sharedTaskId = '0198ecc5-cc2b-735b-b478-93f8db7202ce' + + it('accepts the same task id from two users (one per personal workspace)', async () => { + await insertUser('collideA', 'a@test.com') + await insertUser('collideB', 'b@test.com') + const wsA = await bootstrapPersonalViaUpload('collideA') + const wsB = await bootstrapPersonalViaUpload('collideB') + + const putFor = (workspaceId: string): UploadOp => ({ + op: 'PUT', + type: 'tasks', + id: sharedTaskId, + data: { workspace_id: workspaceId, item: 'Connect your email', order: 100, is_complete: 0 }, + }) + + expect((await applyUploadBatch(db, [putFor(wsA)], ctxFor('collideA'))).ok).toBe(true) + expect((await applyUploadBatch(db, [putFor(wsB)], ctxFor('collideB'))).ok).toBe(true) + + const rows = await db.select().from(tasksTable).where(eq(tasksTable.id, sharedTaskId)) + expect(rows).toHaveLength(2) + const byWorkspace = Object.fromEntries(rows.map((r) => [r.workspaceId, r.userId])) + expect(byWorkspace[wsA]).toBe('collideA') + expect(byWorkspace[wsB]).toBe('collideB') + }) + + it('PATCH on a shared-id task resolves to the caller-owned row', async () => { + await insertUser('patchA', 'pa@test.com') + await insertUser('patchB', 'pb@test.com') + const wsA = await bootstrapPersonalViaUpload('patchA') + const wsB = await bootstrapPersonalViaUpload('patchB') + + await applyUploadBatch( + db, + [ + { + op: 'PUT', + type: 'tasks', + id: sharedTaskId, + data: { workspace_id: wsA, item: 'A original', order: 100, is_complete: 0 }, + }, + ], + ctxFor('patchA'), + ) + await applyUploadBatch( + db, + [ + { + op: 'PUT', + type: 'tasks', + id: sharedTaskId, + data: { workspace_id: wsB, item: 'B original', order: 100, is_complete: 0 }, + }, + ], + ctxFor('patchB'), + ) + + // Patch from B should land on B's row, not A's. With the pre-fix bare-id + // lookup, validate() would resolve to A's row and reject as NOT_ROW_OWNER. + const patchResult = await applyUploadBatch( + db, + [{ op: 'PATCH', type: 'tasks', id: sharedTaskId, data: { item: 'B edited' } }], + ctxFor('patchB'), + ) + expect(patchResult.ok).toBe(true) + if (patchResult.ok) { + expect(patchResult.rejected).toHaveLength(0) + } + + const aRow = (await db.select().from(tasksTable).where(eq(tasksTable.workspaceId, wsA)))[0] + const bRow = (await db.select().from(tasksTable).where(eq(tasksTable.workspaceId, wsB)))[0] + expect(aRow.item).toBe('A original') + expect(bRow.item).toBe('B edited') + }) + + // Same collision shape on a non-userPrivate, scopeAware table. PUT didn't + // fail loud pre-fix (the NOT_ROW_OWNER branch only fires for userPrivate or + // scope='user' rows), but PATCH/DELETE on a personal-workspace default + // would resolve to the other user's row and reject as NOT_WORKSPACE_MEMBER. + const sharedModelId = 'd045a4c0-3f93-4f30-a608-24e07856e11d' + + it('accepts the same model id from two users (one per personal workspace)', async () => { + await insertUser('mdlA', 'mdla@test.com') + await insertUser('mdlB', 'mdlb@test.com') + const wsA = await bootstrapPersonalViaUpload('mdlA') + const wsB = await bootstrapPersonalViaUpload('mdlB') + + const putFor = (workspaceId: string): UploadOp => ({ + op: 'PUT', + type: 'models', + id: sharedModelId, + data: { + workspace_id: workspaceId, + provider: 'openai', + name: 'Default model', + model: 'gpt-test', + enabled: 1, + scope: 'workspace', + }, + }) + + expect((await applyUploadBatch(db, [putFor(wsA)], ctxFor('mdlA'))).ok).toBe(true) + expect((await applyUploadBatch(db, [putFor(wsB)], ctxFor('mdlB'))).ok).toBe(true) + + const rows = await db.select().from(modelsTable).where(eq(modelsTable.id, sharedModelId)) + expect(rows).toHaveLength(2) + const byWorkspace = Object.fromEntries(rows.map((r) => [r.workspaceId, r.userId])) + expect(byWorkspace[wsA]).toBe('mdlA') + expect(byWorkspace[wsB]).toBe('mdlB') + }) + + it('PATCH on a shared-id model resolves to the caller-owned row', async () => { + await insertUser('mdlPA', 'mdlpa@test.com') + await insertUser('mdlPB', 'mdlpb@test.com') + const wsA = await bootstrapPersonalViaUpload('mdlPA') + const wsB = await bootstrapPersonalViaUpload('mdlPB') + + const seed = (workspaceId: string, name: string): UploadOp => ({ + op: 'PUT', + type: 'models', + id: sharedModelId, + data: { + workspace_id: workspaceId, + provider: 'openai', + name, + model: 'gpt-test', + enabled: 1, + scope: 'workspace', + }, + }) + await applyUploadBatch(db, [seed(wsA, 'A original')], ctxFor('mdlPA')) + await applyUploadBatch(db, [seed(wsB, 'B original')], ctxFor('mdlPB')) + + const patchResult = await applyUploadBatch( + db, + [{ op: 'PATCH', type: 'models', id: sharedModelId, data: { name: 'B edited' } }], + ctxFor('mdlPB'), + ) + expect(patchResult.ok).toBe(true) + if (patchResult.ok) { + expect(patchResult.rejected).toHaveLength(0) + } + + const aRow = (await db.select().from(modelsTable).where(eq(modelsTable.workspaceId, wsA)))[0] + const bRow = (await db.select().from(modelsTable).where(eq(modelsTable.workspaceId, wsB)))[0] + expect(aRow.name).toBe('A original') + expect(bRow.name).toBe('B edited') + }) + }) + + // During the Workspaces v1 rollout, stale clients on the previous build still + // PUT rows without a `workspace_id`. The handler falls them back into the + // caller's personal workspace (computed deterministically — same as where the + // FE pre-workspaces-attach migration would land them after upgrade), so + // creates aren't silently dropped during the rollout window. + describe('stale-client PUT fallback (no workspace_id)', () => { + it('routes a missing-workspace_id PUT into the caller personal workspace', async () => { + await insertUser('stale1', 'stale1@test.com') + const personalId = await bootstrapPersonalViaUpload('stale1') + + const modelId = uuidv7() + const result = await applyUploadBatch( + db, + [ + { + op: 'PUT', + type: 'models', + id: modelId, + data: { + provider: 'openai', + name: 'Stale-client model', + model: 'gpt-test', + enabled: 1, + scope: 'workspace', + }, + }, + ], + ctxFor('stale1'), + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const rows = await db.select().from(modelsTable).where(eq(modelsTable.id, modelId)) + expect(rows).toHaveLength(1) + expect(rows[0].workspaceId).toBe(personalId) + expect(rows[0].userId).toBe('stale1') + }) + }) +}) diff --git a/backend/src/powersync/upload-handlers/workspace-memberships.ts b/backend/src/powersync/upload-handlers/workspace-memberships.ts new file mode 100644 index 000000000..452524a44 --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-memberships.ts @@ -0,0 +1,322 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + countWorkspaceAdmins, + countWorkspaceMemberships, + deleteMembership, + getMembershipById, + getMembershipByWorkspaceAndUser, + getWorkspaceById, + isPersonalWorkspace, + type Role, + updateMembership, + upsertMembership, +} from '@/dal/workspaces' +import { getUserById } from '@/dal/users' +import { computePersonalAdminMembershipId, computePersonalWorkspaceId } from '@shared/workspaces' +import { allow, callerSatisfiesPermission, reject } from './helpers' +import { UploadRejection, type UploadHandler, type UploadTx } from './types' + +const isRole = (v: unknown): v is Role => v === 'admin' || v === 'member' + +/** + * Personal workspaces are created by the FE (uploaded with a deterministic id); + * the very first admin membership for that workspace has to land via upload + * too. The handler's general rule — "must be admin of the target workspace to + * write a membership" — would reject this initial claim because no admin exists + * yet. This narrow exception unlocks exactly one shape of row: + * + * - target workspace is personal + * - workspace's owner is the caller + * - membership id matches the canonical admin-self id for the caller + * - membership references the caller as the user + * - role is admin + * + * Existing-state guard (idempotent re-bootstrap): + * - if no memberships exist yet → first claim, allow + * - if a single canonical admin row already exists at the same id, pointing + * at the same workspace + user + admin role → re-claim is a no-op upsert, + * allow. This covers the rollout case where Drizzle 0020 backfilled the + * membership server-side and a FE on the new build re-uploads its locally + * created row on first sign-in. Rejecting would force PowerSync to revert + * the local oplog entry, which causes `WorkspaceGate` to flicker closed. + * + * Anything else (different user, different role, extra rows) still falls + * through to the immutable rejection. + */ +const isPersonalAdminBootstrap = async ( + tx: UploadTx, + ctx: { userId: string }, + membershipId: string, + data: Record | undefined, +): Promise => { + if (membershipId !== computePersonalAdminMembershipId(ctx.userId)) { + return false + } + const targetWorkspaceId = typeof data?.workspace_id === 'string' ? data.workspace_id : null + if (targetWorkspaceId !== computePersonalWorkspaceId(ctx.userId)) { + return false + } + const targetUserId = typeof data?.user_id === 'string' ? data.user_id : null + if (targetUserId !== ctx.userId) { + return false + } + if (data?.role !== 'admin') { + return false + } + const workspace = await getWorkspaceById(tx, targetWorkspaceId) + if (!workspace || !workspace.isPersonal || workspace.ownerUserId !== ctx.userId) { + return false + } + const existingMemberships = await countWorkspaceMemberships(tx, targetWorkspaceId) + if (existingMemberships === 0) { + return true + } + // Idempotent re-bootstrap path: the only acceptable existing state is the + // exact canonical admin row we're being asked to upsert. Anything else (a + // co-member, a non-admin claim at the canonical id) must still reject so a + // hostile client can't slip past the immutability invariant. + const existing = await getMembershipById(tx, membershipId) + return ( + existingMemberships === 1 && + existing !== null && + existing.workspaceId === targetWorkspaceId && + existing.userId === ctx.userId && + existing.role === 'admin' + ) +} + +/** + * Upload handler for `workspace_memberships`. Enforces: + * + * - Each op gates on a `workspace_permissions` key — `invite_users` for PUT, + * `change_roles` for PATCH, `remove_users` for DELETE — defaulting to + * admin-only when the permission row is absent (Decision 11). + * - PUT that would effectively change an existing membership's role + * additionally requires `change_roles`. PUT applies via `upsertMembership` + * (ON CONFLICT DO UPDATE SET role), so a payload demoting an admin to + * member at the same `(workspace_id, user_id)` would otherwise bypass + * PATCH's `change_roles` gate. + * - Adding a brand-new membership with `role: 'admin'` requires + * `change_roles` for the same reason — `invite_users` alone could + * otherwise mint new admins (also via the pending-invite signup-promote + * path). + * - Personal workspaces are immutable past the FE-driven admin bootstrap + * (`isPersonalAdminBootstrap`), which lands exactly one admin row for the + * owner the first time the workspace appears server-side. + * - The first admin membership for a freshly-created shared workspace is + * allowed by `isSharedWorkspaceAdminBootstrap` (caller is the row's user, + * role is admin, workspace has zero members yet). + * - DELETE that would leave zero remaining admins is permanently rejected. + * The count is taken inside the same transaction so concurrent revokes + * can't both pass the check. + */ +/** + * Shared workspace creator bootstrap: the FE creates a shared workspace and its + * own admin membership in the same upload batch. When the membership arrives the + * workspace exists but has zero members, so the general "must be admin" check + * would reject it. This exception allows exactly one initial claim: + * + * - target workspace exists and is NOT personal + * - membership's user_id equals the caller (no impersonation) + * - role is admin + * - no memberships exist on the workspace yet + */ +const isSharedWorkspaceAdminBootstrap = async ( + tx: UploadTx, + ctx: { userId: string }, + data: Record | undefined, +): Promise => { + const targetWorkspaceId = typeof data?.workspace_id === 'string' ? data.workspace_id : null + if (!targetWorkspaceId) { + return false + } + const targetUserId = typeof data?.user_id === 'string' ? data.user_id : null + if (targetUserId !== ctx.userId) { + return false + } + if (data?.role !== 'admin') { + return false + } + const workspace = await getWorkspaceById(tx, targetWorkspaceId) + if (!workspace || workspace.isPersonal) { + return false + } + const existingMemberships = await countWorkspaceMemberships(tx, targetWorkspaceId) + return existingMemberships === 0 +} + +export const workspaceMembershipsHandler: UploadHandler = { + validate: async (op, ctx, tx) => { + if (op.op === 'PUT' && (await isPersonalAdminBootstrap(tx, ctx, op.id, op.data))) { + return allow() + } + if (op.op === 'PUT' && (await isSharedWorkspaceAdminBootstrap(tx, ctx, op.data))) { + return allow() + } + + // E2EE no longer blocks cross-user memberships: shared-workspace + // collaborative resources travel plaintext under the temporary per-workspace + // scope (see `src/db/encryption/upload-encoder.ts`). When workspace-aware + // E2EE lands (THU-593), reinstate a guard that rejects cross-user invites + // until envelopes can be issued to every member. + + const targetWorkspaceId = + op.op === 'PUT' ? (typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : null) : null + + // Per-op permission keys read from `workspace_permissions.required_role`. + // Defaults to admin when the row is absent (Decision 11). Aligned with what + // the FE Members UI checks via `useWorkspacePermission` so a workspace that + // grants `member` the permission can actually exercise it on upload. + const newRole = isRole(op.data?.role) ? op.data.role : null + const targetsAdminRole = newRole === 'admin' + if (op.op === 'PUT' && !targetWorkspaceId) { + const existing = await getMembershipById(tx, op.id) + if (!existing) { + return reject('permanent', 'WORKSPACE_ID_REQUIRED') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + // Effective role change (either direction) requires `change_roles`. + // `upsertMembership` overwrites `role` on conflict, so a PUT that + // changes role would otherwise bypass PATCH's gate. + const wouldChangeRole = newRole !== null && existing.role !== newRole + if ( + (wouldChangeRole || targetsAdminRole) && + !(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'change_roles')) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + + if (op.op === 'PUT') { + // Insert-or-update path: target workspace is whatever the payload carries. + if (await isPersonalWorkspace(tx, targetWorkspaceId!)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, targetWorkspaceId!, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + // Resolve the existing row at the conflict target `(workspace_id, user_id)` + // — that's what `upsertMembership` updates, not the row at `op.id`. + // A PUT changing an existing role (either direction) requires + // `change_roles`; a fresh insert with `role: 'admin'` also requires it. + const payloadUserId = typeof op.data?.user_id === 'string' ? op.data.user_id : null + const existing = + payloadUserId !== null ? await getMembershipByWorkspaceAndUser(tx, targetWorkspaceId!, payloadUserId) : null + const wouldChangeRole = existing !== null && newRole !== null && existing.role !== newRole + const wouldMintNewAdmin = existing === null && targetsAdminRole + if ( + (wouldChangeRole || wouldMintNewAdmin) && + !(await callerSatisfiesPermission(tx, targetWorkspaceId!, ctx.userId, 'change_roles')) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + + // PATCH / DELETE both target an existing membership row. + const existing = await getMembershipById(tx, op.id) + if (!existing) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + const permissionKey: 'change_roles' | 'remove_users' = op.op === 'PATCH' ? 'change_roles' : 'remove_users' + if (!(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, permissionKey))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + }, + + apply: async (op, _ctx, tx) => { + switch (op.op) { + case 'PUT': { + const workspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : null + const userId = typeof op.data?.user_id === 'string' ? op.data.user_id : null + const role = isRole(op.data?.role) ? op.data.role : null + if (!workspaceId || !userId || !role) { + throw new UploadRejection('permanent', 'MEMBERSHIP_FIELDS_REQUIRED') + } + // Last-admin protection mirrors PATCH/DELETE. `upsertMembership` does + // ON CONFLICT DO UPDATE SET role on `(workspace_id, user_id)`, so a + // PUT demoting the workspace's only admin to member would otherwise + // bypass the guard those paths enforce. + const existing = await getMembershipByWorkspaceAndUser(tx, workspaceId, userId) + if (existing && existing.role === 'admin' && role !== 'admin') { + const remainingAdmins = await countWorkspaceAdmins(tx, workspaceId, existing.id) + if (remainingAdmins === 0) { + throw new UploadRejection('permanent', 'LAST_ADMIN_PROTECTED') + } + } + // Enrich the row with the canonical name/email from `auth.user` so the + // FE Members page has display info without a synced `users` table. The + // FE never gets to set these fields directly — the BE is the only + // source of truth. + const targetUser = await getUserById(tx, userId) + if (!targetUser) { + throw new UploadRejection('permanent', 'USER_NOT_FOUND') + } + await upsertMembership(tx, { + id: op.id, + workspaceId, + userId, + role, + userName: targetUser.name, + userEmail: targetUser.email, + }) + return + } + case 'PATCH': { + const role = isRole(op.data?.role) ? op.data.role : undefined + if (role === undefined) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + // Demoting the last admin would leave the workspace orphaned; capture the + // existing row inside the tx and reject before applying when applicable. + const before = await getMembershipById(tx, op.id) + if (!before) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + if (before.role === 'admin' && role !== 'admin') { + const remainingAdmins = await countWorkspaceAdmins(tx, before.workspaceId, before.id) + if (remainingAdmins === 0) { + throw new UploadRejection('permanent', 'LAST_ADMIN_PROTECTED') + } + } + + const affected = await updateMembership(tx, op.id, { role }) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const before = await getMembershipById(tx, op.id) + if (!before) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + if (before.role === 'admin') { + const remainingAdmins = await countWorkspaceAdmins(tx, before.workspaceId, before.id) + if (remainingAdmins === 0) { + throw new UploadRejection('permanent', 'LAST_ADMIN_PROTECTED') + } + } + const affected = await deleteMembership(tx, op.id) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, +} diff --git a/backend/src/powersync/upload-handlers/workspace-pending-memberships.ts b/backend/src/powersync/upload-handlers/workspace-pending-memberships.ts new file mode 100644 index 000000000..21a470c26 --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-pending-memberships.ts @@ -0,0 +1,188 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + deletePendingMembership, + deletePendingMembershipByWorkspaceAndEmail, + getPendingMembershipById, + insertMembershipIfMissing, + isPersonalWorkspace, + type Role, + updatePendingMembership, + upsertPendingMembership, +} from '@/dal/workspaces' +import { getUserByEmail } from '@/dal/users' +import { isValidEmailFormat, normalizeEmail } from '@/lib/email' +import { allow, callerSatisfiesPermission, reject } from './helpers' +import { UploadRejection, type UploadHandler } from './types' + +const isRole = (v: unknown): v is Role => v === 'admin' || v === 'member' + +/** + * Upload handler for `workspace_pending_memberships`. Every write gates on the + * `invite_users` permission (admin by default, Decision 11). Promoting/editing + * a pending invite to `role: 'admin'` additionally requires `change_roles` — + * see the inline guard. Personal workspaces cannot have pending memberships. + * Email is normalized server-side by the DAL to match the Better Auth `before` hook. + */ +export const workspacePendingMembershipsHandler: UploadHandler = { + validate: async (op, ctx, tx) => { + // E2EE no longer blocks invites: shared-workspace collaborative resources + // travel plaintext under the temporary per-workspace scope (see + // `src/db/encryption/upload-encoder.ts`). When workspace-aware E2EE lands + // (THU-593), reinstate a guard that rejects pending memberships until + // envelopes can be issued to invitees. + + // All pending-membership writes (create / edit / cancel an invite) gate on + // `invite_users` so a workspace that grants `member` the permission can + // exercise it on upload. Defaults to admin via Decision 11. + // + // Inviting (or editing the invite to) `role: 'admin'` additionally requires + // `change_roles` — otherwise `invite_users` alone could mint new admins + // via the signup-promote path, bypassing the gate that protects existing + // members from being promoted by non-role-managers. + const targetsAdminRole = op.data?.role === 'admin' + if (op.op === 'PUT') { + const targetWorkspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : undefined + if (!targetWorkspaceId) { + const existing = await getPendingMembershipById(tx, op.id) + if (!existing) { + return reject('permanent', 'WORKSPACE_ID_REQUIRED') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + if ( + targetsAdminRole && + !(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'change_roles')) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + if (await isPersonalWorkspace(tx, targetWorkspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, targetWorkspaceId, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + if (targetsAdminRole && !(await callerSatisfiesPermission(tx, targetWorkspaceId, ctx.userId, 'change_roles'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + + const existing = await getPendingMembershipById(tx, op.id) + if (!existing) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + // Guard fires only on promotions (role becoming admin). Demoting an + // existing pending admin invite to `member` stays gated on `invite_users` + // alone — it's tampering, not escalation, and matching the broader + // "any role change requires change_roles" rule from the memberships + // handler would cost an extra DB read (load existing pending row) for + // a non-security concern. + if ( + op.op === 'PATCH' && + targetsAdminRole && + !(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'change_roles')) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + }, + + apply: async (op, ctx, tx) => { + switch (op.op) { + case 'PUT': { + const workspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : null + const email = typeof op.data?.email === 'string' ? op.data.email : null + const role = isRole(op.data?.role) ? op.data.role : null + if (!workspaceId || !email || !role) { + throw new UploadRejection('permanent', 'PENDING_FIELDS_REQUIRED') + } + if (!isValidEmailFormat(email)) { + throw new UploadRejection('permanent', 'INVALID_EMAIL') + } + // `invitedByUserId` is server-truth — the caller is the inviter, full + // stop. Trusting the payload would let a client attribute the invite + // to someone else. + await upsertPendingMembership(tx, { + id: op.id, + workspaceId, + email, + role, + invitedByUserId: ctx.userId, + }) + + // Promote-on-insert: if the invited email already belongs to a real + // user on this server, write a membership row + delete the pending + // row in the same transaction. PostgreSQL emits both ops in WAL order + // so PowerSync ships the insert+delete back to the originating FE, + // which removes its optimistic local pending row organically. The + // signup hook (`promotePendingMemberships`) covers the unknown-email + // path when the invitee later signs up. + // + // DO-NOTHING semantics: if the user is already a member of this + // workspace, the invite must NOT overwrite their current role — + // inviting an existing admin's email would otherwise downgrade them + // to whatever role the invite carried. Mirrors + // `promotePendingMemberships` (the signup-time bulk-promote path). + const matched = await getUserByEmail(tx, normalizeEmail(email)) + if (matched) { + await insertMembershipIfMissing(tx, { + id: crypto.randomUUID(), + workspaceId, + userId: matched.id, + role, + userName: matched.name, + userEmail: matched.email, + }) + // Delete by `(workspace_id, email)` rather than `op.id` — the upsert + // above hit the `(workspace_id, email)` unique constraint when the + // pending row already existed, in which case Postgres kept the + // original id and the upload's `op.id` no longer matches. Keying on + // workspace+email always lands on the actual row. + await deletePendingMembershipByWorkspaceAndEmail(tx, workspaceId, email) + } + return + } + case 'PATCH': { + // `invited_by_user_id` is server-truth and not patchable by the + // client — silently drop it from the payload rather than rewriting + // it to an attacker-supplied value. + const email = typeof op.data?.email === 'string' ? op.data.email : undefined + const role = isRole(op.data?.role) ? op.data.role : undefined + + if (email === undefined && role === undefined) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + if (email !== undefined && !isValidEmailFormat(email)) { + throw new UploadRejection('permanent', 'INVALID_EMAIL') + } + const affected = await updatePendingMembership(tx, op.id, { email, role }) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const affected = await deletePendingMembership(tx, op.id) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, +} diff --git a/backend/src/powersync/upload-handlers/workspace-permissions.ts b/backend/src/powersync/upload-handlers/workspace-permissions.ts new file mode 100644 index 000000000..670a57fbb --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-permissions.ts @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + deleteWorkspacePermission, + getWorkspacePermissionById, + isPersonalWorkspace, + isWorkspaceAdmin, + type Role, + updateWorkspacePermission, + upsertWorkspacePermission, +} from '@/dal/workspaces' +import { isWorkspacePermissionKey } from '@shared/workspaces' +import { allow, reject } from './helpers' +import { UploadRejection, type UploadHandler } from './types' + +const isRole = (v: unknown): v is Role => v === 'admin' || v === 'member' + +/** + * Upload handler for `workspace_permissions`. All operations require admin role + * in the target workspace; personal workspaces have no configurable permissions + * in v1 (Decision 11). + */ +export const workspacePermissionsHandler: UploadHandler = { + validate: async (op, ctx, tx) => { + if (op.op === 'PUT') { + const targetWorkspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : undefined + if (!targetWorkspaceId) { + const existing = await getWorkspacePermissionById(tx, op.id) + if (!existing) { + return reject('permanent', 'WORKSPACE_ID_REQUIRED') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await isWorkspaceAdmin(tx, existing.workspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + return allow() + } + if (await isPersonalWorkspace(tx, targetWorkspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await isWorkspaceAdmin(tx, targetWorkspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + return allow() + } + + const existing = await getWorkspacePermissionById(tx, op.id) + if (!existing) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await isWorkspaceAdmin(tx, existing.workspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + return allow() + }, + + apply: async (op, _ctx, tx) => { + switch (op.op) { + case 'PUT': { + const workspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : null + const permissionKey = isWorkspacePermissionKey(op.data?.permission_key) ? op.data.permission_key : null + const requiredRole = isRole(op.data?.required_role) ? op.data.required_role : null + if (!workspaceId || !permissionKey || !requiredRole) { + throw new UploadRejection('permanent', 'PERMISSION_FIELDS_REQUIRED') + } + await upsertWorkspacePermission(tx, { + id: op.id, + workspaceId, + permissionKey, + requiredRole, + }) + return + } + case 'PATCH': { + const requiredRole = isRole(op.data?.required_role) ? op.data.required_role : undefined + if (requiredRole === undefined) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + const affected = await updateWorkspacePermission(tx, op.id, { requiredRole }) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const affected = await deleteWorkspacePermission(tx, op.id) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, +} diff --git a/backend/src/powersync/upload-handlers/workspace-scoped.ts b/backend/src/powersync/upload-handlers/workspace-scoped.ts new file mode 100644 index 000000000..dfa6349ae --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-scoped.ts @@ -0,0 +1,416 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { isWorkspaceMember } from '@/dal/workspaces' +import { + powersyncConflictTarget, + powersyncDbNameToSchemaKey, + powersyncPkColumn, + powersyncTablesByName, +} from '@/db/powersync-schema' +import type { PowerSyncTableName } from '@shared/powersync-tables' +import { computePersonalWorkspaceId, type WorkspacePermissionKey } from '@shared/workspaces' +import { and, eq } from 'drizzle-orm' +import type { AnyPgColumn, AnyPgTable } from 'drizzle-orm/pg-core' +import { allow, callerSatisfiesPermission, reject, toSchemaRecord } from './helpers' +import { UploadRejection, type UploadHandler, type UploadTx } from './types' + +export type WorkspaceScopedConfig = { + tableName: PowerSyncTableName + /** + * When true, the row's `user_id` column must equal the caller. Applied to + * chat_threads / chat_messages / tasks per spec §3.7: those rows are user-private + * within a shared workspace. When false (models, modes, prompts, skills, triggers, + * model_profiles) any workspace member may write the row. + */ + userPrivate: boolean + /** Columns the client may not set; stripped from PUT/PATCH payloads. */ + denyColumns?: readonly string[] + /** + * Permission key gating PUT and "edit" PATCHes. When set, the handler reads + * `workspace_permissions.required_role` for this key and rejects the op + * unless the caller's role satisfies it. Default (no key) keeps the "any + * workspace member may write" behaviour. + */ + addPermissionKey?: WorkspacePermissionKey + /** + * Permission key gating DELETE and "soft-delete" PATCHes (see + * `softDeleteColumn`). Same lookup semantics as `addPermissionKey`. + */ + removePermissionKey?: WorkspacePermissionKey + /** + * Column name (in PowerSync upload's snake_case form) used by the table for + * soft-delete tombstones. When set, a PATCH that writes this column to a + * non-null value is treated as a remove and gates on `removePermissionKey` + * instead of `addPermissionKey`. PATCHes that don't touch the column — or + * that set it back to null (restore) — continue to gate as adds. + * + * Required for the resource tables (agents, skills, models) whose FE DAL + * soft-deletes via UPDATE rather than DELETE. + */ + softDeleteColumn?: string + /** + * When true, the table carries a `scope` column (`'workspace' | 'user'`) that + * gates per-row visibility (THU-603). Rows with `scope = 'user'` are private + * to their author within the workspace: any PATCH/DELETE — and any + * upsert-style PUT against an existing row — is rejected for callers other + * than the row owner, in addition to the workspace-membership checks. + * + * Scope changes are accepted on PATCH from any caller with the relevant + * add permission — flipping a shared row to user-private transfers ownership + * to the caller (their `user_id` is stamped as the new owner). Flipping a + * user-private row back to shared is implicitly owner-only because the + * broader `isRowOwnerOnly` check blocks any PATCH from non-owners on + * user-scoped rows. PUT-as-update still preserves the existing row's scope + * (the upsert path is for inserts; edits go through PATCH). + * + * PUTs and PATCHes that attempt to set `scope = 'user'` are rejected when + * `settings.allowUserScopedResources` is false (deployment-level kill switch). + */ + scopeAware?: boolean +} + +const isString = (v: unknown): v is string => typeof v === 'string' + +type RowScope = { + workspaceId: string + userId: string | null + /** `'workspace' | 'user'` on scope-aware tables; `null` otherwise. */ + scope: 'workspace' | 'user' | null +} + +/** + * Looks up the existing row's `workspace_id` (and `user_id`, plus `scope` for + * scope-aware tables) by primary key. + * + * Composite-PK tables have `(id, workspace_id)` so the same id can repeat across + * workspaces — most synced rows use uuidv7 ids that are globally unique, but + * default-data rows (`defaults/tasks`, `defaults/models`, …) ship with fixed + * ids that `reconcileDefaults` re-inserts into every user's personal workspace. + * A bare-id lookup against those defaults returns whichever user happened to + * sync first, so a second user's upsert is mis-routed to the wrong workspace + * and rejected as `NOT_ROW_OWNER` / `NOT_WORKSPACE_MEMBER`. + * + * `hint` disambiguates: pass `workspaceId` on PUT (the payload carries it) for + * an exact composite-key lookup; pass `userId` on PATCH/DELETE (the payload + * doesn't carry workspace_id) to prefer the row authored by the caller. Both + * fall back to bare-id if no row matches the hint, so non-default rows keep + * working unchanged. + */ +const fetchRowScope = async ( + tx: UploadTx, + tableName: PowerSyncTableName, + rowId: string, + scopeAware: boolean, + hint: { workspaceId?: string; userId?: string } = {}, +): Promise => { + const table = powersyncTablesByName[tableName] as AnyPgTable & { + workspaceId: AnyPgColumn + userId: AnyPgColumn + scope?: AnyPgColumn + } + const pkColumn = powersyncPkColumn[tableName] + + const select: Record = { workspaceId: table.workspaceId, userId: table.userId } + if (scopeAware && table.scope) { + select.scope = table.scope + } + + const toScope = (row: Record): RowScope => { + const rawScope = row.scope + return { + workspaceId: row.workspaceId as string, + userId: (row.userId as string | null) ?? null, + scope: rawScope === 'user' || rawScope === 'workspace' ? rawScope : null, + } + } + + const runQuery = async (where: ReturnType): Promise => { + const rows = await tx.select(select).from(table).where(where).limit(1) + return rows[0] ? toScope(rows[0] as Record) : null + } + + // `workspaceId` hint is authoritative: when the caller knows the target + // workspace (PUT carries it in the payload), the (id, workspace_id) composite + // is the exact PK. No match means "no existing row in that workspace" — fall + // through to the bare-id lookup would re-introduce the cross-workspace bleed + // this whole helper exists to prevent. + if (hint.workspaceId) { + return runQuery(and(eq(pkColumn, rowId), eq(table.workspaceId, hint.workspaceId))!) + } + // PATCH/DELETE don't carry workspace_id. Prefer the row authored by the + // caller (covers user-private tables and the default-data case where each + // user's personal workspace owns its own copy under the same id). Fall back + // to bare-id so shared-workspace rows authored by another member still + // resolve — downstream membership / userPrivate checks gate the actual write. + if (hint.userId) { + const owned = await runQuery(and(eq(pkColumn, rowId), eq(table.userId, hint.userId))!) + if (owned) { + return owned + } + } + return runQuery(eq(pkColumn, rowId)) +} + +/** + * Builds an `UploadHandler` for a workspace-scoped synced table. Enforces: + * + * - Every row write requires the caller to be a member of the target workspace. + * For PUT, the target is `op.data.workspace_id`; for PATCH/DELETE it's read from + * the row's current `workspace_id`. + * - When `userPrivate` is true, PATCH/DELETE additionally require the row's + * `user_id` to equal the caller — protecting one member's chat threads from + * being edited by another member of the same workspace. + * - PUT forces `user_id = ctx.userId` so a client cannot impersonate another + * member when authoring rows. PATCH/DELETE silently drop any payload `user_id`. + * - PATCH/DELETE silently drop any payload `workspace_id` so a row cannot be + * moved between workspaces via upload. + */ +export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): UploadHandler => { + const { + tableName, + userPrivate, + denyColumns = [], + addPermissionKey, + removePermissionKey, + softDeleteColumn, + scopeAware = false, + } = cfg + + /** + * Classifies the op as 'add' (PUT, PATCH-edit, PATCH-restore) or 'remove' + * (DELETE, PATCH that sets `softDeleteColumn` to a truthy value). Drives + * which permission key gates the write. + */ + const opIntent = (op: { op: 'PUT' | 'PATCH' | 'DELETE'; data?: Record }): 'add' | 'remove' => { + if (op.op === 'DELETE') { + return 'remove' + } + if ( + op.op === 'PATCH' && + softDeleteColumn !== undefined && + op.data && + Object.prototype.hasOwnProperty.call(op.data, softDeleteColumn) && + op.data[softDeleteColumn] != null + ) { + return 'remove' + } + return 'add' + } + + /** + * True when the row's effective access mode is "private to its author" — either + * the whole table is userPrivate (chat tables) or the specific row carries + * `scope = 'user'` (THU-603). + */ + const isRowOwnerOnly = (rowScope: RowScope): boolean => userPrivate || (scopeAware && rowScope.scope === 'user') + + return { + validate: async (op, ctx, tx) => { + if (op.op === 'PUT') { + const payloadScope = scopeAware && isString(op.data?.scope) ? op.data.scope : null + if (scopeAware && payloadScope !== null && payloadScope !== 'workspace' && payloadScope !== 'user') { + // Mirror PATCH's INVALID_SCOPE guard — reject malformed values here + // rather than letting Postgres throw a check-constraint error later. + return reject('permanent', 'INVALID_SCOPE') + } + if (scopeAware && payloadScope === 'user' && !ctx.settings.allowUserScopedResources) { + return reject('permanent', 'USER_SCOPE_DISABLED') + } + const targetWorkspaceId = isString(op.data?.workspace_id) ? op.data.workspace_id : null + // For an upsert against an existing row, fall back to the row's workspace + // if the payload doesn't carry one. The `workspaceId` hint disambiguates + // default-data rows that repeat the same id across users' workspaces. + const existing = await fetchRowScope(tx, tableName, op.id, scopeAware, { + workspaceId: targetWorkspaceId ?? undefined, + userId: ctx.userId, + }) + // Stale pre-Workspaces clients PUT rows without a `workspace_id`. Route + // them into the caller's personal workspace (the FE migration would land + // them there anyway after upgrade); avoids losing creates during rollout. + const resolvedWorkspaceId = targetWorkspaceId ?? existing?.workspaceId ?? computePersonalWorkspaceId(ctx.userId) + if (!(await isWorkspaceMember(tx, resolvedWorkspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_MEMBER') + } + // Upsert against an existing user-private row by anyone other than the owner + // is treated identically to a PATCH against that row — reject so the privacy + // contract holds across all write ops. Run before the permission check so a + // non-owner who lacks add permission still gets the more informative reason. + if (existing && isRowOwnerOnly(existing) && existing.userId !== ctx.userId) { + return reject('permanent', 'NOT_ROW_OWNER') + } + if ( + addPermissionKey && + !(await callerSatisfiesPermission(tx, resolvedWorkspaceId, ctx.userId, addPermissionKey)) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + + const scope = await fetchRowScope(tx, tableName, op.id, scopeAware, { userId: ctx.userId }) + if (!scope) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (!(await isWorkspaceMember(tx, scope.workspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_MEMBER') + } + // PATCH must respect the same deployment kill-switch as PUT — without + // this an owner can flip an existing workspace-scoped row to user-scoped + // while `allowUserScopedResources` is off, sneaking around the flag. Only + // gates the *change* to `'user'`; flipping back to `'workspace'` while + // the flag is off is always allowed because it removes the user-scoped + // state rather than creating it. + if ( + op.op === 'PATCH' && + scopeAware && + isString(op.data?.scope) && + op.data.scope === 'user' && + !ctx.settings.allowUserScopedResources + ) { + return reject('permanent', 'USER_SCOPE_DISABLED') + } + if (isRowOwnerOnly(scope) && scope.userId !== ctx.userId) { + return reject('permanent', 'NOT_ROW_OWNER') + } + const requiredPermissionKey = opIntent(op) === 'remove' ? removePermissionKey : addPermissionKey + if ( + requiredPermissionKey && + !(await callerSatisfiesPermission(tx, scope.workspaceId, ctx.userId, requiredPermissionKey)) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + }, + + apply: async (op, ctx, tx) => { + const table = powersyncTablesByName[tableName] as AnyPgTable & { + workspaceId: AnyPgColumn + userId: AnyPgColumn + } + const dbNameToKey = powersyncDbNameToSchemaKey[tableName] + const pkColumn = powersyncPkColumn[tableName] + const conflictTarget = powersyncConflictTarget[tableName] + const validDbNames = new Set(Object.keys(dbNameToKey)) + + switch (op.op) { + case 'PUT': { + const targetWorkspaceId = isString(op.data?.workspace_id) ? op.data.workspace_id : null + const resolvedWorkspaceId = + targetWorkspaceId ?? + (await fetchRowScope(tx, tableName, op.id, scopeAware, { userId: ctx.userId }))?.workspaceId ?? + computePersonalWorkspaceId(ctx.userId) + + const payload = { ...(op.data ?? {}) } as Record + delete payload.id + delete payload.user_id + delete payload.workspace_id + for (const col of denyColumns) { + delete payload[col] + } + + const rawData: Record = { + ...payload, + id: op.id, + workspace_id: resolvedWorkspaceId, + user_id: ctx.userId, + } + const schemaValues = toSchemaRecord(rawData, validDbNames, dbNameToKey) + if (Object.keys(schemaValues).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + const updateSet = { ...schemaValues } + delete updateSet.id + delete updateSet.key + delete updateSet.workspaceId + // Preserve the row's original `user_id` on update so co-members editing a + // shared row don't rewrite authorship. + delete updateSet.userId + // `scope` is set at create-time only — drop it from the ON CONFLICT update + // so an upsert can't flip a workspace-scoped row to user-scoped or back. + if (scopeAware) { + delete (updateSet as { scope?: unknown }).scope + } + + const insertQuery = tx.insert(table).values(schemaValues as never) + if (Object.keys(updateSet).length > 0) { + await insertQuery.onConflictDoUpdate({ + target: conflictTarget, + set: updateSet as never, + setWhere: eq(table.workspaceId, resolvedWorkspaceId), + }) + } else { + await insertQuery.onConflictDoNothing({ target: conflictTarget }) + } + return + } + case 'PATCH': { + if (!op.data || Object.keys(op.data).length === 0) { + return + } + const patchPayload = { ...op.data } as Record + delete patchPayload.id + delete patchPayload.user_id + delete patchPayload.workspace_id + if (scopeAware && patchPayload.scope !== undefined) { + if (patchPayload.scope !== 'workspace' && patchPayload.scope !== 'user') { + // Reject obviously-malformed values rather than letting Postgres + // throw a check-constraint error later. + throw new UploadRejection('permanent', 'INVALID_SCOPE') + } + // Flipping to user-private transfers ownership to the caller — + // they're explicitly taking the row private, so they become the new + // owner. The earlier `delete patchPayload.user_id` ran before this + // block, so the assignment here is the final word. + if (patchPayload.scope === 'user') { + patchPayload.user_id = ctx.userId + } + } + for (const col of denyColumns) { + delete patchPayload[col] + } + const schemaPatch = toSchemaRecord(patchPayload, validDbNames, dbNameToKey) + if (Object.keys(schemaPatch).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + // Re-fetch scope to pin workspace_id in the WHERE clause. Composite PK + // (id, workspace_id) means WHERE id alone could touch rows across workspaces. + const patchScope = await fetchRowScope(tx, tableName, op.id, scopeAware, { userId: ctx.userId }) + if (!patchScope) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + + const patched = await tx + .update(table) + .set(schemaPatch as never) + .where(and(eq(pkColumn, op.id), eq(table.workspaceId, patchScope.workspaceId))) + .returning() + + if (patched.length === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const deleteScope = await fetchRowScope(tx, tableName, op.id, scopeAware, { userId: ctx.userId }) + if (!deleteScope) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + + const deleted = await tx + .delete(table) + .where(and(eq(pkColumn, op.id), eq(table.workspaceId, deleteScope.workspaceId))) + .returning() + + if (deleted.length === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, + } +} diff --git a/backend/src/powersync/upload-handlers/workspaces.ts b/backend/src/powersync/upload-handlers/workspaces.ts new file mode 100644 index 000000000..aee4ec592 --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspaces.ts @@ -0,0 +1,204 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + getWorkspaceById, + insertPersonalWorkspaceIfMissing, + isAdminOfAnyWorkspace, + isWorkspaceAdmin, + updateWorkspace, + upsertWorkspace, +} from '@/dal/workspaces' +import { computePersonalWorkspaceId } from '@shared/workspaces' +import { allow, reject } from './helpers' +import { UploadRejection, type UploadHandler } from './types' + +/** + * Upload handler for the `workspaces` table. Enforces: + * + * - **Personal workspace PUT**: allowed iff the row id matches the canonical + * `computePersonalWorkspaceId(ctx.userId)` AND `owner_user_id === ctx.userId`. + * First-write wins on the name + icon — if the row already exists, the PUT + * is a no-op so a second device's idempotent bootstrap doesn't clobber data + * the user changed elsewhere. `slug` always stays null on personal (personal + * workspaces don't carry URL slugs). + * - **Personal workspace PATCH**: name and icon mutable; slug is rejected + * (`PERSONAL_WORKSPACE_SLUG_FORBIDDEN`). Gated on admin of the workspace. + * - **Shared workspace PUT**: gated by `allowWorkspaceCreationByMembers`. + * Members may create only when the flag is on; users who already admin some + * workspace can always create regardless of the flag. + * - **Shared workspace PATCH**: requires the caller to be admin of the target. + * name, slug, and icon are mutable. + * - **Duplicate slug**: Postgres `UNIQUE INDEX idx_workspaces_slug` rejects + * the write; the apply layer catches it and surfaces `WORKSPACE_SLUG_TAKEN` + * as a permanent rejection. + * - **DELETE**: out of scope for v1. + */ +export const workspacesHandler: UploadHandler = { + validate: async (op, ctx, tx) => { + if (op.op === 'DELETE') { + return reject('permanent', 'WORKSPACE_DELETE_DISABLED') + } + + const existing = await getWorkspaceById(tx, op.id) + + if (op.op === 'PATCH') { + if (!existing) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (!(await isWorkspaceAdmin(tx, op.id, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + if (existing.isPersonal && op.data?.slug !== undefined) { + return reject('permanent', 'PERSONAL_WORKSPACE_SLUG_FORBIDDEN') + } + return allow() + } + + // PUT — split by personal vs shared via the payload + id checks. + const payloadIsPersonal = op.data?.is_personal === true + const canonicalPersonalId = computePersonalWorkspaceId(ctx.userId) + const idMatchesCanonical = op.id === canonicalPersonalId + + if (payloadIsPersonal || idMatchesCanonical || existing?.isPersonal) { + // This is a personal-workspace PUT. Accept only if both the canonical id + // and the ownership claim match the caller. Anything else — wrong id, + // someone else's owner_user_id — is rejected. + if (!idMatchesCanonical) { + return reject('permanent', 'PERSONAL_WORKSPACE_ID_NOT_CANONICAL') + } + const claimedOwner = typeof op.data?.owner_user_id === 'string' ? op.data.owner_user_id : undefined + if (claimedOwner !== undefined && claimedOwner !== ctx.userId) { + return reject('permanent', 'PERSONAL_WORKSPACE_OWNER_MISMATCH') + } + if (existing && !existing.isPersonal) { + // The canonical id is already used by a non-personal workspace — + // structurally impossible if the canonical hashing is correct, but + // refuse anyway rather than silently mutate a shared workspace. + return reject('permanent', 'PERSONAL_WORKSPACE_ID_COLLISION') + } + return allow() + } + + // Shared workspace PUT. + if (existing) { + // Updating an existing shared workspace via PUT. + if (!(await isWorkspaceAdmin(tx, op.id, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + return allow() + } + + const memberMayCreate = + ctx.settings.allowWorkspaceCreationByMembers || (await isAdminOfAnyWorkspace(tx, ctx.userId)) + if (!memberMayCreate) { + return reject('permanent', 'WORKSPACE_CREATION_DISABLED') + } + + return allow() + }, + + apply: async (op, ctx, tx) => { + switch (op.op) { + case 'PUT': { + const canonicalPersonalId = computePersonalWorkspaceId(ctx.userId) + const isPersonalPut = op.data?.is_personal === true || op.id === canonicalPersonalId + const incomingName = typeof op.data?.name === 'string' ? op.data.name : null + // Personal workspaces default to "Default" if the client omitted the name + // (defensive — current clients always send one). Shared workspaces require it. + const name = incomingName ?? (isPersonalPut ? 'Default' : null) + if (!name) { + throw new UploadRejection('permanent', 'WORKSPACE_NAME_REQUIRED') + } + // Distinguish "key omitted from payload" (undefined) from "explicitly + // null" so an admin's idempotent PUT that doesn't carry slug/icon + // doesn't clobber values already on the server. `upsertWorkspace`'s + // ON CONFLICT only writes columns whose input value is `!== undefined`. + const slug = op.data?.slug === undefined ? undefined : typeof op.data.slug === 'string' ? op.data.slug : null + const icon = op.data?.icon === undefined ? undefined : typeof op.data.icon === 'string' ? op.data.icon : null + if (isPersonalPut) { + // `DO NOTHING` on conflict — preserves any later changes if a second + // device's bootstrap PUT lands after the user already mutated the row. + await insertPersonalWorkspaceIfMissing(tx, { + id: op.id, + name, + icon: icon ?? null, + ownerUserId: ctx.userId, + }) + return + } + await runWithSlugViolationGuard(() => + upsertWorkspace(tx, { + id: op.id, + name, + slug, + icon, + isPersonal: false, + ownerUserId: null, + }), + ) + return + } + case 'PATCH': { + const patch: { name?: string; slug?: string | null; icon?: string | null } = {} + if (typeof op.data?.name === 'string') { + patch.name = op.data.name + } + if (op.data?.slug !== undefined) { + patch.slug = typeof op.data.slug === 'string' ? op.data.slug : null + } + if (op.data?.icon !== undefined) { + patch.icon = typeof op.data.icon === 'string' ? op.data.icon : null + } + if (Object.keys(patch).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + const affected = await runWithSlugViolationGuard(() => updateWorkspace(tx, op.id, patch)) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + throw new UploadRejection('permanent', 'WORKSPACE_DELETE_DISABLED') + } + } + }, +} + +/** + * Wrap a write that may collide with the partial-unique slug index. Postgres + * raises `unique_violation` (SQLSTATE 23505) which would otherwise bubble out + * as a transient retry — we want a permanent `WORKSPACE_SLUG_TAKEN` instead. + */ +const runWithSlugViolationGuard = async (write: () => Promise): Promise => { + try { + return await write() + } catch (err) { + if (isUniqueViolationOnSlugIndex(err)) { + throw new UploadRejection('permanent', 'WORKSPACE_SLUG_TAKEN') + } + throw err + } +} + +const isUniqueViolationOnSlugIndex = (err: unknown): boolean => { + // Drizzle wraps the underlying postgres-js error in `.cause`; check both the + // outer object and its cause. + for (const candidate of [err, (err as { cause?: unknown } | null)?.cause]) { + if (!candidate || typeof candidate !== 'object') { + continue + } + const e = candidate as { code?: unknown; constraint_name?: unknown; constraint?: unknown } + if (e.code !== '23505') { + continue + } + const constraint = + typeof e.constraint === 'string' ? e.constraint : typeof e.constraint_name === 'string' ? e.constraint_name : null + if (constraint && constraint.includes('idx_workspaces_slug')) { + return true + } + } + return false +} diff --git a/backend/src/test-utils/db.ts b/backend/src/test-utils/db.ts index 0d17b66ca..d71729eac 100644 --- a/backend/src/test-utils/db.ts +++ b/backend/src/test-utils/db.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { PGlite } from '@electric-sql/pglite' -import { sql } from 'drizzle-orm' +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp' import { drizzle } from 'drizzle-orm/pglite' import { migrate } from 'drizzle-orm/pglite/migrator' import { resolve } from 'path' @@ -24,7 +24,7 @@ class TestDbManager { return } - this.client = new PGlite() + this.client = new PGlite({ extensions: { uuid_ossp } }) this.db = drizzle({ client: this.client, schema }) const migrationsFolder = resolve(import.meta.dir, '../../drizzle') await migrate(this.db, { migrationsFolder }) @@ -42,22 +42,57 @@ class TestDbManager { } /** - * Create a test database instance with transaction isolation + * Create a test database instance with transaction isolation. + * + * Opens a Drizzle transaction and exposes the Transaction object as `db`. + * Why a Drizzle transaction (not raw `BEGIN`/`ROLLBACK` SQL): production + * upload handlers wrap operations in `db.transaction(...)`. When that runs + * against a Drizzle Database/Session, PGlite issues raw `BEGIN`/`COMMIT` + * SQL — a nested `BEGIN` is a no-op (Postgres warning), but the matching + * `COMMIT` ends the outer test transaction, breaking isolation. + * + * Against a Drizzle Transaction, `db.transaction(...)` uses `SAVEPOINT` + * instead, which nests cleanly and rolls back with the outer test + * transaction on cleanup. + * + * The outer transaction is opened via a deferred promise so we can hand + * the Transaction object to the caller and hold it open until cleanup. + * Throwing a sentinel from inside the transaction callback triggers + * Drizzle's `ROLLBACK`; the catch outside swallows the sentinel. */ async createTestDb() { if (!this.initialized) { await this.initialize() } - // Start a transaction using Drizzle's API - await this.db!.execute(sql`BEGIN`) + const rollbackSentinel = new Error('__test_cleanup_rollback__') + + let resolveTx!: (tx: typeof DbType) => void + let signalRollback!: () => void + + const txReady = new Promise((resolve) => { + resolveTx = resolve + }) + + const txDone = this.db!.transaction(async (tx) => { + resolveTx(tx as unknown as typeof DbType) + await new Promise((_, reject) => { + signalRollback = () => reject(rollbackSentinel) + }) + }).catch((err) => { + if (err !== rollbackSentinel) { + throw err + } + }) + + const tx = await txReady return { client: this.client!, - db: this.db!, - // Cleanup function to roll back the transaction + db: tx, cleanup: async () => { - await this.db!.execute(sql`ROLLBACK`) + signalRollback() + await txDone }, } } @@ -97,7 +132,7 @@ export type IsolatedTestDb = { * `--rerun-each` (see test-setup.ts). */ export const createIsolatedTestDb = async (): Promise => { - const client = new PGlite() + const client = new PGlite({ extensions: { uuid_ossp } }) const db = drizzle({ client, schema }) const migrationsFolder = resolve(import.meta.dir, '../../drizzle') await migrate(db, { migrationsFolder }) diff --git a/backend/src/test-utils/settings.ts b/backend/src/test-utils/settings.ts index f4a0b9662..37e449c08 100644 --- a/backend/src/test-utils/settings.ts +++ b/backend/src/test-utils/settings.ts @@ -10,6 +10,7 @@ import type { Settings } from '@/config/settings' * matching the schema in `@/config/settings`. */ export const createTestSettings = (overrides: Partial = {}): Settings => ({ + serverId: '00000000-0000-0000-0000-000000000000', fireworksApiKey: '', mistralApiKey: '', anthropicApiKey: '', @@ -23,6 +24,10 @@ export const createTestSettings = (overrides: Partial = {}): Settings microsoftClientSecret: '', authMode: 'consumer' as const, authAllowAnonymous: false, + allowWorkspaceCreationByAnon: false, + allowWorkspaceCreationByMembers: false, + allowUserScopedResources: true, + allowWorkspacePermissionsUi: false, oidcClientId: '', oidcClientSecret: '', oidcIssuer: '', diff --git a/backend/src/test-utils/test-setup.ts b/backend/src/test-utils/test-setup.ts index 24a747fd2..b73ef43f0 100644 --- a/backend/src/test-utils/test-setup.ts +++ b/backend/src/test-utils/test-setup.ts @@ -25,6 +25,10 @@ process.env.RATE_LIMIT_ENABLED = 'false' // so that test signToken() helpers produce matching signatures process.env.BETTER_AUTH_SECRET = 'better-auth-secret-12345678901234567890' +// Required by settingsSchema (z.string().uuid(), no default). Tests that call +// clearSettingsCache() re-parse from env — without this they throw a ZodError. +process.env.SERVER_ID = '00000000-0000-0000-0000-000000000000' + // Initialize the database before any tests run console.log('🔧 Initializing test database...') await testDbManager.initialize() diff --git a/bun.lock b/bun.lock index 9f92e9dff..057243d87 100644 --- a/bun.lock +++ b/bun.lock @@ -63,6 +63,7 @@ "dayjs": "^1.11.13", "drizzle-orm": "^0.45.2", "framer-motion": "^12.23.12", + "frimousse": "^0.3.0", "input-otp": "^1.4.2", "katex": "^0.16.0", "lucide-react": "^1.8.0", @@ -1335,6 +1336,8 @@ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "frimousse": ["frimousse@0.3.0", "", { "peerDependencies": { "react": "^18 || ^19", "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], diff --git a/deploy/config/powersync-config.yaml b/deploy/config/powersync-config.yaml index 8af7dfecf..bae4380fd 100644 --- a/deploy/config/powersync-config.yaml +++ b/deploy/config/powersync-config.yaml @@ -14,27 +14,80 @@ port: 8080 sync_rules: content: | bucket_definitions: - user_essentials: + # Account-level data — one bucket per user. Device list and user-scoped + # settings. Workspaces and permissions are joined via the workspace bucket + # below (PowerSync sync rules don't support IN (SELECT ...) subqueries). + user_account: priority: 1 parameters: SELECT request.user_id() as user_id data: - SELECT * FROM powersync.settings WHERE user_id = bucket.user_id - - SELECT * FROM powersync.models WHERE user_id = bucket.user_id - - SELECT * FROM powersync.modes WHERE user_id = bucket.user_id - - SELECT * FROM powersync.model_profiles WHERE user_id = bucket.user_id - SELECT * FROM powersync.devices WHERE user_id = bucket.user_id - - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - user_data: - priority: 2 + + # User-private workspace-scoped tables — parameterized by user_id so we can + # filter without referencing request.user_id() inside the data query (which + # PowerSync rejects). Rows for every workspace the user is a member of land + # in the local DB; the FE narrows to the active workspace at query time. + user_private: + priority: 1 parameters: SELECT request.user_id() as user_id data: + - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - SELECT * FROM powersync.chat_messages WHERE user_id = bucket.user_id - SELECT * FROM powersync.tasks WHERE user_id = bucket.user_id - - SELECT * FROM powersync.mcp_servers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.prompts WHERE user_id = bucket.user_id - - SELECT * FROM powersync.skills WHERE user_id = bucket.user_id - - SELECT * FROM powersync.triggers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.agents WHERE user_id = bucket.user_id + + # Workspace essentials — one bucket per workspace the user is a member of. + # Carries the workspace row itself and the permission policy. Promoted to + # priority 1 so the workspace selector / sidebar and permission-gated UI + # render on first paint without waiting for the heavier shared config. + workspace_essentials: + priority: 1 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspaces WHERE id = bucket.workspace_id + - SELECT * FROM powersync.workspace_permissions WHERE workspace_id = bucket.workspace_id + + # Workspace shared data — full member list, pending invites, and shared + # workspace-scoped config. Pending memberships sync to every member (not + # just admins) because `invite_users`/`change_roles`/`remove_users` are + # per-key permissions that can be granted to `member` role; without the + # row syncing down, a permitted non-admin would see an empty pending list. + # Write authorization is enforced by the upload handlers, not by the + # sync rule. + # + # Resource rows filter `scope = 'workspace'` so user-private rows + # (`scope = 'user'`) sync via `user_scope_resources` below instead. + workspace_data: + priority: 2 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspace_memberships WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.workspace_pending_memberships WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + + # User-private workspace resources — same 8 resource tables, but the row + # is only visible to its author. Bucket fans out one entry per + # (workspace_id, user_id) the caller is a member of (always the same + # user_id, but the membership join still parameterizes by workspace). + # Write authorization (row owner == caller) is enforced by the upload + # handler; this bucket is the read-side complement. + user_scope_resources: + priority: 2 + parameters: SELECT workspace_id, request.user_id() as user_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' client_auth: supabase: false diff --git a/deploy/k8s/templates/configmaps.yaml b/deploy/k8s/templates/configmaps.yaml index 25ba358a1..5ccbdaf4c 100644 --- a/deploy/k8s/templates/configmaps.yaml +++ b/deploy/k8s/templates/configmaps.yaml @@ -22,27 +22,80 @@ data: sync_rules: content: | bucket_definitions: - user_essentials: + # Account-level data — one bucket per user. Device list and user-scoped + # settings. Workspaces and permissions are joined via the workspace bucket + # below (PowerSync sync rules don't support IN (SELECT ...) subqueries). + user_account: priority: 1 parameters: SELECT request.user_id() as user_id data: - SELECT * FROM powersync.settings WHERE user_id = bucket.user_id - - SELECT * FROM powersync.models WHERE user_id = bucket.user_id - - SELECT * FROM powersync.modes WHERE user_id = bucket.user_id - - SELECT * FROM powersync.model_profiles WHERE user_id = bucket.user_id - SELECT * FROM powersync.devices WHERE user_id = bucket.user_id - - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - user_data: - priority: 2 + + # User-private workspace-scoped tables — parameterized by user_id so we can + # filter without referencing request.user_id() inside the data query (which + # PowerSync rejects). Rows for every workspace the user is a member of land + # in the local DB; the FE narrows to the active workspace at query time. + user_private: + priority: 1 parameters: SELECT request.user_id() as user_id data: + - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - SELECT * FROM powersync.chat_messages WHERE user_id = bucket.user_id - SELECT * FROM powersync.tasks WHERE user_id = bucket.user_id - - SELECT * FROM powersync.mcp_servers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.prompts WHERE user_id = bucket.user_id - - SELECT * FROM powersync.skills WHERE user_id = bucket.user_id - - SELECT * FROM powersync.triggers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.agents WHERE user_id = bucket.user_id + + # Workspace essentials — one bucket per workspace the user is a member of. + # Carries the workspace row itself and the permission policy. Promoted to + # priority 1 so the workspace selector / sidebar and permission-gated UI + # render on first paint without waiting for the heavier shared config. + workspace_essentials: + priority: 1 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspaces WHERE id = bucket.workspace_id + - SELECT * FROM powersync.workspace_permissions WHERE workspace_id = bucket.workspace_id + + # Workspace shared data — full member list, pending invites, and shared + # workspace-scoped config. Pending memberships sync to every member (not + # just admins) because `invite_users`/`change_roles`/`remove_users` are + # per-key permissions that can be granted to `member` role; without the + # row syncing down, a permitted non-admin would see an empty pending list. + # Write authorization is enforced by the upload handlers, not by the + # sync rule. + # + # Resource rows filter `scope = 'workspace'` so user-private rows + # (`scope = 'user'`) sync via `user_scope_resources` below instead. + workspace_data: + priority: 2 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspace_memberships WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.workspace_pending_memberships WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + + # User-private workspace resources — same 8 resource tables, but the row + # is only visible to its author. Bucket fans out one entry per + # (workspace_id, user_id) the caller is a member of (always the same + # user_id, but the membership join still parameterizes by workspace). + # Write authorization (row owner == caller) is enforced by the upload + # handler; this bucket is the read-side complement. + user_scope_resources: + priority: 2 + parameters: SELECT workspace_id, request.user_id() as user_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' client_auth: supabase: false diff --git a/docs/architecture/e2e-encryption.md b/docs/architecture/e2e-encryption.md index 5752839a8..05b5a0003 100644 --- a/docs/architecture/e2e-encryption.md +++ b/docs/architecture/e2e-encryption.md @@ -86,9 +86,23 @@ The download and upload middleware both read from `encryptedColumnsMap` in [src/ | **Sign out** | All local keys cleared → next sign-in is treated as a new device. | | **Revoke device** | Envelope deleted server-side, `revoked_at` set → device can no longer decrypt or sync. | +## Scope (temporary — pending THU-593) + +E2EE is currently scoped per-row, not per-account. Until the encryption pipeline supports multi-recipient envelopes per workspace (THU-593), shared-workspace collaborative resources must travel as plaintext so other members can read them. Classification: + +| Group | Tables | Behaviour | +|-------|--------|-----------| +| Per-account | `settings`, `devices` | Always encrypted — no workspace dimension. | +| Per-user inside a workspace (`alwaysEncryptedTables`) | `chat_threads`, `chat_messages`, `tasks` | Always encrypted — rows are user-scoped (filtered by `user_id`), so only the writing user's own CK ever decrypts them. Safe in both personal and shared workspaces. | +| Workspace-scoped collaborative resources | `models`, `prompts`, `skills`, `modes`, `model_profiles`, `triggers`, `workspaces.name` | Encrypted only when the row belongs to the active user's personal workspace; in shared workspaces these columns ride plaintext on the wire (TLS + at-rest only). | + +The scope check lives in [`src/db/encryption/upload-encoder.ts`](../src/db/encryption/upload-encoder.ts) and runs once per upload batch (one local PK-lookup for the personal-workspace id). Download decryption is unchanged — `EncryptionMiddleware` is prefix-driven (`__enc:`), so mixed ciphertext + plaintext in the same table decodes correctly. + +When workspace-aware E2EE lands, the per-row scope check goes away and every encrypted-column table encrypts unconditionally again. + ## Adding a New Encrypted Column -To encrypt a new column, add the table and column name to `encryptedColumnsMap` in [src/db/encryption/config.ts](../src/db/encryption/config.ts). The existing `encryptionMiddleware` handles every column in the map automatically — both download decryption and upload encryption. +To encrypt a new column, add the table and column name to `encryptedColumnsMap` in [src/db/encryption/config.ts](../src/db/encryption/config.ts). If the table is per-user (filtered by `user_id`) and should remain encrypted in shared workspaces, also add it to `alwaysEncryptedTables` in the same file. The existing `encryptionMiddleware` handles every column in the map automatically — both download decryption and upload encryption. ## Key Files diff --git a/docs/architecture/pre-workspaces-attach.md b/docs/architecture/pre-workspaces-attach.md new file mode 100644 index 000000000..48b7219d5 --- /dev/null +++ b/docs/architecture/pre-workspaces-attach.md @@ -0,0 +1,51 @@ +# Pre-Workspaces Attach Migration + +One-shot data migration that bridges the pre-Workspaces v1 local state (an +un-namespaced auth token, an un-namespaced IndexedDB key store, and a +`thunderbolt-sync.db` SQLite file with no `workspace_id` columns) into the +Workspaces v1 layout (per-server namespaced keys, `server-.db`, every +synced row stamped with `workspace_id`). + +It runs at most once per device per server. The localStorage and IndexedDB +steps fire from `useAppInitialization` right after `activateServer()`; the +SQLite step fires from `runPostAuthBootstrap` between +`ensurePersonalWorkspace` and `reconcileDefaults`. + +Implementation lives in `src/migrations/pre-workspaces-attach/`. + +## Files + +- `table-list.ts` — canonical list of legacy SQLite tables + (`syncedLegacyTables`, `localLegacyTables`, `allLegacyTables`) + per-table + `needsWorkspaceId` / `needsScope` stamping flags. +- `legacy-db-path.ts` — locates `thunderbolt-sync.db` (fallback + `thunderbolt.db`) in OPFS, the only filesystem wa-sqlite reads from. +- `completion-flag.ts` — per-device localStorage flags. + `pre_workspaces_attach_completed` is device-global (no serverId): once set, + EVERY step of the migration short-circuits on subsequent boots regardless of + which server the user signs into. Without it, a user with cloud accounts on + server A and server B would re-import the device-global legacy + `thunderbolt-sync.db` into both workspaces, bleeding A's rows into B. + `pre_workspaces_attach_data_completed__` lands the instant the + destructive table-copy + `ps_crud` replacement succeed, so a partial-failure + retry doesn't re-run the queue wipe and clobber interim writes. + `pre_workspaces_attach_completed__` lands after every step + (including the api-key stamp); later boots for the SAME server short-circuit + on it even when the global flag isn't yet set. localStorage rather than the + synced `settings` table so the flags stay device-local and can't race a + second device's first-time migration. + +## Removal (once every active install has migrated) + +1. Revert the three call sites: + - `src/hooks/use-app-initialization.ts` — `migrateLocalStorageIfNeeded` / + `migrateEncryptionKeysIfNeeded`. + - `src/lib/post-auth-bootstrap.ts` — `runLocalDbMigration`. + - `backend/src/powersync/upload-handlers/workspace-scoped.ts` — the + `computePersonalWorkspaceId(ctx.userId)` fallback in `validate()`/`apply()` + for PUTs with no `workspace_id`. +2. Delete `src/migrations/pre-workspaces-attach/`. +3. Delete this doc. +4. Optional housekeeping: delete the localStorage flags and the legacy + `thunderbolt-sync.db` files via a one-off boot-time cleanup. Not required — + both are harmless once the migration code is gone. diff --git a/package.json b/package.json index 9251bbe78..af6401e43 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "dayjs": "^1.11.13", "drizzle-orm": "^0.45.2", "framer-motion": "^12.23.12", + "frimousse": "^0.3.0", "input-otp": "^1.4.2", "katex": "^0.16.0", "lucide-react": "^1.8.0", diff --git a/playwright.config.ts b/playwright.config.ts index 5efdcef47..202f9e4f3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -92,6 +92,11 @@ export default defineConfig({ timeout: 120_000, env: { PORT: String(oidcBackendPort), + // Stable per-deployment UUID — required by the settings schema (no default). Locally, + // `backend/.env` supplies one via `make doctor`, but CI inherits no such file, so we + // pin a deterministic fixture here. Distinct from the SAML backend's id so the two + // e2e backends model independent trust domains. + SERVER_ID: 'e2e0e2e0-e2e0-4e2e-8e2e-e2e0e2e00001', AUTH_MODE: 'oidc', OIDC_CLIENT_ID: 'thunderbolt-app', OIDC_CLIENT_SECRET: 'thunderbolt-dev-secret', @@ -127,6 +132,8 @@ export default defineConfig({ timeout: 120_000, env: { PORT: String(samlBackendPort), + // Distinct from the OIDC backend's id — see comment there for why we pin a fixture. + SERVER_ID: 'e2e0e2e0-e2e0-4e2e-8e2e-e2e0e2e00002', AUTH_MODE: 'saml', SAML_ENTRY_POINT: `http://localhost:${mockSamlPort}/saml/sso`, SAML_ENTITY_ID: 'e2e-saml-sp', diff --git a/powersync-service/config/config.yaml b/powersync-service/config/config.yaml index a81ab5278..c859ee5ba 100644 --- a/powersync-service/config/config.yaml +++ b/powersync-service/config/config.yaml @@ -14,26 +14,80 @@ port: 8080 sync_rules: content: | bucket_definitions: - user_essentials: + # Account-level data — one bucket per user. Device list and user-scoped + # settings. Workspaces and permissions are joined via the workspace bucket + # below (PowerSync sync rules don't support IN (SELECT ...) subqueries). + user_account: priority: 1 parameters: SELECT request.user_id() as user_id data: - SELECT * FROM powersync.settings WHERE user_id = bucket.user_id - - SELECT * FROM powersync.models WHERE user_id = bucket.user_id - - SELECT * FROM powersync.modes WHERE user_id = bucket.user_id - - SELECT * FROM powersync.model_profiles WHERE user_id = bucket.user_id - SELECT * FROM powersync.devices WHERE user_id = bucket.user_id - - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - user_data: - priority: 2 + + # User-private workspace-scoped tables — parameterized by user_id so we can + # filter without referencing request.user_id() inside the data query (which + # PowerSync rejects). Rows for every workspace the user is a member of land + # in the local DB; the FE narrows to the active workspace at query time. + user_private: + priority: 1 parameters: SELECT request.user_id() as user_id data: + - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - SELECT * FROM powersync.chat_messages WHERE user_id = bucket.user_id - SELECT * FROM powersync.tasks WHERE user_id = bucket.user_id - - SELECT * FROM powersync.prompts WHERE user_id = bucket.user_id - - SELECT * FROM powersync.skills WHERE user_id = bucket.user_id - - SELECT * FROM powersync.triggers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.agents WHERE user_id = bucket.user_id + + # Workspace essentials — one bucket per workspace the user is a member of. + # Carries the workspace row itself and the permission policy. Promoted to + # priority 1 so the workspace selector / sidebar and permission-gated UI + # render on first paint without waiting for the heavier shared config. + workspace_essentials: + priority: 1 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspaces WHERE id = bucket.workspace_id + - SELECT * FROM powersync.workspace_permissions WHERE workspace_id = bucket.workspace_id + + # Workspace shared data — full member list, pending invites, and shared + # workspace-scoped config. Pending memberships sync to every member (not + # just admins) because `invite_users`/`change_roles`/`remove_users` are + # per-key permissions that can be granted to `member` role; without the + # row syncing down, a permitted non-admin would see an empty pending list. + # Write authorization is enforced by the upload handlers, not by the + # sync rule. + # + # Resource rows filter `scope = 'workspace'` so user-private rows + # (`scope = 'user'`) sync via `user_scope_resources` below instead. + workspace_data: + priority: 2 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspace_memberships WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.workspace_pending_memberships WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + + # User-private workspace resources — same 8 resource tables, but the row + # is only visible to its author. Bucket fans out one entry per + # (workspace_id, user_id) the caller is a member of (always the same + # user_id, but the membership join still parameterizes by workspace). + # Write authorization (row owner == caller) is enforced by the upload + # handler; this bucket is the read-side complement. + user_scope_resources: + priority: 2 + parameters: SELECT workspace_id, request.user_id() as user_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' + - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id AND user_id = bucket.user_id AND scope = 'user' client_auth: supabase: false diff --git a/scripts/thunderdoctor.sh b/scripts/thunderdoctor.sh index 2a5325361..fe05b6661 100755 --- a/scripts/thunderdoctor.sh +++ b/scripts/thunderdoctor.sh @@ -220,6 +220,41 @@ if [ -f "$PROJECT_ROOT/backend/.env" ]; then echo -e " ${PASS} BETTER_AUTH_SECRET is set" fi + # SERVER_ID: stable per-deployment UUID returned by GET /v1/config. Required at boot; + # the frontend namespaces its trust-domain registry by it. Auto-generate for local dev + # following the same TTY / THUNDERDOCTOR_AUTOFIX gate as BETTER_AUTH_SECRET above. + current_server_id=$(grep -E '^SERVER_ID=' "$PROJECT_ROOT/backend/.env" | head -1 | sed -E 's/^SERVER_ID=//; s/^"(.*)"$/\1/' || true) + if ! echo "$current_server_id" | grep -Eqi '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then + if [ ! -t 1 ] && [ "${THUNDERDOCTOR_AUTOFIX:-}" != "true" ]; then + has_critical_failure=true + echo -e " ${FAIL} SERVER_ID is empty/invalid — won't auto-fix from a non-interactive shell. Run \`make doctor\` from a terminal, or set THUNDERDOCTOR_AUTOFIX=true to opt in." + else + if command -v uuidgen >/dev/null 2>&1; then + new_server_id=$(uuidgen | tr '[:upper:]' '[:lower:]') + elif [ -r /proc/sys/kernel/random/uuid ]; then + new_server_id=$(cat /proc/sys/kernel/random/uuid) + else + new_server_id="" + fi + if [ -n "$new_server_id" ]; then + echo -e " ${YELLOW}→${NC} Writing a fresh SERVER_ID to backend/.env..." + tmp=$(mktemp) + NEW_SERVER_ID="$new_server_id" awk ' + BEGIN { val = ENVIRON["NEW_SERVER_ID"]; replaced = 0 } + /^SERVER_ID=/ { print "SERVER_ID=" val; replaced = 1; next } + { print } + END { if (!replaced) print "SERVER_ID=" val } + ' "$PROJECT_ROOT/backend/.env" > "$tmp" && mv "$tmp" "$PROJECT_ROOT/backend/.env" + echo -e " ${PASS} SERVER_ID generated and written to backend/.env" + else + has_critical_failure=true + echo -e " ${FAIL} SERVER_ID is empty/invalid and neither uuidgen nor /proc/sys/kernel/random/uuid is available — set it manually to a UUID" + fi + fi + elif [ "$QUIET" = false ]; then + echo -e " ${PASS} SERVER_ID is set" + fi + # POWERSYNC_URL is required when DATABASE_DRIVER=postgres; the backend silently 503s without it. driver=$(grep -E '^DATABASE_DRIVER=' "$PROJECT_ROOT/backend/.env" | head -1 | sed -E 's/^DATABASE_DRIVER=//; s/^"(.*)"$/\1/' || true) if [ "$driver" = "postgres" ]; then diff --git a/shared/powersync-tables.ts b/shared/powersync-tables.ts index 89e3cd733..44738e9dd 100644 --- a/shared/powersync-tables.ts +++ b/shared/powersync-tables.ts @@ -22,6 +22,10 @@ export const powersyncTableNames = [ 'model_profiles', 'devices', 'agents', + 'workspaces', + 'workspace_memberships', + 'workspace_pending_memberships', + 'workspace_permissions', ] as const export type PowerSyncTableName = (typeof powersyncTableNames)[number] @@ -46,4 +50,8 @@ export const powersyncTableToQueryKeys: { model_profiles: [['modelProfiles']], devices: [['devices']], agents: [['agents']], + workspaces: [['workspaces']], + workspace_memberships: [['workspaceMemberships']], + workspace_pending_memberships: [['workspacePendingMemberships']], + workspace_permissions: [['workspacePermissions']], } diff --git a/shared/workspaces.test.ts b/shared/workspaces.test.ts new file mode 100644 index 000000000..43e6e03af --- /dev/null +++ b/shared/workspaces.test.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { + isWorkspacePermissionKey, + isWorkspacePermissionRole, + permissionAllows, + workspacePermissionKeys, +} from './workspaces' + +describe('permissionAllows', () => { + it('admin satisfies admin requirements', () => { + expect(permissionAllows('admin', 'admin')).toBe(true) + }) + + it('admin satisfies member requirements', () => { + expect(permissionAllows('admin', 'member')).toBe(true) + }) + + it('member satisfies member requirements', () => { + expect(permissionAllows('member', 'member')).toBe(true) + }) + + it('member does not satisfy admin requirements', () => { + expect(permissionAllows('member', 'admin')).toBe(false) + }) + + it('null/undefined user roles never satisfy anything', () => { + expect(permissionAllows(null, 'admin')).toBe(false) + expect(permissionAllows(null, 'member')).toBe(false) + expect(permissionAllows(undefined, 'admin')).toBe(false) + expect(permissionAllows(undefined, 'member')).toBe(false) + }) +}) + +describe('isWorkspacePermissionKey', () => { + it('accepts every key declared in workspacePermissionKeys', () => { + for (const key of workspacePermissionKeys) { + expect(isWorkspacePermissionKey(key)).toBe(true) + } + }) + + it('rejects unknown strings and non-strings', () => { + expect(isWorkspacePermissionKey('unknown_key')).toBe(false) + expect(isWorkspacePermissionKey('')).toBe(false) + expect(isWorkspacePermissionKey(123)).toBe(false) + expect(isWorkspacePermissionKey(null)).toBe(false) + expect(isWorkspacePermissionKey(undefined)).toBe(false) + }) +}) + +describe('isWorkspacePermissionRole', () => { + it('accepts admin and member', () => { + expect(isWorkspacePermissionRole('admin')).toBe(true) + expect(isWorkspacePermissionRole('member')).toBe(true) + }) + + it('rejects anything else', () => { + expect(isWorkspacePermissionRole('owner')).toBe(false) + expect(isWorkspacePermissionRole('')).toBe(false) + expect(isWorkspacePermissionRole(null)).toBe(false) + }) +}) diff --git a/shared/workspaces.ts b/shared/workspaces.ts new file mode 100644 index 000000000..e60e439e5 --- /dev/null +++ b/shared/workspaces.ts @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { v5 as uuidv5 } from 'uuid' + +/** + * Fixed UUID namespace for deriving deterministic personal workspace and + * admin-membership ids from a user id. Do not change — changing this constant + * orphans every existing personal workspace. + */ +const PERSONAL_WORKSPACE_NAMESPACE = 'e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b' + +/** + * Derive the canonical personal workspace id for a user. + * + * The personal workspace is FE-created on first sign-in and uploaded via + * PowerSync. Multiple devices that sign in for the same account independently + * compute the same id and upload the same row, so concurrent first-sign-ins + * become idempotent upserts on the BE rather than a partial-unique-index race. + * + * Used as the canonical anchor in the BE upload handler — the personal + * workspace PUT is accepted only when `op.id === computePersonalWorkspaceId(ctx.userId)`. + */ +export const computePersonalWorkspaceId = (userId: string): string => + uuidv5(`personal:${userId}`, PERSONAL_WORKSPACE_NAMESPACE) + +/** + * Derive the canonical admin-membership id for a user's personal workspace. + * + * Same rationale as the workspace id — two devices uploading the bootstrap + * admin membership for the same user end up with the same row id, so the + * upload becomes an upsert no-op rather than two rows pointing at the same + * `(workspace_id, user_id)` natural key (which the unique constraint would + * reject for the second device). + */ +export const computePersonalAdminMembershipId = (userId: string): string => + uuidv5(`personal-admin:${userId}`, PERSONAL_WORKSPACE_NAMESPACE) + +/** + * Single source of truth for the keys that may appear in the + * `workspace_permissions.permission_key` column. Order matches the rendering + * order of the Permissions settings page (lifecycle → capabilities → admin). + * + * `manage_members` is a legacy key superseded by per-operation gates + * (`invite_users`/`change_roles`/`remove_users`). Kept in the union so older + * `workspace_permissions` rows still type-check; not surfaced on the + * Permissions page and not consulted by any route guard or upload handler. + * + * Add a new key here, then the FE/BE schemas, the FE/BE types, and the upload + * handler's runtime check all stay in sync via this constant. + */ +export const workspacePermissionKeys = [ + 'manage_members', + 'join_workspace', + 'invite_users', + 'change_roles', + 'remove_users', + 'add_agents', + 'remove_agents', + 'add_skills', + 'remove_skills', + 'add_models', + 'remove_models', + 'change_general_settings', + 'change_permissions', + 'delete_workspace', +] as const + +export type WorkspacePermissionKey = (typeof workspacePermissionKeys)[number] + +/** The two role buckets used by both `workspace_memberships.role` and `workspace_permissions.required_role`. */ +export const workspacePermissionRoles = ['admin', 'member'] as const + +export type WorkspacePermissionRole = (typeof workspacePermissionRoles)[number] + +/** Runtime narrowing for upload-handler payloads — checks `v` against `workspacePermissionKeys`. */ +export const isWorkspacePermissionKey = (v: unknown): v is WorkspacePermissionKey => + typeof v === 'string' && (workspacePermissionKeys as readonly string[]).includes(v) + +/** Runtime narrowing for upload-handler payloads — checks `v` against `workspacePermissionRoles`. */ +export const isWorkspacePermissionRole = (v: unknown): v is WorkspacePermissionRole => + typeof v === 'string' && (workspacePermissionRoles as readonly string[]).includes(v) + +/** + * Whether `userRole` satisfies a `requiredRole` policy. Admins always satisfy; + * a `member` requirement is satisfied by both admins and members. + * + * Shared between FE (`useWorkspacePermission`) and BE (upload handler authz) + * so the same predicate gates UI affordances and write enforcement. + */ +export const permissionAllows = ( + userRole: WorkspacePermissionRole | null | undefined, + requiredRole: WorkspacePermissionRole, +): boolean => { + if (!userRole) { + return false + } + if (userRole === 'admin') { + return true + } + return requiredRole === 'member' +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d8d28a827..ca81b97da 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ "security": { "csp": { "default-src": "'self' tauri: asset:", - "connect-src": "'self' ipc: http://ipc.localhost https: wss: ws://localhost:8000 http://localhost:8000 http://localhost:11434", + "connect-src": "'self' ipc: http://ipc.localhost https: wss: ws://localhost:8000 http://localhost:8000 ws://localhost:8080 http://localhost:8080 http://localhost:11434", "img-src": "'self' asset: data: https://api.thunderbolt.io", "font-src": "'self' data:", "style-src": "'self' 'unsafe-inline'", diff --git a/src/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index dca112df4..03da12b70 100644 --- a/src/acp/transports/index.test.ts +++ b/src/acp/transports/index.test.ts @@ -23,7 +23,7 @@ import '@/testing-library' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { wsTargetPrefix } from '@shared/proxy-protocol' import { encodeWsBearer } from '@shared/ws-bearer' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' import { openTransport } from './index' import { type WebSocketEventMap } from './websocket' @@ -73,7 +73,11 @@ beforeEach(() => { // Cast through unknown — FakeBrowserSocket only implements the surface the // transport uses, not the full DOM `WebSocket` interface. globalThis.WebSocket = FakeBrowserSocket as unknown as typeof WebSocket - useLocalSettingsStore.setState({ cloudUrl: 'http://cloud.test/v1' }) + const fixtureServerId = '00000000-0000-0000-0000-0000000000ac' + useTrustDomainRegistry.setState({ + servers: { [fixtureServerId]: { serverId: fixtureServerId, cloudUrl: 'http://cloud.test/v1' } }, + activeTrustDomain: { kind: 'server', serverId: fixtureServerId }, + }) }) afterEach(() => { diff --git a/src/acp/transports/index.ts b/src/acp/transports/index.ts index 782c22b12..b05bc1325 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -32,7 +32,7 @@ import { getAuthToken } from '@/lib/auth-token' import type { HttpClient } from '@/lib/http' import { isTauri } from '@/lib/platform' import { computeEffectiveProxyEnabled, createProxyWebSocket } from '@/lib/proxy-fetch' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { getActiveCloudUrl } from '@/stores/trust-domain-registry' import type { AgentType } from '@shared/acp-types' import { encodeWsBearer, wsBearerSubprotocolPrefix, wsCarrierSubprotocol } from '@shared/ws-bearer' import type { AcpTransport } from '../types' @@ -62,7 +62,7 @@ export type OpenTransportInputs = { getAuthToken?: () => string | null } -const cloudWsUrl = (): string => useLocalSettingsStore.getState().cloudUrl +const cloudWsUrl = (): string => getActiveCloudUrl() ?? '' /** Decide if the transport should use the native (Standalone) path or the * cloud-proxy path. Mirrors `computeEffectiveProxyEnabled` exactly — when the diff --git a/src/ai/eval/debug-single.ts b/src/ai/eval/debug-single.ts index 8ed981b61..2f4fd3c8f 100644 --- a/src/ai/eval/debug-single.ts +++ b/src/ai/eval/debug-single.ts @@ -8,7 +8,7 @@ */ import { aiFetchStreamingResponse } from '@/ai/fetch' import { setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' -import { getLocalSetting } from '@/stores/local-settings-store' +import { seedTestTrustDomain } from '@/test-utils/powersync-reactivity-test' import { defaultModelOpus48 } from '@/defaults/models' import { defaultModeChat } from '@/defaults/modes' import { isSsoMode } from '@/lib/auth-mode' @@ -24,6 +24,7 @@ const run = async () => { console.log('[1/5] Setting up database...') await setupTestDatabase() + seedTestTrustDomain() console.log('[1/5] Database ready.\n') const modelId = defaultModelOpus48.id @@ -38,7 +39,9 @@ const run = async () => { console.log(`[2/5] Mode: ${defaultModeChat.name}`) console.log(`[2/5] Prompt: "${prompt}"\n`) - const cloudUrl = getLocalSetting('cloudUrl') + // CLI script — no boot, no trust-domain registry to read from. Source the URL from + // the env var directly with the same localhost fallback the boot resolver uses. + const cloudUrl = import.meta.env.VITE_THUNDERBOLT_CLOUD_URL || 'http://localhost:8000/v1' const httpClient = createAuthenticatedClient(cloudUrl, getAuthToken, { credentials: isSsoMode() ? 'include' : undefined, }) diff --git a/src/ai/eval/run.ts b/src/ai/eval/run.ts index c1258b872..5abe22b8d 100644 --- a/src/ai/eval/run.ts +++ b/src/ai/eval/run.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' +import { seedTestTrustDomain } from '@/test-utils/powersync-reactivity-test' import { generateReport } from './report' import { runPool } from './runner' import { getScenarios } from './scenarios' @@ -27,6 +28,9 @@ const main = async () => { // Set up a single shared database for all scenarios (read-only for evals) await setupTestDatabase() + // Seed the trust-domain registry so requireActiveWorkspaceId can resolve + // the personal workspace from the test DB (getActiveUserId() reads from here). + seedTestTrustDomain() // Suppress noisy console output from fetch.ts unless --verbose if (!verbose) { diff --git a/src/ai/eval/runner.ts b/src/ai/eval/runner.ts index 2ccec4bae..8948d72e7 100644 --- a/src/ai/eval/runner.ts +++ b/src/ai/eval/runner.ts @@ -7,8 +7,8 @@ import { createPrompt } from '@/ai/prompt' import { getSettings } from '@/dal' import { getModel } from '@/dal/models' import { getModelProfile } from '@/dal/model-profiles' +import { wsId as evalWorkspaceId } from '@/dal/test-utils' import { getDb } from '@/db/database' -import { getLocalSetting } from '@/stores/local-settings-store' import { isSsoMode } from '@/lib/auth-mode' import { getAuthToken } from '@/lib/auth-token' import { createAuthenticatedClient } from '@/lib/http' @@ -21,12 +21,15 @@ import type { EvalResult, EvalScenario } from './types' const timeout = parseInt(process.env.EVAL_timeout ?? '120000') +// CLI eval — no boot, no trust-domain registry to read from. The env var fallback +// matches the boot resolver's default so dev and eval point at the same backend. +const evalCloudUrl = import.meta.env.VITE_THUNDERBOLT_CLOUD_URL || 'http://localhost:8000/v1' + let _evalHttpClientPromise: Promise | null = null const getEvalHttpClient = () => { if (!_evalHttpClientPromise) { _evalHttpClientPromise = (async () => { - const cloudUrl = getLocalSetting('cloudUrl') - return createAuthenticatedClient(cloudUrl, getAuthToken, { + return createAuthenticatedClient(evalCloudUrl, getAuthToken, { credentials: isSsoMode() ? 'include' : undefined, }) })() @@ -47,7 +50,10 @@ const logVerbosePrompt = async (scenario: EvalScenario, modeSystemPrompt: string const db = getDb() const modelId = getModelId(scenario.modelName) - const [model, profile] = await Promise.all([getModel(db, modelId), getModelProfile(db, modelId)]) + const [model, profile] = await Promise.all([ + getModel(db, evalWorkspaceId, modelId), + getModelProfile(db, evalWorkspaceId, modelId), + ]) const settings = await getSettings(db, { preferred_name: '', location_name: '', @@ -131,9 +137,8 @@ export const runScenario = async (scenario: EvalScenario): Promise = const httpClient = await getEvalHttpClient() // Eval runs in Node, not a browser — no React tree, no `ProxyFetchProvider`. - // Build the proxy fetch directly from the same cloudUrl the HTTP client uses. - const cloudUrl = getLocalSetting('cloudUrl') - const proxyFetch = createProxyFetch({ cloudUrl }) + // Build the proxy fetch from the same env cloudUrl the HTTP client uses. + const proxyFetch = createProxyFetch({ cloudUrl: evalCloudUrl }) // Call the actual AI pipeline with a timeout const response = await Promise.race([ diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index e709b2fc3..0dcbbc68c 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -13,11 +13,12 @@ import { shouldRetry, } from '@/ai/step-logic' import { getAllSkills, getIntegrationStatus, getModel, getModelProfile, getSettings } from '@/dal' +import { requireActiveWorkspaceId } from '@/lib/active-workspace' import { getMessage } from '@/dal/chat-messages' import { extractLastUserText, resolveSkillTokenInstructions } from '@/skills/resolve-skill-system-messages' import { collectAskEntriesFromCache, formatAskResponsesNote } from '@/widgets/ask/lib' import { getDb } from '@/db/database' -import { getLocalSetting } from '@/stores/local-settings-store' +import { getActiveCloudUrl } from '@/stores/trust-domain-registry' import { isSsoMode } from '@/lib/auth-mode' import { getAuthToken } from '@/lib/auth-token' import { fetch as baseFetch } from '@/lib/fetch' @@ -93,7 +94,11 @@ let userTinfoilClient: SecureClient | null = null export const getSystemTinfoilClient = async (): Promise => { // cloudUrl already ends in /v1 (shared with the OpenAI chat baseURL). - const cloudUrl = getLocalSetting('cloudUrl').replace(/\/$/, '') + const activeCloudUrl = getActiveCloudUrl() + if (!activeCloudUrl) { + throw new Error('Cannot use the system Tinfoil client without an active server trust domain') + } + const cloudUrl = activeCloudUrl.replace(/\/$/, '') let client = systemTinfoilClients.get(cloudUrl) if (!client) { const { SecureClient } = await import('tinfoil') @@ -109,7 +114,11 @@ export const getSystemTinfoilClient = async (): Promise => { * inside the SDK's own reset+retry — the cached client's transport is wedged * and only a brand-new instance breaks the cycle. */ const evictSystemTinfoilClient = (): void => { - const cloudUrl = getLocalSetting('cloudUrl').replace(/\/$/, '') + const activeCloudUrl = getActiveCloudUrl() + if (!activeCloudUrl) { + return + } + const cloudUrl = activeCloudUrl.replace(/\/$/, '') systemTinfoilClients.delete(cloudUrl) } @@ -258,7 +267,10 @@ export const createModel = async (modelConfig: Model, getProxyFetch: () => Fetch // (e.g. cloudUrl, proxy_enabled toggle) is picked up. switch (modelConfig.provider) { case 'thunderbolt': { - const cloudUrl = getLocalSetting('cloudUrl') + const cloudUrl = getActiveCloudUrl() + if (!cloudUrl) { + throw new Error('Cannot use the thunderbolt provider without an active server trust domain') + } const token = getAuthToken() || 'thunderbolt' // SSO web flow authenticates via session cookies — the SSO callback is a // browser redirect, not an XHR, so `set-auth-token` never reaches the @@ -433,6 +445,7 @@ export const aiFetchStreamingResponse = async ({ // reach this function the user turn is already persisted. const db = getDb() + const workspaceId = await requireActiveWorkspaceId(db) // Fetch all settings in a single query (returns camelCase by default) const settings = await getSettings(db, { @@ -450,13 +463,13 @@ export const aiFetchStreamingResponse = async ({ const integrationStatus = await getIntegrationStatus(db) - const model = await getModel(db, modelId) + const model = await getModel(db, workspaceId, modelId) if (!model) { throw new Error('Model not found') } - const profile = await getModelProfile(db, modelId) + const profile = await getModelProfile(db, workspaceId, modelId) const supportsTools = model.toolUsage !== 0 @@ -646,7 +659,7 @@ export const aiFetchStreamingResponse = async ({ // the context-overflow estimate so the budget and the actual prepend // stay in lockstep. const lastUserText = extractLastUserText(messages) - const allSkills = await getAllSkills(db) + const allSkills = await getAllSkills(db, workspaceId) const instructionBySlug = new Map() for (const skill of allSkills) { if (skill.enabled === 1 && skill.name && skill.instruction) { @@ -672,7 +685,7 @@ export const aiFetchStreamingResponse = async ({ messages .filter((message) => message.role === 'assistant') .map(async (message) => { - const stored = await getMessage(db, message.id) + const stored = await getMessage(db, workspaceId, message.id) return stored?.cache ? collectAskEntriesFromCache(stored.cache as Record) : [] }), ) diff --git a/src/ai/prompt.test.ts b/src/ai/prompt.test.ts index 54cb999a5..8b4e202c3 100644 --- a/src/ai/prompt.test.ts +++ b/src/ai/prompt.test.ts @@ -30,6 +30,8 @@ const createStubProfile = (overrides: Partial = {}): ModelProfile defaultHash: null, deletedAt: null, userId: null, + workspaceId: null, + scope: null, ...overrides, }) diff --git a/src/ai/step-logic.test.ts b/src/ai/step-logic.test.ts index c53c3add5..273d06c4c 100644 --- a/src/ai/step-logic.test.ts +++ b/src/ai/step-logic.test.ts @@ -40,6 +40,8 @@ const createStubProfile = (overrides: Partial = {}): ModelProfile defaultHash: null, deletedAt: null, userId: null, + workspaceId: null, + scope: null, ...overrides, }) diff --git a/src/api/config-store.ts b/src/api/config-store.ts index 7597391e4..0f5f86435 100644 --- a/src/api/config-store.ts +++ b/src/api/config-store.ts @@ -6,7 +6,21 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' export type AppConfig = { + /** Stable per-deployment UUID. Returned by `GET /v1/config`; required in + * server trust domains (the FE uses it to namespace auth token / device ID / + * encryption keys / DB filename). Optional on this type because an offline + * boot may have no cached config yet — boot code MUST treat its absence as + * "no server reachable", never as "any server is fine". */ + serverId?: string e2eeEnabled?: boolean + /** Server-side anonymous-sessions flag. When false, the BE rejects + * `/sign-in/anonymous`. Mirrors the BE `AUTH_ALLOW_ANONYMOUS`. */ + allowAnonUsers?: boolean + /** Workspace creation policy. Enforced by the BE upload-handler factory; UI + * hides the "Create workspace" affordance when both gates evaluate to false + * for the active user. */ + allowWorkspaceCreationByAnon?: boolean + allowWorkspaceCreationByMembers?: boolean /** Deployment-level UI capability flags from `GET /config`. Optional so an * empty/offline config (standalone mode) reads as "default behavior": * built-in agent shown, custom agents allowed. */ @@ -15,6 +29,15 @@ export type AppConfig = { /** Minimum semver string the server allows. Clients below this are hard-blocked * until they upgrade. Absent/empty = no enforcement. */ minAppVersion?: string + /** Per-row scope on the 8 workspace-shared resource tables (THU-603). When + * false the UI hides the scope picker and the BE upload handler rejects + * `scope = 'user'` PUTs. Absent (offline/standalone) reads as allowed — + * same opt-out posture as the server-side default. */ + allowUserScopedResources?: boolean + /** Workspace Permissions settings page/menu/link. Explicit opt-in per + * deployment: absent config reads as disabled so the feature stays hidden + * until a server flips ALLOW_WORKSPACE_PERMISSIONS_UI to `true`. */ + allowWorkspacePermissionsUi?: boolean } type ConfigStore = { @@ -41,3 +64,12 @@ export const selectBuiltInAgentEnabled = (config: AppConfig): boolean => config. /** Whether the UI offers adding custom agents. Absent config defaults to allowed. */ export const selectAllowCustomAgents = (config: AppConfig): boolean => config.allowCustomAgents !== false + +/** Whether the UI offers per-row scope (workspace vs private) on the 8 shared + * resource tables. Absent config defaults to allowed, mirroring the BE default. */ +export const selectAllowUserScopedResources = (config: AppConfig): boolean => config.allowUserScopedResources !== false + +/** Whether the workspace Permissions settings page/menu/link is exposed. + * Opt-in: absent or non-`true` config reads as disabled. */ +export const selectAllowWorkspacePermissionsUi = (config: AppConfig): boolean => + config.allowWorkspacePermissionsUi === true diff --git a/src/api/encryption.test.ts b/src/api/encryption.test.ts index 14dc98d8a..67331e609 100644 --- a/src/api/encryption.test.ts +++ b/src/api/encryption.test.ts @@ -3,13 +3,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' +import { testServerId } from '@/testing-library' import { type HttpClient } from '@/contexts' import { getAuthToken } from '@/lib/auth-token' import { createAuthenticatedClient } from '@/lib/http' import { registerDevice, storeEnvelope, fetchMyEnvelope, fetchCanary } from './encryption' -const deviceIdKey = 'thunderbolt_device_id' -const authTokenKey = 'thunderbolt_auth_token' +const deviceIdKey = `thunderbolt_device_id__${testServerId}` +const authTokenKey = `thunderbolt_auth_token__${testServerId}` type CapturedRequest = { url: string; method: string; body: Record | null; headers: Headers } diff --git a/src/app.tsx b/src/app.tsx index f082cd2c5..77bdd11a7 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -37,11 +37,17 @@ import { useViewportLock } from '@/hooks/use-viewport-lock' import { useMcpSync } from '@/hooks/use-mcp-sync' import { PostHogProvider } from '@/lib/posthog' import { ThemeProvider } from '@/lib/theme-provider' +import { AppErrorBoundary } from './components/app-error-boundary' import { AppErrorScreen } from './components/app-error-screen' +import { ModePicker } from './components/boot/mode-picker' import { UpgradeRequired } from './components/upgrade-required' import { useConfigStore } from '@/api/config-store' import { compareSemver } from '@/lib/compare-semver' import { AuthGate } from './components/auth-gate' +import { WorkspaceGate } from './components/workspace-gate' +import { WorkspaceMembershipGate } from './components/workspace-membership-gate' +import { RequireWorkspaceAdmin } from './settings/workspace/require-permission' +import { useWorkspacePermissionsUiEnabled } from '@/hooks/use-workspace-permissions-ui-enabled' import { OnboardingDialog } from './components/onboarding/onboarding-dialog' import { WelcomeDialog } from './components/welcome-dialog' import { PendingDeviceModal } from './components/pending-device-modal' @@ -62,7 +68,7 @@ import { isSsoMode, isWaitlistBypassed } from './lib/auth-mode' import { isTauri } from './lib/platform' import { getPowerSyncInstance } from './db/powersync/sync-state' import { refreshSystemAgents } from '@/db/seeding/seed-agents' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useActiveCloudUrl } from '@/stores/trust-domain-registry' import { type ComponentProps, Suspense, lazy, useEffect, useState } from 'react' import { markAppMounted } from '@/lib/init-timing' import { LazyMotion } from 'framer-motion' @@ -83,6 +89,9 @@ const McpServersPage = lazy(() => import('@/settings/mcp-servers')) const SkillsPage = lazy(() => import('@/settings/skills')) const AgentsSettingsPage = lazy(() => import('@/routes/settings/agents')) const IntegrationsPage = lazy(() => import('@/settings/integrations')) +const WorkspaceGeneralPage = lazy(() => import('@/settings/workspace/general')) +const WorkspaceMembersPage = lazy(() => import('@/settings/workspace/members')) +const WorkspacePermissionsPage = lazy(() => import('@/settings/workspace/permissions')) // Lazily import SSO components so non-enterprise deployments don't pay // for the extra bundle size and attack surface. @@ -95,6 +104,52 @@ const MessageSimulatorPage = import.meta.env.DEV ? lazy(() => import('./devtools const queryClient = new QueryClient() +/** + * Shared route sub-tree mounted under both the personal workspace (unprefixed) + * and `/w/:workspaceId` (shared, membership-gated). Paths are relative so each + * mount resolves them against its parent — `chats/new` becomes either + * `/chats/new` or `/w//chats/new`. + */ +const renderWorkspaceRoutes = ({ + experimentalFeatureTasks, + permissionsUiEnabled, +}: { + experimentalFeatureTasks: boolean + permissionsUiEnabled: boolean +}) => ( + <> + {/* Home routes with HomeLayout */} + }> + } /> + } /> + {experimentalFeatureTasks && } />} + {import.meta.env.DEV && } />} + + + {/* Settings routes with SettingsLayout */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + {permissionsUiEnabled && ( + }> + } /> + + )} + + {import.meta.env.DEV && } />} + + +) + /** * Hydrate the local-only `agents_system` table from the backend's `/agents` * discovery endpoint when the user has a real (non-anonymous) session. @@ -110,7 +165,7 @@ const useBootstrapSystemAgents = () => { const httpClient = useHttpClient() const authClient = useAuth() const { data: session } = authClient.useSession() - const cloudUrl = useLocalSettingsStore((s) => s.cloudUrl) + const cloudUrl = useActiveCloudUrl() const isRealUser = !!session?.user && session.user.isAnonymous !== true @@ -130,11 +185,13 @@ const AppContent = ({ initData }: { initData: InitData }) => { useSafeAreaInset() return ( - - - - - + + + + + + + ) } @@ -146,6 +203,7 @@ const AppRoutes = ({ initData }: { initData: InitData }) => { experimental_feature_tasks: initData.experimentalFeatureTasks, }) + const permissionsUiEnabled = useWorkspacePermissionsUiEnabled() const ssoMode = isSsoMode() const shouldBypassWaitlist = isWaitlistBypassed() @@ -169,37 +227,51 @@ const AppRoutes = ({ initData }: { initData: InitData }) => { )} {/* Main app routes - authenticated only. The gate decides redirect - targets internally from VITE_AUTH_MODE + VITE_AUTH_ENABLE_ANONYMOUS. */} + targets internally from VITE_AUTH_MODE + VITE_AUTH_ENABLE_ANONYMOUS. + `WorkspaceGate` then holds the routes until `runPostAuthBootstrap` + has resolved the active workspace — keeps DAL inserts from firing + with a null workspace id between authentication and sync landing. + + The shared sub-tree (chat / settings / dev surfaces) is mounted + twice: unprefixed for the personal workspace (canonical) and under + `/w/:workspaceId/` for shared workspaces (membership-gated). Index + and child paths are relative so the same JSX resolves to either + `/chats/new` or `/w/:workspaceId/chats/new` based on its parent. */} }> - - - - - - } - > - {/* Home routes with HomeLayout */} - }> - } /> - } /> - {experimentalFeatureTasks.value && } />} - {import.meta.env.DEV && } />} + }> + {/* Personal workspace — unprefixed canonical URLs. */} + + + + + + } + > + {renderWorkspaceRoutes({ + experimentalFeatureTasks: experimentalFeatureTasks.value, + permissionsUiEnabled, + })} - {/* Settings routes with SettingsLayout */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {import.meta.env.DEV && } />} + {/* Shared workspaces — `/w/:workspaceId/...`, membership-gated. */} + }> + + + + + + } + > + {renderWorkspaceRoutes({ + experimentalFeatureTasks: experimentalFeatureTasks.value, + permissionsUiEnabled, + })} + @@ -240,6 +312,10 @@ export const App = () => { if (upgradeRequired) { return } + + if (initError?.code === 'NO_TRUST_DOMAIN') { + return + } if (initError) { if (initError.code === 'STORAGE_UNAVAILABLE') { return diff --git a/src/automations/use-trigger-scheduler.ts b/src/automations/use-trigger-scheduler.ts index 3cf93e36a..2c120af04 100644 --- a/src/automations/use-trigger-scheduler.ts +++ b/src/automations/use-trigger-scheduler.ts @@ -5,9 +5,11 @@ import { getDb } from '@/db/database' import { useSettings } from '@/hooks/use-settings' import { getAllEnabledTriggers, runAutomation } from '@/dal' +import { useActiveWorkspaceId } from '@/lib/active-workspace' import { useEffect, useRef } from 'react' export const useTriggerScheduler = () => { + const workspaceId = useActiveWorkspaceId() const { isTriggersEnabled } = useSettings({ is_triggers_enabled: false, }) @@ -15,7 +17,7 @@ export const useTriggerScheduler = () => { useEffect(() => { const plan = async () => { - if (!isTriggersEnabled.value) { + if (!isTriggersEnabled.value || !workspaceId) { return } @@ -23,7 +25,7 @@ export const useTriggerScheduler = () => { timers.current = [] const db = getDb() - const triggers = await getAllEnabledTriggers(db) + const triggers = await getAllEnabledTriggers(db, workspaceId) triggers.forEach((t) => { if (t.triggerTime) { @@ -35,13 +37,16 @@ export const useTriggerScheduler = () => { } const delay = next.getTime() - Date.now() timers.current.push( - setTimeout(() => runAutomation(getDb(), t.promptId).catch(console.error), delay) as unknown as number, + setTimeout( + () => runAutomation(getDb(), workspaceId, t.promptId).catch(console.error), + delay, + ) as unknown as number, ) } }) } - if (!isTriggersEnabled.value) { + if (!isTriggersEnabled.value || !workspaceId) { return } @@ -53,5 +58,5 @@ export const useTriggerScheduler = () => { clearInterval(id) timers.current.forEach(clearTimeout) } - }, [isTriggersEnabled.value]) + }, [isTriggersEnabled.value, workspaceId]) } diff --git a/src/chats/agent-routing.test.ts b/src/chats/agent-routing.test.ts index 60f8137b6..d0e353748 100644 --- a/src/chats/agent-routing.test.ts +++ b/src/chats/agent-routing.test.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { wsId } from '@/dal/test-utils' + /** * `createAgentRoutingFetch` dispatch tests. Verifies the `customFetch` the AI * SDK consumes correctly routes each `chat.sendMessage(...)` to the cached @@ -95,6 +97,7 @@ const hydrateSessionWith = (id: string, agent: Agent, chatThread: ChatThread | n connectionStatus: 'idle', connectionError: null, id, + workspaceId: wsId, pendingPermission: null, retryCount: 0, retriesExhausted: false, @@ -119,7 +122,7 @@ describe('createAgentRoutingFetch', () => { const connectToAgent = mock(async (_agent: Agent) => adapter) hydrateSessionWith('t-built-in', builtInAgent) - const customFetch = createAgentRoutingFetch('t-built-in', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-built-in', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -138,7 +141,7 @@ describe('createAgentRoutingFetch', () => { const connectToAgent = mock(async (_agent: Agent) => adapter) hydrateSessionWith('t-remote', remoteAgent) - const customFetch = createAgentRoutingFetch('t-remote', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-remote', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -155,7 +158,7 @@ describe('createAgentRoutingFetch', () => { const connectToAgent = mock(async (_agent: Agent) => adapter) hydrateSessionWith('t-cache', remoteAgent) - const customFetch = createAgentRoutingFetch('t-cache', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-cache', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -176,7 +179,7 @@ describe('createAgentRoutingFetch', () => { hydrateSessionWith('t-switch', remoteAgent) - const customFetch = createAgentRoutingFetch('t-switch', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-switch', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -225,7 +228,7 @@ describe('createAgentRoutingFetch', () => { hydrateSessionWith('thread-77', remoteAgent, chatThread) - const customFetch = createAgentRoutingFetch('thread-77', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('thread-77', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: updateChatThread as never, getDb: (() => fakeDb) as never, @@ -239,10 +242,11 @@ describe('createAgentRoutingFetch', () => { await capturedOnAcpSessionId!('acp-sess-xyz') expect(updateChatThread).toHaveBeenCalledTimes(1) - const call = updateChatThread.mock.calls[0] as unknown as [unknown, string, { acpSessionId: string }] + const call = updateChatThread.mock.calls[0] as unknown as [unknown, string, string, { acpSessionId: string }] expect(call[0]).toBe(fakeDb) - expect(call[1]).toBe('thread-77') - expect(call[2]).toMatchObject({ acpSessionId: 'acp-sess-xyz' }) + expect(call[1]).toBe(wsId) + expect(call[2]).toBe('thread-77') + expect(call[3]).toMatchObject({ acpSessionId: 'acp-sess-xyz' }) }) it('saves the user message before invoking the adapter (built-in agent)', async () => { @@ -271,7 +275,7 @@ describe('createAgentRoutingFetch', () => { parts: [{ type: 'text', text: 'Hello world' }], } - const customFetch = createAgentRoutingFetch('t-save-builtin', saveMessagesSpy, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-save-builtin', wsId, saveMessagesSpy, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -298,7 +302,7 @@ describe('createAgentRoutingFetch', () => { parts: [{ type: 'text', text: 'ACP question' }], } - const customFetch = createAgentRoutingFetch('t-save-remote', saveMessagesSpy, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-save-remote', wsId, saveMessagesSpy, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -336,7 +340,7 @@ describe('createAgentRoutingFetch', () => { hydrateSessionWith('t-no-thread', remoteAgent, null) - const customFetch = createAgentRoutingFetch('t-no-thread', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-no-thread', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: updateChatThread as never, getDb: (() => fakeDb) as never, @@ -357,7 +361,7 @@ describe('createAgentRoutingFetch', () => { ]) hydrateSessionWith('t-skill', remoteAgent) - const customFetch = createAgentRoutingFetch('t-skill', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-skill', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, getAllSkills: getAllSkills as never, getDb: (() => ({})) as never, @@ -377,7 +381,7 @@ describe('createAgentRoutingFetch', () => { const getAllSkills = mock(async () => []) hydrateSessionWith('t-builtin-skill', builtInAgent) - const customFetch = createAgentRoutingFetch('t-builtin-skill', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-builtin-skill', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, getAllSkills: getAllSkills as never, getDb: (() => ({})) as never, diff --git a/src/chats/chat-instance-permission.test.ts b/src/chats/chat-instance-permission.test.ts index 130e845f0..a54f59e24 100644 --- a/src/chats/chat-instance-permission.test.ts +++ b/src/chats/chat-instance-permission.test.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { wsId } from '@/dal/test-utils' + /** * Tests that the routing fetch's permission bridge stashes pending requests * on the store and that `resolvePendingPermission` completes the adapter's @@ -62,7 +64,7 @@ describe('requestPermission bridge', () => { const connectToAgent = mock(async () => adapter) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, diff --git a/src/chats/chat-instance.test.ts b/src/chats/chat-instance.test.ts index 473b95652..06756c556 100644 --- a/src/chats/chat-instance.test.ts +++ b/src/chats/chat-instance.test.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { wsId } from '@/dal/test-utils' + /** * `createAgentRoutingFetch` connection-status tests. Verifies the customFetch * factory writes `connectionStatus` transitions into the chat-store around @@ -58,7 +60,7 @@ describe('createAgentRoutingFetch — connection status', () => { return makeAdapter(agent) }) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, @@ -76,7 +78,7 @@ describe('createAgentRoutingFetch — connection status', () => { throw new Error('boom') }) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, @@ -92,7 +94,7 @@ describe('createAgentRoutingFetch — connection status', () => { it('only re-connects when the agent identity changes (cache hit stays ready)', async () => { const connectToAgent = mock(async (agent: Agent) => makeAdapter(agent)) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, @@ -109,7 +111,7 @@ describe('createAgentRoutingFetch — connection status', () => { const altAgent: Agent = { ...builtInAgent, id: 'alt' } const connectToAgent = mock(async (agent: Agent) => makeAdapter(agent)) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, diff --git a/src/chats/chat-instance.ts b/src/chats/chat-instance.ts index b2ffd514f..989eded1b 100644 --- a/src/chats/chat-instance.ts +++ b/src/chats/chat-instance.ts @@ -104,6 +104,7 @@ export type CreateChatInstanceDeps = { */ export const createAgentRoutingFetch = ( id: string, + workspaceId: string, saveMessages: SaveMessagesFunction, httpClient: HttpClient, getProxyFetch: () => FetchFn, @@ -129,7 +130,7 @@ export const createAgentRoutingFetch = ( return [] } const instructionBySlug = new Map() - for (const skill of await getAllSkills(getDb())) { + for (const skill of await getAllSkills(getDb(), workspaceId)) { if (skill.enabled === 1 && skill.name && skill.instruction) { instructionBySlug.set(skill.name, skill.instruction) } @@ -174,7 +175,7 @@ export const createAgentRoutingFetch = ( if (!chatThread) { return } - await updateChatThread(getDb(), chatThread.id, { acpSessionId: newSessionId }) + await updateChatThread(getDb(), workspaceId, chatThread.id, { acpSessionId: newSessionId }) } // Surface `connecting` only when routing to a different agent than this @@ -230,13 +231,14 @@ export const createAgentRoutingFetch = ( export const createChatInstance = ( id: string, + workspaceId: string, messages: ThunderboltUIMessage[], saveMessages: SaveMessagesFunction, httpClient: HttpClient, getProxyFetch: () => FetchFn, deps: CreateChatInstanceDeps = {}, ) => { - const customFetch = createAgentRoutingFetch(id, saveMessages, httpClient, getProxyFetch, deps) + const customFetch = createAgentRoutingFetch(id, workspaceId, saveMessages, httpClient, getProxyFetch, deps) let retryCount = 0 let retryTimeout: ReturnType | null = null diff --git a/src/chats/chat-store.test.ts b/src/chats/chat-store.test.ts index 399d9e9aa..8a4ef67ad 100644 --- a/src/chats/chat-store.test.ts +++ b/src/chats/chat-store.test.ts @@ -4,7 +4,7 @@ import { getSettings } from '@/dal' import { createChatThread, getChatThread } from '@/dal/chat-threads' -import { setupTestDatabase, teardownTestDatabase, resetTestDatabase } from '@/dal/test-utils' +import { setupTestDatabase, teardownTestDatabase, resetTestDatabase, wsId } from '@/dal/test-utils' import { getDb } from '@/db/database' import { builtInAgent } from '@/defaults/agents' import type { Mode } from '@/types' @@ -119,6 +119,7 @@ describe('chat-store', () => { connectionStatus: 'idle' as const, connectionError: null, id: 'test-id', + workspaceId: wsId, pendingPermission: null, selectedAgent: builtInAgent, selectedMode: null as unknown as Mode, @@ -367,6 +368,7 @@ describe('chat-store', () => { await createChatThread( getDb(), + wsId, { id: chatThread.id, title: 'x', contextSize: null, triggeredBy: null, wasTriggeredByAutomation: 0 }, model, ) @@ -383,7 +385,7 @@ describe('chat-store', () => { await useChatStore.getState().setSelectedAgent(chatThread.id, customAgent) - const stored = await getChatThread(getDb(), chatThread.id) + const stored = await getChatThread(getDb(), wsId, chatThread.id) expect(stored?.agentId).toBe(customAgent.id) }) @@ -410,7 +412,7 @@ describe('chat-store', () => { expect(session?.selectedAgent.id).toBe(customAgent.id) // Verify no row was created behind the scenes. - const stored = await getChatThread(getDb(), 'thread-no-row') + const stored = await getChatThread(getDb(), wsId, 'thread-no-row') expect(stored).toBeNull() }) diff --git a/src/chats/chat-store.ts b/src/chats/chat-store.ts index 6e2a84c9a..4db68548a 100644 --- a/src/chats/chat-store.ts +++ b/src/chats/chat-store.ts @@ -34,6 +34,7 @@ export type ChatSession = { connectionStatus: ConnectionStatus connectionError: Error | null id: string + workspaceId: string pendingPermission: PendingPermission | null retryCount: number retriesExhausted: boolean @@ -170,8 +171,12 @@ export const useChatStore = create()((set, get) => ({ const db = getDb() + // `session.workspaceId` is the source of truth — the in-memory thread row + // may have been hydrated before the workspace_id column was added or from + // a partial row, but the session carries the workspaceId as a required + // field. The thread is the gate (need its id to PATCH). if (session.chatThread) { - await updateChatThread(db, session.chatThread.id, { agentId: agent.id }) + await updateChatThread(db, session.workspaceId, session.chatThread.id, { agentId: agent.id }) } // Persist the global last-used agent so new chats default to it (mirrors diff --git a/src/chats/detail.tsx b/src/chats/detail.tsx index f65b46b82..8b68146f7 100644 --- a/src/chats/detail.tsx +++ b/src/chats/detail.tsx @@ -16,14 +16,13 @@ type ChatHydrateHandlerProps = PropsWithChildren<{ }> const ChatHydrateHandler = ({ children, id, isNew }: ChatHydrateHandlerProps) => { - const { hydrateChatStore, isReady, saveMessages } = useHydrateChatStore({ id, isNew }) + const { hydrateChatStore, isReady, saveMessages, workspaceId } = useHydrateChatStore({ id, isNew }) - useHandleIntegrationCompletion({ saveMessages }) + useHandleIntegrationCompletion({ saveMessages, workspaceId }) useEffect(() => { - hydrateChatStore() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]) + void hydrateChatStore() + }, [id, workspaceId]) // eslint-disable-line react-hooks/exhaustive-deps if (!isReady) { return null diff --git a/src/chats/use-hydrate-chat-store.test.tsx b/src/chats/use-hydrate-chat-store.test.tsx index 69b711f72..7c63ce831 100644 --- a/src/chats/use-hydrate-chat-store.test.tsx +++ b/src/chats/use-hydrate-chat-store.test.tsx @@ -2,8 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { setupTestDatabase, teardownTestDatabase, resetTestDatabase } from '@/dal/test-utils' +import { setupTestDatabase, teardownTestDatabase, resetTestDatabase, wsId } from '@/dal/test-utils' import { getCurrentSession, resetStore } from '@/test-utils/chat-store-mocks' +import { resetTestTrustDomain, seedTestTrustDomain } from '@/test-utils/powersync-reactivity-test' import { createQueryTestWrapper } from '@/test-utils/react-query' import { act, cleanup, renderHook } from '@testing-library/react' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' @@ -38,6 +39,7 @@ const createDefaultMode = async () => { order: 0, deletedAt: null, defaultHash: null, + workspaceId: wsId, }) return 'mode-chat' @@ -64,6 +66,7 @@ const createSystemModel = async () => { deletedAt: null, url: null, defaultHash: null, + workspaceId: wsId, }) return modelId @@ -90,6 +93,7 @@ const createTestModel = async () => { deletedAt: null, url: null, defaultHash: null, + workspaceId: wsId, }) return modelId @@ -99,13 +103,14 @@ const createTestModel = async () => { * Helper function to create a test thread */ const createTestThread = async (modelId: string, title: string = 'Test Thread') => { - const model = await getModel(getDb(), modelId) + const model = await getModel(getDb(), wsId, modelId) if (!model) { throw new Error('Test setup failed') } const threadId = uuidv7() await createChatThread( getDb(), + wsId, { id: threadId, title, @@ -150,6 +155,10 @@ describe('useHydrateChatStore', () => { }) beforeEach(async () => { + // Seed the trust-domain registry with a standalone user so `useActiveWorkspaceId` + // resolves to `wsId` (the personal workspace seeded by setupTestDatabase). The + // hook early-returns null without this, so hydrateChatStore would no-op. + seedTestTrustDomain() // Reset store state before each test resetStore() await resetTestDatabase() @@ -163,6 +172,7 @@ describe('useHydrateChatStore', () => { cleanup() // Reset store state after each test resetStore() + resetTestTrustDomain() await resetTestDatabase() }) @@ -227,6 +237,29 @@ describe('useHydrateChatStore', () => { expect(session?.triggerData).toBeDefined() }) + it('handles concurrent hydration calls for the same id without throwing', async () => { + // Regression: when `[id, workspaceId]` flips twice in quick succession + // (e.g. landing on `/w//chats/new` right after workspace creation), + // two `hydrateChatStore()` calls race past the early dedup and both reach + // `createSession`. The store's `createSession` throws on duplicate id, so + // the second invocation used to crash with "Session already exists". + const systemModelId = await createSystemModel() + const threadId = await createTestThread(systemModelId) + + const { result } = renderHook(() => useHydrateChatStore({ id: threadId, isNew: false }), { + wrapper: TestWrapper, + }) + + // Kick off two concurrent calls; both must resolve without throwing. + await act(async () => { + await Promise.all([result.current.hydrateChatStore(), result.current.hydrateChatStore()]) + }) + + const storeState = useChatStore.getState() + expect(storeState.sessions.has(threadId)).toBe(true) + expect(storeState.currentSessionId).toBe(threadId) + }) + it('should reset store before hydrating', async () => { const systemModelId = await createSystemModel() const threadId1 = await createTestThread(systemModelId, 'Thread 1') @@ -269,7 +302,7 @@ describe('useHydrateChatStore', () => { createTestMessage({ role: 'assistant', parts: [{ type: 'text', text: 'Hi there' }] }), ] - await saveMessagesWithContextUpdate(getDb(), threadId, messages) + await saveMessagesWithContextUpdate(getDb(), wsId, threadId, messages) const { result } = renderHook(() => useHydrateChatStore({ id: threadId, isNew: false }), { wrapper: TestWrapper, @@ -307,7 +340,7 @@ describe('useHydrateChatStore', () => { // A new chat has no `chat_threads` row, so the agent resolves from the // global `selected_agent` setting (the user's last pick). It must win over // `allAgents[0]`, which is always the built-in. - await createAgent(getDb(), { + await createAgent(getDb(), wsId, { id: 'custom-last-used', name: 'Last Used Agent', type: 'remote-acp', @@ -429,7 +462,7 @@ describe('useHydrateChatStore', () => { }) // The newly-created thread row should carry the agent the user picked. - const stored = await getThread(getDb(), threadId) + const stored = await getThread(getDb(), wsId, threadId) expect(stored?.agentId).toBe('haystack-rag') }) diff --git a/src/chats/use-hydrate-chat-store.ts b/src/chats/use-hydrate-chat-store.ts index 7f9775481..bab63c08e 100644 --- a/src/chats/use-hydrate-chat-store.ts +++ b/src/chats/use-hydrate-chat-store.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useDatabase, useHttpClient } from '@/contexts' +import { getActiveWorkspaceId, useActiveWorkspaceId } from '@/lib/active-workspace' import { useProxyFetchGetter } from '@/lib/proxy-fetch-context' import { composeAllAgents, @@ -30,6 +31,7 @@ import { convertDbChatMessageToUIMessage } from '@/lib/utils' import type { SaveMessagesFunction, ThunderboltUIMessage } from '@/types' import { useState } from 'react' import { useNavigate } from 'react-router' +import { useWorkspaceNavigate } from '@/lib/active-workspace' import { useChatStore } from './chat-store' import { createChatInstance } from './chat-instance' @@ -51,15 +53,17 @@ const trackChatReadyOnce = () => { export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => { const db = useDatabase() + const workspaceId = useActiveWorkspaceId() const httpClient = useHttpClient() const getProxyFetch = useProxyFetchGetter() const navigate = useNavigate() + const workspaceNavigate = useWorkspaceNavigate() const [isReady, setIsReady] = useState(false) const { getEnabledClients, reconnectClient } = useMCP() - const updateThreadTitle = async (messages: ThunderboltUIMessage[], threadId: string) => { + const updateThreadTitle = async (messages: ThunderboltUIMessage[], threadId: string, workspaceId: string) => { const firstUserMessage = messages.find((msg) => msg.role === 'user') if (!firstUserMessage) { return @@ -75,10 +79,19 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => } const title = await generateTitle(textContent) - await updateChatThread(db, threadId, { title }) + await updateChatThread(db, workspaceId, threadId, { title }) } const saveMessages: SaveMessagesFunction = async ({ id, messages }) => { + // Resolve workspaceId at call time rather than relying on the hook's + // closure-captured value, which may be stale on the first render before + // `useActiveWorkspaceId`'s React Query has resolved. The async getter + // reads the trust-domain registry + DB synchronously w.r.t. React's + // render lifecycle, so it's deterministic in tests. + const workspaceId = await getActiveWorkspaceId(db) + if (!workspaceId) { + throw new Error('No active workspace') + } const { sessions, updateSession } = useChatStore.getState() const session = sessions.get(id) @@ -91,50 +104,74 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => // Pass `selectedAgent.id` so a brand-new thread is created with the user's // currently-selected agent — otherwise the row would default to `null` // and a reload would silently fall back to the built-in agent. - const thread = await getOrCreateChatThread(db, id, session.selectedModel.id, session.selectedAgent.id) + const thread = await getOrCreateChatThread(db, workspaceId, id, session.selectedModel.id, session.selectedAgent.id) // Save messages and update context size using DAL - await saveMessagesWithContextUpdate(db, id, messages) + await saveMessagesWithContextUpdate(db, workspaceId, id, messages) // Generate title in background if needed if (thread?.title === 'New Chat') { - updateThreadTitle(messages, id) + updateThreadTitle(messages, id, workspaceId) } if (!session.chatThread) { updateSession(id, { chatThread: thread }) - navigate(`/chats/${id}`, { relative: 'path' }) + workspaceNavigate(`/chats/${id}`) } } const hydrateChatStore = async () => { + // Resolve workspaceId at call time rather than relying on the hook's + // closure-captured value. `useActiveWorkspaceId` may briefly return null + // on the first render (race between WorkspaceGate and React Query's + // resolution). The async getter reads the trust-domain registry + DB + // directly, so it's deterministic regardless of React render timing — + // which also means tests don't need to flush React Query before calling. + const workspaceId = await getActiveWorkspaceId(db) + if (!workspaceId) { + return + } const { createSession, sessions, setCurrentSessionId, setGetMcpClients, setReconnectClient, setModes, setModels } = useChatStore.getState() // Check if this ID belongs to a deleted chat - redirect to 404 if so - const isDeleted = await isChatThreadDeleted(db, id) + const isDeleted = await isChatThreadDeleted(db, workspaceId, id) if (isDeleted) { navigate('/not-found', { replace: true }) return } - // If the session already exists, set the current session id and update the mcp clients and models - if (sessions.has(id)) { - setCurrentSessionId(id) - - const [modes, models] = await Promise.all([getAllModes(db), getAvailableModels(db)]) - - // Store the provider's getter (not a snapshot) so each send reads the - // current connected clients, including any swapped in by a reconnect. - setGetMcpClients(getEnabledClients) - setReconnectClient(reconnectClient) - setModes(modes) - setModels(models) - - setIsReady(true) - trackChatReadyOnce() - - return + // If the session already exists, reuse it — unless it was built for a different + // workspace (workspace switch). In that case, evict it so the full create path + // rebuilds the closures with the new workspaceId. + const existingSession = sessions.get(id) + if (existingSession) { + if (existingSession.workspaceId !== workspaceId) { + // Drop `isReady` before we evict so consumers don't render against a + // session we've just removed during the async rebuild below — they'd + // see `isReady=true` with no matching session entry and throw + // missing-session errors. + setIsReady(false) + const nextSessions = new Map(sessions) + nextSessions.delete(id) + useChatStore.setState({ sessions: nextSessions }) + // fall through to full create path + } else { + setCurrentSessionId(id) + + const [modes, models] = await Promise.all([getAllModes(db, workspaceId), getAvailableModels(db, workspaceId)]) + + // Store the provider's getter (not a snapshot) so each send reads the + // current connected clients, including any swapped in by a reconnect. + setGetMcpClients(getEnabledClients) + setReconnectClient(reconnectClient) + setModes(modes) + setModels(models) + + setIsReady(true) + trackChatReadyOnce() + return + } } // If the session does not exist, create it below @@ -151,14 +188,14 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => customAgentRows, systemAgentRows, ] = await Promise.all([ - getDefaultModelForThread(db, id, settings.selectedModel ?? undefined), - getSelectedMode(db), - getChatThread(db, id), - getChatMessages(db, id), - getAllModes(db), - getAvailableModels(db), - getTriggerPromptForThread(db, id), - getAllAgents(db), + getDefaultModelForThread(db, workspaceId, id, settings.selectedModel ?? undefined), + getSelectedMode(db, workspaceId), + getChatThread(db, workspaceId, id), + getChatMessages(db, workspaceId, id), + getAllModes(db, workspaceId), + getAvailableModels(db, workspaceId), + getTriggerPromptForThread(db, workspaceId, id), + getAllAgents(db, workspaceId), getAllSystemAgents(db), ]) @@ -213,8 +250,26 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => return } + // Re-read the session map immediately before the write. The top-of-function + // existing-session check (above) is separated from `createSession` by the + // big Promise.all, so two concurrent hydrations for the same `id` can both + // pass the early dedup and race to `createSession` — which throws when + // both reach it. Surfaces when `[id, workspaceId]` flips twice in quick + // succession (e.g. landing on `/w//chats/new` right after workspace + // creation, where `useActiveWorkspaceId()` is briefly null then resolves). + if (useChatStore.getState().sessions.has(id)) { + setCurrentSessionId(id) + setGetMcpClients(getEnabledClients) + setReconnectClient(reconnectClient) + setModes(modes) + setModels(models) + setIsReady(true) + return + } + const chatInstance = createChatInstance( id, + workspaceId, initialMessages.map(convertDbChatMessageToUIMessage) as ThunderboltUIMessage[], saveMessages, httpClient, @@ -227,6 +282,7 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => connectionStatus: 'idle', connectionError: null, id, + workspaceId, pendingPermission: null, retryCount: 0, retriesExhausted: false, @@ -251,5 +307,5 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => trackChatReadyOnce() } - return { hydrateChatStore, isReady, saveMessages } + return { hydrateChatStore, isReady, saveMessages, workspaceId } } diff --git a/src/chats/use-warm-acp-commands.test.tsx b/src/chats/use-warm-acp-commands.test.tsx index aed7f255a..3a0963d79 100644 --- a/src/chats/use-warm-acp-commands.test.tsx +++ b/src/chats/use-warm-acp-commands.test.tsx @@ -76,7 +76,7 @@ const remote: Agent = { isSystem: 0, } -const thread: ChatThread = { id: 'thread-1', acpSessionId: null } as ChatThread +const thread: ChatThread = { id: 'thread-1', workspaceId: 'ws-1', acpSessionId: null } as ChatThread const wrapper = ({ children }: { children: ReactNode }) => ( @@ -144,8 +144,9 @@ describe('useWarmAcpCommands', () => { await flush() expect(updateChatThread).toHaveBeenCalledTimes(1) - expect(updateChatThread.mock.calls[0]?.[1]).toBe('thread-1') - expect(updateChatThread.mock.calls[0]?.[2]).toEqual({ acpSessionId: 'sess-new' }) + expect(updateChatThread.mock.calls[0]?.[1]).toBe('ws-1') + expect(updateChatThread.mock.calls[0]?.[2]).toBe('thread-1') + expect(updateChatThread.mock.calls[0]?.[3]).toEqual({ acpSessionId: 'sess-new' }) }) it('releases the guard on a failed connect so a later effect run retries', async () => { diff --git a/src/chats/use-warm-acp-commands.ts b/src/chats/use-warm-acp-commands.ts index 8d7cbf6e5..f5ad82b06 100644 --- a/src/chats/use-warm-acp-commands.ts +++ b/src/chats/use-warm-acp-commands.ts @@ -90,8 +90,8 @@ export const useWarmAcpCommands = ( threadId: id, acpSessionId: chatThread?.acpSessionId ?? null, onAcpSessionId: async (sessionId) => { - if (chatThread) { - await updateChatThread(getDb(), chatThread.id, { acpSessionId: sessionId }) + if (chatThread?.workspaceId) { + await updateChatThread(getDb(), chatThread.workspaceId, chatThread.id, { acpSessionId: sessionId }) } }, }) diff --git a/src/components/account-deleted.test.tsx b/src/components/account-deleted.test.tsx index 66a3f7b30..bbc1d33bd 100644 --- a/src/components/account-deleted.test.tsx +++ b/src/components/account-deleted.test.tsx @@ -12,6 +12,7 @@ const mockReplace = mock() Object.defineProperty(window, 'location', { value: { replace: mockReplace }, writable: true, + configurable: true, }) describe('AccountDeleted', () => { diff --git a/src/components/app-error-boundary.test.tsx b/src/components/app-error-boundary.test.tsx new file mode 100644 index 000000000..12cd34e92 --- /dev/null +++ b/src/components/app-error-boundary.test.tsx @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { setupConsoleSpy, type ConsoleSpies } from '@/test-utils/console-spies' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { AppErrorBoundary } from './app-error-boundary' + +const Boom = ({ message }: { message: string }) => { + throw new Error(message) +} + +describe('AppErrorBoundary', () => { + let consoleSpies: ConsoleSpies + + beforeEach(() => { + consoleSpies = setupConsoleSpy() + }) + + afterEach(() => { + consoleSpies.restore() + }) + + it('renders the children when no error is thrown', () => { + render( + + OK + , + ) + expect(screen.getByText('OK')).toBeInTheDocument() + }) + + it('renders AppErrorScreen with the caught message when a child throws', () => { + render( + + + , + ) + expect(screen.getByText('Failed to initialize app')).toBeInTheDocument() + expect(screen.getByText('bootstrap failed')).toBeInTheDocument() + }) +}) diff --git a/src/components/app-error-boundary.tsx b/src/components/app-error-boundary.tsx new file mode 100644 index 000000000..fdab92176 --- /dev/null +++ b/src/components/app-error-boundary.tsx @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { AppErrorScreen } from './app-error-screen' + +type AppErrorBoundaryState = { + error: Error | null +} + +/** + * Catches uncaught render-time errors so a failed bootstrap (e.g. the + * `SessionToWorkspaceBootstrap` throw in `auth-context.tsx`) lands on an + * actionable error screen instead of a blank page. Mounted just above + * `BrowserRouter` so the boundary scope covers every route. + */ +export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErrorBoundaryState> { + state: AppErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): AppErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[AppErrorBoundary] Uncaught render error:', error, info.componentStack) + } + + render(): ReactNode { + const { error } = this.state + if (error) { + return ( + window.location.reload()} + /> + ) + } + return this.props.children + } +} diff --git a/src/components/boot/mode-picker.test.tsx b/src/components/boot/mode-picker.test.tsx new file mode 100644 index 000000000..e4d62f4e7 --- /dev/null +++ b/src/components/boot/mode-picker.test.tsx @@ -0,0 +1,326 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getClock } from '@/testing-library' +import '@testing-library/jest-dom' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' +import { ModePicker, type ValidateServerUrlFn, type ValidationResult } from './mode-picker' + +// --- Injected validator (DI) --- +// +// The `validate` prop replaces the previous `mock.module('@/lib/http', ...)` +// shim. That global module mock leaked across test files because it only +// shipped `createClient`/`HttpError` — downstream tests that imported other +// `@/lib/http` exports (or expected a fully-featured HttpClient back from +// `createClient`) crashed. DI keeps the mock scoped to this file. +const mockValidate = mock(async () => ({ ok: false, message: 'not configured' })) + +const renderModePicker = () => render() + +// --- window.location.reload mock --- + +const mockReload = mock() + +beforeEach(() => { + // @ts-expect-error — jsdom does not allow overwriting location directly + delete window.location + // @ts-expect-error — jsdom does not allow overwriting location directly + window.location = { ...window.location, reload: mockReload } + + mockValidate.mockClear() + // Default to a failing validation so accidental real-network calls don't blow up. + mockValidate.mockImplementation(async () => ({ ok: false, message: 'not configured' })) + mockReload.mockClear() + + useTrustDomainRegistry.setState({ servers: {}, activeTrustDomain: undefined }) +}) + +afterEach(() => { + useTrustDomainRegistry.setState({ servers: {}, activeTrustDomain: undefined }) +}) + +const okValidation = (overrides: Partial> = {}) => ({ + ok: true as const, + serverId: 'test-server-id', + cloudUrl: 'http://localhost:8000/v1', + ...overrides, +}) + +const errorValidation = (message: string): ValidationResult => ({ ok: false, message }) + +// Helper: flush all pending async work (promises + fake timers) inside act +const flush = async () => { + await act(async () => { + await getClock().runAllAsync() + }) +} + +describe('ModePicker', () => { + describe('initial render', () => { + it('shows both option cards', () => { + renderModePicker() + + expect(screen.getByText('Set up an on-device agent')).toBeInTheDocument() + expect(screen.getByText('Connect to AI server')).toBeInTheDocument() + }) + + it('Continue (arrow) is disabled on initial render (server selected, URL empty)', () => { + renderModePicker() + + const buttons = screen.getAllByRole('button') + const arrowBtn = buttons[buttons.length - 1] + expect(arrowBtn).toBeDisabled() + }) + + it('shows URL input on initial render (server is the default selection)', () => { + renderModePicker() + + expect(screen.getByPlaceholderText('app.thunderbolt.io/')).toBeInTheDocument() + }) + }) + + describe('standalone selection (Skip-only flow)', () => { + // The "Set up an on-device agent" card is a disabled placeholder in the + // current UI — the only path into standalone mode is the Skip button. + it('Skip writes activateStandalone and reloads', () => { + renderModePicker() + + fireEvent.click(screen.getByRole('button', { name: /skip/i })) + + expect(useTrustDomainRegistry.getState().activeTrustDomain).toEqual({ kind: 'standalone' }) + expect(mockReload).toHaveBeenCalledTimes(1) + }) + + it('standalone card is disabled (placeholder for future on-device agent flow)', () => { + renderModePicker() + + const standaloneCard = screen.getByText('Set up an on-device agent').closest('button') + expect(standaloneCard).toBeDisabled() + }) + }) + + describe('server selection', () => { + it('reveals URL input when server card is clicked', () => { + renderModePicker() + + fireEvent.click(screen.getByText('Connect to AI server')) + + expect(screen.getByPlaceholderText('app.thunderbolt.io/')).toBeInTheDocument() + expect(screen.getByText("Enter Server's URL:")).toBeInTheDocument() + }) + + it('Continue is disabled when server is selected but URL is empty', () => { + renderModePicker() + + fireEvent.click(screen.getByText('Connect to AI server')) + + const buttons = screen.getAllByRole('button') + expect(buttons[buttons.length - 1]).toBeDisabled() + }) + + it('Continue enables once a URL is typed', () => { + renderModePicker() + + fireEvent.click(screen.getByText('Connect to AI server')) + fireEvent.change(screen.getByPlaceholderText('app.thunderbolt.io/'), { + target: { value: 'http://localhost:8000' }, + }) + + const buttons = screen.getAllByRole('button') + expect(buttons[buttons.length - 1]).not.toBeDisabled() + }) + }) + + describe('blur validation', () => { + it('shows a checkmark on successful blur validation', async () => { + mockValidate.mockResolvedValue(okValidation()) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://localhost:8000' } }) + fireEvent.blur(input) + + await flush() + + // A Check icon should appear inside the input wrapper + expect(input.parentElement?.querySelector('svg')).toBeInTheDocument() + }) + + it('shows "Couldn\'t reach" error on validate failure during blur', async () => { + mockValidate.mockResolvedValue(errorValidation("Couldn't reach this server")) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://unreachable.local' } }) + fireEvent.blur(input) + + await flush() + + expect(screen.getByText("Couldn't reach this server")).toBeInTheDocument() + }) + + it('shows "doesn\'t look like" error on validate failure during blur', async () => { + mockValidate.mockResolvedValue(errorValidation("This URL doesn't look like a Thunderbolt server")) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://not-thunderbolt.local' } }) + fireEvent.blur(input) + + await flush() + + expect(screen.getByText("This URL doesn't look like a Thunderbolt server")).toBeInTheDocument() + }) + }) + + describe('Continue with server (submit-time validation)', () => { + it('writes activateServer and reloads on valid validate result', async () => { + const serverId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + mockValidate.mockResolvedValue(okValidation({ serverId, cloudUrl: 'http://localhost:8000/v1' })) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + fireEvent.change(screen.getByPlaceholderText('app.thunderbolt.io/'), { + target: { value: 'http://localhost:8000' }, + }) + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[buttons.length - 1]) + + await flush() + + expect(mockReload).toHaveBeenCalledTimes(1) + const registry = useTrustDomainRegistry.getState() + expect(registry.activeTrustDomain).toEqual({ kind: 'server', serverId }) + expect(registry.servers[serverId]?.cloudUrl).toBe('http://localhost:8000/v1') + }) + + it('passes the raw user URL through to validate (URL normalization is its responsibility)', async () => { + mockValidate.mockResolvedValue(okValidation()) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + fireEvent.change(screen.getByPlaceholderText('app.thunderbolt.io/'), { + target: { value: 'http://localhost:8000/v1' }, + }) + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[buttons.length - 1]) + + await flush() + + // Submit-time: handleContinue forwards the raw URL to validate; tests of + // normalizeBaseUrl behavior live alongside `validateServerUrl` directly. + expect(mockValidate).toHaveBeenCalledWith('http://localhost:8000/v1') + }) + + it('does not leave Continue stuck disabled after a stale blur + edit', async () => { + let resolveValidate: (r: ValidationResult) => void = () => {} + mockValidate.mockImplementation( + () => + new Promise((resolve) => { + resolveValidate = resolve + }), + ) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://stale.local' } }) + fireEvent.blur(input) + // Edit before validate() resolves — isValidating must clear so Continue + // enables once a non-empty URL is in the field. + fireEvent.change(input, { target: { value: 'http://fresh.local' } }) + + resolveValidate(okValidation()) + await flush() + + const buttons = screen.getAllByRole('button') + expect((buttons[buttons.length - 1] as HTMLButtonElement).disabled).toBe(false) + }) + + it('drops a stale blur result when the user edits the field during validation', async () => { + // Hold validate() pending so we can interleave a SET_URL between + // dispatch(VALIDATE_START) and the result. + let resolveValidate: (r: ValidationResult) => void = () => {} + mockValidate.mockImplementation( + () => + new Promise((resolve) => { + resolveValidate = resolve + }), + ) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://stale.local' } }) + fireEvent.blur(input) + // No flush yet — validate() is still pending. + + // User edits the field while validate is in flight. + fireEvent.change(input, { target: { value: 'http://fresh.local' } }) + + // Stale validate finally resolves with success for the OLD URL. + resolveValidate(okValidation()) + await flush() + + // Checkmark must NOT appear — the success was for stale text. + expect(input.parentElement?.querySelector('svg')).not.toBeInTheDocument() + }) + + it('reuses the blur-time validation when Continue submits the same URL', async () => { + const serverId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + mockValidate.mockResolvedValue(okValidation({ serverId })) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://localhost:8000' } }) + fireEvent.blur(input) + await flush() + + // Blur fires one validate call. + expect(mockValidate).toHaveBeenCalledTimes(1) + + // Continue against the SAME URL must not re-validate. + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[buttons.length - 1]) + await flush() + + expect(mockValidate).toHaveBeenCalledTimes(1) + expect(useTrustDomainRegistry.getState().activeTrustDomain).toEqual({ kind: 'server', serverId }) + }) + + it('shows inline error and returns to picker on validation failure', async () => { + mockValidate.mockResolvedValue(errorValidation("Couldn't reach this server")) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + fireEvent.change(screen.getByPlaceholderText('app.thunderbolt.io/'), { + target: { value: 'http://unreachable.local' }, + }) + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[buttons.length - 1]) + + await flush() + + expect(screen.getByText("Couldn't reach this server")).toBeInTheDocument() + expect(mockReload).not.toHaveBeenCalled() + // Picker heading should still be visible (not the connecting screen) + expect(screen.getByText('How would you like to use Thunderbolt?')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/boot/mode-picker.tsx b/src/components/boot/mode-picker.tsx new file mode 100644 index 000000000..3b0d77899 --- /dev/null +++ b/src/components/boot/mode-picker.tsx @@ -0,0 +1,320 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useReducer, useRef } from 'react' +import { ArrowRight, Bot, Check, Server } from 'lucide-react' +import { AppLogo } from '@/components/app-logo' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import type { AppConfig } from '@/api/config-store' +import { createClient, HttpError } from '@/lib/http' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' + +type Mode = 'standalone' | 'server' + +type State = { + selection: Mode | null + serverUrl: string + serverUrlError: string | null + isValidating: boolean + isUrlValidated: boolean + stage: 'picker' | 'connecting' +} + +type Action = + | { type: 'SELECT'; mode: Mode } + | { type: 'SET_URL'; url: string } + | { type: 'VALIDATE_START' } + | { type: 'VALIDATE_SUCCESS' } + | { type: 'VALIDATE_ERROR'; message: string } + | { type: 'CONNECT' } + +const initialState: State = { + selection: 'server', + serverUrl: '', + serverUrlError: null, + isValidating: false, + isUrlValidated: false, + stage: 'picker', +} + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'SELECT': + return { ...state, selection: action.mode, serverUrlError: null, isUrlValidated: false } + case 'SET_URL': + // Reset `isValidating` too: if a blur kicked off `validate()` and the + // user then edits the field, the in-flight result is stale and gets + // dropped without dispatching success/error — without this reset the + // flag would stick at true and the Continue button would never enable. + return { ...state, serverUrl: action.url, serverUrlError: null, isUrlValidated: false, isValidating: false } + case 'VALIDATE_START': + return { ...state, isValidating: true, serverUrlError: null } + case 'VALIDATE_SUCCESS': + return { ...state, isValidating: false, isUrlValidated: true } + case 'VALIDATE_ERROR': + return { ...state, isValidating: false, isUrlValidated: false, serverUrlError: action.message, stage: 'picker' } + case 'CONNECT': + return { ...state, stage: 'connecting' } + } +} + +// Normalize whatever the user typed into a bare base URL (no trailing slash, no /v1). +// Accepted forms: "app.thunderbolt.io", "app.thunderbolt.io/", "http://...", "https://.../v1" +const normalizeBaseUrl = (url: string): string => { + let s = url.trim().replace(/\/+$/, '') + if (s.endsWith('/v1')) { + s = s.slice(0, -3) + } + if (!s.startsWith('http://') && !s.startsWith('https://')) { + const isLocal = s.startsWith('localhost') || s.startsWith('127.0.0.1') + s = `${isLocal ? 'http' : 'https'}://${s}` + } + return s +} + +export type ValidationResult = { ok: true; serverId: string; cloudUrl: string } | { ok: false; message: string } + +export type ValidateServerUrlFn = (userUrl: string) => Promise + +export const validateServerUrl: ValidateServerUrlFn = async (userUrl) => { + const base = normalizeBaseUrl(userUrl) + const client = createClient({ prefixUrl: `${base}/v1` }) + try { + const config = await client.get('config', { timeout: 5_000 }).json() + if (!config?.serverId) { + return { ok: false, message: "This URL doesn't look like a Thunderbolt server" } + } + return { ok: true, serverId: config.serverId, cloudUrl: `${base}/v1` } + } catch (err) { + return err instanceof HttpError + ? { ok: false, message: "This URL doesn't look like a Thunderbolt server" } + : { ok: false, message: "Couldn't reach this server" } + } +} + +type ModePickerProps = { + /** + * Server URL validator. Optional override for tests — defaults to the real + * `validateServerUrl` which hits `GET /v1/config`. Tests pass a mock + * here instead of `mock.module('@/lib/http', ...)` so the global module + * stays intact for other test files. + */ + validate?: ValidateServerUrlFn +} + +export const ModePicker = ({ validate = validateServerUrl }: ModePickerProps = {}) => { + const [state, dispatch] = useReducer(reducer, initialState) + + // Mirror of the live `serverUrl` so async validate callbacks can detect that + // the user typed something else while the request was in flight, and bail + // out of applying a result that no longer matches. Updated on every render + // AND synchronously in the input's `onChange` — the render-time write alone + // races with a validate Promise that resolves before React commits the + // SET_URL re-render, since the closure's `urlAtCall` and the (stale) ref + // both still equal the previous value at that moment. + const serverUrlRef = useRef(state.serverUrl) + serverUrlRef.current = state.serverUrl + + // Cache of the last validate() resolution by URL. Reused when Continue + // submits the same URL the user just blurred, so we don't pay the + // /v1/config round-trip twice (and don't visually "re-validate" a URL the + // user already saw a checkmark on). + const lastValidationRef = useRef<{ url: string; result: ValidationResult } | null>(null) + + const isServerMode = state.selection === 'server' + // Dots: left = initial pick, right = server URL step + const activeDot = isServerMode ? 1 : 0 + + const canContinue = + state.selection !== null && !state.isValidating && (state.selection !== 'server' || state.serverUrl.trim() !== '') + + const handleSkip = () => { + useTrustDomainRegistry.getState().activateStandalone() + window.location.reload() + } + + const handleBlur = async () => { + if (!isServerMode || !state.serverUrl.trim()) { + return + } + const urlAtCall = state.serverUrl + // Skip if we already have a cached result for this exact URL — the second + // blur after Continue's pre-validation would otherwise re-fire the network. + if (lastValidationRef.current?.url === urlAtCall) { + const cached = lastValidationRef.current.result + dispatch(cached.ok ? { type: 'VALIDATE_SUCCESS' } : { type: 'VALIDATE_ERROR', message: cached.message }) + return + } + dispatch({ type: 'VALIDATE_START' }) + const result = await validate(urlAtCall) + // Drop the result if the input has changed since we started — applying it + // would either show a checkmark for the new text (when the old URL passed) + // or show an error for text the user already replaced. + if (urlAtCall !== serverUrlRef.current) { + return + } + lastValidationRef.current = { url: urlAtCall, result } + dispatch(result.ok ? { type: 'VALIDATE_SUCCESS' } : { type: 'VALIDATE_ERROR', message: result.message }) + } + + const handleContinue = async () => { + if (state.selection === 'standalone') { + useTrustDomainRegistry.getState().activateStandalone() + window.location.reload() + return + } + + if (state.selection === 'server') { + // Reuse the blur-time result when the URL hasn't changed since — avoids + // a second round-trip and the click-while-blur-pending double-tap UX. + const cached = lastValidationRef.current + if (cached && cached.url === state.serverUrl && cached.result.ok) { + dispatch({ type: 'CONNECT' }) + useTrustDomainRegistry + .getState() + .activateServer({ serverId: cached.result.serverId, cloudUrl: cached.result.cloudUrl }) + window.location.reload() + return + } + + dispatch({ type: 'CONNECT' }) + const urlAtCall = state.serverUrl + const result = await validate(urlAtCall) + if (urlAtCall !== serverUrlRef.current) { + // User edited the field during the network call — bail rather than + // surface a result for stale text. Reset the stage so we don't strand + // the user on the "Connecting to Server" screen with no recovery path. + dispatch({ type: 'VALIDATE_ERROR', message: '' }) + return + } + lastValidationRef.current = { url: urlAtCall, result } + if (!result.ok) { + dispatch({ type: 'VALIDATE_ERROR', message: result.message }) + return + } + useTrustDomainRegistry.getState().activateServer({ serverId: result.serverId, cloudUrl: result.cloudUrl }) + window.location.reload() + } + } + + if (state.stage === 'connecting') { + return ( +
+ + Connecting to Server +
+ ) + } + + return ( +
+ {/* Branding */} +
+ + Thunderbolt +
+ +
+

How would you like to use Thunderbolt?

+ + {/* Option cards */} +
+ + + +
+ + {/* Server URL input */} + {isServerMode && ( +
+ +
+ { + // Close the in-flight-validate race window — keep the ref + // in lockstep with user input so a Promise resolving before + // the SET_URL render commit can still detect the mismatch. + serverUrlRef.current = e.target.value + dispatch({ type: 'SET_URL', url: e.target.value }) + }} + onBlur={handleBlur} + state={state.serverUrlError ? 'error' : 'default'} + disabled={state.isValidating} + className={cn(state.isUrlValidated && 'pr-9')} + inputSize="lg" + /> + {state.isUrlValidated && ( + + )} +
+ {state.serverUrlError && ( +

{state.serverUrlError}

+ )} +
+ )} +
+ + {/* Footer: Skip — dots — Continue */} +
+ + +
+ {[0, 1].map((i) => ( +
+ ))} +
+ + +
+
+ ) +} diff --git a/src/components/chat/chat-model-picker.test.tsx b/src/components/chat/chat-model-picker.test.tsx index 3b90abd73..261b40e52 100644 --- a/src/components/chat/chat-model-picker.test.tsx +++ b/src/components/chat/chat-model-picker.test.tsx @@ -21,6 +21,13 @@ import { MemoryRouter } from 'react-router' import type { ReactNode } from 'react' import { ChatModelPicker } from './chat-model-picker' +const fakeUseWorkspacePermission = (isAllowed: boolean) => + (() => ({ + requiredRole: 'admin' as const, + isAllowed, + isResolved: true, + })) as unknown as typeof import('@/hooks/use-workspace-permission').useWorkspacePermission + const remoteAcpAgent: Agent = { id: 'remote-1', name: 'Remote Agent', @@ -131,6 +138,32 @@ describe('ChatModelPicker', () => { expect(container.firstChild).toBeNull() }) + it('renders the "Add Models" footer when the user has add_models permission', async () => { + setupWithAgent(builtInAgent) + + render(, { wrapper: TestWrapper }) + + await act(async () => { + fireEvent.click(screen.getByText('GPT-4')) + }) + + expect(await screen.findByText('Add Models')).toBeInTheDocument() + }) + + it('hides the "Add Models" footer when the user lacks add_models permission', async () => { + setupWithAgent(builtInAgent) + + render(, { wrapper: TestWrapper }) + + await act(async () => { + fireEvent.click(screen.getByText('GPT-4')) + }) + + // Wait for the dropdown to settle, then assert the footer is absent. + await screen.findByText('GPT-5') + expect(screen.queryByText('Add Models')).not.toBeInTheDocument() + }) + it('changes the selected model in the store on click', async () => { setupWithAgent(builtInAgent) diff --git a/src/components/chat/chat-model-picker.tsx b/src/components/chat/chat-model-picker.tsx index 786d3721d..c50e2bef2 100644 --- a/src/components/chat/chat-model-picker.tsx +++ b/src/components/chat/chat-model-picker.tsx @@ -5,7 +5,8 @@ import { useChatStore, useCurrentChatSession } from '@/chats/chat-store' import { ModelSelector } from '@/components/ui/model-selector' import { useIsMobile } from '@/hooks/use-mobile' -import { useNavigate } from 'react-router' +import { useWorkspacePermission as useWorkspacePermission_default } from '@/hooks/use-workspace-permission' +import { useWorkspaceNavigate } from '@/lib/active-workspace' /** * Model picker for the chat composer. Renders to the immediate right of the @@ -17,12 +18,23 @@ import { useNavigate } from 'react-router' * ModeSelector sitting beside it, and mirrors its open direction (down on * desktop, up on mobile) so the two dropdowns stay consistent. */ -export const ChatModelPicker = () => { +type ChatModelPickerProps = { + /** Test seam — defaults to the real hook. Pages exercise this via tests + * that inject a fake to assert the gated-affordance is hidden. */ + useWorkspacePermission?: typeof useWorkspacePermission_default +} + +export const ChatModelPicker = ({ + useWorkspacePermission = useWorkspacePermission_default, +}: ChatModelPickerProps = {}) => { const models = useChatStore((state) => state.models) const setSelectedModel = useChatStore((state) => state.setSelectedModel) - const navigate = useNavigate() + const navigate = useWorkspaceNavigate() const { isMobile } = useIsMobile() const { id: chatThreadId, selectedAgent, selectedModel, chatThread } = useCurrentChatSession() + // Suppress the "Add Models" footer when the user can't add — they'd land on + // a settings page where the affordance is also hidden. + const { isAllowed: canAddModels } = useWorkspacePermission('add_models') if (selectedAgent.type !== 'built-in' || models.length === 0) { return null @@ -39,7 +51,7 @@ export const ChatModelPicker = () => { selectedModel={selectedModel ?? null} chatThread={chatThread ?? null} onModelChange={handleModelChange} - onAddModels={() => navigate('/settings/models')} + onAddModels={canAddModels ? () => navigate('/settings/models') : undefined} side={isMobile ? 'top' : 'bottom'} align="start" /> diff --git a/src/components/chat/chat-skills-bar.test.tsx b/src/components/chat/chat-skills-bar.test.tsx index 27fc4dc98..d4602e318 100644 --- a/src/components/chat/chat-skills-bar.test.tsx +++ b/src/components/chat/chat-skills-bar.test.tsx @@ -20,6 +20,8 @@ const skill = (id: string, name: string): Skill => ({ deletedAt: null, defaultHash: null, userId: null, + workspaceId: null, + scope: null, }) const fakeUsePinnedSkills = (overrides?: { @@ -49,6 +51,13 @@ const fakeUseEnabledSkills = (enabledIds: ReadonlySet) => setEnabled: async () => undefined, })) as unknown as typeof import('@/skills/use-skills').useEnabledSkills +const fakeUseWorkspacePermission = (isAllowed: boolean) => + (() => ({ + requiredRole: 'admin' as const, + isAllowed, + isResolved: true, + })) as unknown as typeof import('@/hooks/use-workspace-permission').useWorkspacePermission + const renderBar = (props: Partial[0]> = {}) => { return render( @@ -59,6 +68,7 @@ const renderBar = (props: Partial[0]> = {}) => usePinnedSkills={props.usePinnedSkills ?? fakeUsePinnedSkills({ pinned: [] })} useLibrarySkills={props.useLibrarySkills ?? fakeUseLibrarySkills([])} useEnabledSkills={props.useEnabledSkills ?? fakeUseEnabledSkills(new Set())} + useWorkspacePermission={props.useWorkspacePermission ?? fakeUseWorkspacePermission(true)} /> , @@ -124,4 +134,42 @@ describe('ChatSkillsBar', () => { // The chip's click → onAddToChat path is exercised end-to-end at the // composer level; here we trust Radix's primitives. + + describe('permission gating (add_skills)', () => { + it('hides the "+ Pin a skill" trigger when the user lacks add_skills', () => { + const a = skill('a', 'daily-brief') + renderBar({ + usePinnedSkills: fakeUsePinnedSkills({ pinned: [] }), + useLibrarySkills: fakeUseLibrarySkills([a]), + useEnabledSkills: fakeUseEnabledSkills(new Set(['a'])), + useWorkspacePermission: fakeUseWorkspacePermission(false), + }) + + expect(screen.queryByLabelText('Pin a skill')).not.toBeInTheDocument() + }) + + it('renders nothing when the user lacks add_skills and has no pinned chips, regardless of candidates', () => { + const a = skill('a', 'daily-brief') + const { container } = renderBar({ + usePinnedSkills: fakeUsePinnedSkills({ pinned: [] }), + useLibrarySkills: fakeUseLibrarySkills([a]), + useEnabledSkills: fakeUseEnabledSkills(new Set(['a'])), + useWorkspacePermission: fakeUseWorkspacePermission(false), + }) + expect(container.firstChild).toBeNull() + }) + + it('still renders pinned chips when the user lacks add_skills (read-only chips)', () => { + const a = skill('a', 'daily-brief') + renderBar({ + usePinnedSkills: fakeUsePinnedSkills({ pinned: [a] }), + useLibrarySkills: fakeUseLibrarySkills([a]), + useEnabledSkills: fakeUseEnabledSkills(new Set(['a'])), + useWorkspacePermission: fakeUseWorkspacePermission(false), + }) + + expect(screen.getByText('/daily-brief')).toBeTruthy() + expect(screen.queryByLabelText('Pin a skill')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/chat/chat-skills-bar.tsx b/src/components/chat/chat-skills-bar.tsx index 544d54d1e..e7b7082d6 100644 --- a/src/components/chat/chat-skills-bar.tsx +++ b/src/components/chat/chat-skills-bar.tsx @@ -11,6 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { maxPinnedSkills } from '@/dal' import { useIsMobile } from '@/hooks/use-mobile' +import { useWorkspacePermission as useWorkspacePermission_default } from '@/hooks/use-workspace-permission' import { ReorderPanel } from '@/skills/reorder-panel' import { SuggestionChip } from '@/skills/suggestion-chip' import { useSkillTelemetry } from '@/skills/telemetry' @@ -35,6 +36,7 @@ type ChatSkillsBarProps = { usePinnedSkills?: typeof usePinnedSkills_default useLibrarySkills?: typeof useLibrarySkills_default useEnabledSkills?: typeof useEnabledSkills_default + useWorkspacePermission?: typeof useWorkspacePermission_default } /** @@ -54,12 +56,17 @@ export const ChatSkillsBar = ({ usePinnedSkills = usePinnedSkills_default, useLibrarySkills = useLibrarySkills_default, useEnabledSkills = useEnabledSkills_default, + useWorkspacePermission = useWorkspacePermission_default, }: ChatSkillsBarProps) => { const { pinned, pinnedSet, reorderPins, togglePin } = usePinnedSkills() const { skills: library } = useLibrarySkills() const { isEnabled } = useEnabledSkills() const { isMobile } = useIsMobile() const trackSkillEvent = useSkillTelemetry() + // Pin / unpin / reorder all PATCH the `skills` row — the BE gates them + // on `add_skills`. Hide the affordances when the user can't satisfy that + // permission so we don't surface actions that round-trip-fail. + const { isAllowed: canEditSkills } = useWorkspacePermission('add_skills') const [openChipId, setOpenChipId] = useState(null) const [reorderMode, setReorderMode] = useState(false) @@ -115,10 +122,12 @@ export const ChatSkillsBar = ({ ? 'No more skills to pin' : 'Pin a skill' - // Hide the whole bar only when there's nothing to display *and* nothing - // to add. If the user has zero pins but unpinned skills exist, we still - // show the `+` button so they can pin one. - if (pinned.length === 0 && pinnable.length === 0) { + // Hide the whole bar when there's nothing to display *and* nothing the user + // can act on. Zero pins + unpinned candidates still warrants the `+` button + // — but only when the user can actually pin (`canEditSkills`); otherwise + // both the chips row and the trigger are empty and the strip would render + // as a thin blank line above the composer. + if (pinned.length === 0 && (pinnable.length === 0 || !canEditSkills)) { return null } @@ -131,6 +140,7 @@ export const ChatSkillsBar = ({ key={skill.id} label={skill.name} dimmed={openChipId !== null && openChipId !== skill.id} + canEdit={canEditSkills} onClick={() => onAddToChat(skill.name)} onOpenChange={(open) => setOpenChipId(open ? skill.id : null)} onAddInstruction={() => onAddInstruction(skill.instruction)} @@ -147,26 +157,27 @@ export const ChatSkillsBar = ({ }} /> ))} - - - - - - - - {addTooltip} - - {/* + {canEditSkills && ( + + + + + + + + {addTooltip} + + {/* `collisionPadding={16}` keeps the popover 16px off the viewport edges. On mobile the content is sized to `calc(100vw-2rem)` (32px narrower than the viewport), so collision avoidance pins it to a @@ -175,49 +186,52 @@ export const ChatSkillsBar = ({ leaves room, so the padding never shifts the `align="start"` anchor off the `+` button. */} - -
    - {pinnable.map((skill) => ( -
  • - -
  • - ))} -
-
-
+ {skill.description && ( + + {skill.description} + + )} + + + ))} + + +
+ )}
) diff --git a/src/components/logout-modal.test.tsx b/src/components/logout-modal.test.tsx index 6019d002a..4c793670e 100644 --- a/src/components/logout-modal.test.tsx +++ b/src/components/logout-modal.test.tsx @@ -13,16 +13,20 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' import { LogoutModal } from './logout-modal' -const mockClearLocalData = mock(() => Promise.resolve()) - -const env = import.meta.env as Record +const mockSignOutAndWipe = mock(async ({ onComplete }: { signOut?: () => Promise; onComplete: () => void }) => { + onComplete() +}) -// Mock window.location -const mockReload = mock() const mockReplace = mock() +const mockReload = mock() +// `reload` must be on the top-level stub so the consumer-mode signOut path +// (which calls `window.location.reload()` from `onComplete`) doesn't blow up +// in tests that don't install their own reload mock. Earlier the consumer-mode +// test below added it ad-hoc, which made other tests order-dependent. Object.defineProperty(window, 'location', { - value: { reload: mockReload, replace: mockReplace }, + value: { replace: mockReplace, reload: mockReload }, writable: true, + configurable: true, }) describe('LogoutModal', () => { @@ -44,14 +48,13 @@ describe('LogoutModal', () => { await resetTestDatabase() mockOnOpenChange = mock() mockSignOut = mock(() => Promise.resolve()) - mockClearLocalData.mockClear() - mockReload.mockClear() + mockSignOutAndWipe.mockClear() mockReplace.mockClear() + mockReload.mockClear() }) afterEach(() => { mockOnOpenChange.mockClear() - delete env.VITE_AUTH_MODE }) const renderModal = (props: Partial<{ open: boolean; onOpenChange: (open: boolean) => void }> = {}) => { @@ -59,7 +62,7 @@ describe('LogoutModal', () => { signOut: mockSignOut, }) return render( - , + , { wrapper: createTestProvider({ authClient }), }, @@ -67,10 +70,12 @@ describe('LogoutModal', () => { } describe('rendering', () => { - it('renders when open', () => { + it('renders title and wipe-warning description when open', () => { renderModal({ open: true }) - // Check for the dialog title specifically expect(screen.getByRole('heading', { name: 'Log out' })).toBeInTheDocument() + expect( + screen.getByText('Signing out will remove all chats, settings, and cached data from this device.'), + ).toBeInTheDocument() }) it('does not render content when closed', () => { @@ -78,305 +83,79 @@ describe('LogoutModal', () => { expect(screen.queryByText('Log out')).not.toBeInTheDocument() }) - it('displays description text', () => { - renderModal() - expect(screen.getByText('What would you like to do with your local data?')).toBeInTheDocument() - }) - - it('displays both data options', () => { - renderModal() - expect(screen.getByText('Leave data on device')).toBeInTheDocument() - expect(screen.getByText('Delete data from device')).toBeInTheDocument() - }) - it('displays cancel and logout buttons', () => { renderModal() expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Log out' })).toBeInTheDocument() }) - }) - - describe('option selection', () => { - it('has "keep" option selected by default', () => { - renderModal() - const keepOption = screen.getByText('Leave data on device').closest('button') - // Check the radio indicator is styled as selected (has the inner dot) - expect(keepOption?.querySelector('.bg-primary')).toBeInTheDocument() - }) - - it('selects "delete" option when clicked', () => { - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - - fireEvent.click(deleteOption) - - // Check the delete option is now selected - expect(deleteOption.querySelector('.bg-destructive')).toBeInTheDocument() - }) - - it('allows switching between options', () => { - renderModal() - const keepOption = screen.getByText('Leave data on device').closest('button')! - const deleteOption = screen.getByText('Delete data from device').closest('button')! - - // Select delete - fireEvent.click(deleteOption) - expect(deleteOption.querySelector('.bg-destructive')).toBeInTheDocument() - - // Switch back to keep - fireEvent.click(keepOption) - expect(keepOption.querySelector('.bg-primary')).toBeInTheDocument() - }) - }) - - describe('logout flow with keep data', () => { - it('calls signOut and reloads when logging out with keep option', async () => { - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockSignOut).toHaveBeenCalled() - expect(mockClearLocalData).toHaveBeenCalledWith({ clearDatabase: false }) - expect(mockReload).toHaveBeenCalled() - }) - - it('shows loading state during logout', async () => { - let resolveSignOut: (value?: unknown) => void - mockSignOut.mockReturnValue( - new Promise((resolve) => { - resolveSignOut = resolve - }), - ) - - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().tickAsync(0) - }) - - expect(screen.getByText('Logging out...')).toBeInTheDocument() - - // Clean up - resolveSignOut!() - await act(async () => { - await getClock().runAllAsync() - }) - }) - }) - - describe('logout flow with delete data', () => { - it('calls signOut, clearLocalData with clearDatabase, and reloads when deleting data', async () => { - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(deleteOption) - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockSignOut).toHaveBeenCalled() - expect(mockClearLocalData).toHaveBeenCalledWith({ clearDatabase: true }) - expect(mockReload).toHaveBeenCalled() - }) - - it('shows delete-specific loading text', async () => { - let resolveSignOut: (value?: unknown) => void - mockSignOut.mockReturnValue( - new Promise((resolve) => { - resolveSignOut = resolve - }), - ) - - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(deleteOption) - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().tickAsync(0) - }) - - expect(screen.getByText('Deleting...')).toBeInTheDocument() - - // Clean up - resolveSignOut!() - await act(async () => { - await getClock().runAllAsync() - }) - }) - it('uses destructive button variant when delete is selected', () => { + it('does not offer a "keep my data" affordance', () => { renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(deleteOption) - - // Check for destructive variant class - expect(logoutButton.className).toContain('destructive') - }) - }) - - describe('error handling', () => { - it('continues to reload even if signOut fails', async () => { - mockSignOut.mockRejectedValue(new Error('Network error')) - - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockReload).toHaveBeenCalled() + expect(screen.queryByText(/keep data/i)).not.toBeInTheDocument() + expect(screen.queryByText(/leave data/i)).not.toBeInTheDocument() }) - it('continues to reload even if clearLocalData fails', async () => { - mockClearLocalData.mockRejectedValueOnce(new Error('Cleanup error')) - + it('styles the logout button as destructive', () => { renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(deleteOption) - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockReload).toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Log out' }).className).toContain('destructive') }) }) - describe('SSO mode logout', () => { - beforeEach(() => { + describe('logout flow', () => { + it('passes the Better Auth signOut callback and an SSO-aware onComplete to signOutAndWipe', async () => { + const env = import.meta.env as Record env.VITE_AUTH_MODE = 'sso' - }) - - it('navigates to /signed-out instead of reloading in SSO mode', async () => { - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) + try { + renderModal() + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) - await act(async () => { - await getClock().runAllAsync() - }) + await act(async () => { + await getClock().runAllAsync() + }) - expect(mockReplace).toHaveBeenCalledWith('/signed-out') - expect(mockReload).not.toHaveBeenCalled() + expect(mockSignOutAndWipe).toHaveBeenCalledTimes(1) + const arg = mockSignOutAndWipe.mock.calls[0][0] + expect(typeof arg.signOut).toBe('function') + await arg.signOut?.() + expect(mockSignOut).toHaveBeenCalled() + // SSO mode → onComplete lands on /signed-out via replace(). + expect(mockReplace).toHaveBeenCalledWith('/signed-out') + } finally { + delete env.VITE_AUTH_MODE + } }) - it('navigates to /signed-out even if signOut fails', async () => { - mockSignOut.mockRejectedValue(new Error('Network error')) - + it('reloads instead of redirecting in consumer mode', async () => { + // Default test env has no VITE_AUTH_MODE set → isSsoMode() === false. + // `mockReload` is installed at module top-level so this test inherits it. renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) await act(async () => { await getClock().runAllAsync() }) - expect(mockReplace).toHaveBeenCalledWith('/signed-out') - expect(mockReload).not.toHaveBeenCalled() + expect(mockReload).toHaveBeenCalled() + expect(mockReplace).not.toHaveBeenCalled() }) }) - describe('modal behavior during logout', () => { - it('prevents closing while logging out', async () => { - let resolveSignOut: (value?: unknown) => void - mockSignOut.mockReturnValue( - new Promise((resolve) => { - resolveSignOut = resolve - }), - ) - - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().tickAsync(0) - }) - - // Try to cancel - should be disabled - const cancelButton = screen.getByRole('button', { name: 'Cancel' }) - expect(cancelButton).toBeDisabled() - - // Clean up - resolveSignOut!() - await act(async () => { - await getClock().runAllAsync() - }) - }) - - it('disables logout button while logging out', async () => { - let resolveSignOut: (value?: unknown) => void - mockSignOut.mockReturnValue( - new Promise((resolve) => { - resolveSignOut = resolve - }), - ) - + describe('double-click guard', () => { + it('only fires signOutAndWipe once when Log out is clicked rapidly', () => { renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().tickAsync(0) - }) - - // The button text changes and becomes disabled - expect(screen.getByRole('button', { name: /Logging out|Deleting/i })).toBeDisabled() - - // Clean up - resolveSignOut!() - await act(async () => { - await getClock().runAllAsync() - }) + const button = screen.getByRole('button', { name: 'Log out' }) + fireEvent.click(button) + fireEvent.click(button) + fireEvent.click(button) + expect(mockSignOutAndWipe).toHaveBeenCalledTimes(1) }) }) describe('cancel behavior', () => { - it('calls onOpenChange(false) when cancel is clicked', () => { + it('closes the dialog when cancel is clicked', () => { renderModal() - const cancelButton = screen.getByRole('button', { name: 'Cancel' }) - - fireEvent.click(cancelButton) - + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(mockOnOpenChange).toHaveBeenCalledWith(false) }) - - it('resets option selection when modal is closed', () => { - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - - // Select delete option - fireEvent.click(deleteOption) - - // Close and reopen - selection should reset via onOpenChange handler - // The component resets selectedOption to 'keep' when newOpen is false - expect(mockOnOpenChange).not.toHaveBeenCalled() - }) }) }) diff --git a/src/components/logout-modal.tsx b/src/components/logout-modal.tsx index d110974c3..467b21110 100644 --- a/src/components/logout-modal.tsx +++ b/src/components/logout-modal.tsx @@ -2,116 +2,98 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { HardDrive, Loader2, Trash2 } from 'lucide-react' -import { useState } from 'react' - -import { Button } from '@/components/ui/button' +import { useRef, useState } from 'react' import { - ResponsiveModal, - ResponsiveModalContent, - ResponsiveModalDescription, - ResponsiveModalFooter, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from '@/components/ui/responsive-modal' -import { SelectableCard, type DataOption } from '@/components/ui/selectable-card' + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { useAuth } from '@/contexts' import { isSsoMode } from '@/lib/auth-mode' -import { clearLocalData as defaultClearLocalData } from '@/lib/cleanup' +import { signOutAndWipe as defaultSignOutAndWipe } from '@/lib/cleanup' type LogoutModalProps = { open: boolean onOpenChange: (open: boolean) => void /** - * Injectable for tests. Defaults to the real {@link clearLocalData} — the - * shipped UI never overrides this. Lets the test pass a stub instead of + * Injectable for tests. Defaults to the real {@link signOutAndWipe} — the shipped + * UI never overrides this. Lets the test pass a stub instead of * `mock.module('@/lib/cleanup', ...)`, which would leak across files (see * `docs/development/testing.md` §65). */ - clearLocalData?: typeof defaultClearLocalData + signOutAndWipe?: typeof defaultSignOutAndWipe } -export const LogoutModal = ({ open, onOpenChange, clearLocalData = defaultClearLocalData }: LogoutModalProps) => { +export const LogoutModal = ({ open, onOpenChange, signOutAndWipe = defaultSignOutAndWipe }: LogoutModalProps) => { const authClient = useAuth() - const [selectedOption, setSelectedOption] = useState('keep') const [isLoggingOut, setIsLoggingOut] = useState(false) + // Ref mirrors the state for synchronous reads: Radix fires onOpenToggle in the same + // click-handler tick as our onClick, before React commits the setState above. + const isLoggingOutRef = useRef(false) - const handleLogout = async () => { - setIsLoggingOut(true) - - try { - await authClient.signOut() - } catch (error) { - console.error('Failed to sign out:', error) - } - - try { - await clearLocalData({ clearDatabase: selectedOption === 'delete' }) - } catch (error) { - console.error('Failed to clear local data:', error) - } - - if (isSsoMode()) { - window.location.replace('/signed-out') - } else { - window.location.reload() + // Per addendum decision #16 (THU-549 §5): signing out always wipes the active trust + // domain's local data. The previous "keep my data" affordance was incompatible with + // per-trust-domain SQLite files — leftover data without a matching auth token has no + // path back to the user. + const handleLogout = () => { + // Ref-guard at entry: the button's `disabled={isLoggingOut}` only applies + // after React commits the setState below. A rapid double-click (or a + // queued click event firing in the same tick as the first) would + // otherwise launch a second signOutAndWipe concurrent with the first. + if (isLoggingOutRef.current) { + return } + isLoggingOutRef.current = true + setIsLoggingOut(true) + // SSO lands on `/signed-out` because IdP-bounce-back would silently re-auth the + // user on reload; consumer mode reloads so the user re-enters the normal unauth + // landing (sign-in or waitlist). + void signOutAndWipe({ + signOut: async () => { + await authClient.signOut() + }, + onComplete: () => { + if (isSsoMode()) { + window.location.replace('/signed-out') + } else { + window.location.reload() + } + }, + }) } const handleOpenChange = (newOpen: boolean) => { - if (isLoggingOut) { + if (isLoggingOutRef.current) { return } - if (!newOpen) { - setSelectedOption('keep') - } onOpenChange(newOpen) } return ( - - - Log out - What would you like to do with your local data? - - - - setSelectedOption('keep')} - icon={} - title="Leave data on device" - description="Your chats and settings will remain on this device for next time." - /> - setSelectedOption('delete')} - icon={} - title="Delete data from device" - description="Remove all chats, settings, and cached data from this device." - variant="destructive" - /> - - - - - - - + + + + Log out + + Signing out will remove all chats, settings, and cached data from this device. + + + + Cancel + + {isLoggingOut ? 'Logging out…' : 'Log out'} + + + + ) } diff --git a/src/components/magic-link-verify.tsx b/src/components/magic-link-verify.tsx index 3a8721737..7aa490800 100644 --- a/src/components/magic-link-verify.tsx +++ b/src/components/magic-link-verify.tsx @@ -12,6 +12,7 @@ import { challengeTokenHeader } from '@/lib/constants' import { useAuth } from '@/contexts' import { getOtpErrorMessage } from '@/lib/otp-error-messages' import { useSettings } from '@/hooks/use-settings' +import { runPostAuthBootstrap } from '@/lib/post-auth-bootstrap' type VerifyState = { status: 'verifying' } | { status: 'success' } | { status: 'error'; message: string } @@ -72,6 +73,22 @@ export const MagicLinkVerify = () => { // This ensures the sidebar and other components see the new session immediately await refetchSession() + // Post-auth pipeline: connect sync, resolve personal workspace, reconcile. + // Idempotent + deduped vs. the AuthProvider observer. + if (result.data?.user?.id) { + try { + await runPostAuthBootstrap({ + kind: 'server', + userId: result.data.user.id, + isAnonymous: result.data.user.isAnonymous === true, + }) + } catch (bootstrapError) { + console.error('Post-auth bootstrap failed:', bootstrapError) + setState({ status: 'error', message: 'Could not sync your account. Please retry.' }) + return + } + } + setState({ status: 'success' }) } catch { setState({ status: 'error', message: 'Something went wrong. Please try again.' }) diff --git a/src/components/pending-device-modal.test.tsx b/src/components/pending-device-modal.test.tsx index 096734cd3..7c32882df 100644 --- a/src/components/pending-device-modal.test.tsx +++ b/src/components/pending-device-modal.test.tsx @@ -13,14 +13,15 @@ import { getClock } from '@/testing-library' import '@testing-library/jest-dom' import { act, cleanup, fireEvent, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' +import { testServerId } from '@/testing-library' import { v7 as uuidv7 } from 'uuid' import type { ReactNode } from 'react' const currentDeviceId = uuidv7() const pendingDeviceId1 = uuidv7() -const deviceIdKey = 'thunderbolt_device_id' -const authTokenKey = 'thunderbolt_auth_token' +const deviceIdKey = `thunderbolt_device_id__${testServerId}` +const authTokenKey = `thunderbolt_auth_token__${testServerId}` const sessionStorageKey = 'pending_device_dismissed_ids' // Defend against bleed from other test files that fully mock '@/db/powersync/sync-state'. diff --git a/src/components/revoked-device-modal.test.tsx b/src/components/revoked-device-modal.test.tsx index 785d5d038..d9e111bca 100644 --- a/src/components/revoked-device-modal.test.tsx +++ b/src/components/revoked-device-modal.test.tsx @@ -10,15 +10,15 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' import { RevokedDeviceModal } from './revoked-device-modal' -const mockClearLocalData = mock(() => Promise.resolve()) -mock.module('@/lib/cleanup', () => ({ - clearLocalData: mockClearLocalData, -})) +const mockSignOutAndWipe = mock(async ({ onComplete }: { signOut?: () => Promise; onComplete: () => void }) => { + onComplete() +}) const mockReplace = mock() Object.defineProperty(window, 'location', { value: { replace: mockReplace }, writable: true, + configurable: true, }) describe('RevokedDeviceModal', () => { @@ -32,21 +32,23 @@ describe('RevokedDeviceModal', () => { beforeEach(async () => { await resetTestDatabase() - mockClearLocalData.mockClear() + mockSignOutAndWipe.mockClear() mockReplace.mockClear() }) const renderModal = (props: Partial<{ open: boolean }> = {}) => - render(, { + render(, { wrapper: createTestProvider(), }) describe('rendering', () => { - it('renders when open', () => { + it('renders title and wipe-warning description when open', () => { renderModal({ open: true }) expect(screen.getByRole('heading', { name: 'Device access revoked' })).toBeInTheDocument() expect( - screen.getByText('This device has been signed out remotely. Choose what to do with your local data.'), + screen.getByText( + 'This device has been signed out remotely. Your local chats, settings, and cached data will be removed from this device.', + ), ).toBeInTheDocument() }) @@ -55,52 +57,26 @@ describe('RevokedDeviceModal', () => { expect(screen.queryByRole('heading', { name: 'Device access revoked' })).not.toBeInTheDocument() }) - it('displays both data options', () => { + it('displays a single destructive confirm button', () => { renderModal() - expect(screen.getByText('Keep data on device')).toBeInTheDocument() - expect(screen.getByText('Delete data from device')).toBeInTheDocument() + const button = screen.getByRole('button', { name: 'Confirm' }) + expect(button).toBeInTheDocument() + expect(button.className).toContain('destructive') }) - it('displays confirm button', () => { + it('does not offer a "keep my data" affordance', () => { renderModal() - expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument() + expect(screen.queryByText(/keep data/i)).not.toBeInTheDocument() }) - it('does not show close button', () => { + it('has no cancel button (revocation is non-optional)', () => { renderModal() - expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() - }) - }) - - describe('option selection', () => { - it('has "keep" option selected by default', () => { - renderModal() - const keepOption = screen.getByText('Keep data on device').closest('button') - expect(keepOption?.querySelector('.bg-primary')).toBeInTheDocument() - }) - - it('selects "delete" option when clicked', () => { - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - fireEvent.click(deleteOption) - expect(deleteOption.querySelector('.bg-destructive')).toBeInTheDocument() - }) - - it('allows switching between options', () => { - renderModal() - const keepOption = screen.getByText('Keep data on device').closest('button')! - const deleteOption = screen.getByText('Delete data from device').closest('button')! - - fireEvent.click(deleteOption) - expect(deleteOption.querySelector('.bg-destructive')).toBeInTheDocument() - - fireEvent.click(keepOption) - expect(keepOption.querySelector('.bg-primary')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() }) }) - describe('confirm flow with keep data', () => { - it('calls window.location.replace("/") when confirming with keep option', async () => { + describe('confirm flow', () => { + it('invokes signOutAndWipe with no signOut and an onComplete that replaces to /', async () => { renderModal() fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) @@ -108,61 +84,12 @@ describe('RevokedDeviceModal', () => { await getClock().runAllAsync() }) - expect(mockClearLocalData).toHaveBeenCalledWith({ clearDatabase: false }) + expect(mockSignOutAndWipe).toHaveBeenCalledTimes(1) + const arg = mockSignOutAndWipe.mock.calls[0][0] + expect(arg.signOut).toBeUndefined() + expect(typeof arg.onComplete).toBe('function') + // The mock invokes onComplete itself; assert the side-effect that landed. expect(mockReplace).toHaveBeenCalledWith('/') }) - - it('shows loading state during confirm', async () => { - renderModal() - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) - - await act(async () => { - await getClock().tickAsync(0) - }) - - expect(screen.getByText('Signing out...')).toBeInTheDocument() - - await act(async () => { - await getClock().runAllAsync() - }) - }) - }) - - describe('confirm flow with delete data', () => { - it('calls clearLocalData with clearDatabase and window.location.replace when confirming with delete option', async () => { - renderModal() - fireEvent.click(screen.getByText('Delete data from device').closest('button')!) - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockClearLocalData).toHaveBeenCalledWith({ clearDatabase: true }) - expect(mockReplace).toHaveBeenCalledWith('/') - }) - - it('shows delete-specific loading text', async () => { - renderModal() - fireEvent.click(screen.getByText('Delete data from device').closest('button')!) - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) - - await act(async () => { - await getClock().tickAsync(0) - }) - - expect(screen.getByText('Deleting...')).toBeInTheDocument() - - await act(async () => { - await getClock().runAllAsync() - }) - }) - - it('uses destructive button variant when delete is selected', () => { - renderModal() - fireEvent.click(screen.getByText('Delete data from device').closest('button')!) - const confirmButton = screen.getByRole('button', { name: 'Confirm' }) - expect(confirmButton.className).toContain('destructive') - }) }) }) diff --git a/src/components/revoked-device-modal.tsx b/src/components/revoked-device-modal.tsx index d5d89ebec..6cae22c52 100644 --- a/src/components/revoked-device-modal.tsx +++ b/src/components/revoked-device-modal.tsx @@ -2,84 +2,63 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { HardDrive, Loader2, Trash2 } from 'lucide-react' -import { useState } from 'react' - -import { Button } from '@/components/ui/button' +import { useRef } from 'react' import { - ResponsiveModal, - ResponsiveModalContent, - ResponsiveModalDescription, - ResponsiveModalFooter, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from '@/components/ui/responsive-modal' -import { SelectableCard, type DataOption } from '@/components/ui/selectable-card' -import { clearLocalData } from '@/lib/cleanup' + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { signOutAndWipe as defaultSignOutAndWipe } from '@/lib/cleanup' type RevokedDeviceModalProps = { open: boolean + /** + * Injectable for tests (mirrors the LogoutModal pattern). Avoids a + * `mock.module('@/lib/cleanup', ...)` here that would leak across files + * (see `docs/development/testing.md` §65). The shipped UI never overrides. + */ + signOutAndWipe?: typeof defaultSignOutAndWipe } -export const RevokedDeviceModal = ({ open }: RevokedDeviceModalProps) => { - const [selectedOption, setSelectedOption] = useState('keep') - const [isProcessing, setIsProcessing] = useState(false) +export const RevokedDeviceModal = ({ open, signOutAndWipe = defaultSignOutAndWipe }: RevokedDeviceModalProps) => { + const wipingRef = useRef(false) - const handleConfirm = async () => { - setIsProcessing(true) - await clearLocalData({ clearDatabase: selectedOption === 'delete' }) - window.location.replace('/') + // Server kicked this device — the active server's auth token is gone, encryption + // keys are useless without it, and the per-trust-domain DB file has no path back + // to the user. Per the THU-549 wipe model (addendum decision #16), revocation + // takes the same path as voluntary logout (minus the Better Auth signOut call — + // the server already invalidated the session). The user has no opt-out — there's + // no Cancel button: confirm is the only path. + const handleConfirm = () => { + if (wipingRef.current) { + return + } + wipingRef.current = true + void signOutAndWipe({ + onComplete: () => window.location.replace('/'), + }) } return ( - {}} - showCloseButton={false} - onInteractOutside={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > - - Device access revoked - - This device has been signed out remotely. Choose what to do with your local data. - - - - - setSelectedOption('keep')} - icon={} - title="Keep data on device" - description="Your chats and settings will remain on this device for offline use." - /> - setSelectedOption('delete')} - icon={} - title="Delete data from device" - description="Remove all chats, settings, and cached data from this device." - variant="destructive" - /> - - - - - - + + e.preventDefault()}> + + Device access revoked + + This device has been signed out remotely. Your local chats, settings, and cached data will be removed from + this device. + + + + + Confirm + + + + ) } diff --git a/src/components/scope-badge.tsx b/src/components/scope-badge.tsx new file mode 100644 index 000000000..2b7555d9e --- /dev/null +++ b/src/components/scope-badge.tsx @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Lock, Users } from 'lucide-react' + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import type { ResourceScope } from '@/components/scope-picker' + +export type ScopeBadgeProps = { + /** Row's `scope` value. `'workspace'` or `null`/`undefined` renders the + * "Shared" variant (the default); `'user'` renders "Private". */ + scope: ResourceScope | null | undefined + /** Gate the badge on the caller's visibility decision — typically + * `useScopePickerEnabled()` resolved once at the page level and threaded + * down. When `false` the component renders nothing. Required so this + * component stays purely visual (no hook → no provider dependency in + * component-level tests). */ + show: boolean + /** Extra classes appended to the badge ``. Use for margins / sizing + * tweaks per consumer; the base look stays consistent. */ + className?: string +} + +/** + * Inline pill that surfaces a workspace-resource's `scope` (`workspace` vs + * `user`). Hides itself when `show` is false — the distinction is meaningless + * in a personal workspace or when the deployment flag is off, so callers gate + * it on `useScopePickerEnabled()`. + * + * Visual: muted bg, xs text, icon + label. Matches the models page badge (the + * original implementation site) so every resource list looks the same. + */ +export const ScopeBadge = ({ scope, show, className }: ScopeBadgeProps) => { + if (!show) { + return null + } + const isPrivate = scope === 'user' + const Icon = isPrivate ? Lock : Users + const label = isPrivate ? 'Private' : 'Shared' + const tooltip = isPrivate ? 'Only visible to you in this workspace.' : 'Shared with everyone in this workspace.' + return ( + + + + + + {label} + + + +

{tooltip}

+
+
+
+ ) +} diff --git a/src/components/scope-picker.test.tsx b/src/components/scope-picker.test.tsx new file mode 100644 index 000000000..42b7085b2 --- /dev/null +++ b/src/components/scope-picker.test.tsx @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it, mock } from 'bun:test' +import { fireEvent, render, screen, cleanup } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { ScopePicker } from './scope-picker' + +describe('ScopePicker', () => { + it('shows the workspace hint when value=workspace', () => { + render( {}} />) + expect(screen.getByText(/shared with everyone/i)).toBeInTheDocument() + }) + + it('shows the private hint when value=user', () => { + render( {}} />) + expect(screen.getByText(/only you can see/i)).toBeInTheDocument() + }) + + it('calls onChange with the next scope when the user picks the other option', () => { + const onChange = mock(() => {}) + render() + fireEvent.click(screen.getByRole('radio', { name: /private/i })) + expect(onChange).toHaveBeenCalledWith('user') + }) + + it('ignores the deselect that Radix emits when clicking the already-active item', () => { + const onChange = mock(() => {}) + render() + fireEvent.click(screen.getByRole('radio', { name: /shared with the workspace/i })) + expect(onChange).not.toHaveBeenCalled() + cleanup() + }) + + it('disables both options when disabled', () => { + render( {}} disabled />) + expect(screen.getByRole('radio', { name: /shared with the workspace/i })).toBeDisabled() + expect(screen.getByRole('radio', { name: /private to you/i })).toBeDisabled() + }) + + it('readOnly silences clicks without dimming the selected state', () => { + const onChange = mock(() => {}) + render() + // The picker still reflects the value (private hint visible)… + expect(screen.getByText(/only you can see/i)).toBeInTheDocument() + // …but clicking the other option doesn't fire onChange (pointer-events: none). + fireEvent.click(screen.getByRole('radio', { name: /shared with the workspace/i })) + expect(onChange).not.toHaveBeenCalled() + // And the items aren't marked disabled (would dim them). + expect(screen.getByRole('radio', { name: /shared with the workspace/i })).not.toBeDisabled() + }) +}) diff --git a/src/components/scope-picker.tsx b/src/components/scope-picker.tsx new file mode 100644 index 000000000..bde1325b4 --- /dev/null +++ b/src/components/scope-picker.tsx @@ -0,0 +1,115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Lock, Users } from 'lucide-react' + +import { Label } from '@/components/ui/label' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' + +export type ResourceScope = 'workspace' | 'user' + +export type ScopePickerProps = { + /** Currently-selected scope. */ + value: ResourceScope + /** Fires with the next scope on user interaction. */ + onChange: (next: ResourceScope) => void + /** + * Optional id used to associate the visible label with the toggle group for + * screen readers. The toggle group itself can't accept `id` because Radix + * forwards it to an inner element, so we anchor the htmlFor on the label + * and rely on `aria-describedby` for the hint text. + */ + id?: string + /** Optional override label. Defaults to "Visibility". */ + label?: string + /** Disables the picker — used when the form is mid-submit. */ + disabled?: boolean + /** + * Render the picker as a non-interactive display of the current value (e.g. + * the skill detail page). Distinct from `disabled` — `readOnly` keeps the + * normal selected-state styling (no opacity dimming) and silences clicks, + * which is the right look for "this is what's set" rather than "you can't + * change this right now." + */ + readOnly?: boolean +} + +/** + * Per-row visibility picker for the 8 workspace-shared resource tables + * (THU-603). Two states: + * + * - `workspace` — shared with every workspace member (the historical default). + * - `user` — private to the row's author within the workspace; other members + * never see the row. + * + * Callers are responsible for gating mount on `selectAllowUserScopedResources` + * (deployment flag) and on the active workspace being shared (the choice is + * meaningless in a personal workspace where the only member IS the user). + */ +export const ScopePicker = ({ value, onChange, id, label = 'Visibility', disabled, readOnly }: ScopePickerProps) => { + const hintId = id ? `${id}-hint` : undefined + const hint = value === 'user' ? 'Only you can see this in the workspace.' : 'Shared with everyone in the workspace.' + + return ( +
+ + { + if (readOnly) { + return + } + // Radix emits an empty string when the user clicks the already-active + // item; the picker is a required choice, so ignore the deselect. + if (next === 'workspace' || next === 'user') { + onChange(next) + } + }} + aria-describedby={hintId} + aria-readonly={readOnly || undefined} + disabled={disabled} + // `rounded-lg` overrides the toggle-group default `rounded-md` to match + // the surrounding form chrome (Inputs / Selects in the model + skill + // editors all use `rounded-lg`); without this the picker looks visibly + // sharper than the controls above and below it. + // `pointer-events-none` blocks the click without applying the + // disabled-state opacity dim — read-only should look "informational", + // not "unavailable." + className={`rounded-lg ${readOnly ? 'pointer-events-none' : ''}`.trim()} + > + {/* Items override the group's default `flex-1` so each option sizes to + its content with comfortable horizontal padding — keeps "Workspace" + from looking cramped against the icon. The first/last `rounded-l-lg` + / `rounded-r-lg` overrides match the parent group's larger radius + (the default item rounding is `first:rounded-l-md last:rounded-r-md`, + which leaves a visible step against the group's `rounded-lg`). */} + + + Workspace + + + + Private + + +

+ {hint} +

+
+ ) +} diff --git a/src/components/settings/agents/add-custom-agent-dialog.test.tsx b/src/components/settings/agents/add-custom-agent-dialog.test.tsx index 2fbbd7259..9203d3147 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.test.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.test.tsx @@ -148,6 +148,9 @@ describe('AddCustomAgentDialog', () => { url: 'wss://example.com/ws', description: 'Demo', transport: 'websocket', + // Personal workspace by default in tests → picker hidden → falls back + // to workspace scope to match historical behavior. + scope: 'workspace', }) // Closes dialog on success. expect(onOpenChange).toHaveBeenCalledWith(false) @@ -405,6 +408,7 @@ describe('AddCustomAgentDialog — edit mode', () => { // Empty description is normalized to null, matching the create path. description: null, transport: 'websocket', + scope: 'workspace', }) expect(onOpenChange).toHaveBeenCalledWith(false) }) diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index 59c294e53..d2c51bec3 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -14,6 +14,7 @@ import { ResponsiveModalTitle, } from '@/components/ui/responsive-modal' import { Dialog } from '@/components/ui/dialog' +import { ScopePicker, type ResourceScope } from '@/components/scope-picker' import { StatusCard } from '@/components/ui/status-card' import { getPlatform, isTauri } from '@/lib/platform' import { testAcpConnection as defaultTestAcpConnection } from '@/acp' @@ -61,6 +62,9 @@ export type AddCustomAgentPayload = { url: string description: string | null transport: 'websocket' + /** `'workspace'` (default) shares with all members; `'user'` keeps the agent + * private to its author within the workspace (THU-603). */ + scope: ResourceScope } /** Async probe signature the dialog uses to test a remote agent endpoint. @@ -82,12 +86,20 @@ type AddCustomAgentDialogProps = { isIos?: () => boolean /** Test/DI override for the connection probe. Production callers omit this. */ testAcpConnection?: TestAcpConnectionFn + /** + * Whether to mount the per-row scope picker (THU-603). Production callers + * derive this from `useScopePickerEnabled` (deployment flag + non-personal + * workspace). Defaults to `false` so tests and callers that don't need it + * stay simple. When false, `onSubmit` always reports `scope: 'workspace'`. + */ + showScopePicker?: boolean } type AgentDialogState = { name: string url: string description: string + scope: ResourceScope submitting: boolean isTestingConnection: boolean connectionStatus: 'idle' | 'success' | 'error' @@ -98,6 +110,7 @@ type AgentDialogAction = | { type: 'SET_NAME'; value: string } | { type: 'SET_URL'; value: string } | { type: 'SET_DESCRIPTION'; value: string } + | { type: 'SET_SCOPE'; value: ResourceScope } | { type: 'START_SUBMIT' } | { type: 'END_SUBMIT' } | { type: 'START_CONNECTION_TEST' } @@ -109,6 +122,7 @@ const emptyState: AgentDialogState = { name: '', url: '', description: '', + scope: 'workspace', submitting: false, isTestingConnection: false, connectionStatus: 'idle', @@ -125,6 +139,7 @@ const buildInitialState = (agent: Agent | null): AgentDialogState => name: agent.name, url: agent.url ?? '', description: agent.description ?? '', + scope: agent.scope ?? 'workspace', } : emptyState @@ -138,6 +153,8 @@ const agentDialogReducer = (state: AgentDialogState, action: AgentDialogAction): return { ...state, url: action.value, connectionStatus: 'idle', connectionError: null } case 'SET_DESCRIPTION': return { ...state, description: action.value } + case 'SET_SCOPE': + return { ...state, scope: action.value } case 'START_SUBMIT': return { ...state, submitting: true } case 'END_SUBMIT': @@ -162,6 +179,7 @@ export const AddCustomAgentDialog = ({ editingAgent, isIos, testAcpConnection = defaultTestAcpConnection, + showScopePicker = false, }: AddCustomAgentDialogProps) => { const isEditing = !!editingAgent // Lazy init seeds the form from the agent on first mount. The parent varies @@ -214,6 +232,9 @@ export const AddCustomAgentDialog = ({ url: trimmedUrl, description: trimmedDescription.length > 0 ? trimmedDescription : null, transport: validation.transport, + // When the picker is hidden (deployment disabled or personal workspace) + // we drop back to 'workspace' — the row default matches today's behavior. + scope: showScopePicker ? state.scope : 'workspace', }) dispatch({ type: 'END_SUBMIT' }) dispatch({ type: 'RESET', next: buildInitialState(editingAgent ?? null) }) @@ -232,6 +253,14 @@ export const AddCustomAgentDialog = ({
+ {showScopePicker && ( + dispatch({ type: 'SET_SCOPE', value })} + disabled={state.submitting} + /> + )}
{} @@ -53,27 +60,56 @@ describe('canDeleteAgent', () => { expect(canDeleteAgent(systemAgent, 'user-42')).toBe(false) }) - it('returns true for customs owned by the current user', () => { + it('returns true for workspace-scoped customs owned by the current user', () => { expect(canDeleteAgent(customAgent, 'user-42')).toBe(true) }) - it('returns false for customs owned by a different user', () => { - expect(canDeleteAgent(customAgent, 'someone-else')).toBe(false) + it('returns true for workspace-scoped customs owned by a different user (any add-permitted member may delete)', () => { + expect(canDeleteAgent(customAgent, 'someone-else')).toBe(true) + }) + + it('returns true for user-scoped customs owned by the current user', () => { + expect(canDeleteAgent(privateAgent, 'user-42')).toBe(true) + }) + + it('returns false for user-scoped customs owned by a different user (BE rejects non-owner)', () => { + expect(canDeleteAgent(privateAgent, 'someone-else')).toBe(false) }) it('returns false when no user is signed in', () => { expect(canDeleteAgent(customAgent, null)).toBe(false) }) + + it('returns false when the workspace `remove_agents` permission denies the user', () => { + expect(canDeleteAgent(customAgent, 'user-42', false)).toBe(false) + }) + + it('returns true when canRemoveAgents defaults (omitted), preserving prior behaviour', () => { + expect(canDeleteAgent(customAgent, 'user-42')).toBe(true) + }) }) describe('canEditAgent', () => { - it('mirrors canDeleteAgent — built-in and system are non-editable, customs are owned by the user', () => { + it('mirrors canDeleteAgent — built-in / system are non-editable, customs follow the scope rule', () => { expect(canEditAgent(builtInAgent, 'user-42')).toBe(false) expect(canEditAgent(systemAgent, 'user-42')).toBe(false) + // Workspace-scoped custom: any add-permitted member can edit. expect(canEditAgent(customAgent, 'user-42')).toBe(true) - expect(canEditAgent(customAgent, 'someone-else')).toBe(false) + expect(canEditAgent(customAgent, 'someone-else')).toBe(true) + // User-scoped custom: owner only. + expect(canEditAgent(privateAgent, 'user-42')).toBe(true) + expect(canEditAgent(privateAgent, 'someone-else')).toBe(false) + // No session: never. expect(canEditAgent(customAgent, null)).toBe(false) }) + + it('returns false when the workspace `add_agents` permission denies the user', () => { + expect(canEditAgent(customAgent, 'user-42', false)).toBe(false) + }) + + it('returns true when canEditAgents defaults (omitted), preserving prior behaviour', () => { + expect(canEditAgent(customAgent, 'user-42')).toBe(true) + }) }) describe('AgentList', () => { @@ -173,6 +209,60 @@ describe('AgentList', () => { expect(screen.getByTestId(`agent-toggle-${customAgent.id}`)).not.toBeDisabled() }) + + it('hides the Remove affordance on every row when canRemoveAgents is false', () => { + const onToggle = mock(() => {}) + const onDelete = mock(() => {}) + + render( + , + ) + + expect(screen.queryByTestId(`agent-delete-${customAgent.id}`)).not.toBeInTheDocument() + }) + + it('disables the row toggle when canEditAgents is false (even for an owned custom)', () => { + const onToggle = mock(() => {}) + const onDelete = mock(() => {}) + + render( + , + ) + + expect(screen.getByTestId(`agent-toggle-${customAgent.id}`)).toBeDisabled() + }) + + it('hides the Edit affordance on every row when canEditAgents is false', () => { + const onToggle = mock(() => {}) + const onDelete = mock(() => {}) + + render( + , + ) + + expect(screen.queryByTestId(`agent-edit-${customAgent.id}`)).not.toBeInTheDocument() + }) }) describe('agentToggleDisabled', () => { diff --git a/src/components/settings/agents/agent-list.tsx b/src/components/settings/agents/agent-list.tsx index 788b7cf86..433a1afd8 100644 --- a/src/components/settings/agents/agent-list.tsx +++ b/src/components/settings/agents/agent-list.tsx @@ -8,6 +8,15 @@ import type { Agent } from '@/types/acp' type AgentListProps = { agents: Agent[] currentUserId: string | null + /** Defaults to true. Mirrors `add_agents`; gates the row enable/disable toggle. */ + canEditAgents?: boolean + /** Defaults to true — when false the Remove affordance is hidden on every row. + * Mirrors the workspace `remove_agents` permission; the BE is authoritative. */ + canRemoveAgents?: boolean + /** Mount the scope badge on `remote-acp` rows. Resolved once at the page + * level via `useScopePickerEnabled()`; defaults to false so component-level + * tests don't need to wire the provider chain. */ + scopePickerEnabled?: boolean onToggle: (agent: Agent, enabled: boolean) => void onEdit: (agent: Agent) => void onDelete: (agent: Agent) => void @@ -16,13 +25,25 @@ type AgentListProps = { /** Renders the unified agent list returned by `useAllAgents` (built-in first, * then system, then user customs). Composition lives in the DAL — this * component is purely visual + event-dispatching so it stays trivial to test. */ -export const AgentList = ({ agents, currentUserId, onToggle, onEdit, onDelete }: AgentListProps) => ( +export const AgentList = ({ + agents, + currentUserId, + canEditAgents = true, + canRemoveAgents = true, + scopePickerEnabled = false, + onToggle, + onEdit, + onDelete, +}: AgentListProps) => (
{agents.map((agent) => ( { return 'Remote' } -/** Predicate for the delete action's visibility. Customs the current user owns - * can be soft-deleted; built-in and system agents are managed externally and - * must not be removable from the UI. Exported for unit testing without - * rendering the full row tree. */ -export const canDeleteAgent = (agent: Agent, currentUserId: string | null): boolean => { +/** Predicate for the delete action's visibility. Built-in and system agents + * are managed externally and must not be removable from the UI. For custom + * agents the rule depends on the row's scope: + * - `scope='user'`: owner-only (the BE rejects non-owner PATCH/DELETE). + * - `scope='workspace'`: any member with `remove_agents` permission. + * + * Exported for unit testing without rendering the full row tree. + */ +export const canDeleteAgent = ( + agent: Agent, + currentUserId: string | null, + canRemoveAgents: boolean = true, +): boolean => { if (agent.type === 'built-in') { return false } @@ -47,14 +56,26 @@ export const canDeleteAgent = (agent: Agent, currentUserId: string | null): bool if (!currentUserId) { return false } - return agent.userId === currentUserId + if (!canRemoveAgents) { + return false + } + if (agent.scope === 'user') { + return agent.userId === currentUserId + } + return true } /** Predicate for the edit action's visibility. Mirrors `canDeleteAgent`: * built-in is in-code, system agents are managed via env vars, and customs - * belong to the user who created them. */ -export const canEditAgent = (agent: Agent, currentUserId: string | null): boolean => - canDeleteAgent(agent, currentUserId) + * follow the scope rule (owner-only for `scope='user'`, any add-permitted + * member for `scope='workspace'`). + * + * `canEditAgents` reflects the workspace `add_agents` permission — when + * false, no row's Edit affordance is shown regardless of ownership. Defaults + * to true so existing callers keep working. + */ +export const canEditAgent = (agent: Agent, currentUserId: string | null, canEditAgents: boolean = true): boolean => + canDeleteAgent(agent, currentUserId, canEditAgents) /** Computes the toggle's disabled state and the corresponding "always available" * tooltip text. Built-in is an in-code constant; system agents are configured @@ -73,17 +94,37 @@ export const agentToggleDisabled = (agent: Agent): { disabled: boolean; disabled type AgentRowProps = { agent: Agent currentUserId: string | null + /** Defaults to true. Mirrors the workspace `add_agents` permission — also + * used for the enable/disable toggle since toggling is a PATCH the BE + * gates on `add_agents`. */ + canEditAgents?: boolean + /** Defaults to true. Mirrors the workspace `remove_agents` permission. */ + canRemoveAgents?: boolean + /** When true, the scope badge (Private / Shared) shows on `remote-acp` rows. + * Resolved once at the page level via `useScopePickerEnabled()` and threaded + * down — keeps the row purely visual + provider-free for tests. */ + scopePickerEnabled?: boolean onToggle: (agent: Agent, enabled: boolean) => void onEdit: (agent: Agent) => void onDelete: (agent: Agent) => void } -export const AgentRow = ({ agent, currentUserId, onToggle, onEdit, onDelete }: AgentRowProps) => { +export const AgentRow = ({ + agent, + currentUserId, + canEditAgents = true, + canRemoveAgents = true, + scopePickerEnabled = false, + onToggle, + onEdit, + onDelete, +}: AgentRowProps) => { const Icon = iconForAgent(agent) const badge = badgeForAgent(agent) - const showEdit = canEditAgent(agent, currentUserId) - const showDelete = canDeleteAgent(agent, currentUserId) + const showEdit = canEditAgent(agent, currentUserId, canEditAgents) + const showDelete = canDeleteAgent(agent, currentUserId, canRemoveAgents) const { disabled: toggleDisabled, disabledTooltip } = agentToggleDisabled(agent) + const finalToggleDisabled = toggleDisabled || !canEditAgents const isEnabled = agent.enabled === 1 const [deleteOpen, setDeleteOpen] = useState(false) @@ -111,6 +152,9 @@ export const AgentRow = ({ agent, currentUserId, onToggle, onEdit, onDelete }: A {agent.description && (

{agent.description}

)} + {agent.type === 'remote-acp' && ( + + )}
@@ -120,7 +164,7 @@ export const AgentRow = ({ agent, currentUserId, onToggle, onEdit, onDelete }: A onToggle(agent, checked)} />
diff --git a/src/components/sidebar-footer.tsx b/src/components/sidebar-footer.tsx index bf26f0506..84c441617 100644 --- a/src/components/sidebar-footer.tsx +++ b/src/components/sidebar-footer.tsx @@ -19,6 +19,7 @@ import { useSidebar, } from '@/components/ui/sidebar' import { useAuth, useSignInModal } from '@/contexts' +import { useWorkspaceUrl } from '@/lib/active-workspace' import { getDownloadUrl } from '@/lib/download-links' import { isWebDesktopPlatform, isTauri } from '@/lib/platform' import { edgeSpacing, mobileSidebarWidthRatio } from '@/lib/constants' @@ -79,6 +80,8 @@ export const SidebarFooter = ({ className }: SidebarFooterProps) => { const { openSignInModal } = useSignInModal() const [logoutModalOpen, setLogoutModalOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false) + const devSettingsUrl = useWorkspaceUrl('/settings/dev-settings') + const messageSimulatorUrl = useWorkspaceUrl('/message-simulator') // On mobile, always treat the sidebar as expanded when it's open const isExpanded = isMobile || state === 'expanded' @@ -258,13 +261,13 @@ export const SidebarFooter = ({ className }: SidebarFooterProps) => { } label="Dev Settings" - to="/settings/dev-settings" + to={devSettingsUrl} onNavigate={handleMenuNavigate} /> } label="Message Simulator" - to="/message-simulator" + to={messageSimulatorUrl} onNavigate={handleMenuNavigate} />
diff --git a/src/components/sign-in-modal.test.tsx b/src/components/sign-in-modal.test.tsx index a2e3c1510..b4e2f2e44 100644 --- a/src/components/sign-in-modal.test.tsx +++ b/src/components/sign-in-modal.test.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { resetTestDatabase, setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' import type { ConsoleSpies } from '@/test-utils/console-spies' import { setupConsoleSpy } from '@/test-utils/console-spies' import { createTestProvider } from '@/test-utils/test-provider' @@ -87,12 +87,15 @@ describe('SignInModal', () => { const { httpClient, fetchSpy } = createSpyHttpClient(undefined, waitlistResponse) mockHttpClient = httpClient mockFetchSpy = fetchSpy - // Pin `cloudUrl` to a localhost value so the post-submit assertion in - // `shows sent state after successful submission` is deterministic. The - // store *defaults* to localhost, but `VITE_THUNDERBOLT_CLOUD_URL` - // overrides that default in CI — leaving the test order-dependent - // under `--randomize`. See THU-561. - useLocalSettingsStore.setState({ cloudUrl: 'http://localhost:8000/v1' }) + // Pin the active server's cloudUrl to a localhost value so the post-submit assertion + // in `shows sent state after successful submission` is deterministic. The env + // `VITE_THUNDERBOLT_CLOUD_URL` overrides the default in CI — leaving the test + // order-dependent under `--randomize` without this fixture. See THU-561. + const fixtureServerId = '00000000-0000-0000-0000-000000000001' + useTrustDomainRegistry.setState({ + servers: { [fixtureServerId]: { serverId: fixtureServerId, cloudUrl: 'http://localhost:8000/v1' } }, + activeTrustDomain: { kind: 'server', serverId: fixtureServerId }, + }) }) afterEach(async () => { diff --git a/src/components/sign-in/sign-in-form.tsx b/src/components/sign-in/sign-in-form.tsx index b447433be..71a69c6a2 100644 --- a/src/components/sign-in/sign-in-form.tsx +++ b/src/components/sign-in/sign-in-form.tsx @@ -4,7 +4,7 @@ import { useAuth, useHttpClient } from '@/contexts' import { useSettings } from '@/hooks/use-settings' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useActiveCloudUrl } from '@/stores/trust-domain-registry' import { isLocalhostUrl } from '@/lib/utils' import { type ReactNode, type RefObject, useCallback, useEffect } from 'react' import { SignInEmailStep } from './sign-in-email-step' @@ -79,7 +79,7 @@ export const SignInForm = ({ }: SignInFormProps) => { const authClient = useAuth() const httpClient = useHttpClient() - const cloudUrl = useLocalSettingsStore((s) => s.cloudUrl) + const cloudUrl = useActiveCloudUrl() ?? '' const { preferredName } = useSettings({ preferred_name: '' }) const isLocalhost = isLocalhostUrl(cloudUrl) const displayName = preferredName.value as string diff --git a/src/components/sign-in/use-sign-in-form-state.ts b/src/components/sign-in/use-sign-in-form-state.ts index b2fe3cafd..1adeaebb3 100644 --- a/src/components/sign-in/use-sign-in-form-state.ts +++ b/src/components/sign-in/use-sign-in-form-state.ts @@ -9,6 +9,7 @@ import { HttpError, type HttpClient } from '@/lib/http' import { getOtpErrorMessage } from '@/lib/otp-error-messages' import { updateSettings } from '@/dal' import { getDb, getDatabaseInstance } from '@/db/database' +import { runPostAuthBootstrap } from '@/lib/post-auth-bootstrap' import { isValidEmailFormat } from '@/lib/utils' import { useReducer, type FormEvent } from 'react' @@ -238,6 +239,24 @@ export const useSignInFormState = ({ analytics.onPromotionSuccess(result.data.user.id) } + // Post-auth pipeline: connect sync, await the personal workspace, reconcile + // defaults. Fires here so the success step doesn't appear until the user can + // actually use the app. The dedupe in `runPostAuthBootstrap` keeps this + // safe even if `SessionToWorkspaceBootstrap` is firing in parallel. + if (result.data?.user?.id) { + try { + await runPostAuthBootstrap({ + kind: 'server', + userId: result.data.user.id, + isAnonymous: result.data.user.isAnonymous === true, + }) + } catch (bootstrapError) { + console.error('Post-auth bootstrap failed:', bootstrapError) + dispatch({ type: 'VERIFY_ERROR', payload: 'Could not sync your account. Please retry.' }) + return + } + } + // Sign-in successful - show success state dispatch({ type: 'VERIFY_SUCCESS' }) } catch (error) { diff --git a/src/components/sso-redirect.tsx b/src/components/sso-redirect.tsx index ea2cb2654..f266b3597 100644 --- a/src/components/sso-redirect.tsx +++ b/src/components/sso-redirect.tsx @@ -8,7 +8,7 @@ import { setAuthToken } from '@/lib/auth-token' import { isSafeUrl } from '@/lib/url-utils' import { isTauri } from '@/lib/platform' import { startSsoFlowLoopback } from '@/lib/sso-loopback' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useActiveCloudUrl } from '@/stores/trust-domain-registry' import { useAnonymousPromotionAnalytics } from '@/lib/analytics/use-anonymous-promotion-analytics' import { useAuth } from '@/contexts' import Loading from '@/loading' @@ -21,7 +21,7 @@ import Loading from '@/loading' * of navigating the webview (WKWebView drops cookies during cross-origin redirects). */ const SsoRedirect = () => { - const cloudUrl = useLocalSettingsStore((s) => s.cloudUrl) + const cloudUrl = useActiveCloudUrl() ?? '' const authClient = useAuth() const analytics = useAnonymousPromotionAnalytics() const [error, setError] = useState(false) diff --git a/src/components/ui/chat-nav-button.tsx b/src/components/ui/chat-nav-button.tsx index 4eab648f4..207a467c4 100644 --- a/src/components/ui/chat-nav-button.tsx +++ b/src/components/ui/chat-nav-button.tsx @@ -19,7 +19,7 @@ import { cn } from '@/lib/utils' import { Slot } from '@radix-ui/react-slot' import { Ellipsis, Trash2 } from 'lucide-react' import { type HTMLAttributes } from 'react' -import { useNavigate } from 'react-router' +import { useWorkspaceNavigate } from '@/lib/active-workspace' type ChatNavButtonProps = HTMLAttributes & { chatTitle: string @@ -29,7 +29,7 @@ type ChatNavButtonProps = HTMLAttributes & { export const ChatNavButton = ({ chatTitle, threadId, className, asChild = false, ...props }: ChatNavButtonProps) => { const Comp = asChild ? Slot : 'div' - const navigate = useNavigate() + const navigate = useWorkspaceNavigate() const handleButtonClick = () => { navigate(`/chats/${threadId}`) diff --git a/src/components/ui/email-chip-input.test.tsx b/src/components/ui/email-chip-input.test.tsx new file mode 100644 index 000000000..901152b5c --- /dev/null +++ b/src/components/ui/email-chip-input.test.tsx @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import '@testing-library/jest-dom' +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { EmailChipInput } from './email-chip-input' + +const placeholderText = 'Enter emails…' + +const Harness = ({ initial = [] as string[] }: { initial?: string[] }) => { + const [value, setValue] = useState(initial) + return ( + <> + +
{JSON.stringify(value)}
+ + ) +} + +const findInput = (initial: string[] = []): HTMLInputElement => { + // Placeholder only renders when value is empty; fall back to the email + // autocomplete attribute (the input always has it) when we seeded chips. + if (initial.length === 0) { + return screen.getByPlaceholderText(placeholderText) as HTMLInputElement + } + return screen.getByDisplayValue('') as HTMLInputElement +} + +const typeAndCommit = (input: HTMLInputElement, text: string, commitKey: 'Enter' | ',' | ' ') => { + fireEvent.change(input, { target: { value: text } }) + fireEvent.keyDown(input, { key: commitKey }) +} + +describe('EmailChipInput', () => { + afterEach(() => { + cleanup() + }) + + it('commits a valid email as a chip on Enter', () => { + render() + const input = findInput() + typeAndCommit(input, 'alice@test.com', 'Enter') + expect(screen.getByTestId('email-chip-alice@test.com')).toBeInTheDocument() + expect(screen.getByTestId('value-json')).toHaveTextContent('["alice@test.com"]') + expect(input).toHaveValue('') + }) + + it('commits on comma', () => { + render() + typeAndCommit(findInput(), 'a@test.com', ',') + expect(screen.getByTestId('email-chip-a@test.com')).toBeInTheDocument() + }) + + it('commits on space', () => { + render() + typeAndCommit(findInput(), 'b@test.com', ' ') + expect(screen.getByTestId('email-chip-b@test.com')).toBeInTheDocument() + }) + + it('normalizes to lowercase + trim', () => { + render() + typeAndCommit(findInput(), ' Mixed@TEST.com ', 'Enter') + expect(screen.getByTestId('email-chip-mixed@test.com')).toBeInTheDocument() + expect(screen.getByTestId('value-json')).toHaveTextContent('["mixed@test.com"]') + }) + + it('does not commit invalid email; shows error; leaves text in input', () => { + render() + const input = findInput() + typeAndCommit(input, 'not-an-email', 'Enter') + expect(screen.queryByTestId(/email-chip-/)).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent(/not a valid email/i) + expect(input).toHaveValue('not-an-email') + }) + + it('clears error when the user types again', () => { + render() + const input = findInput() + typeAndCommit(input, 'bad', 'Enter') + expect(screen.getByRole('alert')).toBeInTheDocument() + fireEvent.change(input, { target: { value: 'badx' } }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('dedupes within the chip list', () => { + render() + typeAndCommit(findInput(['alice@test.com']), 'alice@test.com', 'Enter') + expect(screen.getAllByTestId(/email-chip-/)).toHaveLength(1) + }) + + it('backspace on empty input removes the last chip', () => { + render() + fireEvent.keyDown(findInput(['a@test.com', 'b@test.com']), { key: 'Backspace' }) + expect(screen.queryByTestId('email-chip-b@test.com')).not.toBeInTheDocument() + expect(screen.getByTestId('email-chip-a@test.com')).toBeInTheDocument() + }) + + it('does not remove chips when backspacing with text in input', () => { + render() + const input = findInput(['a@test.com', 'b@test.com']) + fireEvent.change(input, { target: { value: 'x' } }) + fireEvent.keyDown(input, { key: 'Backspace' }) + expect(screen.getByTestId('email-chip-a@test.com')).toBeInTheDocument() + expect(screen.getByTestId('email-chip-b@test.com')).toBeInTheDocument() + }) + + it('clicking the chip × removes that chip', () => { + render() + fireEvent.click(screen.getByLabelText('Remove a@test.com')) + expect(screen.queryByTestId('email-chip-a@test.com')).not.toBeInTheDocument() + expect(screen.getByTestId('email-chip-b@test.com')).toBeInTheDocument() + }) + + it('paste of comma-separated emails commits all valid + reports invalid', () => { + render() + const input = findInput() + fireEvent.paste(input, { + clipboardData: { + getData: () => 'one@test.com, two@test.com; not-email three@test.com', + }, + }) + expect(screen.getByTestId('email-chip-one@test.com')).toBeInTheDocument() + expect(screen.getByTestId('email-chip-two@test.com')).toBeInTheDocument() + expect(screen.getByTestId('email-chip-three@test.com')).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent(/not-email/) + }) + + it('commits the current draft on blur', () => { + render() + const input = findInput() + fireEvent.change(input, { target: { value: 'blur@test.com' } }) + fireEvent.blur(input) + expect(screen.getByTestId('email-chip-blur@test.com')).toBeInTheDocument() + }) +}) diff --git a/src/components/ui/email-chip-input.tsx b/src/components/ui/email-chip-input.tsx new file mode 100644 index 000000000..0d9e98db8 --- /dev/null +++ b/src/components/ui/email-chip-input.tsx @@ -0,0 +1,206 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { cn, isValidEmailFormat } from '@/lib/utils' +import { X } from 'lucide-react' +import { type ClipboardEvent, type KeyboardEvent, useId, useRef, useState } from 'react' + +const separatorRegex = /[\s,;]+/ + +const normalize = (email: string): string => email.toLowerCase().trim() + +type EmailChipInputProps = { + value: string[] + onChange: (next: string[]) => void + placeholder?: string + disabled?: boolean + className?: string + /** Optional id for the visible input (use to associate a