From 1121e4a6224b3119c59a8cdf1cf2b00655fba796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Wed, 17 Jun 2026 18:48:03 -0300 Subject: [PATCH 01/14] chore(THU-603): add scope column to workspace resource schemas --- backend/drizzle/0023_user_scope_resources.sql | 12 + backend/drizzle/meta/0023_snapshot.json | 3235 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/db/powersync-schema.ts | 24 + src/ai/prompt.test.ts | 1 + src/ai/step-logic.test.ts | 1 + src/components/chat/chat-skills-bar.test.tsx | 1 + src/dal/skills.ts | 1 + src/db/tables.ts | 8 + src/defaults/automations.ts | 2 + src/defaults/model-profiles.test.ts | 1 + src/defaults/model-profiles/deepseek.ts | 1 + src/defaults/model-profiles/glm.ts | 1 + src/defaults/model-profiles/gpt-oss.ts | 1 + src/defaults/model-profiles/kimi.ts | 1 + src/defaults/model-profiles/opus.ts | 1 + src/defaults/models.ts | 5 + src/defaults/modes.ts | 3 + src/defaults/skills.ts | 2 + src/lib/defaults.test.ts | 1 + src/lib/reconcile-defaults.test.ts | 2 + src/settings/models/index.tsx | 1 + src/skills/find-dependents.test.ts | 1 + src/skills/library-row.test.tsx | 1 + src/skills/skills-list.test.tsx | 1 + src/skills/skills-view-state.test.ts | 1 + src/skills/slash-popup.stories.tsx | 2 + src/skills/use-slash-command.test.ts | 1 + src/stories/DependentsDialog.stories.tsx | 1 + 29 files changed, 3320 insertions(+) create mode 100644 backend/drizzle/0023_user_scope_resources.sql create mode 100644 backend/drizzle/meta/0023_snapshot.json diff --git a/backend/drizzle/0023_user_scope_resources.sql b/backend/drizzle/0023_user_scope_resources.sql new file mode 100644 index 000000000..674619ba5 --- /dev/null +++ b/backend/drizzle/0023_user_scope_resources.sql @@ -0,0 +1,12 @@ +-- 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/. + +ALTER TABLE "powersync"."agents" ADD COLUMN "scope" text DEFAULT 'workspace' NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."mcp_servers" 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; \ No newline at end of file diff --git a/backend/drizzle/meta/0023_snapshot.json b/backend/drizzle/meta/0023_snapshot.json new file mode 100644 index 000000000..6d6ecfaa3 --- /dev/null +++ b/backend/drizzle/meta/0023_snapshot.json @@ -0,0 +1,3235 @@ +{ + "id": "e8aa22c8-fdd0-44e9-a35d-cdda2355398d", + "prevId": "f6bed693-da35-4fbe-bceb-c9c41077463e", + "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.mcp_servers": { + "name": "mcp_servers", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'http'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "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_mcp_servers_user_id": { + "name": "idx_mcp_servers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_mcp_servers_workspace_id": { + "name": "idx_mcp_servers_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_user_id_user_id_fk": { + "name": "mcp_servers_user_id_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_workspace_id_workspaces_id_fk": { + "name": "mcp_servers_workspace_id_workspaces_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_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 + }, + "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 b9d7c46dc..1660ce908 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1781128111482, "tag": "0022_workspace_memberships_user_display", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1781731977793, + "tag": "0023_user_scope_resources", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/powersync-schema.ts b/backend/src/db/powersync-schema.ts index 30eb1ba38..c9561a89e 100644 --- a/backend/src/db/powersync-schema.ts +++ b/backend/src/db/powersync-schema.ts @@ -261,6 +261,9 @@ export const modelsTable = powersyncSchema.table( workspaceId: text('workspace_id') .notNull() .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, (table) => [ primaryKey({ columns: [table.id, table.workspaceId] }), @@ -286,6 +289,9 @@ export const mcpServersTable = powersyncSchema.table( workspaceId: text('workspace_id') .notNull() .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, (table) => [ index('idx_mcp_servers_user_id').on(table.userId), @@ -306,6 +312,9 @@ export const promptsTable = powersyncSchema.table( workspaceId: text('workspace_id') .notNull() .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, (table) => [ primaryKey({ columns: [table.id, table.workspaceId] }), @@ -329,6 +338,9 @@ export const skillsTable = powersyncSchema.table( workspaceId: text('workspace_id') .notNull() .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, (table) => [ primaryKey({ columns: [table.id, table.workspaceId] }), @@ -350,6 +362,9 @@ export const triggersTable = powersyncSchema.table( workspaceId: text('workspace_id') .notNull() .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, (table) => [index('idx_triggers_user_id').on(table.userId), index('idx_triggers_workspace_id').on(table.workspaceId)], ) @@ -370,6 +385,9 @@ export const modesTable = powersyncSchema.table( workspaceId: text('workspace_id') .notNull() .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, (table) => [ primaryKey({ columns: [table.id, table.workspaceId] }), @@ -407,6 +425,9 @@ export const modelProfilesTable = powersyncSchema.table( workspaceId: text('workspace_id') .notNull() .references(() => workspacesTable.id, { onDelete: 'cascade' }), + scope: text('scope', { enum: ['workspace', 'user'] }) + .notNull() + .default('workspace'), }, (table) => [ primaryKey({ columns: [table.id, table.workspaceId] }), @@ -457,6 +478,9 @@ 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.workspaceId] }), diff --git a/src/ai/prompt.test.ts b/src/ai/prompt.test.ts index 8fb9fcbf7..8b4e202c3 100644 --- a/src/ai/prompt.test.ts +++ b/src/ai/prompt.test.ts @@ -31,6 +31,7 @@ const createStubProfile = (overrides: Partial = {}): ModelProfile 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 108174010..273d06c4c 100644 --- a/src/ai/step-logic.test.ts +++ b/src/ai/step-logic.test.ts @@ -41,6 +41,7 @@ const createStubProfile = (overrides: Partial = {}): ModelProfile deletedAt: null, userId: null, workspaceId: null, + scope: null, ...overrides, }) diff --git a/src/components/chat/chat-skills-bar.test.tsx b/src/components/chat/chat-skills-bar.test.tsx index 74d27e96c..d4602e318 100644 --- a/src/components/chat/chat-skills-bar.test.tsx +++ b/src/components/chat/chat-skills-bar.test.tsx @@ -21,6 +21,7 @@ const skill = (id: string, name: string): Skill => ({ defaultHash: null, userId: null, workspaceId: null, + scope: null, }) const fakeUsePinnedSkills = (overrides?: { diff --git a/src/dal/skills.ts b/src/dal/skills.ts index eefe26c48..d88db2c1a 100644 --- a/src/dal/skills.ts +++ b/src/dal/skills.ts @@ -188,6 +188,7 @@ export const createSkill = async ( defaultHash: null, userId: null, workspaceId, + scope: 'workspace', } await db.insert(skillsTable).values(row) return row diff --git a/src/db/tables.ts b/src/db/tables.ts index c838b6d00..b3c51b758 100644 --- a/src/db/tables.ts +++ b/src/db/tables.ts @@ -109,6 +109,7 @@ export const modelsTable = sqliteTable( description: text('description'), userId: text('user_id'), workspaceId: text('workspace_id'), + scope: text('scope', { enum: ['workspace', 'user'] }).default('workspace'), }, (table) => [ index('idx_models_active') @@ -146,6 +147,7 @@ export const mcpServersTable = sqliteTable( deletedAt: text('deleted_at'), userId: text('user_id'), workspaceId: text('workspace_id'), + scope: text('scope', { enum: ['workspace', 'user'] }).default('workspace'), }, (table) => [ index('idx_mcp_servers_active') @@ -166,6 +168,7 @@ export const promptsTable = sqliteTable( defaultHash: text('default_hash'), userId: text('user_id'), workspaceId: text('workspace_id'), + scope: text('scope', { enum: ['workspace', 'user'] }).default('workspace'), }, (table) => [ index('idx_prompts_active') @@ -188,6 +191,7 @@ export const skillsTable = sqliteTable( defaultHash: text('default_hash'), userId: text('user_id'), workspaceId: text('workspace_id'), + scope: text('scope', { enum: ['workspace', 'user'] }).default('workspace'), }, (table) => [ index('idx_skills_active') @@ -208,6 +212,7 @@ export const triggersTable = sqliteTable( deletedAt: text('deleted_at'), userId: text('user_id'), workspaceId: text('workspace_id'), + scope: text('scope', { enum: ['workspace', 'user'] }).default('workspace'), }, (table) => [ index('idx_triggers_active') @@ -246,6 +251,7 @@ export const modelProfilesTable = sqliteTable( deletedAt: text('deleted_at'), userId: text('user_id'), workspaceId: text('workspace_id'), + scope: text('scope', { enum: ['workspace', 'user'] }).default('workspace'), }, (table) => [ index('idx_model_profiles_active') @@ -269,6 +275,7 @@ export const modesTable = sqliteTable( deletedAt: text('deleted_at'), userId: text('user_id'), workspaceId: text('workspace_id'), + scope: text('scope', { enum: ['workspace', 'user'] }).default('workspace'), }, (table) => [ index('idx_modes_active') @@ -307,6 +314,7 @@ export const agentsTable = sqliteTable( deletedAt: text('deleted_at'), userId: text('user_id'), workspaceId: text('workspace_id'), + scope: text('scope', { enum: ['workspace', 'user'] }).default('workspace'), }, (table) => [ index('idx_agents_active') diff --git a/src/defaults/automations.ts b/src/defaults/automations.ts index 8d4e64752..3ffc5305c 100644 --- a/src/defaults/automations.ts +++ b/src/defaults/automations.ts @@ -25,6 +25,7 @@ export const defaultAutomationDailyBrief: Prompt = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', modelId: defaultModelGptOss120b.id, prompt: `Create a daily brief with the following sections. Do not ask me for any missing information - just skip sections for which you are missing information or tools. @@ -70,6 +71,7 @@ export const defaultAutomationImportantEmails: Prompt = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', modelId: defaultModelGptOss120b.id, prompt: `Review my inbox and summarize the 5 most important emails that need my attention today. Include sender, subject, and why each is important.`, } diff --git a/src/defaults/model-profiles.test.ts b/src/defaults/model-profiles.test.ts index b702f389c..8b2149f33 100644 --- a/src/defaults/model-profiles.test.ts +++ b/src/defaults/model-profiles.test.ts @@ -31,6 +31,7 @@ const createStubProfile = (overrides: Partial = {}): ModelProfile deletedAt: null, userId: null, workspaceId: null, + scope: null, ...overrides, }) diff --git a/src/defaults/model-profiles/deepseek.ts b/src/defaults/model-profiles/deepseek.ts index b34d7d9ee..b2b06d865 100644 --- a/src/defaults/model-profiles/deepseek.ts +++ b/src/defaults/model-profiles/deepseek.ts @@ -30,4 +30,5 @@ export const defaultModelProfileDeepseekV4Pro: ModelProfile = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } diff --git a/src/defaults/model-profiles/glm.ts b/src/defaults/model-profiles/glm.ts index f7b56fc11..0c7d4d6c8 100644 --- a/src/defaults/model-profiles/glm.ts +++ b/src/defaults/model-profiles/glm.ts @@ -30,4 +30,5 @@ export const defaultModelProfileGlm51: ModelProfile = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } diff --git a/src/defaults/model-profiles/gpt-oss.ts b/src/defaults/model-profiles/gpt-oss.ts index a35394008..118b4ad6f 100644 --- a/src/defaults/model-profiles/gpt-oss.ts +++ b/src/defaults/model-profiles/gpt-oss.ts @@ -40,4 +40,5 @@ CITATION CHECK: Before finishing your response, count your [N] citations. If you defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } diff --git a/src/defaults/model-profiles/kimi.ts b/src/defaults/model-profiles/kimi.ts index 0df6843bf..88cfdf45b 100644 --- a/src/defaults/model-profiles/kimi.ts +++ b/src/defaults/model-profiles/kimi.ts @@ -30,4 +30,5 @@ export const defaultModelProfileKimiK26: ModelProfile = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } diff --git a/src/defaults/model-profiles/opus.ts b/src/defaults/model-profiles/opus.ts index 662da9cee..9a8cf6dfd 100644 --- a/src/defaults/model-profiles/opus.ts +++ b/src/defaults/model-profiles/opus.ts @@ -30,4 +30,5 @@ export const defaultModelProfileOpus48: ModelProfile = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } diff --git a/src/defaults/models.ts b/src/defaults/models.ts index cd1d69adc..1a024c313 100644 --- a/src/defaults/models.ts +++ b/src/defaults/models.ts @@ -52,6 +52,7 @@ export const defaultModelGptOss120b: Model = { description: 'Fast', userId: null, workspaceId: null, + scope: 'workspace', } /** @@ -78,6 +79,7 @@ export const defaultModelOpus48: Model = { description: 'Top-tier Anthropic reasoning', userId: null, workspaceId: null, + scope: 'workspace', } export const defaultModelDeepseekV4Pro: Model = { @@ -100,6 +102,7 @@ export const defaultModelDeepseekV4Pro: Model = { description: 'Confidential reasoning via Tinfoil', userId: null, workspaceId: null, + scope: 'workspace', } export const defaultModelKimiK26: Model = { @@ -122,6 +125,7 @@ export const defaultModelKimiK26: Model = { description: 'Confidential chat via Tinfoil', userId: null, workspaceId: null, + scope: 'workspace', } export const defaultModelGlm51: Model = { @@ -144,6 +148,7 @@ export const defaultModelGlm51: Model = { description: 'Confidential chat via Tinfoil', userId: null, workspaceId: null, + scope: 'workspace', } /** diff --git a/src/defaults/modes.ts b/src/defaults/modes.ts index fffb4c886..4cfefb93b 100644 --- a/src/defaults/modes.ts +++ b/src/defaults/modes.ts @@ -30,6 +30,7 @@ export const defaultModeChat: Mode = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } export const defaultModeSearch: Mode = { @@ -44,6 +45,7 @@ export const defaultModeSearch: Mode = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } export const defaultModeResearch: Mode = { @@ -58,6 +60,7 @@ export const defaultModeResearch: Mode = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } /** diff --git a/src/defaults/skills.ts b/src/defaults/skills.ts index 43b3476da..e6b94341c 100644 --- a/src/defaults/skills.ts +++ b/src/defaults/skills.ts @@ -72,6 +72,7 @@ export const defaultSkillDailyBrief: Skill = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } export const defaultSkillImportantEmails: Skill = { @@ -86,6 +87,7 @@ export const defaultSkillImportantEmails: Skill = { defaultHash: null, userId: null, workspaceId: null, + scope: 'workspace', } export const defaultSkills: ReadonlyArray = [defaultSkillDailyBrief, defaultSkillImportantEmails] as const diff --git a/src/lib/defaults.test.ts b/src/lib/defaults.test.ts index c51e1e532..20569e3b1 100644 --- a/src/lib/defaults.test.ts +++ b/src/lib/defaults.test.ts @@ -91,6 +91,7 @@ describe('defaults-hash', () => { description: model.description, userId: model.userId, workspaceId: model.workspaceId, + scope: model.scope, } expect(hashModel(model)).toBe(hashModel(reorderedModel)) }) diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 37699d902..a010a01ac 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -41,6 +41,7 @@ const buildRetiredModel = (overrides: Partial = {}): Model => { description: 'Retired', userId: null, workspaceId: wsId, + scope: null, ...overrides, } return { ...base, defaultHash: hashModel(base) } @@ -73,6 +74,7 @@ const buildRetiredProfile = (overrides: Partial = {}): ModelProfil defaultHash: null, userId: null, workspaceId: wsId, + scope: null, ...overrides, } return { ...base, defaultHash: hashModelProfile(base) } diff --git a/src/settings/models/index.tsx b/src/settings/models/index.tsx index f6c17b3f7..94b08ddc5 100644 --- a/src/settings/models/index.tsx +++ b/src/settings/models/index.tsx @@ -527,6 +527,7 @@ export default function ModelsPage({ useWorkspacePermission = useWorkspacePermis description: null, userId: null, workspaceId: null, + scope: 'workspace' as const, } const model = await createModel(modelConfigWithDefaults, getProxyFetch) diff --git a/src/skills/find-dependents.test.ts b/src/skills/find-dependents.test.ts index 9a4619a62..3e4665e16 100644 --- a/src/skills/find-dependents.test.ts +++ b/src/skills/find-dependents.test.ts @@ -15,6 +15,7 @@ const skill = (overrides: Partial & { id: string; name: string }): Skill defaultHash: null, userId: null, workspaceId: null, + scope: null, ...overrides, }) diff --git a/src/skills/library-row.test.tsx b/src/skills/library-row.test.tsx index b90335ab8..47a501de2 100644 --- a/src/skills/library-row.test.tsx +++ b/src/skills/library-row.test.tsx @@ -25,6 +25,7 @@ const skill: Skill = { defaultHash: null, userId: null, workspaceId: null, + scope: null, } const renderRow = (props: { canEdit?: boolean; canDelete?: boolean } = {}) => { diff --git a/src/skills/skills-list.test.tsx b/src/skills/skills-list.test.tsx index 539ad0a37..06f450c47 100644 --- a/src/skills/skills-list.test.tsx +++ b/src/skills/skills-list.test.tsx @@ -26,6 +26,7 @@ const skill: Skill = { defaultHash: null, userId: null, workspaceId: null, + scope: null, } const renderList = (props: { canCreate?: boolean } = {}) => { diff --git a/src/skills/skills-view-state.test.ts b/src/skills/skills-view-state.test.ts index 03701ddad..2287dcd16 100644 --- a/src/skills/skills-view-state.test.ts +++ b/src/skills/skills-view-state.test.ts @@ -22,6 +22,7 @@ const skill = (id: string, name: string): Skill => ({ defaultHash: null, userId: null, workspaceId: null, + scope: null, }) /** Apply a sequence of actions to the initial state. Useful for "in mode X, when Y, expect Z" tests. */ diff --git a/src/skills/slash-popup.stories.tsx b/src/skills/slash-popup.stories.tsx index 2d50f284e..cd51a97e1 100644 --- a/src/skills/slash-popup.stories.tsx +++ b/src/skills/slash-popup.stories.tsx @@ -49,6 +49,7 @@ const sampleSkills: Skill[] = [ defaultHash: null, userId: null, workspaceId: null, + scope: null, }, { id: '2', @@ -61,6 +62,7 @@ const sampleSkills: Skill[] = [ defaultHash: null, userId: null, workspaceId: null, + scope: null, }, ] diff --git a/src/skills/use-slash-command.test.ts b/src/skills/use-slash-command.test.ts index bab5c443b..05add048f 100644 --- a/src/skills/use-slash-command.test.ts +++ b/src/skills/use-slash-command.test.ts @@ -20,6 +20,7 @@ const fakeSkill = (name: string): Skill => ({ defaultHash: null, userId: null, workspaceId: null, + scope: null, }) /** Build a partial KeyboardEvent that's just enough to satisfy the hook. */ diff --git a/src/stories/DependentsDialog.stories.tsx b/src/stories/DependentsDialog.stories.tsx index afa88fa13..eb2b22215 100644 --- a/src/stories/DependentsDialog.stories.tsx +++ b/src/stories/DependentsDialog.stories.tsx @@ -18,6 +18,7 @@ const dep = (id: string, name: string): Skill => ({ defaultHash: null, userId: null, workspaceId: null, + scope: null, }) const meta = { From 37cfc36469d6c83dfe755a9278e57267372367d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Wed, 17 Jun 2026 18:53:49 -0300 Subject: [PATCH 02/14] feat(THU-603): split sync rules by resource scope --- deploy/config/powersync-config.yaml | 38 ++++++++++++++++++++++------ deploy/k8s/templates/configmaps.yaml | 38 ++++++++++++++++++++++------ powersync-service/config/config.yaml | 38 ++++++++++++++++++++++------ 3 files changed, 90 insertions(+), 24 deletions(-) diff --git a/deploy/config/powersync-config.yaml b/deploy/config/powersync-config.yaml index be11fa24c..3d27b7f32 100644 --- a/deploy/config/powersync-config.yaml +++ b/deploy/config/powersync-config.yaml @@ -54,20 +54,42 @@ sync_rules: # 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 - - SELECT * FROM powersync.mcp_servers WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.mcp_servers 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.mcp_servers 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 c44164ca7..fefede2d6 100644 --- a/deploy/k8s/templates/configmaps.yaml +++ b/deploy/k8s/templates/configmaps.yaml @@ -62,20 +62,42 @@ data: # 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 - - SELECT * FROM powersync.mcp_servers WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.mcp_servers 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.mcp_servers 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/powersync-service/config/config.yaml b/powersync-service/config/config.yaml index 5dee7b4ba..74638ee09 100644 --- a/powersync-service/config/config.yaml +++ b/powersync-service/config/config.yaml @@ -54,20 +54,42 @@ sync_rules: # 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 - - SELECT * FROM powersync.mcp_servers WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.prompts WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.skills WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.triggers WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.modes WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.model_profiles WHERE workspace_id = bucket.workspace_id - - SELECT * FROM powersync.agents WHERE workspace_id = bucket.workspace_id + - SELECT * FROM powersync.models WHERE workspace_id = bucket.workspace_id AND scope = 'workspace' + - SELECT * FROM powersync.mcp_servers 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.mcp_servers 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 From 229ba83432f2d7183f8b74b8419efbbff495510a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 18 Jun 2026 07:12:09 -0300 Subject: [PATCH 03/14] feat(THU-603): scope-aware upload handler + config flag --- backend/src/api/config.test.ts | 8 + backend/src/api/config.ts | 1 + backend/src/config/settings.ts | 6 + .../src/powersync/upload-handlers/registry.ts | 17 +- .../workspace-handlers.test.ts | 250 ++++++++++++++++++ .../upload-handlers/workspace-scoped.ts | 100 +++++-- backend/src/test-utils/settings.ts | 1 + 7 files changed, 360 insertions(+), 23 deletions(-) diff --git a/backend/src/api/config.test.ts b/backend/src/api/config.test.ts index b9f58105e..6def492dc 100644 --- a/backend/src/api/config.test.ts +++ b/backend/src/api/config.test.ts @@ -64,6 +64,14 @@ describe('Config Routes', () => { expect(forbidden.body.allowCustomAgents).toBe(false) }) + 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('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 c4ecd73c1..0cd6af6b6 100644 --- a/backend/src/api/config.ts +++ b/backend/src/api/config.ts @@ -18,6 +18,7 @@ export const createConfigRoutes = (settings: Settings) => allowAnonUsers: settings.authAllowAnonymous, allowWorkspaceCreationByAnon: settings.allowWorkspaceCreationByAnon, allowWorkspaceCreationByMembers: settings.allowWorkspaceCreationByMembers, + allowUserScopedResources: settings.allowUserScopedResources, // 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/config/settings.ts b/backend/src/config/settings.ts index b0d805fdf..fffd18abf 100644 --- a/backend/src/config/settings.ts +++ b/backend/src/config/settings.ts @@ -56,6 +56,11 @@ const settingsSchema = z // 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), oidcClientId: z.string().default(''), oidcClientSecret: z.string().default(''), oidcIssuer: z.string().default(''), @@ -182,6 +187,7 @@ const parseSettings = (): Settings => { 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', oidcClientId: process.env.OIDC_CLIENT_ID || '', oidcClientSecret: process.env.OIDC_CLIENT_SECRET || '', oidcIssuer: process.env.OIDC_ISSUER || '', diff --git a/backend/src/powersync/upload-handlers/registry.ts b/backend/src/powersync/upload-handlers/registry.ts index 0d1d9a13f..4c27b71a0 100644 --- a/backend/src/powersync/upload-handlers/registry.ts +++ b/backend/src/powersync/upload-handlers/registry.ts @@ -37,10 +37,14 @@ export const handlers: Record = { chat_messages: createWorkspaceScopedHandler({ tableName: 'chat_messages', userPrivate: true }), tasks: createWorkspaceScopedHandler({ tableName: 'tasks', userPrivate: true }), - // Workspace-scoped, shared (any member of the workspace may read/write). + // 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', @@ -48,24 +52,27 @@ export const handlers: Record = { mcp_servers: createWorkspaceScopedHandler({ tableName: 'mcp_servers', userPrivate: false, + scopeAware: true, addPermissionKey: 'add_mcp_servers', removePermissionKey: 'remove_mcp_servers', softDeleteColumn: 'deleted_at', }), - prompts: createWorkspaceScopedHandler({ tableName: 'prompts', userPrivate: false }), + 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 }), - modes: createWorkspaceScopedHandler({ tableName: 'modes', userPrivate: false }), - model_profiles: createWorkspaceScopedHandler({ tableName: 'model_profiles', userPrivate: false }), + 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', diff --git a/backend/src/powersync/upload-handlers/workspace-handlers.test.ts b/backend/src/powersync/upload-handlers/workspace-handlers.test.ts index eb0f5d6d5..6999ee30b 100644 --- a/backend/src/powersync/upload-handlers/workspace-handlers.test.ts +++ b/backend/src/powersync/upload-handlers/workspace-handlers.test.ts @@ -2002,4 +2002,254 @@ describe('workspace upload handlers', () => { 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 cannot flip scope (silently dropped)', 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: 'changed' }, + } + 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('workspace') + expect(stored[0].description).toBe('changed') + }) + + 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') + }) + }) }) diff --git a/backend/src/powersync/upload-handlers/workspace-scoped.ts b/backend/src/powersync/upload-handlers/workspace-scoped.ts index 95b421964..2336b5a2e 100644 --- a/backend/src/powersync/upload-handlers/workspace-scoped.ts +++ b/backend/src/powersync/upload-handlers/workspace-scoped.ts @@ -50,41 +50,67 @@ export type WorkspaceScopedConfig = { * mcp_servers) 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` is set at create-time only — subsequent PATCHes silently drop it + * and a PUT-as-update preserves the existing row's scope. PUTs 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 optionally `user_id`) by primary - * key. Used by PATCH/DELETE validation to discover the workspace the operation - * targets — composite-PK tables have `(id, workspace_id)` so a bare id match must - * fan out across whatever rows share the id, but in practice rows are uuid-keyed - * and globally unique, so the first match is the row. + * Looks up the existing row's `workspace_id` (and `user_id`, plus `scope` for + * scope-aware tables) by primary key. Used by PATCH/DELETE validation to + * discover the workspace the operation targets — composite-PK tables have + * `(id, workspace_id)` so a bare id match must fan out across whatever rows + * share the id, but in practice rows are uuid-keyed and globally unique, so + * the first match is the row. */ const fetchRowScope = async ( tx: UploadTx, tableName: PowerSyncTableName, rowId: string, -): Promise<{ workspaceId: string; userId: string | null } | null> => { + scopeAware: boolean, +): Promise => { const table = powersyncTablesByName[tableName] as AnyPgTable & { workspaceId: AnyPgColumn userId: AnyPgColumn + scope?: AnyPgColumn } const pkColumn = powersyncPkColumn[tableName] - const rows = await tx - .select({ workspaceId: table.workspaceId, userId: table.userId }) - .from(table) - .where(eq(pkColumn, rowId)) - .limit(1) + const select: Record = { workspaceId: table.workspaceId, userId: table.userId } + if (scopeAware && table.scope) { + select.scope = table.scope + } + + const rows = await tx.select(select).from(table).where(eq(pkColumn, rowId)).limit(1) const row = rows[0] if (!row) { return null } + const rawScope = (row as { scope?: unknown }).scope return { workspaceId: row.workspaceId as string, userId: (row.userId as string | null) ?? null, + scope: rawScope === 'user' || rawScope === 'workspace' ? rawScope : null, } } @@ -103,7 +129,15 @@ const fetchRowScope = async ( * moved between workspaces via upload. */ export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): UploadHandler => { - const { tableName, userPrivate, denyColumns = [], addPermissionKey, removePermissionKey, softDeleteColumn } = cfg + const { + tableName, + userPrivate, + denyColumns = [], + addPermissionKey, + removePermissionKey, + softDeleteColumn, + scopeAware = false, + } = cfg /** * Classifies the op as 'add' (PUT, PATCH-edit, PATCH-restore) or 'remove' @@ -126,19 +160,38 @@ export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): Upload 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 === '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. - const resolvedWorkspaceId = targetWorkspaceId ?? (await fetchRowScope(tx, tableName, op.id))?.workspaceId + const existing = await fetchRowScope(tx, tableName, op.id, scopeAware) + const resolvedWorkspaceId = targetWorkspaceId ?? existing?.workspaceId if (!resolvedWorkspaceId) { return reject('permanent', 'WORKSPACE_ID_REQUIRED') } 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)) @@ -148,14 +201,14 @@ export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): Upload return allow() } - const scope = await fetchRowScope(tx, tableName, op.id) + const scope = await fetchRowScope(tx, tableName, op.id, scopeAware) if (!scope) { return reject('permanent', 'ROW_NOT_FOUND') } if (!(await isWorkspaceMember(tx, scope.workspaceId, ctx.userId))) { return reject('permanent', 'NOT_WORKSPACE_MEMBER') } - if (userPrivate && scope.userId !== ctx.userId) { + if (isRowOwnerOnly(scope) && scope.userId !== ctx.userId) { return reject('permanent', 'NOT_ROW_OWNER') } const requiredPermissionKey = opIntent(op) === 'remove' ? removePermissionKey : addPermissionKey @@ -181,7 +234,8 @@ export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): Upload 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))?.workspaceId + const resolvedWorkspaceId = + targetWorkspaceId ?? (await fetchRowScope(tx, tableName, op.id, scopeAware))?.workspaceId if (!resolvedWorkspaceId) { throw new UploadRejection('permanent', 'WORKSPACE_ID_REQUIRED') } @@ -212,6 +266,11 @@ export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): Upload // 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) { @@ -233,6 +292,11 @@ export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): Upload delete patchPayload.id delete patchPayload.user_id delete patchPayload.workspace_id + if (scopeAware) { + // `scope` is immutable after create — silently drop, mirroring the + // existing workspace_id / user_id behaviour. + delete patchPayload.scope + } for (const col of denyColumns) { delete patchPayload[col] } @@ -243,7 +307,7 @@ export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): Upload // 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) + const patchScope = await fetchRowScope(tx, tableName, op.id, scopeAware) if (!patchScope) { throw new UploadRejection('permanent', 'ROW_NOT_FOUND') } @@ -260,7 +324,7 @@ export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): Upload return } case 'DELETE': { - const deleteScope = await fetchRowScope(tx, tableName, op.id) + const deleteScope = await fetchRowScope(tx, tableName, op.id, scopeAware) if (!deleteScope) { throw new UploadRejection('permanent', 'ROW_NOT_FOUND') } diff --git a/backend/src/test-utils/settings.ts b/backend/src/test-utils/settings.ts index 88b2449ab..2b13ad533 100644 --- a/backend/src/test-utils/settings.ts +++ b/backend/src/test-utils/settings.ts @@ -28,6 +28,7 @@ export const createTestSettings = (overrides: Partial = {}): Settings authAllowAnonymous: false, allowWorkspaceCreationByAnon: false, allowWorkspaceCreationByMembers: false, + allowUserScopedResources: true, oidcClientId: '', oidcClientSecret: '', oidcIssuer: '', From 33acc360a2b4585d84bfb45e75128d26f8d32820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 18 Jun 2026 07:20:21 -0300 Subject: [PATCH 04/14] feat(THU-603): thread scope through FE config and DAL --- src/api/config-store.ts | 9 +++++++++ src/dal/agents.test.ts | 28 ++++++++++++++++++++++++++++ src/dal/agents.ts | 4 ++++ src/dal/mcp-servers.ts | 6 ++++-- src/dal/model-profiles.ts | 6 +++++- src/dal/models.ts | 10 ++++++---- src/dal/prompts.ts | 6 ++++-- src/dal/skills.test.ts | 22 ++++++++++++++++++++++ src/dal/skills.ts | 11 +++++++++-- src/dal/triggers.ts | 6 ++++-- 10 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/api/config-store.ts b/src/api/config-store.ts index e83727c0f..8d4cf3c3f 100644 --- a/src/api/config-store.ts +++ b/src/api/config-store.ts @@ -26,6 +26,11 @@ export type AppConfig = { * built-in agent shown, custom agents allowed. */ builtInAgentEnabled?: boolean allowCustomAgents?: boolean + /** 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 } type ConfigStore = { @@ -52,3 +57,7 @@ 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 diff --git a/src/dal/agents.test.ts b/src/dal/agents.test.ts index 297088df4..8c68b666c 100644 --- a/src/dal/agents.test.ts +++ b/src/dal/agents.test.ts @@ -106,6 +106,34 @@ describe('agents DAL', () => { const row = await getDb().select().from(agentsTable).get() expect(row?.enabled).toBe(1) }) + + it("defaults scope to 'workspace' when input omits it (THU-603)", async () => { + await createAgent(getDb(), wsId, { + id: 'agent-scope-default', + name: 'Defaults', + type: 'remote-acp', + transport: 'websocket', + url: 'wss://x', + userId: 'u1', + }) + const row = await getDb().select().from(agentsTable).get() + expect(row?.scope).toBe('workspace') + }) + + it("persists scope='user' for user-private agents (THU-603)", async () => { + await createAgent(getDb(), wsId, { + id: 'agent-private', + name: 'Private', + type: 'remote-acp', + transport: 'websocket', + url: 'wss://x', + userId: 'u1', + scope: 'user', + }) + const row = await getDb().select().from(agentsTable).get() + expect(row?.scope).toBe('user') + expect(row?.userId).toBe('u1') + }) }) describe('updateAgent', () => { diff --git a/src/dal/agents.ts b/src/dal/agents.ts index e6c22ad7d..05bd13ea9 100644 --- a/src/dal/agents.ts +++ b/src/dal/agents.ts @@ -123,6 +123,9 @@ export type CreateAgentInput = { icon?: string | null enabled?: 0 | 1 userId: string + /** Per-row visibility (THU-603). `'workspace'` (default) shares with all + * members; `'user'` keeps the agent private to its author. */ + scope?: 'workspace' | 'user' } /** Insert a new custom agent into the synced table in the given workspace. @@ -143,6 +146,7 @@ export const createAgent = async ( enabled: data.enabled ?? 1, userId: data.userId, workspaceId, + scope: data.scope ?? 'workspace', }) } diff --git a/src/dal/mcp-servers.ts b/src/dal/mcp-servers.ts index 8e086ca53..c51c66a02 100644 --- a/src/dal/mcp-servers.ts +++ b/src/dal/mcp-servers.ts @@ -71,12 +71,14 @@ export const deleteMcpServer = async (db: AnyDrizzleDatabase, workspaceId: strin } /** - * Creates a new MCP server in the given workspace + * Creates a new MCP server in the given workspace. Defaults `scope` to + * `'workspace'`; pass `scope: 'user'` (with a matching `userId`) to make the + * row private to its author (THU-603). */ export const createMcpServer = async ( db: AnyDrizzleDatabase, workspaceId: string, data: Partial & Pick, ): Promise => { - await db.insert(mcpServersTable).values({ ...data, workspaceId }) + await db.insert(mcpServersTable).values({ ...data, workspaceId, scope: data.scope ?? 'workspace' }) } diff --git a/src/dal/model-profiles.ts b/src/dal/model-profiles.ts index 19bf0b514..4ddd369f8 100644 --- a/src/dal/model-profiles.ts +++ b/src/dal/model-profiles.ts @@ -58,11 +58,14 @@ export const upsertModelProfile = async ( }) } -/** Create default profile for a model in the given workspace using seed data */ +/** Create default profile for a model in the given workspace using seed data. + * `scope` mirrors the parent model's scope so a user-private model's profile + * stays in the same per-user bucket (THU-603). */ export const createDefaultModelProfile = async ( db: AnyDrizzleDatabase, workspaceId: string, modelId: string, + scope: 'workspace' | 'user' = 'workspace', ): Promise => { const defaultProfile = defaultModelProfiles.find((p) => p.modelId === modelId) if (!defaultProfile) { @@ -75,6 +78,7 @@ export const createDefaultModelProfile = async ( ...defaultProfile, defaultHash: hashModelProfile(defaultProfile), workspaceId, + scope, }) .onConflictDoNothing() } diff --git a/src/dal/models.ts b/src/dal/models.ts index b131d5912..e94ed3909 100644 --- a/src/dal/models.ts +++ b/src/dal/models.ts @@ -234,19 +234,21 @@ export const deleteModel = async (db: AnyDrizzleDatabase, workspaceId: string, i } /** - * Creates a new model in the given workspace + * Creates a new model in the given workspace. Defaults `scope` to `'workspace'` + * when the caller doesn't set it explicitly; pass `scope: 'user'` with a + * matching `userId` to make the row private to its author (THU-603). */ export const createModel = async ( db: AnyDrizzleDatabase, workspaceId: string, data: Partial & Pick, ): Promise => { - const { apiKey, ...modelData } = data + const { apiKey, scope, ...modelData } = data await db.transaction(async (tx) => { - await tx.insert(modelsTable).values({ ...modelData, workspaceId }) + await tx.insert(modelsTable).values({ ...modelData, workspaceId, scope: scope ?? 'workspace' }) if (apiKey != null) { await tx.insert(modelsSecretsTable).values({ modelId: data.id, apiKey }) } - await createDefaultModelProfile(tx, workspaceId, data.id) + await createDefaultModelProfile(tx, workspaceId, data.id, scope ?? 'workspace') }) } diff --git a/src/dal/prompts.ts b/src/dal/prompts.ts index ebfb724f8..15382ef7f 100644 --- a/src/dal/prompts.ts +++ b/src/dal/prompts.ts @@ -193,14 +193,16 @@ export const getPrompt = async (db: AnyDrizzleDatabase, workspaceId: string, id: } /** - * Creates a new prompt/automation in the given workspace. + * Creates a new prompt/automation in the given workspace. Defaults `scope` to + * `'workspace'`; pass `scope: 'user'` with a matching `userId` to make the row + * private to its author (THU-603). */ export const createAutomation = async ( db: AnyDrizzleDatabase, workspaceId: string, data: Partial & Pick, ): Promise => { - await db.insert(promptsTable).values({ ...data, workspaceId }) + await db.insert(promptsTable).values({ ...data, workspaceId, scope: data.scope ?? 'workspace' }) } /** diff --git a/src/dal/skills.test.ts b/src/dal/skills.test.ts index 7fe58b726..c79f2aada 100644 --- a/src/dal/skills.test.ts +++ b/src/dal/skills.test.ts @@ -118,6 +118,28 @@ describe('skills DAL', () => { await expect(seed({ name: '-leading-hyphen' })).rejects.toBeInstanceOf(SkillNameInvalidError) await expect(seed({ name: 'double--hyphen' })).rejects.toBeInstanceOf(SkillNameInvalidError) }) + + it("defaults scope to 'workspace' when input omits it (THU-603)", async () => { + const skill = await seed({ name: 'default-scope' }) + expect(skill.scope).toBe('workspace') + const fetched = await getSkill(getDb(), wsId, skill.id) + expect(fetched?.scope).toBe('workspace') + }) + + it("persists scope='user' with userId on the row (THU-603)", async () => { + const skill = await createSkill(getDb(), wsId, { + name: 'private-skill', + description: 'Only me', + instruction: 'do it', + scope: 'user', + userId: 'user-1', + }) + expect(skill.scope).toBe('user') + expect(skill.userId).toBe('user-1') + const fetched = await getSkill(getDb(), wsId, skill.id) + expect(fetched?.scope).toBe('user') + expect(fetched?.userId).toBe('user-1') + }) }) describe('updateSkill', () => { diff --git a/src/dal/skills.ts b/src/dal/skills.ts index d88db2c1a..4da4e271f 100644 --- a/src/dal/skills.ts +++ b/src/dal/skills.ts @@ -160,6 +160,13 @@ export type CreateSkillInput = { name: string description: string instruction: string + /** Per-row visibility. Defaults to `'workspace'` — shared with every member. + * `'user'` keeps the row private to its author within the workspace (THU-603). */ + scope?: 'workspace' | 'user' + /** Owner of the row. Required for `scope: 'user'` so the BE handler / sync + * rules can resolve the per-user bucket; pass the active user's id from the + * caller's session. Optional for `scope: 'workspace'` (any member may write). */ + userId?: string | null } /** @@ -186,9 +193,9 @@ export const createSkill = async ( pinnedOrder: null, deletedAt: null, defaultHash: null, - userId: null, + userId: input.userId ?? null, workspaceId, - scope: 'workspace', + scope: input.scope ?? 'workspace', } await db.insert(skillsTable).values(row) return row diff --git a/src/dal/triggers.ts b/src/dal/triggers.ts index ad4fd537a..e0e031e50 100644 --- a/src/dal/triggers.ts +++ b/src/dal/triggers.ts @@ -90,12 +90,14 @@ export const deleteTriggersForPrompts = async ( } /** - * Creates a new trigger in the given workspace + * Creates a new trigger in the given workspace. Defaults `scope` to `'workspace'` + * — triggers typically belong to a shared prompt, but the column is plumbed + * through for parity with the other resource tables (THU-603). */ export const createTrigger = async ( db: AnyDrizzleDatabase, workspaceId: string, data: Partial & Pick, ): Promise => { - await db.insert(triggersTable).values({ ...data, workspaceId }) + await db.insert(triggersTable).values({ ...data, workspaceId, scope: data.scope ?? 'workspace' }) } From 3762b4951fb8771dcb01a4fc80f8b47716fde834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 18 Jun 2026 10:41:56 -0300 Subject: [PATCH 05/14] feat(THU-603): add scope picker primitive and gate hook --- src/components/scope-picker.test.tsx | 54 ++++++++++ src/components/scope-picker.tsx | 108 +++++++++++++++++++ src/hooks/use-scope-picker-enabled.test.tsx | 113 ++++++++++++++++++++ src/hooks/use-scope-picker-enabled.ts | 29 +++++ 4 files changed, 304 insertions(+) create mode 100644 src/components/scope-picker.test.tsx create mode 100644 src/components/scope-picker.tsx create mode 100644 src/hooks/use-scope-picker-enabled.test.tsx create mode 100644 src/hooks/use-scope-picker-enabled.ts 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..28bfc0f05 --- /dev/null +++ b/src/components/scope-picker.tsx @@ -0,0 +1,108 @@ +/* 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} + // `pointer-events-none` blocks the click without applying the + // disabled-state opacity dim — read-only should look "informational", + // not "unavailable." + className={readOnly ? 'pointer-events-none' : undefined} + > + {/* 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. */} + + + Workspace + + + + Private + + +

+ {hint} +

+
+ ) +} diff --git a/src/hooks/use-scope-picker-enabled.test.tsx b/src/hooks/use-scope-picker-enabled.test.tsx new file mode 100644 index 000000000..590d498f4 --- /dev/null +++ b/src/hooks/use-scope-picker-enabled.test.tsx @@ -0,0 +1,113 @@ +/* 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 { useConfigStore } from '@/api/config-store' +import { DatabaseProvider } from '@/contexts' +import { otherWsId, resetTestDatabase, setupTestDatabase, teardownTestDatabase, wsId } from '@/dal/test-utils' +import { getDb } from '@/db/database' +import { workspacesTable } from '@/db/tables' +import { useActiveWorkspace } from '@/lib/active-workspace' +import { + renderWithReactivity, + resetTestTrustDomain, + seedTestTrustDomain, + waitForElement, +} from '@/test-utils/powersync-reactivity-test' +import '@testing-library/jest-dom' +import { cleanup, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import type { ReactNode } from 'react' +import { useScopePickerEnabled } from './use-scope-picker-enabled' + +const DbWrapper = ({ children }: { children: ReactNode }) => ( + {children} +) + +const Probe = () => { + const enabled = useScopePickerEnabled() + // Surface the resolved workspace id alongside the boolean so tests can wait + // for the underlying query to settle before reading `enabled` — otherwise the + // initial-render `false` (workspace pending) is indistinguishable from the + // intentional `false` (resolved-as-personal). + const workspace = useActiveWorkspace() + return ( + <> + {workspace?.id ?? 'none'} + {String(enabled)} + + ) +} + +const seedSharedWorkspace = async () => { + await getDb().insert(workspacesTable).values({ id: otherWsId, name: 'Acme', isPersonal: 0, ownerUserId: null }) +} + +const renderAt = (route: string) => + renderWithReactivity(, { + route, + routePath: '/*', + tables: ['workspaces'], + wrapper: DbWrapper, + }) + +const waitForWorkspace = (expectedId: string) => + waitForElement(() => + screen.getByTestId('workspace-id').textContent === expectedId ? screen.getByTestId('workspace-id') : null, + ) + +describe('useScopePickerEnabled', () => { + beforeAll(async () => { + await setupTestDatabase() + }) + + afterAll(async () => { + await teardownTestDatabase() + }) + + beforeEach(async () => { + await resetTestDatabase() + seedTestTrustDomain() + // Reset the persisted config store between tests so the + // `allowUserScopedResources` flag from one case doesn't leak into the next. + useConfigStore.setState({ config: {} }) + }) + + afterEach(() => { + resetTestTrustDomain() + cleanup() + }) + + it('returns false in a personal workspace (single-member; the workspace/user split is meaningless)', async () => { + // The default fixture seeds a personal workspace under `wsId` — visiting + // its unprefixed URL resolves the active workspace as personal. + renderAt('/skills') + await waitForWorkspace(wsId) + expect(screen.getByTestId('enabled').textContent).toBe('false') + }) + + it('returns true in a shared workspace when allowUserScopedResources is on (default)', async () => { + await seedSharedWorkspace() + renderAt(`/w/${otherWsId}/skills`) + await waitForWorkspace(otherWsId) + expect(screen.getByTestId('enabled').textContent).toBe('true') + }) + + it('returns false when allowUserScopedResources is disabled by the deployment', async () => { + await seedSharedWorkspace() + useConfigStore.setState({ config: { allowUserScopedResources: false } }) + + renderAt(`/w/${otherWsId}/skills`) + await waitForWorkspace(otherWsId) + expect(screen.getByTestId('enabled').textContent).toBe('false') + }) + + it('treats an absent allowUserScopedResources as allowed (matches BE default)', async () => { + await seedSharedWorkspace() + useConfigStore.setState({ config: {} }) + + renderAt(`/w/${otherWsId}/skills`) + await waitForWorkspace(otherWsId) + expect(screen.getByTestId('enabled').textContent).toBe('true') + }) +}) diff --git a/src/hooks/use-scope-picker-enabled.ts b/src/hooks/use-scope-picker-enabled.ts new file mode 100644 index 000000000..1932ba1b3 --- /dev/null +++ b/src/hooks/use-scope-picker-enabled.ts @@ -0,0 +1,29 @@ +/* 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 { selectAllowUserScopedResources, useConfigStore } from '@/api/config-store' +import { useActiveWorkspace } from '@/lib/active-workspace' + +/** + * True when the create-resource scope picker should be mountable — the + * deployment flag `allowUserScopedResources` is enabled AND the active + * workspace is shared (non-personal). Personal workspaces have a single + * member, so the workspace vs user distinction collapses to a single state; + * we keep the UI free of a no-op control. + * + * Returns `false` while the active workspace is still resolving (PowerSync + * hasn't synced the row yet) — defers the picker until we know which kind of + * workspace we're in. + */ +export const useScopePickerEnabled = (): boolean => { + const allow = useConfigStore((state) => selectAllowUserScopedResources(state.config)) + const workspace = useActiveWorkspace() + if (!allow) { + return false + } + if (!workspace) { + return false + } + return workspace.isPersonal !== 1 +} From f931ed961349faf10c1be48cc2a9d695f3ef8a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 18 Jun 2026 10:42:14 -0300 Subject: [PATCH 06/14] feat(THU-603): expose scope picker in resource UIs --- .../agents/add-custom-agent-dialog.test.tsx | 3 + .../agents/add-custom-agent-dialog.tsx | 28 +++++ src/dal/skills.ts | 2 +- src/routes/settings/agents/index.tsx | 4 + src/settings/mcp-servers.tsx | 33 ++++- src/settings/models/index.tsx | 64 ++++++++++ src/skills/skill-detail.test.tsx | 40 +++++- src/skills/skill-detail.tsx | 11 ++ src/skills/skill-form.tsx | 42 ++++++- src/skills/skills-view.test.tsx | 115 +++++++++++++++++- src/skills/skills-view.tsx | 45 ++++++- src/stories/SkillForm.stories.tsx | 1 + 12 files changed, 373 insertions(+), 15 deletions(-) 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 8140660e8..bda4a3ecb 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.test.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.test.tsx @@ -147,6 +147,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) diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index b3c66bc9c..2a91717ce 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' @@ -60,6 +61,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. @@ -76,12 +80,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' @@ -92,6 +104,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' } @@ -103,6 +116,7 @@ const initialState: AgentDialogState = { name: '', url: '', description: '', + scope: 'workspace', submitting: false, isTestingConnection: false, connectionStatus: 'idle', @@ -119,6 +133,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': @@ -142,6 +158,7 @@ export const AddCustomAgentDialog = ({ onSubmit, isIos, testAcpConnection = defaultTestAcpConnection, + showScopePicker = false, }: AddCustomAgentDialogProps) => { const [state, dispatch] = useReducer(agentDialogReducer, initialState) @@ -188,6 +205,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' }) @@ -204,6 +224,14 @@ export const AddCustomAgentDialog = ({
+ {showScopePicker && ( + dispatch({ type: 'SET_SCOPE', value })} + disabled={state.submitting} + /> + )}
> +export type UpdateSkillInput = Partial> /** * Patch an existing skill in the given workspace. Throws {@link SkillNameInvalidError} diff --git a/src/routes/settings/agents/index.tsx b/src/routes/settings/agents/index.tsx index 78f3a307f..98ffbcd63 100644 --- a/src/routes/settings/agents/index.tsx +++ b/src/routes/settings/agents/index.tsx @@ -17,6 +17,7 @@ import { useAuth } from '@/contexts' import { useActiveWorkspaceId, useWorkspaceUrl } from '@/lib/active-workspace' import { selectAllowCustomAgents, useConfigStore } from '@/api/config-store' import { useAgentsSettingsHidden } from '@/hooks/use-agents-settings-hidden' +import { useScopePickerEnabled } from '@/hooks/use-scope-picker-enabled' import { useWorkspacePermission as useWorkspacePermission_default } from '@/hooks/use-workspace-permission' import type { Agent } from '@/types/acp' @@ -50,6 +51,7 @@ export default function AgentsSettingsPage({ const currentUserId = session?.user?.id ?? null const agentsHidden = useAgentsSettingsHidden({ isStandalone }) const allowCustomAgents = useConfigStore((state) => selectAllowCustomAgents(state.config)) + const scopePickerEnabled = useScopePickerEnabled() const settingsUrl = useWorkspaceUrl('/settings') // Workspace `add_agents` / `remove_agents` permissions — BE enforces too, FE // just hides affordances so the user isn't presented with actions that @@ -105,6 +107,7 @@ export default function AgentsSettingsPage({ description: payload.description, enabled: 1, userId: currentUserId, + scope: payload.scope, }) } @@ -139,6 +142,7 @@ export default function AgentsSettingsPage({ onOpenChange={setDialogOpen} onSubmit={handleSubmit} testAcpConnection={testAcpConnection} + showScopePicker={scopePickerEnabled} />
) diff --git a/src/settings/mcp-servers.tsx b/src/settings/mcp-servers.tsx index d1b3ac412..2aa80c791 100644 --- a/src/settings/mcp-servers.tsx +++ b/src/settings/mcp-servers.tsx @@ -3,7 +3,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { AvailableTools } from '@/components/available-tools' +import { ScopePicker, type ResourceScope } from '@/components/scope-picker' import { StatusIndicator } from '@/components/status-indicator' +import { useScopePickerEnabled } from '@/hooks/use-scope-picker-enabled' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogTrigger } from '@/components/ui/dialog' @@ -21,6 +23,7 @@ import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createMcpServer, deleteMcpServer, getHttpMcpServers, updateMcpServer } from '@/dal' import { useDatabase } from '@/contexts' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' import { useWorkspacePermission as useWorkspacePermission_default } from '@/hooks/use-workspace-permission' import { useMcpSync } from '@/hooks/use-mcp-sync' import { useActiveWorkspaceId } from '@/lib/active-workspace' @@ -49,6 +52,16 @@ export default function McpServersPage({ }: McpServersPageProps = {}) { const db = useDatabase() const workspaceId = useActiveWorkspaceId() + const currentUserId = useTrustDomainRegistry((state) => { + if (state.activeTrustDomain?.kind === 'standalone') { + return state.localUserId + } + if (state.activeTrustDomain?.kind === 'server') { + return state.servers[state.activeTrustDomain.serverId]?.userId + } + return undefined + }) + const scopePickerEnabled = useScopePickerEnabled() // Workspace `add_mcp_servers` / `remove_mcp_servers` — BE enforces; FE // hides affordances so the user isn't presented with actions that // round-trip-fail. @@ -57,6 +70,7 @@ export default function McpServersPage({ const { servers: mcpServers } = useMcpSync() const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [newServerUrl, setNewServerUrl] = useState('') + const [newServerScope, setNewServerScope] = useState('workspace') const [isTestingConnection, setIsTestingConnection] = useState(false) const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle') const [serverCapabilities, setServerCapabilities] = useState([]) @@ -130,7 +144,7 @@ export default function McpServersPage({ }) const addServerMutation = useMutation({ - mutationFn: async ({ name, url }: { name: string; url: string }) => { + mutationFn: async ({ name, url, scope }: { name: string; url: string; scope: ResourceScope }) => { if (!workspaceId) { throw new Error('No active workspace') } @@ -139,11 +153,16 @@ export default function McpServersPage({ name, url, enabled: 1, + scope, + // userId is required for scope='user' to land in the per-user sync + // bucket. For 'workspace' rows it's informational (any member writes). + userId: currentUserId ?? null, }) }, onSuccess: () => { setIsAddDialogOpen(false) setNewServerUrl('') + setNewServerScope('workspace') setConnectionStatus('idle') setServerCapabilities([]) }, @@ -228,7 +247,13 @@ export default function McpServersPage({ const url = new URL(newServerUrl) const name = `${url.hostname}${url.port ? `:${url.port}` : ''} MCP Server` - addServerMutation.mutate({ name, url: newServerUrl }) + addServerMutation.mutate({ + name, + url: newServerUrl, + // When the picker is hidden (deployment off, or personal workspace) we + // fall back to workspace scope to match historical behavior. + scope: scopePickerEnabled ? newServerScope : 'workspace', + }) } const handleUrlKeyDown = (e: KeyboardEvent) => { @@ -339,6 +364,10 @@ export default function McpServersPage({ Add a new MCP server
+ {scopePickerEnabled && ( + + )} +
{ @@ -206,6 +210,7 @@ const editFormSchema = z.object({ model: z.string().min(1, { message: 'Model name is required.' }), url: z.string().optional(), apiKey: z.string().optional(), + scope: z.enum(['workspace', 'user']), }) const buildEditFormSchema = (provider: Model['provider']) => @@ -219,11 +224,17 @@ const EditModelForm = ({ onCancel, onSubmit, isPending, + showScopePicker = false, }: { model: Model onCancel: () => void onSubmit: (values: z.infer & { id: string }) => void isPending: boolean + /** Mount the scope picker (THU-603). Parent computes this as + * `scopePickerEnabled && active.userId === currentUserId` — i.e. only the + * row's author sees it in edit mode. Same pattern as `SkillForm` so the + * ownership rule looks identical across resources. */ + showScopePicker?: boolean }) => { const form = useForm>({ resolver: zodResolver(buildEditFormSchema(model.provider)), @@ -232,6 +243,7 @@ const EditModelForm = ({ model: model.model || '', url: model.url || '', apiKey: model.apiKey || '', + scope: model.scope ?? 'workspace', }, }) @@ -242,6 +254,20 @@ const EditModelForm = ({ return (
+ {showScopePicker && ( + ( + + + + + + + )} + /> + )} void onSubmit: (values: z.infer & { id: string }) => void isPending: boolean + showScopePicker?: boolean }) => ( @@ -339,6 +367,7 @@ const EditModelModal = ({ onCancel={() => onOpenChange(false)} onSubmit={onSubmit} isPending={isPending} + showScopePicker={showScopePicker} /> )} @@ -354,6 +383,16 @@ type ModelsPageProps = { export default function ModelsPage({ useWorkspacePermission = useWorkspacePermission_default }: ModelsPageProps = {}) { const db = useDatabase() const workspaceId = useActiveWorkspaceId() + const currentUserId = useTrustDomainRegistry((state) => { + if (state.activeTrustDomain?.kind === 'standalone') { + return state.localUserId + } + if (state.activeTrustDomain?.kind === 'server') { + return state.servers[state.activeTrustDomain.serverId]?.userId + } + return undefined + }) + const scopePickerEnabled = useScopePickerEnabled() const getProxyFetch = useProxyFetchGetter() const [state, dispatch] = useReducer(modelReducer, initialState) const [editingModel, setEditingModel] = useState(null) @@ -402,6 +441,10 @@ export default function ModelsPage({ useWorkspacePermission = useWorkspacePermis enabled: 1, toolUsage: values.toolUsage ? 1 : 0, contextWindow: null, + // Pass through the picker's scope when it was mounted; userId stamps + // the row's author so 'user' scope syncs into the per-user bucket. + scope: scopePickerEnabled ? values.scope : 'workspace', + userId: currentUserId ?? null, }) }, onSuccess: () => { @@ -429,6 +472,8 @@ export default function ModelsPage({ useWorkspacePermission = useWorkspacePermis throw new Error('No active workspace') } const { id, ...fields } = values + // `scope` flows through to updateModel — BE handler applies it for the + // row's owner and silently drops it for non-owners. await updateModel(db, workspaceId, id, { ...fields, apiKey: fields.apiKey || null, @@ -469,6 +514,7 @@ export default function ModelsPage({ useWorkspacePermission = useWorkspacePermis url: '', apiKey: '', toolUsage: true, + scope: 'workspace', }, }) @@ -932,6 +978,20 @@ export default function ModelsPage({ useWorkspacePermission = useWorkspacePermis + {scopePickerEnabled && ( + ( + + + + + + + )} + /> + )} !open && setEditingModel(null)} onSubmit={(values) => editModelMutation.mutate(values)} isPending={editModelMutation.isPending} + // Only the row's author sees the picker in edit mode — matches the + // SkillForm pattern and the BE handler's owner-only scope flip rule. + // Defensive null check covers pre-THU-603 rows without a recorded owner. + showScopePicker={scopePickerEnabled && editingModel?.userId != null && editingModel.userId === currentUserId} /> {/* Delete Confirmation */} diff --git a/src/skills/skill-detail.test.tsx b/src/skills/skill-detail.test.tsx index e500a72ef..2f6c3bf65 100644 --- a/src/skills/skill-detail.test.tsx +++ b/src/skills/skill-detail.test.tsx @@ -10,7 +10,14 @@ import { SkillDetail } from './skill-detail' afterEach(cleanup) -const renderDetail = (props: { canEdit?: boolean; canDelete?: boolean } = {}) => { +const renderDetail = ( + props: { + canEdit?: boolean + canDelete?: boolean + scope?: 'workspace' | 'user' + showScope?: boolean + } = {}, +) => { render( enabled canEdit={props.canEdit} canDelete={props.canDelete} + scope={props.scope} + showScope={props.showScope} onToggleEnabled={mock(() => {})} onEdit={mock(() => {})} onDelete={mock(() => {})} @@ -64,3 +73,32 @@ describe('SkillDetail — permission gating', () => { expect(screen.getByText('Delete')).toBeInTheDocument() }) }) + +describe('SkillDetail — read-only scope picker (THU-603)', () => { + it('does not render the scope picker when showScope is false', () => { + renderDetail({ scope: 'workspace', showScope: false }) + expect(screen.queryByRole('radio', { name: /shared with the workspace/i })).not.toBeInTheDocument() + }) + + it("reflects scope='workspace' as the selected option, read-only", () => { + renderDetail({ scope: 'workspace', showScope: true }) + const workspaceItem = screen.getByRole('radio', { name: /shared with the workspace/i }) + expect(workspaceItem).toHaveAttribute('data-state', 'on') + // Read-only must not dim the items (no disabled attribute) — the picker is + // informational, not an "unavailable" control. + expect(workspaceItem).not.toBeDisabled() + expect(screen.getByText(/shared with everyone/i)).toBeInTheDocument() + }) + + it("reflects scope='user' as the selected option with the private hint", () => { + renderDetail({ scope: 'user', showScope: true }) + const privateItem = screen.getByRole('radio', { name: /private to you/i }) + expect(privateItem).toHaveAttribute('data-state', 'on') + expect(screen.getByText(/only you can see/i)).toBeInTheDocument() + }) + + it('does not render the picker when scope is undefined (defensive)', () => { + renderDetail({ scope: undefined, showScope: true }) + expect(screen.queryByRole('radio', { name: /shared with the workspace/i })).not.toBeInTheDocument() + }) +}) diff --git a/src/skills/skill-detail.tsx b/src/skills/skill-detail.tsx index a5bcca0c1..e04487270 100644 --- a/src/skills/skill-detail.tsx +++ b/src/skills/skill-detail.tsx @@ -15,6 +15,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { ScopePicker, type ResourceScope } from '@/components/scope-picker' import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useIsMobile } from '@/hooks/use-mobile' @@ -29,6 +30,8 @@ export const SkillDetail = ({ description, instruction, enabled, + scope, + showScope, canEdit = true, canDelete = true, onToggleEnabled, @@ -40,6 +43,12 @@ export const SkillDetail = ({ description: string instruction: string enabled: boolean + /** Read-only display of the row's scope. Mounted only when `showScope` is true. */ + scope?: ResourceScope + /** Whether to render the read-only scope picker (THU-603). Parent gates on + * `useScopePickerEnabled` so the control disappears in personal workspaces + * and on deployments that disabled the feature. */ + showScope?: boolean /** Defaults to true. Mirrors `add_skills`; gates the enable toggle + Edit menu item. */ canEdit?: boolean /** Defaults to true. Mirrors the workspace `remove_skills` permission. */ @@ -155,6 +164,8 @@ export const SkillDetail = ({
+ {showScope && scope && {}} readOnly />} + void onSubmit: (values: SkillFormValues) => void @@ -46,6 +51,9 @@ export const SkillForm = ({ initialValues?: SkillFormValues /** Inline name-uniqueness error from the DAL pre-check. */ nameError?: string | null + /** Mount the per-row scope picker (THU-603). Production callers pass the + * value of `useScopePickerEnabled()`; tests/stories default to `false`. */ + showScopePicker?: boolean }) => { // Strip a leading `/` defensively — names are stored bare per the // AgentSkills spec, but legacy rows from before THU-534 landed may still @@ -53,10 +61,16 @@ export const SkillForm = ({ const initialName = (initialValues?.name ?? '').replace(/^\/+/, '') const initialDescription = initialValues?.description ?? '' const initialInstruction = initialValues?.instruction ?? '' + const initialScope: ResourceScope = initialValues?.scope ?? 'workspace' const [name, setName] = useState(initialName) const [description, setDescription] = useState(initialDescription) const [instruction, setInstruction] = useState(initialInstruction) + const [scope, setScope] = useState(initialScope) + // The parent decides when the picker is interactive — for edit mode it + // typically also requires the active user to be the row's author (the BE + // applies scope changes only when the caller owns the row). + const renderScopePicker = showScopePicker // Auto-focus the name input on mount for `create` mode — the user just // clicked "+", they're about to type a name. Edit mode skips this so we @@ -88,15 +102,21 @@ export const SkillForm = ({ // Compute dirty against a hypothetical next-state so each onChange handler // can report it before React has applied the setState. Avoids the // useEffect-notifying-parent anti-pattern. - const computeDirty = (next: { name: string; description: string; instruction: string }) => + const computeDirty = (next: { name: string; description: string; instruction: string; scope: ResourceScope }) => mode === 'edit' - ? next.name !== initialName || next.description !== initialDescription || next.instruction !== initialInstruction - : next.name.length > 0 || next.description.length > 0 || next.instruction.length > 0 + ? next.name !== initialName || + next.description !== initialDescription || + next.instruction !== initialInstruction || + next.scope !== initialScope + : next.name.length > 0 || + next.description.length > 0 || + next.instruction.length > 0 || + next.scope !== initialScope const handleNameChange = (raw: string) => { const v = raw.replace(/^\/+/, '') setName(v) - onDirtyChange?.(computeDirty({ name: v, description, instruction })) + onDirtyChange?.(computeDirty({ name: v, description, instruction, scope })) // A "name already exists" error from the parent applies to the *previous* // value; clear it as soon as the user edits so they don't see a stale // message about a name they're no longer trying to submit. @@ -104,11 +124,15 @@ export const SkillForm = ({ } const handleDescriptionChange = (v: string) => { setDescription(v) - onDirtyChange?.(computeDirty({ name, description: v, instruction })) + onDirtyChange?.(computeDirty({ name, description: v, instruction, scope })) } const handleInstructionChange = (v: string) => { setInstruction(v) - onDirtyChange?.(computeDirty({ name, description, instruction: v })) + onDirtyChange?.(computeDirty({ name, description, instruction: v, scope })) + } + const handleScopeChange = (next: ResourceScope) => { + setScope(next) + onDirtyChange?.(computeDirty({ name, description, instruction, scope: next })) } const [prevResetSignal, setPrevResetSignal] = useState(resetSignal) @@ -117,6 +141,7 @@ export const SkillForm = ({ setName(initialName) setDescription(initialDescription) setInstruction(initialInstruction) + setScope(initialScope) // Parent already knows it triggered the reset; it sets its own isDirty // back to false in the same handler, so no notification needed here. } @@ -129,6 +154,7 @@ export const SkillForm = ({ name: name.trim(), description: description.trim(), instruction: instruction.trim(), + scope, }) } @@ -137,6 +163,10 @@ export const SkillForm = ({

{mode === 'edit' ? 'Edit Skill' : 'Create Skill'}

+ {renderScopePicker && ( + + )} +