From 9e86f37e21532f8e5e62d8a5e4c83beb26d93b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Vieira=20Adriano?= Date: Tue, 16 Jun 2026 17:11:11 -0300 Subject: [PATCH 01/20] feat(THU-549): Workspaces Foundation (#932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(THU-549): serverId + extended /v1/config payload * feat(THU-549): trust-domain registry + namespaced auth/device/keys * feat(THU-549): boot env vars + trust-domain decision tree * feat(THU-549): scope DB filename by trust domain + lifecycle broadcast * feat(THU-549): logout = unconditional wipe * feat(THU-549): scope cloud URL by active server * refactor(THU-549): collapse registry actions into activateServer/activateStandalone * feat(THU-549): persist per-server session on ServerEntry + wire mirror * refactor(THU-549): drop unused db-reopened event + subscribeDbLifecycle * refactor(THU-549): extract shared sync-worker-name format * fix(THU-549): pin SERVER_ID for e2e backends * fix(THU-549): address PR review — timeout, Tauri OPFS, logout loading state, test SERVER_ID, auth-token let * fix(THU-549): wipe IDB databases on logout + fix SSO redirect race + delete E2EE keys IDB * fix(THU-549): use getActiveCloudUrl in getSystemTinfoilClient * fix(THU-550): fix BE/FE tests broken by THU-549 trust-domain rebase * feat(THU-550): Workspaces data layer (#944) * feat(THU-550): BE workspaces tables + post-create bootstrap hook * feat(THU-550): PowerSync upload handler factory + workspace handlers * feat(THU-550): workspace_id on scoped tables + sync rules + ws handler * feat(THU-550): encrypt workspaces.name * feat(THU-550): shared deterministic personal-workspace id helpers * feat(THU-550): BE — FE-owned personal workspace creation * feat(THU-550): FE — workspace-aware DAL + post-auth bootstrap + gate * fix(THU-550): log permanently rejected upload ops * fix(THU-550): pin workspace_id in PATCH/DELETE WHERE clause * fix(THU-550): scope reconcile-defaults lookups and updates by workspace_id * fix(THU-550): force personal workspace name + shared workspace admin bootstrap * fix(THU-550): throw bootstrap error + update stale 400 upload test assertions * fix(THU-550): add workspaceId to model profile defaults + fix getSystemTinfoilClient cloud URL * fix(THU-550): restore BE test isolation broken by upload handler transactions * ci(THU-550): install root deps before BE type-check (shared/ uses uuid) * feat(THU-578): scope all FE DAL methods to active workspaceId (#951) * feat(THU-578): scope all FE DAL methods to active workspaceId * fix(THU-578): seed trust domain in eval CLI + scope model-profile upsert by workspace * fix(THU-578): retry hydration when workspaceId resolves after WorkspaceGate * fix(THU-578): evict stale chat session on workspace switch * fix(THU-578): scope prompt join in getTriggerPromptForThread to active workspace * fix(THU-578): fix random test failures from workspaceId resolution races * feat(THU-553): mode picker UI — layout, selection state, and connectivity (#958) * feat(THU-553): polish mode-picker UI — layout, selection state, and connectivity * fix(THU-553): DI for mode-picker, drop mock.module('@/lib/http') leakage * feat(THU-551): workspace selector in sidebar + URL-based active workspace (#961) * feat(THU-551): reactive active-workspace + URL helpers * feat(THU-551): useWorkspacesQuery hook + DAL test * feat(THU-551): WorkspaceMembershipGate + live membership query * feat(THU-551): mount workspace routes under /w/:workspaceId prefix * feat(THU-551): workspace selector in sidebar header * feat(THU-551): thread workspace prefix through navigation call sites * feat(THU-552): create workspace modal — name + invite by email (#965) * feat(THU-552): promote pending membership to active on email match * feat(THU-552): createSharedWorkspace DAL function * feat(THU-552): useCanCreateWorkspace hook * feat(THU-552): EmailChipInput component * feat(THU-552): CreateWorkspaceModal component * feat(THU-552): Create workspace button in sidebar selector footer * feat(THU-552): seed defaults into newly-created shared workspace * fix(THU-552): scope cleanupRemovedDefaults to a workspace * feat(THU-552): split create + invite into two modals, single Create CTA * feat(THU-552): workspace selector design polish * fix(THU-552): rename SCREAMING_SNAKE constants to camelCase * feat(THU-554): Settings → Workspace → General (rename, slug, icon, duplicate) (#971) * fix(THU-554): align PageHeader actions with content padding * feat(THU-554): allow personal workspace rename via upload handler * feat(THU-554): updateWorkspaceName DAL, default name, createdAt stamps * feat(THU-554): useActiveWorkspaceMembership hook * feat(THU-554): split settings sidebar into Account Settings and Workspace groups * feat(THU-554): Workspace Settings page with autosave rename * feat(THU-554): add slug + icon columns to workspaces * feat(THU-554): workspace slug input with auto-derive from name * feat(THU-554): workspace icon picker (emoji or image upload) * feat(THU-554): render workspace icon in the sidebar selector * feat(THU-554): share WorkspaceFormFields between settings and create modal * feat(THU-554): duplicate workspace action * feat(THU-555): Settings → Workspace → Members (add / remove / role / pending) (#974) * feat(THU-555): add user_name/user_email to workspace_memberships * feat(THU-555): enrich membership upload handler with user display info * feat(THU-555): propagate user name/email changes to memberships * feat(THU-555): mirror userName/userEmail on FE workspace_memberships * feat(THU-555): reactive members + pending queries * feat(THU-555): membership mutation DAL * feat(THU-555): useWorkspacePermission hook * feat(THU-555): RequireWorkspacePermission route wrapper * feat(THU-555): Members page scaffold + route * feat(THU-555): Members sidebar entry gated by manage_members * feat(THU-555): render members table with role dropdown * feat(THU-555): wire Remove member with confirmation * feat(THU-555): use globe icon for General sidebar entry * fix(THU-555): rename SCREAMING_SNAKE constant to camelCase * feat(THU-593): gate member management on E2EE-enabled servers * fix(chat-store): dedupe concurrent hydrateChatStore calls * feat(THU-556): Settings → Workspace → Permissions page + resource-CRUD enforcement (#978) * feat(THU-556): setWorkspacePermissionRequiredRole DAL * feat(THU-556): RequireWorkspaceAdmin route guard * refactor(THU-556): consolidate workspace permission keys into shared/workspaces.ts * feat(THU-556): Permissions page (11 keys, labels only) * feat(THU-556): Permissions sidebar entry * feat(THU-556): mount Permissions route * feat(THU-556): refine Permissions page copy and layout * feat(THU-556): scope Permissions to agents/skills/models/mcps * feat(THU-556): use Lock icon for Permissions sidebar entry * feat(THU-556): permissionAllows + BE permission lookup helpers * feat(THU-556): permission-key gating in workspace-scoped handler * feat(THU-556): enforce add_agents/remove_agents permissions * feat(THU-556): treat soft-delete PATCH as remove for permission gating * feat(THU-556): enforce add_skills/remove_skills permissions * feat(THU-556): enforce add_models/remove_models permissions * feat(THU-556): enforce add_mcp_servers/remove_mcp_servers permissions * feat(THU-556): gate edit + chat bypasses, add permission gating tests * fix(workspaces): address review comments across stacked PRs (#988) * fix(THU-549): clear auth token after signOut so server can revoke session * fix(THU-549): clear registry before db-closing so reloaded tabs land at ModePicker * fix(THU-549): guard LogoutModal handleLogout against rapid double-click * fix(THU-549): friendlier ZodError for unset/invalid SERVER_ID * fix(THU-549): remove Cloud URL dev-settings input (use ModePicker flow instead) * fix(THU-552): preserve existing membership role on promote-on-insert * fix(THU-552): delete pending row by (workspace_id, email) after promote-on-insert * fix(THU-552): skip onCreated when create-workspace modal dismissed mid-flight * fix(THU-554): preserve slug/icon on PUT when payload omits them * fix(THU-554): reflect remote workspace updates into settings form * fix(THU-554): append random suffix to duplicate workspace slug * fix(THU-555): resolve workspace permission state when user has no membership * fix(THU-555): gate member actions on granular permission keys * fix(THU-551): expose URL workspace id immediately + collapse chat-id on workspace switch * fix(THU-550): wrap BrowserRouter in ErrorBoundary so bootstrap throws surface a screen * chore: strip PR-ref noise from review-followup comments * fix(THU-578): reset isReady before chat session eviction on workspace switch * fix(THU-553): dedupe ModePicker validation between blur and Continue * fix(THU-552): force ctx.userId for invitedByUserId in pending memberships * fix(THU-552): reject malformed emails on pending membership writes * fix(THU-578): persist selectedAgent against session.workspaceId not thread * fix(THU-556): hide chat-skills-bar when no edit perm and no pinned chips * docs: refresh workspace-memberships handler docstring for permission-key model * test(THU-578): seed workspace context in LinkPreviewWidget fallback tests * fix(THU-553): reset isValidating on SET_URL to unstick Continue after stale blur * fix(THU-553): reset stage to picker on stale-URL bail-out in handleContinue * fix(THU-549): pass captured serverId to handleFullWipe after registry clear * fix(THU-555): align pending-row gates + block admin escalation via invite * refactor(THU-555): use isResolved for membership loading distinction * refactor(THU-555): assign openRef in render body, drop useEffect * fix(THU-555): widen duplicate-slug suffix to 8 chars for collision resistance * docs(THU-555): refresh stale comments referencing removed manage_members guard * fix(THU-555): mirror serverUrl ref synchronously in mode-picker onChange * fix(THU-555): sync pending memberships to all workspace members * fix(THU-555): require change_roles for membership PUT that changes existing role * fix(THU-549): keep getAuthToken resolvable through signOut after registry clear * fix(THU-555): add last-admin guard to membership PUT apply path * fix(THU-555): gate pending-row Admin option on change_roles --- .env.example | 8 +- .github/workflows/ci.yml | 7 + backend/.env.example | 18 +- backend/bun.lock | 9 +- backend/drizzle/0019_workspaces.sql | 53 + .../0020_workspace_id_on_scoped_tables.sql | 97 + backend/drizzle/0021_workspaces_slug_icon.sql | 7 + ...022_workspace_memberships_user_display.sql | 10 + backend/drizzle/meta/0019_snapshot.json | 556 ++- backend/drizzle/meta/0020_snapshot.json | 1003 +++++- backend/drizzle/meta/0021_snapshot.json | 3167 ++++++++++++++++ backend/drizzle/meta/0022_snapshot.json | 3179 +++++++++++++++++ backend/drizzle/meta/_journal.json | 25 +- backend/package.json | 1 + backend/src/api/account.test.ts | 5 + backend/src/api/config.test.ts | 25 + backend/src/api/config.ts | 4 + backend/src/api/powersync.test.ts | 127 +- backend/src/api/powersync.ts | 50 +- backend/src/auth/auth.ts | 24 + backend/src/config/settings.test.ts | 27 + backend/src/config/settings.ts | 23 + backend/src/dal/index.ts | 29 +- backend/src/dal/powersync.ts | 161 - backend/src/dal/users.test.ts | 4 +- backend/src/dal/users.ts | 4 +- backend/src/dal/workspaces.test.ts | 258 ++ backend/src/dal/workspaces.ts | 620 ++++ backend/src/db/powersync-schema.ts | 254 +- backend/src/lib/email.ts | 10 + .../src/powersync/upload-handlers/helpers.ts | 65 + .../src/powersync/upload-handlers/index.ts | 7 + .../upload-handlers/registry.test.ts | 33 + .../src/powersync/upload-handlers/registry.ts | 173 + .../src/powersync/upload-handlers/types.ts | 64 + .../powersync/upload-handlers/user-scoped.ts | 124 + .../workspace-handlers.test.ts | 2005 +++++++++++ .../upload-handlers/workspace-memberships.ts | 306 ++ .../workspace-pending-memberships.ts | 190 + .../upload-handlers/workspace-permissions.ts | 101 + .../upload-handlers/workspace-scoped.ts | 281 ++ .../powersync/upload-handlers/workspaces.ts | 204 ++ backend/src/test-utils/db.ts | 48 +- backend/src/test-utils/settings.ts | 3 + backend/src/test-utils/test-setup.ts | 4 + bun.lock | 3 + deploy/config/powersync-config.yaml | 57 +- deploy/k8s/templates/configmaps.yaml | 57 +- package.json | 1 + playwright.config.ts | 7 + powersync-service/config/config.yaml | 55 +- scripts/thunderdoctor.sh | 35 + shared/powersync-tables.ts | 8 + shared/workspaces.test.ts | 65 + shared/workspaces.ts | 105 + src/acp/transports/index.test.ts | 8 +- src/acp/transports/index.ts | 4 +- src/ai/eval/debug-single.ts | 7 +- src/ai/eval/run.ts | 4 + src/ai/eval/runner.ts | 19 +- src/ai/fetch.ts | 21 +- src/ai/prompt.test.ts | 1 + src/ai/step-logic.test.ts | 1 + src/api/config-store.ts | 14 + src/api/encryption.test.ts | 5 +- src/app.tsx | 133 +- src/automations/use-trigger-scheduler.ts | 15 +- src/chats/agent-routing.test.ts | 26 +- src/chats/chat-instance-permission.test.ts | 4 +- src/chats/chat-instance.test.ts | 10 +- src/chats/chat-instance.ts | 6 +- src/chats/chat-store.test.ts | 8 +- src/chats/chat-store.ts | 7 +- src/chats/detail.tsx | 9 +- src/chats/use-hydrate-chat-store.test.tsx | 43 +- src/chats/use-hydrate-chat-store.ts | 122 +- src/components/account-deleted.test.tsx | 1 + src/components/app-error-boundary.test.tsx | 44 + src/components/app-error-boundary.tsx | 50 + src/components/boot/mode-picker.test.tsx | 326 ++ src/components/boot/mode-picker.tsx | 320 ++ .../chat/chat-model-picker.test.tsx | 33 + src/components/chat/chat-model-picker.tsx | 20 +- src/components/chat/chat-skills-bar.test.tsx | 47 + src/components/chat/chat-skills-bar.tsx | 142 +- src/components/logout-modal.test.tsx | 337 +- src/components/logout-modal.tsx | 152 +- src/components/magic-link-verify.tsx | 17 + src/components/pending-device-modal.test.tsx | 5 +- src/components/revoked-device-modal.test.tsx | 123 +- src/components/revoked-device-modal.tsx | 117 +- .../settings/agents/agent-list.test.tsx | 42 + src/components/settings/agents/agent-list.tsx | 17 +- src/components/settings/agents/agent-row.tsx | 37 +- src/components/sidebar-footer.tsx | 7 +- src/components/sign-in-modal.test.tsx | 17 +- src/components/sign-in/sign-in-form.tsx | 4 +- .../sign-in/use-sign-in-form-state.ts | 19 + src/components/sso-redirect.tsx | 4 +- src/components/ui/chat-nav-button.tsx | 4 +- src/components/ui/email-chip-input.test.tsx | 138 + src/components/ui/email-chip-input.tsx | 206 ++ src/components/ui/header.test.tsx | 37 +- src/components/ui/header.tsx | 26 +- src/components/ui/page-header.tsx | 2 +- .../ui/searchable-menu/searchable-menu.tsx | 6 +- src/components/ui/searchable-menu/types.ts | 4 + src/components/ui/selectable-card.tsx | 80 - src/components/workspace-gate.tsx | 47 + .../workspace-membership-gate.test.tsx | 152 + src/components/workspace-membership-gate.tsx | 91 + src/components/workspace/icon-picker.tsx | 190 + src/components/workspace/icon-utils.test.ts | 29 + src/components/workspace/icon-utils.ts | 59 + .../workspace/workspace-form-fields.tsx | 175 + src/contexts/auth-context.test.ts | 3 +- src/contexts/auth-context.tsx | 87 +- src/crypto/key-storage.ts | 121 +- src/dal/agents.test.ts | 58 +- src/dal/agents.ts | 54 +- src/dal/chat-messages.test.ts | 66 +- src/dal/chat-messages.ts | 123 +- src/dal/chat-threads.test.ts | 227 +- src/dal/chat-threads.ts | 114 +- src/dal/index.ts | 50 + src/dal/mcp-servers.test.ts | 137 +- src/dal/mcp-servers.ts | 57 +- src/dal/model-profiles.test.ts | 89 +- src/dal/model-profiles.ts | 80 +- src/dal/models.test.ts | 288 +- src/dal/models.ts | 135 +- src/dal/modes.test.ts | 190 +- src/dal/modes.ts | 32 +- src/dal/prompts.test.ts | 64 +- src/dal/prompts.ts | 132 +- src/dal/skills.test.ts | 124 +- src/dal/skills.ts | 134 +- src/dal/tasks.test.ts | 174 +- src/dal/tasks.ts | 88 +- src/dal/test-utils.ts | 63 +- src/dal/triggers.test.ts | 291 +- src/dal/triggers.ts | 68 +- src/dal/workspace-memberships.test.tsx | 159 + src/dal/workspace-memberships.ts | 130 + .../workspace-pending-memberships.test.tsx | 208 ++ src/dal/workspace-pending-memberships.ts | 126 + src/dal/workspace-permissions.test.ts | 103 + src/dal/workspace-permissions.ts | 148 + src/dal/workspaces.test.tsx | 727 ++++ src/dal/workspaces.ts | 568 +++ src/db/database-path.test.ts | 21 + src/db/database-path.ts | 20 + src/db/db-lifecycle-broadcast.test.ts | 28 + src/db/db-lifecycle-broadcast.ts | 63 + src/db/encryption/config.ts | 18 + src/db/encryption/upload-encoder.test.ts | 19 + src/db/powersync/connector.ts | 7 + src/db/powersync/database.ts | 13 +- .../middleware/EncryptionMiddleware.ts | 4 + src/db/powersync/schema.ts | 4 + src/db/sync-worker-name.test.ts | 42 + src/db/sync-worker-name.ts | 27 + src/db/tables.ts | 97 +- src/db/wa-sqlite-integration.test.ts | 13 +- 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/kimi.ts | 1 + src/defaults/model-profiles/opus.ts | 1 + src/defaults/models.ts | 4 + src/defaults/modes.ts | 3 + src/defaults/skills.ts | 2 + src/defaults/tasks.ts | 3 + src/extensions/tasks/tools.ts | 9 +- .../use-active-workspace-membership.test.tsx | 126 + src/hooks/use-active-workspace-membership.ts | 60 + src/hooks/use-app-initialization.test.tsx | 15 +- src/hooks/use-app-initialization.ts | 148 +- src/hooks/use-can-create-workspace.test.tsx | 80 + src/hooks/use-can-create-workspace.ts | 49 + src/hooks/use-context-tracking.test.tsx | 28 +- src/hooks/use-context-tracking.ts | 8 +- .../use-handle-integration-completion.test.ts | 41 +- .../use-handle-integration-completion.ts | 19 +- src/hooks/use-mcp-sync.tsx | 7 +- src/hooks/use-message-cache.ts | 13 +- .../use-pending-device-notification.test.tsx | 5 +- ...sync-credentials-invalid-listener.test.tsx | 24 +- src/hooks/use-workspace-permission.test.tsx | 141 + src/hooks/use-workspace-permission.ts | 75 + .../sidebar/create-workspace-modal.test.tsx | 133 + src/layout/sidebar/create-workspace-modal.tsx | 131 + src/layout/sidebar/index.tsx | 37 +- .../sidebar/invite-members-modal.test.tsx | 166 + src/layout/sidebar/invite-members-modal.tsx | 162 + src/layout/sidebar/navigation-menu.tsx | 18 +- src/layout/sidebar/settings-sidebar.test.tsx | 330 +- src/layout/sidebar/settings-sidebar.tsx | 112 +- src/layout/sidebar/sidebar-header.tsx | 11 +- src/layout/sidebar/sidebar.test.tsx | 17 +- .../sidebar/workspace-selector.test.tsx | 236 ++ src/layout/sidebar/workspace-selector.tsx | 265 ++ src/lib/active-workspace.test.tsx | 294 ++ src/lib/active-workspace.ts | 256 ++ src/lib/auth-token.test.ts | 105 +- src/lib/auth-token.ts | 118 +- src/lib/cleanup.test.ts | 325 ++ src/lib/cleanup.ts | 228 +- .../automations-to-skills.test.ts | 44 +- .../data-migrations/automations-to-skills.ts | 51 +- src/lib/data-migrations/index.ts | 6 +- src/lib/defaults.test.ts | 1 + src/lib/fs.ts | 54 + src/lib/http.test.ts | 3 +- src/lib/mcp-provider.tsx | 4 +- src/lib/platform.ts | 16 +- src/lib/post-auth-bootstrap.ts | 101 + src/lib/posthog.tsx | 8 +- src/lib/proxy-fetch-context.tsx | 11 +- src/lib/reconcile-defaults.test.ts | 68 +- src/lib/reconcile-defaults.ts | 175 +- src/lib/resolve-boot-trust-domain.test.ts | 143 + src/lib/resolve-boot-trust-domain.ts | 70 + src/lib/utils.ts | 1 + src/routes/settings/agents/index.test.tsx | 63 +- src/routes/settings/agents/index.tsx | 37 +- src/services/encryption.test.ts | 5 +- src/services/encryption.ts | 4 +- src/settings/dev-settings.tsx | 26 +- src/settings/mcp-servers.test.tsx | 95 +- src/settings/mcp-servers.tsx | 92 +- src/settings/models/index.test.tsx | 82 +- src/settings/models/index.tsx | 89 +- src/settings/workspace/gate.test.tsx | 154 + src/settings/workspace/gate.tsx | 44 + src/settings/workspace/general.test.tsx | 356 ++ src/settings/workspace/general.tsx | 296 ++ src/settings/workspace/members.test.tsx | 562 +++ src/settings/workspace/members.tsx | 303 ++ src/settings/workspace/permissions.test.tsx | 183 + src/settings/workspace/permissions.tsx | 111 + .../workspace/require-permission.test.tsx | 392 ++ src/settings/workspace/require-permission.tsx | 112 + src/skills/find-dependents.test.ts | 1 + src/skills/library-row.test.tsx | 86 + src/skills/library-row.tsx | 55 +- src/skills/skill-detail.test.tsx | 66 + src/skills/skill-detail.tsx | 29 +- src/skills/skill-token-popover.tsx | 4 +- src/skills/skills-list.test.tsx | 63 + src/skills/skills-list.tsx | 33 +- src/skills/skills-view-state.test.ts | 1 + src/skills/skills-view.test.tsx | 36 +- src/skills/skills-view.tsx | 20 +- src/skills/slash-popup.stories.tsx | 1 + src/skills/suggestion-chip.test.tsx | 51 + src/skills/suggestion-chip.tsx | 60 +- src/skills/use-skills.ts | 52 +- src/skills/use-slash-command.test.ts | 1 + src/stores/local-settings-store.ts | 8 +- src/stores/trust-domain-registry.test.ts | 176 + src/stores/trust-domain-registry.ts | 191 + src/stories/DependentsDialog.stories.tsx | 1 + src/tasks/index.tsx | 39 +- src/test-utils/chat-store-mocks.ts | 3 + src/test-utils/powersync-reactivity-test.tsx | 35 + src/testing-library.ts | 7 + src/types/handle-errors.ts | 5 + src/vite-env.d.ts | 4 + src/waitlist/use-waitlist-state.ts | 16 + src/widgets/link-preview/widget.test.tsx | 30 +- 272 files changed, 29068 insertions(+), 2533 deletions(-) create mode 100644 backend/drizzle/0019_workspaces.sql create mode 100644 backend/drizzle/0020_workspace_id_on_scoped_tables.sql create mode 100644 backend/drizzle/0021_workspaces_slug_icon.sql create mode 100644 backend/drizzle/0022_workspace_memberships_user_display.sql create mode 100644 backend/drizzle/meta/0021_snapshot.json create mode 100644 backend/drizzle/meta/0022_snapshot.json delete mode 100644 backend/src/dal/powersync.ts create mode 100644 backend/src/dal/workspaces.test.ts create mode 100644 backend/src/dal/workspaces.ts create mode 100644 backend/src/powersync/upload-handlers/helpers.ts create mode 100644 backend/src/powersync/upload-handlers/index.ts create mode 100644 backend/src/powersync/upload-handlers/registry.test.ts create mode 100644 backend/src/powersync/upload-handlers/registry.ts create mode 100644 backend/src/powersync/upload-handlers/types.ts create mode 100644 backend/src/powersync/upload-handlers/user-scoped.ts create mode 100644 backend/src/powersync/upload-handlers/workspace-handlers.test.ts create mode 100644 backend/src/powersync/upload-handlers/workspace-memberships.ts create mode 100644 backend/src/powersync/upload-handlers/workspace-pending-memberships.ts create mode 100644 backend/src/powersync/upload-handlers/workspace-permissions.ts create mode 100644 backend/src/powersync/upload-handlers/workspace-scoped.ts create mode 100644 backend/src/powersync/upload-handlers/workspaces.ts create mode 100644 shared/workspaces.test.ts create mode 100644 shared/workspaces.ts create mode 100644 src/components/app-error-boundary.test.tsx create mode 100644 src/components/app-error-boundary.tsx create mode 100644 src/components/boot/mode-picker.test.tsx create mode 100644 src/components/boot/mode-picker.tsx create mode 100644 src/components/ui/email-chip-input.test.tsx create mode 100644 src/components/ui/email-chip-input.tsx delete mode 100644 src/components/ui/selectable-card.tsx create mode 100644 src/components/workspace-gate.tsx create mode 100644 src/components/workspace-membership-gate.test.tsx create mode 100644 src/components/workspace-membership-gate.tsx create mode 100644 src/components/workspace/icon-picker.tsx create mode 100644 src/components/workspace/icon-utils.test.ts create mode 100644 src/components/workspace/icon-utils.ts create mode 100644 src/components/workspace/workspace-form-fields.tsx create mode 100644 src/dal/workspace-memberships.test.tsx create mode 100644 src/dal/workspace-memberships.ts create mode 100644 src/dal/workspace-pending-memberships.test.tsx create mode 100644 src/dal/workspace-pending-memberships.ts create mode 100644 src/dal/workspace-permissions.test.ts create mode 100644 src/dal/workspace-permissions.ts create mode 100644 src/dal/workspaces.test.tsx create mode 100644 src/dal/workspaces.ts create mode 100644 src/db/database-path.test.ts create mode 100644 src/db/database-path.ts create mode 100644 src/db/db-lifecycle-broadcast.test.ts create mode 100644 src/db/db-lifecycle-broadcast.ts create mode 100644 src/db/sync-worker-name.test.ts create mode 100644 src/db/sync-worker-name.ts create mode 100644 src/hooks/use-active-workspace-membership.test.tsx create mode 100644 src/hooks/use-active-workspace-membership.ts create mode 100644 src/hooks/use-can-create-workspace.test.tsx create mode 100644 src/hooks/use-can-create-workspace.ts create mode 100644 src/hooks/use-workspace-permission.test.tsx create mode 100644 src/hooks/use-workspace-permission.ts create mode 100644 src/layout/sidebar/create-workspace-modal.test.tsx create mode 100644 src/layout/sidebar/create-workspace-modal.tsx create mode 100644 src/layout/sidebar/invite-members-modal.test.tsx create mode 100644 src/layout/sidebar/invite-members-modal.tsx create mode 100644 src/layout/sidebar/workspace-selector.test.tsx create mode 100644 src/layout/sidebar/workspace-selector.tsx create mode 100644 src/lib/active-workspace.test.tsx create mode 100644 src/lib/active-workspace.ts create mode 100644 src/lib/cleanup.test.ts create mode 100644 src/lib/post-auth-bootstrap.ts create mode 100644 src/lib/resolve-boot-trust-domain.test.ts create mode 100644 src/lib/resolve-boot-trust-domain.ts create mode 100644 src/settings/workspace/gate.test.tsx create mode 100644 src/settings/workspace/gate.tsx create mode 100644 src/settings/workspace/general.test.tsx create mode 100644 src/settings/workspace/general.tsx create mode 100644 src/settings/workspace/members.test.tsx create mode 100644 src/settings/workspace/members.tsx create mode 100644 src/settings/workspace/permissions.test.tsx create mode 100644 src/settings/workspace/permissions.tsx create mode 100644 src/settings/workspace/require-permission.test.tsx create mode 100644 src/settings/workspace/require-permission.tsx create mode 100644 src/skills/library-row.test.tsx create mode 100644 src/skills/skill-detail.test.tsx create mode 100644 src/skills/skills-list.test.tsx create mode 100644 src/skills/suggestion-chip.test.tsx create mode 100644 src/stores/trust-domain-registry.test.ts create mode 100644 src/stores/trust-domain-registry.ts diff --git a/.env.example b/.env.example index b77c668a3..e9fbd4f76 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ -# Thunderbolt Cloud URL (optional, defaults to http://localhost:8000) +# Thunderbolt Cloud URL — the default-server URL the boot decision tree fetches +# /v1/config from. Optional; defaults to http://localhost:8000. VITE_THUNDERBOLT_CLOUD_URL="http://localhost:8000/v1" +# Boot decision flags. v1 production: standalone off + default URL set + user-added off +# (forced-server). The mode picker UI ships in a later PR; v1 production never reaches it. +# VITE_STANDALONE_MODE_ENABLED="false" +# VITE_ALLOW_USER_ADDED_SERVERS="false" + # Bypass waitlist routes for UI development (set to "true" to skip waitlist) # Note: This only bypasses frontend routing, not backend auth # VITE_BYPASS_WAITLIST="true" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d43e50112..da7494d94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,6 +222,13 @@ jobs: cd backend bun install --frozen-lockfile + # `shared/workspaces.ts` imports `uuid`. TS bundler-mode resolution from + # shared/ walks up to root `node_modules/` (never reaches backend/), so + # the backend type-check fails with "Cannot find module 'uuid'" unless + # the root deps are installed too. + - name: Install root dependencies (needed for shared/ type-check) + run: bun install --frozen-lockfile + - name: Type check backend run: | cd backend diff --git a/backend/.env.example b/backend/.env.example index 5b342cc17..0f0de1890 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,11 +1,19 @@ # Backend dev environment variables. # Copy to .env: cp .env.example .env -# Then run `make doctor` — it generates BETTER_AUTH_SECRET for you. +# Then run `make doctor` — it generates BETTER_AUTH_SECRET and SERVER_ID for you. # # REQUIRED for the app to start: # - BETTER_AUTH_SECRET (auto-generated by `make doctor`) +# - SERVER_ID (auto-generated by `make doctor`) # - At least one AI provider key (ANTHROPIC_API_KEY / FIREWORKS_API_KEY / MISTRAL_API_KEY / THUNDERBOLT_INFERENCE_API_KEY) +# === Required: deployment identity === +# Stable per-deployment UUID returned by GET /v1/config. The frontend uses this to +# namespace its trust-domain registry (auth token, device ID, encryption keys, DB filename +# are all keyed by it), so changing it in place is a hard reset for connected clients. +# Generate with `uuidgen` or run `make doctor`. +SERVER_ID= + # === Required: auth signing secret === # Used to sign bearer tokens and session cookies. # Generate with `openssl rand -base64 32` or run `make doctor`. @@ -46,7 +54,15 @@ POWERSYNC_TOKEN_EXPIRY_SECONDS=3600 AUTH_MODE=consumer # Anonymous-session overlay. When false, the anonymous() plugin is NOT registered # and /v1/api/auth/sign-in/anonymous returns 404. Frontend mirror: VITE_AUTH_ENABLE_ANONYMOUS. +# Surfaced to the UI via GET /v1/config as `allowAnonUsers`. AUTH_ALLOW_ANONYMOUS=false + +# === Workspace creation policy === +# Surfaced to the UI via GET /v1/config; enforced by the PowerSync upload-handler factory +# (workspaces table). Both default to false to match v1 production posture +# (central-admin only); relax per deployment. +ALLOW_WORKSPACE_CREATION_BY_ANON=false +ALLOW_WORKSPACE_CREATION_BY_MEMBERS=false # Backend's public URL used for OIDC redirect URIs (must match what's registered with your IdP) BETTER_AUTH_URL=http://localhost:8000 TRUSTED_ORIGINS=http://localhost:1420,http://localhost:8180 diff --git a/backend/bun.lock b/backend/bun.lock index ba4073c33..a092d1052 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -33,6 +33,7 @@ "rate-limiter-flexible": "^11.0.1", "react-email": "^6.0.5", "resend": "^6.5.2", + "uuid": "^14.0.0", "zod": "^4.3.6", }, "devDependencies": { @@ -1146,7 +1147,7 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1202,6 +1203,8 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@langchain/core/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "@langchain/langgraph/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], "@langchain/langgraph-checkpoint/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], @@ -1326,6 +1329,8 @@ "@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], + "@posthog/ai/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -1354,6 +1359,8 @@ "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "langchain/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "langsmith/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], "nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], diff --git a/backend/drizzle/0019_workspaces.sql b/backend/drizzle/0019_workspaces.sql new file mode 100644 index 000000000..8e1126002 --- /dev/null +++ b/backend/drizzle/0019_workspaces.sql @@ -0,0 +1,53 @@ +-- 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/. + +CREATE TABLE "powersync"."workspace_memberships" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "powersync"."workspace_pending_memberships" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "email" text NOT NULL, + "role" text NOT NULL, + "invited_by_user_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "powersync"."workspace_permissions" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "permission_key" text NOT NULL, + "required_role" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "powersync"."workspaces" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "is_personal" boolean DEFAULT false NOT NULL, + "owner_user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "powersync"."workspace_memberships" ADD CONSTRAINT "workspace_memberships_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_memberships" ADD CONSTRAINT "workspace_memberships_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_pending_memberships" ADD CONSTRAINT "workspace_pending_memberships_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_pending_memberships" ADD CONSTRAINT "workspace_pending_memberships_invited_by_user_id_user_id_fk" FOREIGN KEY ("invited_by_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_permissions" ADD CONSTRAINT "workspace_permissions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."workspaces" ADD CONSTRAINT "workspaces_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspace_memberships_workspace_user" ON "powersync"."workspace_memberships" USING btree ("workspace_id","user_id");--> statement-breakpoint +CREATE INDEX "idx_workspace_memberships_user" ON "powersync"."workspace_memberships" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_workspace_memberships_workspace" ON "powersync"."workspace_memberships" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspace_pending_memberships_workspace_email" ON "powersync"."workspace_pending_memberships" USING btree ("workspace_id","email");--> statement-breakpoint +CREATE INDEX "idx_workspace_pending_memberships_email" ON "powersync"."workspace_pending_memberships" USING btree ("email");--> statement-breakpoint +CREATE INDEX "idx_workspace_pending_memberships_workspace" ON "powersync"."workspace_pending_memberships" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspace_permissions_workspace_key" ON "powersync"."workspace_permissions" USING btree ("workspace_id","permission_key");--> statement-breakpoint +CREATE INDEX "idx_workspace_permissions_workspace" ON "powersync"."workspace_permissions" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspaces_personal_per_owner" ON "powersync"."workspaces" USING btree ("owner_user_id") WHERE "powersync"."workspaces"."is_personal" = true;--> statement-breakpoint +CREATE INDEX "idx_workspaces_owner_user_id" ON "powersync"."workspaces" USING btree ("owner_user_id"); \ No newline at end of file diff --git a/backend/drizzle/0020_workspace_id_on_scoped_tables.sql b/backend/drizzle/0020_workspace_id_on_scoped_tables.sql new file mode 100644 index 000000000..2b7b9f02f --- /dev/null +++ b/backend/drizzle/0020_workspace_id_on_scoped_tables.sql @@ -0,0 +1,97 @@ +-- 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/. + +-- Pre-release reset (per THU-550 addendum): existing rows on workspace-scoped tables +-- are wiped so the NOT NULL `workspace_id` column can be added without a backfill. +-- New rows are created against the user's personal workspace once the FE deploys. +DELETE FROM "powersync"."chat_messages";--> statement-breakpoint +DELETE FROM "powersync"."chat_threads";--> statement-breakpoint +DELETE FROM "powersync"."tasks";--> statement-breakpoint +DELETE FROM "powersync"."models";--> statement-breakpoint +DELETE FROM "powersync"."mcp_servers";--> statement-breakpoint +DELETE FROM "powersync"."prompts";--> statement-breakpoint +DELETE FROM "powersync"."skills";--> statement-breakpoint +DELETE FROM "powersync"."triggers";--> statement-breakpoint +DELETE FROM "powersync"."modes";--> statement-breakpoint +DELETE FROM "powersync"."model_profiles";--> statement-breakpoint +DELETE FROM "powersync"."agents";--> statement-breakpoint +ALTER TABLE "powersync"."agents" DROP CONSTRAINT "agents_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "powersync"."mcp_servers" DROP CONSTRAINT "mcp_servers_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" DROP CONSTRAINT "model_profiles_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "powersync"."models" DROP CONSTRAINT "models_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "powersync"."modes" DROP CONSTRAINT "modes_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "powersync"."prompts" DROP CONSTRAINT "prompts_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "powersync"."skills" DROP CONSTRAINT "skills_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "powersync"."triggers" DROP CONSTRAINT "triggers_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "powersync"."agents" DROP CONSTRAINT "agents_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" DROP CONSTRAINT "model_profiles_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."models" DROP CONSTRAINT "models_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."modes" DROP CONSTRAINT "modes_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."prompts" DROP CONSTRAINT "prompts_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."skills" DROP CONSTRAINT "skills_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."tasks" DROP CONSTRAINT "tasks_id_user_id_pk";--> statement-breakpoint +ALTER TABLE "powersync"."agents" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."mcp_servers" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."models" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."agents" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."chat_messages" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."chat_threads" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."mcp_servers" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."tasks" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ADD COLUMN "workspace_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "powersync"."agents" ADD CONSTRAINT "agents_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD CONSTRAINT "model_profiles_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD CONSTRAINT "models_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD CONSTRAINT "modes_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD CONSTRAINT "prompts_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD CONSTRAINT "skills_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."tasks" ADD CONSTRAINT "tasks_id_workspace_id_pk" PRIMARY KEY("id","workspace_id");--> statement-breakpoint +ALTER TABLE "powersync"."agents" ADD CONSTRAINT "agents_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."agents" ADD CONSTRAINT "agents_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."chat_messages" ADD CONSTRAINT "chat_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."chat_threads" ADD CONSTRAINT "chat_threads_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."mcp_servers" ADD CONSTRAINT "mcp_servers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."mcp_servers" ADD CONSTRAINT "mcp_servers_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD CONSTRAINT "model_profiles_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."model_profiles" ADD CONSTRAINT "model_profiles_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD CONSTRAINT "models_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."models" ADD CONSTRAINT "models_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD CONSTRAINT "modes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."modes" ADD CONSTRAINT "modes_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD CONSTRAINT "prompts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."prompts" ADD CONSTRAINT "prompts_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD CONSTRAINT "skills_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."skills" ADD CONSTRAINT "skills_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."tasks" ADD CONSTRAINT "tasks_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ADD CONSTRAINT "triggers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "powersync"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "powersync"."triggers" ADD CONSTRAINT "triggers_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_agents_workspace_id" ON "powersync"."agents" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_chat_messages_workspace_id" ON "powersync"."chat_messages" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_chat_threads_workspace_id" ON "powersync"."chat_threads" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_mcp_servers_workspace_id" ON "powersync"."mcp_servers" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_model_profiles_workspace_id" ON "powersync"."model_profiles" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_models_workspace_id" ON "powersync"."models" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_modes_workspace_id" ON "powersync"."modes" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_prompts_workspace_id" ON "powersync"."prompts" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_skills_workspace_id" ON "powersync"."skills" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_tasks_workspace_id" ON "powersync"."tasks" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "idx_triggers_workspace_id" ON "powersync"."triggers" USING btree ("workspace_id"); \ No newline at end of file diff --git a/backend/drizzle/0021_workspaces_slug_icon.sql b/backend/drizzle/0021_workspaces_slug_icon.sql new file mode 100644 index 000000000..2949383b1 --- /dev/null +++ b/backend/drizzle/0021_workspaces_slug_icon.sql @@ -0,0 +1,7 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at http://mozilla.org/MPL/2.0/. + +ALTER TABLE "powersync"."workspaces" ADD COLUMN "slug" text;--> statement-breakpoint +ALTER TABLE "powersync"."workspaces" ADD COLUMN "icon" text;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_workspaces_slug" ON "powersync"."workspaces" USING btree ("slug") WHERE "powersync"."workspaces"."slug" IS NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/0022_workspace_memberships_user_display.sql b/backend/drizzle/0022_workspace_memberships_user_display.sql new file mode 100644 index 000000000..a9bfb2d88 --- /dev/null +++ b/backend/drizzle/0022_workspace_memberships_user_display.sql @@ -0,0 +1,10 @@ +-- 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"."workspace_memberships" ADD COLUMN "user_name" text;--> statement-breakpoint +ALTER TABLE "powersync"."workspace_memberships" ADD COLUMN "user_email" text;--> statement-breakpoint +UPDATE "powersync"."workspace_memberships" AS m +SET "user_name" = u."name", "user_email" = u."email" +FROM "user" AS u +WHERE m."user_id" = u."id"; diff --git a/backend/drizzle/meta/0019_snapshot.json b/backend/drizzle/meta/0019_snapshot.json index d28e8d10a..5beb0b72f 100644 --- a/backend/drizzle/meta/0019_snapshot.json +++ b/backend/drizzle/meta/0019_snapshot.json @@ -1,5 +1,5 @@ { - "id": "4d5e4c95-fd4e-42f6-8f79-853c27912026", + "id": "25c87907-8d17-492c-a070-cb10106898c6", "prevId": "88d65b98-3f2a-4df6-9286-b0add722418e", "version": "7", "dialect": "postgresql", @@ -1013,6 +1013,119 @@ "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": true + } + }, + "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": {} + } + }, + "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": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "powersync.model_profiles": { "name": "model_profiles", "schema": "powersync", @@ -1938,6 +2051,447 @@ "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 + }, + "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 + }, + "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": {} + } + }, + "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": "", diff --git a/backend/drizzle/meta/0020_snapshot.json b/backend/drizzle/meta/0020_snapshot.json index f9d82e32d..ab23a3a8b 100644 --- a/backend/drizzle/meta/0020_snapshot.json +++ b/backend/drizzle/meta/0020_snapshot.json @@ -1,6 +1,6 @@ { - "id": "f5c953ed-42f2-4ee1-b614-2ef307e1cde0", - "prevId": "4d5e4c95-fd4e-42f6-8f79-853c27912026", + "id": "49d7f86d-dbe9-4cae-a488-23192b63e683", + "prevId": "25c87907-8d17-492c-a070-cb10106898c6", "version": "7", "dialect": "postgresql", "tables": { @@ -588,6 +588,12 @@ "name": "user_id", "type": "text", "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, "notNull": true }, "name": { @@ -655,6 +661,21 @@ "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": { @@ -668,16 +689,30 @@ "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_user_id_pk": { - "name": "agents_id_user_id_pk", + "agents_id_workspace_id_pk": { + "name": "agents_id_workspace_id_pk", "columns": [ "id", - "user_id" + "workspace_id" ] } }, @@ -755,6 +790,12 @@ "type": "text", "primaryKey": false, "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true } }, "indexes": { @@ -772,6 +813,21 @@ "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": { @@ -787,6 +843,20 @@ ], "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": {}, @@ -866,6 +936,12 @@ "type": "text", "primaryKey": false, "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true } }, "indexes": { @@ -883,6 +959,21 @@ "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": { @@ -898,6 +989,20 @@ ], "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": {}, @@ -973,12 +1078,6 @@ "type": "timestamp", "primaryKey": false, "notNull": false - }, - "app_version": { - "name": "app_version", - "type": "text", - "primaryKey": false, - "notNull": false } }, "indexes": { @@ -1019,6 +1118,154 @@ "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 + } + }, + "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", @@ -1161,6 +1408,12 @@ "name": "user_id", "type": "text", "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, "notNull": true } }, @@ -1179,6 +1432,21 @@ "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": { @@ -1192,16 +1460,30 @@ "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_user_id_pk": { - "name": "model_profiles_id_user_id_pk", + "model_profiles_id_workspace_id_pk": { + "name": "model_profiles_id_workspace_id_pk", "columns": [ "id", - "user_id" + "workspace_id" ] } }, @@ -1320,6 +1602,12 @@ "name": "user_id", "type": "text", "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, "notNull": true } }, @@ -1338,6 +1626,21 @@ "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": { @@ -1351,16 +1654,30 @@ "columnsTo": [ "id" ], - "onDelete": "cascade", + "onDelete": "set null", "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "models_id_user_id_pk": { - "name": "models_id_user_id_pk", - "columns": [ - "id", - "user_id" + }, + "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" ] } }, @@ -1433,6 +1750,12 @@ "name": "user_id", "type": "text", "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, "notNull": true } }, @@ -1451,6 +1774,21 @@ "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": { @@ -1464,16 +1802,30 @@ "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_user_id_pk": { - "name": "modes_id_user_id_pk", + "modes_id_workspace_id_pk": { + "name": "modes_id_workspace_id_pk", "columns": [ "id", - "user_id" + "workspace_id" ] } }, @@ -1526,6 +1878,12 @@ "name": "user_id", "type": "text", "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, "notNull": true } }, @@ -1544,6 +1902,21 @@ "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": { @@ -1557,16 +1930,30 @@ "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_user_id_pk": { - "name": "prompts_id_user_id_pk", + "prompts_id_workspace_id_pk": { + "name": "prompts_id_workspace_id_pk", "columns": [ "id", - "user_id" + "workspace_id" ] } }, @@ -1714,6 +2101,12 @@ "name": "user_id", "type": "text", "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, "notNull": true } }, @@ -1732,6 +2125,21 @@ "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": { @@ -1745,16 +2153,30 @@ "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_user_id_pk": { - "name": "skills_id_user_id_pk", + "skills_id_workspace_id_pk": { + "name": "skills_id_workspace_id_pk", "columns": [ "id", - "user_id" + "workspace_id" ] } }, @@ -1810,6 +2232,12 @@ "type": "text", "primaryKey": false, "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true } }, "indexes": { @@ -1827,6 +2255,21 @@ "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": { @@ -1842,14 +2285,28 @@ ], "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_user_id_pk": { - "name": "tasks_id_user_id_pk", + "tasks_id_workspace_id_pk": { + "name": "tasks_id_workspace_id_pk", "columns": [ "id", - "user_id" + "workspace_id" ] } }, @@ -1903,6 +2360,12 @@ "name": "user_id", "type": "text", "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, "notNull": true } }, @@ -1921,6 +2384,21 @@ "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": { @@ -1934,6 +2412,461 @@ "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 + }, + "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 + }, + "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": {} + } + }, + "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" } diff --git a/backend/drizzle/meta/0021_snapshot.json b/backend/drizzle/meta/0021_snapshot.json new file mode 100644 index 000000000..9aeb624a8 --- /dev/null +++ b/backend/drizzle/meta/0021_snapshot.json @@ -0,0 +1,3167 @@ +{ + "id": "770ab94b-aa1c-4201-9845-e2f0305da156", + "prevId": "49d7f86d-dbe9-4cae-a488-23192b63e683", + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + }, + "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/0022_snapshot.json b/backend/drizzle/meta/0022_snapshot.json new file mode 100644 index 000000000..bb22afb50 --- /dev/null +++ b/backend/drizzle/meta/0022_snapshot.json @@ -0,0 +1,3179 @@ +{ + "id": "f6bed693-da35-4fbe-bceb-c9c41077463e", + "prevId": "770ab94b-aa1c-4201-9845-e2f0305da156", + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "indexes": { + "idx_triggers_user_id": { + "name": "idx_triggers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_triggers_workspace_id": { + "name": "idx_triggers_workspace_id", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "triggers_user_id_user_id_fk": { + "name": "triggers_user_id_user_id_fk", + "tableFrom": "triggers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "triggers_workspace_id_workspaces_id_fk": { + "name": "triggers_workspace_id_workspaces_id_fk", + "tableFrom": "triggers", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.workspace_memberships": { + "name": "workspace_memberships", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_memberships_workspace_user": { + "name": "idx_workspace_memberships_workspace_user", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_memberships_user": { + "name": "idx_workspace_memberships_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_memberships_workspace": { + "name": "idx_workspace_memberships_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_memberships_workspace_id_workspaces_id_fk": { + "name": "workspace_memberships_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_memberships", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_memberships_user_id_user_id_fk": { + "name": "workspace_memberships_user_id_user_id_fk", + "tableFrom": "workspace_memberships", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.workspace_pending_memberships": { + "name": "workspace_pending_memberships", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_pending_memberships_workspace_email": { + "name": "idx_workspace_pending_memberships_workspace_email", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_pending_memberships_email": { + "name": "idx_workspace_pending_memberships_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_pending_memberships_workspace": { + "name": "idx_workspace_pending_memberships_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_pending_memberships_workspace_id_workspaces_id_fk": { + "name": "workspace_pending_memberships_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_pending_memberships", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_pending_memberships_invited_by_user_id_user_id_fk": { + "name": "workspace_pending_memberships_invited_by_user_id_user_id_fk", + "tableFrom": "workspace_pending_memberships", + "tableTo": "user", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.workspace_permissions": { + "name": "workspace_permissions", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "required_role": { + "name": "required_role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_workspace_permissions_workspace_key": { + "name": "idx_workspace_permissions_workspace_key", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_permissions_workspace": { + "name": "idx_workspace_permissions_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_permissions_workspace_id_workspaces_id_fk": { + "name": "workspace_permissions_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_permissions", + "tableTo": "workspaces", + "schemaTo": "powersync", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.workspaces": { + "name": "workspaces", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_personal": { + "name": "is_personal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspaces_personal_per_owner": { + "name": "idx_workspaces_personal_per_owner", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"powersync\".\"workspaces\".\"is_personal\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_owner_user_id": { + "name": "idx_workspaces_owner_user_id", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"powersync\".\"workspaces\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_user_id_user_id_fk": { + "name": "workspaces_owner_user_id_user_id_fk", + "tableFrom": "workspaces", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expire": { + "name": "expire", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rate_limits_expire_idx": { + "name": "rate_limits_expire_idx", + "columns": [ + { + "expression": "expire", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encryption_metadata": { + "name": "encryption_metadata", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canary_iv": { + "name": "canary_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_ctext": { + "name": "canary_ctext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_secret_hash": { + "name": "canary_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encryption_metadata_user_id_user_id_fk": { + "name": "encryption_metadata_user_id_user_id_fk", + "tableFrom": "encryption_metadata", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.envelopes": { + "name": "envelopes", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapped_ck": { + "name": "wrapped_ck", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_envelopes_user_id": { + "name": "idx_envelopes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "envelopes_device_id_devices_id_fk": { + "name": "envelopes_device_id_devices_id_fk", + "tableFrom": "envelopes", + "tableTo": "devices", + "schemaTo": "powersync", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "envelopes_user_id_user_id_fk": { + "name": "envelopes_user_id_user_id_fk", + "tableFrom": "envelopes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_challenge": { + "name": "otp_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_token": { + "name": "challenge_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_challenge_email_unique": { + "name": "otp_challenge_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 16a6dae4a..360f0f81e 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -138,8 +138,29 @@ { "idx": 19, "version": "7", - "when": 1781029599779, - "tag": "0019_handy_masque", + "when": 1780411203148, + "tag": "0019_workspaces", + "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1780433554841, + "tag": "0020_workspace_id_on_scoped_tables", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1781094472865, + "tag": "0021_workspaces_slug_icon", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1781128111482, + "tag": "0022_workspace_memberships_user_display", "breakpoints": true }, { diff --git a/backend/package.json b/backend/package.json index 7af588edb..5545f5cfe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,7 @@ "rate-limiter-flexible": "^11.0.1", "react-email": "^6.0.5", "resend": "^6.5.2", + "uuid": "^14.0.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/backend/src/api/account.test.ts b/backend/src/api/account.test.ts index a672c5173..a2c7f7f8b 100644 --- a/backend/src/api/account.test.ts +++ b/backend/src/api/account.test.ts @@ -6,6 +6,7 @@ import { createAuth } from '@/auth/auth' import { session as sessionTable, user } from '@/db/auth-schema' import { encryptionMetadataTable, envelopesTable } from '@/db/encryption-schema' import { chatThreadsTable, devicesTable, settingsTable, tasksTable } from '@/db/schema' +import { workspacesTable } from '@/db/powersync-schema' import { hashCanarySecret } from '@/lib/canary' import { createTestDb } from '@/test-utils/db' import { createHmac } from 'crypto' @@ -423,15 +424,19 @@ describe('Account API', () => { lastSeen: now, createdAt: now, }) + const workspaceId = '00000000-0000-0000-0000-000000000000' + await db.insert(workspacesTable).values({ id: workspaceId, name: 'Test Workspace' }) await db.insert(tasksTable).values({ id: 'task-cascade-1', item: 'Task', userId, + workspaceId, }) await db.insert(chatThreadsTable).values({ id: 'thread-cascade-1', title: 'Thread', userId, + workspaceId, }) const response = await app.handle( diff --git a/backend/src/api/config.test.ts b/backend/src/api/config.test.ts index 5236636d0..958c4db22 100644 --- a/backend/src/api/config.test.ts +++ b/backend/src/api/config.test.ts @@ -15,6 +15,12 @@ const fetchConfig = async (settings: Parameters[0]) = describe('Config Routes', () => { describe('GET /config', () => { + it('returns the configured serverId', async () => { + const serverId = '11111111-2222-3333-4444-555555555555' + const { body } = await fetchConfig(createTestSettings({ serverId })) + expect(body.serverId).toBe(serverId) + }) + it('reflects e2eeEnabled', async () => { const disabled = await fetchConfig(createTestSettings({ e2eeEnabled: false })) expect(disabled.body.e2eeEnabled).toBe(false) @@ -23,6 +29,25 @@ describe('Config Routes', () => { expect(enabled.body.e2eeEnabled).toBe(true) }) + it('exposes allowAnonUsers from the authAllowAnonymous setting', async () => { + const off = await fetchConfig(createTestSettings({ authAllowAnonymous: false })) + expect(off.body.allowAnonUsers).toBe(false) + + const on = await fetchConfig(createTestSettings({ authAllowAnonymous: true })) + expect(on.body.allowAnonUsers).toBe(true) + }) + + it('exposes workspace creation policy flags', async () => { + const { body } = await fetchConfig( + createTestSettings({ + allowWorkspaceCreationByAnon: true, + allowWorkspaceCreationByMembers: true, + }), + ) + expect(body.allowWorkspaceCreationByAnon).toBe(true) + expect(body.allowWorkspaceCreationByMembers).toBe(true) + }) + it('exposes builtInAgentEnabled: true by default and false when disabled', async () => { const onByDefault = await fetchConfig(createTestSettings()) expect(onByDefault.body.builtInAgentEnabled).toBe(true) diff --git a/backend/src/api/config.ts b/backend/src/api/config.ts index 45029b23b..445f0ef6f 100644 --- a/backend/src/api/config.ts +++ b/backend/src/api/config.ts @@ -13,7 +13,11 @@ import { Elysia } from 'elysia' */ export const createConfigRoutes = (settings: Settings) => new Elysia({ prefix: '/config' }).onError(safeErrorHandler).get('/', () => ({ + serverId: settings.serverId, e2eeEnabled: settings.e2eeEnabled, + allowAnonUsers: settings.authAllowAnonymous, + allowWorkspaceCreationByAnon: settings.allowWorkspaceCreationByAnon, + allowWorkspaceCreationByMembers: settings.allowWorkspaceCreationByMembers, // Inverted so the env reads as an opt-in switch ("disable") while the wire // contract reads as a positive capability ("enabled"). builtInAgentEnabled: !settings.disableBuiltInAgent, diff --git a/backend/src/api/powersync.test.ts b/backend/src/api/powersync.test.ts index 6b78615c1..da4f0ee40 100644 --- a/backend/src/api/powersync.test.ts +++ b/backend/src/api/powersync.test.ts @@ -5,11 +5,19 @@ import type { Settings } from '@/config/settings' import { createBetterAuthPlugin } from '@/auth/elysia-plugin' import { session as sessionTable, user as userTable } from '@/db/auth-schema' -import { devicesTable, modelsTable, promptsTable, settingsTable } from '@/db/schema' +import { + devicesTable, + modelsTable, + promptsTable, + settingsTable, + workspaceMembershipsTable, + workspacesTable, +} from '@/db/schema' import { createTestDb } from '@/test-utils/db' import { createHmac } from 'crypto' import { eq } from 'drizzle-orm' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import { v7 as uuidv7 } from 'uuid' import { clearSettingsCache } from '@/config/settings' import { Elysia } from 'elysia' import { createPowerSyncRoutes } from './powersync' @@ -24,6 +32,9 @@ const signToken = (token: string): string => { } const powersyncSettings: Settings = { + serverId: 'd70c9c16-8665-4eb8-afb3-0d9f0214e4f8', + allowWorkspaceCreationByAnon: false, + allowWorkspaceCreationByMembers: false, fireworksApiKey: '', mistralApiKey: '', anthropicApiKey: '', @@ -81,8 +92,13 @@ describe('PowerSync API', () => { let app: Elysia let db: Awaited>['db'] let cleanup: () => Promise + // Unique per test: the new upload handler uses database.transaction() which + // commits the outer BEGIN/ROLLBACK test transaction in PGlite, so we can't + // rely on rollback isolation. Unique device IDs prevent key collisions. + let testDeviceId: string beforeEach(async () => { + testDeviceId = uuidv7() const testEnv = await createTestDb() db = testEnv.db cleanup = testEnv.cleanup @@ -96,7 +112,7 @@ describe('PowerSync API', () => { } }) - const uploadHeaders = (bearer: string, deviceId = 'test-device-id') => ({ + const uploadHeaders = (bearer: string, deviceId = testDeviceId) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${signToken(bearer)}`, 'X-Device-ID': deviceId, @@ -1067,7 +1083,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -1101,7 +1117,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -1150,7 +1166,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -1249,7 +1265,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'patch_setting', @@ -1301,7 +1317,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'empty_patch_setting', value: 'unchanged', @@ -1357,7 +1373,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'stripped_patch_setting', value: 'unchanged', @@ -1409,7 +1425,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) // No settings row exists for 'nonexistent_key' const response = await app.handle( @@ -1428,12 +1444,13 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('ROW_NOT_FOUND') }) - it('returns 400 when PATCH targets record belonging to another user', async () => { + it('returns 200 with ROW_NOT_FOUND rejection when PATCH targets record belonging to another user', async () => { const userA = 'user-patch-owner' const userB = 'user-patch-attacker' const now = new Date() @@ -1498,9 +1515,10 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('ROW_NOT_FOUND') const rows = await db.select().from(settingsTable).where(eq(settingsTable.key, 'owner_only_setting')) expect(rows).toHaveLength(1) @@ -1529,7 +1547,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'patch_owned', value: 'initial', @@ -1581,13 +1599,27 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) + // Workspace + membership required by migration 0020 FK + workspace-scoped handler. + await db.insert(workspacesTable).values({ + id: '00000000-0000-0000-0000-000000000000', + name: 'test-workspace', + isPersonal: false, + ownerUserId: userId, + }) + await db.insert(workspaceMembershipsTable).values({ + id: 'membership-patch-deleted-at', + workspaceId: '00000000-0000-0000-0000-000000000000', + userId, + role: 'admin', + }) await db.insert(promptsTable).values({ id: 'prompt-to-soft-delete', title: 'My Prompt', prompt: 'Hello', modelId: 'gpt-4', userId, + workspaceId: '00000000-0000-0000-0000-000000000000', }) const deletedAtIso = '2026-02-18T16:41:12.428Z' @@ -1639,7 +1671,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) await db.insert(settingsTable).values({ key: 'to_delete', @@ -1662,7 +1694,7 @@ describe('PowerSync API', () => { expect(rows).toHaveLength(0) }) - it('returns 400 when DELETE targets non-existent record', async () => { + it('returns 200 with ROW_NOT_FOUND rejection when DELETE targets non-existent record', async () => { const userId = 'user-delete-nonexistent' const now = new Date() const expiresAt = new Date(now.getTime() + 3600 * 1000) @@ -1683,7 +1715,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) // No settings row exists for 'nonexistent_to_delete' const response = await app.handle( @@ -1695,12 +1727,13 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('ROW_NOT_FOUND') }) - it('returns 400 when DELETE targets record belonging to another user', async () => { + it('returns 200 with ROW_NOT_FOUND rejection when DELETE targets record belonging to another user', async () => { const userA = 'user-delete-owner' const userB = 'user-delete-attacker' const now = new Date() @@ -1758,9 +1791,10 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('ROW_NOT_FOUND') const rows = await db.select().from(settingsTable).where(eq(settingsTable.key, 'owner_only_to_delete')) expect(rows).toHaveLength(1) @@ -1836,7 +1870,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) // Insert a second device that the attacker will try to delete via PowerSync await db.insert(devicesTable).values({ @@ -1857,9 +1891,10 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const body = (await response.json()) as { code: string } - expect(body.code).toBe('UPLOAD_OPERATION_FAILED') + expect(response.status).toBe(200) + const body = (await response.json()) as { rejected: Array<{ code: string }> } + expect(body.rejected).toHaveLength(1) + expect(body.rejected[0]?.code).toBe('DELETE_NOT_ALLOWED') // Device must still exist const devices = await db.select().from(devicesTable).where(eq(devicesTable.id, deviceId)) @@ -1889,7 +1924,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -2084,7 +2119,7 @@ describe('PowerSync API', () => { expect(themeRows.find((r) => r.userId === userB)?.value).toBe('system') }) - it('returns 400 when an operation fails (invalid table, empty payload, etc.)', async () => { + it('returns 200 with UNKNOWN_TABLE rejection for an unrecognised table name', async () => { const userId = 'user-upload-fail' const now = new Date() const expiresAt = new Date(now.getTime() + 3600 * 1000) @@ -2106,7 +2141,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -2124,13 +2159,15 @@ describe('PowerSync API', () => { }), }), ) - expect(response.status).toBe(400) - const data = (await response.json()) as { error: string; code: string; table: string; id: string; op: string } - expect(data.error).toBe('Upload operation failed') - expect(data.code).toBe('UPLOAD_OPERATION_FAILED') - expect(data.table).toBe('nonexistent_table') - expect(data.id).toBe('some_id') - expect(data.op).toBe('PUT') + expect(response.status).toBe(200) + const data = (await response.json()) as { + rejected: Array<{ code: string; table: string; id: string; op: string }> + } + expect(data.rejected).toHaveLength(1) + expect(data.rejected[0]?.code).toBe('UNKNOWN_TABLE') + expect(data.rejected[0]?.table).toBe('nonexistent_table') + expect(data.rejected[0]?.id).toBe('some_id') + expect(data.rejected[0]?.op).toBe('PUT') }) it('returns 200 with empty operations array', async () => { @@ -2155,7 +2192,7 @@ describe('PowerSync API', () => { updatedAt: now, userId, }) - await insertTrustedDevice('test-device-id', userId) + await insertTrustedDevice(testDeviceId, userId) const response = await app.handle( new Request('http://localhost/powersync/upload', { @@ -2890,7 +2927,9 @@ describe('PowerSync API — anonymous sync guard', () => { ) expect(response.status).toBe(200) const data = await response.json() - expect(data).toEqual({ success: true }) + // Response now includes `rejected: []` alongside `success`; use toMatchObject + // so adding fields to the response shape doesn't break this regression guard. + expect(data).toMatchObject({ success: true }) }) }) diff --git a/backend/src/api/powersync.ts b/backend/src/api/powersync.ts index 0fc994222..1a107fc6b 100644 --- a/backend/src/api/powersync.ts +++ b/backend/src/api/powersync.ts @@ -5,7 +5,8 @@ import type { Auth } from '@/auth/elysia-plugin' import type { Settings } from '@/config/settings' import { isOriginAllowed } from '@/config/settings' -import { applyOperation, getActiveSessionByToken, getDeviceById, getUserById, upsertDevice } from '@/dal' +import { getActiveSessionByToken, getDeviceById, getUserById, upsertDevice } from '@/dal' +import { applyUploadBatch, type UploadOp } from '@/powersync/upload-handlers' import type { db as DbType } from '@/db/client' import { verifySignedBearerToken } from '@/auth/bearer-token' import type { User } from '@shared/types/auth' @@ -260,26 +261,39 @@ export const createPowerSyncRoutes = (auth: Auth, settings: Settings, database: return validation.body } - const operations = body.operations - - // Process operations sequentially to maintain order. - // If any operation fails, return 4xx so the client does not call transaction.complete() - // and PowerSync will retry the batch. - for (const op of operations) { - const ok = await applyOperation(database, op, user.id) - if (!ok) { - set.status = 400 - return { - error: 'Upload operation failed', - code: 'UPLOAD_OPERATION_FAILED', - table: op.type, - id: op.id, - op: op.op, - } + // Dispatch the batch through the per-table upload handler factory. Each op + // runs in its own savepoint inside one outer transaction (see + // `applyUploadBatch`). Permanent rejections accumulate and return 200 so + // PowerSync clears the queue; any transient failure rolls back the whole + // batch and returns 503 so PowerSync retries. + // Elysia validates `type` as a string at the protocol boundary; the dispatcher + // narrows it via the handlers registry (returns `UNKNOWN_TABLE` permanent reject + // for names not in `PowerSyncTableName`). + const result = await applyUploadBatch(database, body.operations as UploadOp[], { + userId: user.id, + settings, + }) + + if (!result.ok) { + set.status = 503 + return { + error: 'Upload batch transient failure', + code: result.code, + table: result.op?.type, + id: result.op?.id, + op: result.op?.op, } } - return { success: true } + return { + success: true, + rejected: result.rejected.map(({ op, code }) => ({ + table: op.type, + id: op.id, + op: op.op, + code, + })), + } }, { body: t.Object({ diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 154b742c5..d2796d645 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -4,6 +4,7 @@ import { approveWaitlistEntry, + promotePendingMemberships, getOrCreateOtpChallenge, createWaitlistEntry, deleteOtpChallengesForEmail, @@ -11,6 +12,7 @@ import { getUserByEmail, getWaitlistByEmail, markUserNotNew, + syncMembershipDisplayInfo, validateOtpChallenge, } from '@/dal' import type { db as DbType } from '@/db/client' @@ -208,6 +210,28 @@ export const createAuth = (database: typeof DbType, emailDeps: AuthEmailDeps = { before: async (userData) => ({ data: { ...userData, email: normalizeEmail(userData.email) }, }), + // Promote any pending memberships invited by email. The personal workspace + // itself is FE-created (uploaded via PowerSync with a deterministic id), so + // this hook only handles the cross-workspace promotion flow — a brand-new + // user isn't a member of anything yet, so no FE client can see (let alone + // act on) the invite at signup time. Skipped for anonymous users — anon + // never receives invites. + after: async (createdUser) => { + const isAnonymous = (createdUser as { isAnonymous?: boolean }).isAnonymous === true + if (isAnonymous) { + return + } + await promotePendingMemberships(database, createdUser.id, createdUser.email, createdUser.name) + }, + }, + // Mirror name/email changes onto every membership row so co-members see + // updated display info on the next sync round-trip. The `workspace_memberships` + // table denormalizes these fields because PowerSync sync rules can't follow + // `user_id` across buckets — without this hook, edits would go stale. + update: { + after: async (updatedUser) => { + await syncMembershipDisplayInfo(database, updatedUser.id, updatedUser.name, updatedUser.email) + }, }, }, }, diff --git a/backend/src/config/settings.test.ts b/backend/src/config/settings.test.ts index e4d83967f..a40020ed5 100644 --- a/backend/src/config/settings.test.ts +++ b/backend/src/config/settings.test.ts @@ -51,6 +51,33 @@ describe('Config Settings', () => { }) }) + describe('SERVER_ID validation', () => { + const savedServerId = process.env.SERVER_ID + + afterEach(() => { + if (savedServerId !== undefined) { + process.env.SERVER_ID = savedServerId + } else { + delete process.env.SERVER_ID + } + clearSettingsCache() + }) + + it('surfaces a setup-pointing error when SERVER_ID is unset', () => { + delete process.env.SERVER_ID + clearSettingsCache() + + expect(() => getSettings()).toThrow(/SERVER_ID env var must be set.*make doctor/) + }) + + it('surfaces a UUID-format error when SERVER_ID is set but not a UUID', () => { + process.env.SERVER_ID = 'not-a-uuid' + clearSettingsCache() + + expect(() => getSettings()).toThrow(/SERVER_ID must be a valid UUID/) + }) + }) + describe('CORS default security', () => { const corsEnvKeys = ['CORS_ORIGINS'] as const diff --git a/backend/src/config/settings.ts b/backend/src/config/settings.ts index cff4914a3..e97aad863 100644 --- a/backend/src/config/settings.ts +++ b/backend/src/config/settings.ts @@ -9,6 +9,20 @@ import { z } from 'zod' */ const settingsSchema = z .object({ + // Stable per-deployment UUID. Returned by GET /v1/config and used by the frontend to + // key the trust-domain registry (auth token, device ID, encryption keys, DB filename + // are all namespaced by this). No default — TS-enforced to prevent ID duplication + // across deployments. `make doctor` auto-generates one for local dev. + // + // Custom messages mirror the BETTER_AUTH_SECRET ergonomics: a raw `Expected + // string, received undefined` ZodError doesn't tell a fresh dev what to do. + serverId: z + .string({ + error: + 'SERVER_ID env var must be set to a stable per-deployment UUID. Run `make doctor` to auto-generate one for local dev.', + }) + .uuid({ error: 'SERVER_ID must be a valid UUID.' }), + // API Keys fireworksApiKey: z.string().default(''), mistralApiKey: z.string().default(''), @@ -33,7 +47,13 @@ const settingsSchema = z // Anonymous-session overlay — opt-in. When false, the anonymous() Better Auth plugin // is NOT registered so /v1/api/auth/sign-in/anonymous returns 404. Defense-in-depth // against a malicious client bypassing the frontend gate via direct curl. + // Surfaced to the UI via GET /config as `allowAnonUsers`. authAllowAnonymous: z.boolean().default(false), + // Workspace creation policy flags. Surfaced via GET /config; enforced by the + // PowerSync upload-handler factory (workspaces table). Both default to false to + // match v1 production posture (central-admin only); relax per deployment. + allowWorkspaceCreationByAnon: z.boolean().default(false), + allowWorkspaceCreationByMembers: z.boolean().default(false), oidcClientId: z.string().default(''), oidcClientSecret: z.string().default(''), oidcIssuer: z.string().default(''), @@ -154,6 +174,7 @@ export type Settings = z.infer const parseSettings = (): Settings => { const isDevelopment = process.env.NODE_ENV === 'development' const env = { + serverId: process.env.SERVER_ID, fireworksApiKey: process.env.FIREWORKS_API_KEY || '', mistralApiKey: process.env.MISTRAL_API_KEY || '', anthropicApiKey: process.env.ANTHROPIC_API_KEY || '', @@ -167,6 +188,8 @@ const parseSettings = (): Settings => { microsoftClientSecret: process.env.MICROSOFT_CLIENT_SECRET || '', authMode: (process.env.AUTH_MODE || 'consumer').toLowerCase(), authAllowAnonymous: process.env.AUTH_ALLOW_ANONYMOUS === 'true', + allowWorkspaceCreationByAnon: process.env.ALLOW_WORKSPACE_CREATION_BY_ANON === 'true', + allowWorkspaceCreationByMembers: process.env.ALLOW_WORKSPACE_CREATION_BY_MEMBERS === 'true', oidcClientId: process.env.OIDC_CLIENT_ID || '', oidcClientSecret: process.env.OIDC_CLIENT_SECRET || '', oidcIssuer: process.env.OIDC_ISSUER || '', diff --git a/backend/src/dal/index.ts b/backend/src/dal/index.ts index d333f47e1..9e118d9da 100644 --- a/backend/src/dal/index.ts +++ b/backend/src/dal/index.ts @@ -22,8 +22,33 @@ export { getActiveSessionByToken, linkSessionToDevice, revokeDeviceSessions } fr // Waitlist export { getWaitlistByEmail, createWaitlistEntry, approveWaitlistEntry } from './waitlist' -// PowerSync -export { applyOperation } from './powersync' +// Workspaces +export { + promotePendingMemberships, + countWorkspaceAdmins, + countWorkspaceMemberships, + deleteMembership, + deletePendingMembership, + deleteWorkspacePermission, + getMembershipById, + getPendingMembershipById, + getWorkspaceById, + getWorkspacePermissionById, + insertPersonalWorkspaceIfMissing, + isAdminOfAnyWorkspace, + isPersonalWorkspace, + isWorkspaceAdmin, + isWorkspaceMember, + syncMembershipDisplayInfo, + updateMembership, + updatePendingMembership, + updateWorkspace, + updateWorkspacePermission, + upsertMembership, + upsertPendingMembership, + upsertWorkspace, + upsertWorkspacePermission, +} from './workspaces' // OTP Challenge (session binding) export { diff --git a/backend/src/dal/powersync.ts b/backend/src/dal/powersync.ts deleted file mode 100644 index a106825fd..000000000 --- a/backend/src/dal/powersync.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import type { db as DbType } from '@/db/client' -import { - powersyncConflictTarget, - powersyncDbNameToSchemaKey, - powersyncPkColumn, - powersyncTablesByName, -} from '@/db/powersync-schema' -import { type PowerSyncTableName, powersyncTableNames } from '@shared/powersync-tables' -import { and, eq } from 'drizzle-orm' -import type { AnyPgTable } from 'drizzle-orm/pg-core' - -const validTables = new Set(powersyncTableNames) - -/** DB column names that clients cannot set via PowerSync upload (server-managed fields). */ -const uploadDenyColumns: Partial> = { - devices: ['revoked_at', 'trusted', 'public_key', 'mlkem_public_key', 'approval_pending', 'app_version'], -} - -/** Tables that cannot be deleted via PowerSync upload — must use dedicated API endpoints. */ -const uploadDenyDelete = new Set(['devices']) - -type PowerSyncOperation = { - op: 'PUT' | 'PATCH' | 'DELETE' - type: string - id: string - data?: Record -} - -/** DB column names that use Drizzle timestamp(); JSON sends them as ISO strings, so we convert to Date. */ -const timestampDbColumns = new Set(['deleted_at', 'last_seen', 'created_at', 'revoked_at', 'updated_at']) - -/** - * Convert payload with DB column names to schema keys and filter to valid columns only. - * Timestamp columns arrive as ISO strings from JSON; convert to Date for Drizzle. - */ -const toSchemaRecord = ( - dbRecord: Record, - validDbNames: Set, - dbNameToKey: Record, -): Record => { - const out: Record = {} - for (const [dbName, value] of Object.entries(dbRecord)) { - if (!validDbNames.has(dbName)) { - continue - } - const schemaKey = dbNameToKey[dbName] - if (schemaKey && value !== undefined) { - let mapped = value - if (timestampDbColumns.has(dbName) && typeof value === 'string') { - const d = new Date(value) - mapped = Number.isNaN(d.getTime()) ? value : d - } - out[schemaKey] = mapped - } - } - return out -} - -/** - * Apply a single PowerSync operation using Drizzle's query builder (parameterized, no raw SQL). - * The user_id is always set to the authenticated user to ensure data isolation. - */ -export const applyOperation = async ( - database: typeof DbType, - op: PowerSyncOperation, - userId: string, -): Promise => { - if (!validTables.has(op.type)) { - return false - } - - const tableName = op.type as PowerSyncTableName - const table = powersyncTablesByName[tableName] - const dbNameToKey = powersyncDbNameToSchemaKey[tableName] - const pkColumn = powersyncPkColumn[tableName] - const conflictTarget = powersyncConflictTarget[tableName] - if (!table || !dbNameToKey || !pkColumn || !conflictTarget) { - return false - } - - const validDbNames = new Set(Object.keys(dbNameToKey)) - const tableWithUserId = table as AnyPgTable & { userId: typeof table.userId } - - switch (op.op) { - case 'PUT': { - const payload = { ...(op.data ?? {}) } as Record - delete payload.id - delete payload.user_id - for (const col of uploadDenyColumns[tableName] ?? []) { - delete payload[col] - } - const rawData: Record = { ...payload, id: op.id, user_id: userId } - const schemaValues = toSchemaRecord(rawData, validDbNames, dbNameToKey) - if (Object.keys(schemaValues).length === 0) { - return false - } - - const updateSet = { ...schemaValues } - delete updateSet.id - delete updateSet.key - delete updateSet.userId - - const insertQuery = database.insert(table).values(schemaValues as never) - if (Object.keys(updateSet).length > 0) { - await insertQuery.onConflictDoUpdate({ - target: conflictTarget, - set: updateSet as never, - setWhere: eq(tableWithUserId.userId, userId), - }) - } else { - await insertQuery.onConflictDoNothing({ target: conflictTarget }) - } - return true - } - case 'PATCH': { - if (!op.data || Object.keys(op.data).length === 0) { - return true - } - const patchPayload = { ...op.data } as Record - delete patchPayload.id - delete patchPayload.user_id - for (const col of uploadDenyColumns[tableName] ?? []) { - delete patchPayload[col] - } - const schemaPatch = toSchemaRecord(patchPayload, validDbNames, dbNameToKey) - // Empty patch after stripping server-managed and unknown columns is a - // harmless no-op (e.g. a buggy client that only sent `{ user_id: null }`). - // Accept it so the client's CRUD queue can drain instead of looping on a - // 400 — refusing it would block every subsequent upload behind a write - // that has nothing to apply anyway. - if (Object.keys(schemaPatch).length === 0) { - return true - } - - const patched = await database - .update(table) - .set(schemaPatch as never) - .where(and(eq(pkColumn, op.id), eq(tableWithUserId.userId, userId))) - .returning() - - return patched.length > 0 - } - case 'DELETE': { - if (uploadDenyDelete.has(tableName)) { - return false - } - - const deleted = await database - .delete(table) - .where(and(eq(pkColumn, op.id), eq(tableWithUserId.userId, userId))) - .returning() - - return deleted.length > 0 - } - } - return false -} diff --git a/backend/src/dal/users.test.ts b/backend/src/dal/users.test.ts index 87af40b94..e75b1c649 100644 --- a/backend/src/dal/users.test.ts +++ b/backend/src/dal/users.test.ts @@ -41,7 +41,7 @@ describe('users DAL', () => { it('returns user when found', async () => { await insertUser('u1', 'u1@test.com') const result = await getUserById(db, 'u1') - expect(result).toEqual({ id: 'u1', isAnonymous: false }) + expect(result).toEqual({ id: 'u1', isAnonymous: false, name: 'Test User', email: 'u1@test.com' }) }) it('returns null when not found', async () => { @@ -54,7 +54,7 @@ describe('users DAL', () => { it('returns user when found', async () => { await insertUser('u2', 'u2@test.com') const result = await getUserByEmail(db, 'u2@test.com') - expect(result).toEqual({ id: 'u2' }) + expect(result).toEqual({ id: 'u2', name: 'Test User', email: 'u2@test.com' }) }) it('returns null when not found', async () => { diff --git a/backend/src/dal/users.ts b/backend/src/dal/users.ts index 0026da0ca..2551d3670 100644 --- a/backend/src/dal/users.ts +++ b/backend/src/dal/users.ts @@ -9,7 +9,7 @@ import { eq } from 'drizzle-orm' /** Get a user by ID. Returns null if not found. */ export const getUserById = async (database: typeof DbType, id: string) => database - .select({ id: user.id, isAnonymous: user.isAnonymous }) + .select({ id: user.id, isAnonymous: user.isAnonymous, name: user.name, email: user.email }) .from(user) .where(eq(user.id, id)) .limit(1) @@ -18,7 +18,7 @@ export const getUserById = async (database: typeof DbType, id: string) => /** Get a user by email. Returns null if not found. */ export const getUserByEmail = async (database: typeof DbType, email: string) => database - .select({ id: user.id }) + .select({ id: user.id, name: user.name, email: user.email }) .from(user) .where(eq(user.email, email)) .limit(1) diff --git a/backend/src/dal/workspaces.test.ts b/backend/src/dal/workspaces.test.ts new file mode 100644 index 000000000..b6bd3f979 --- /dev/null +++ b/backend/src/dal/workspaces.test.ts @@ -0,0 +1,258 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { user } from '@/db/auth-schema' +import { workspaceMembershipsTable, workspacePendingMembershipsTable, workspacesTable } from '@/db/powersync-schema' +import { createTestDb } from '@/test-utils/db' +import { and, eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { v7 as uuidv7 } from 'uuid' +import { promotePendingMemberships, syncMembershipDisplayInfo } from './workspaces' + +describe('promotePendingMemberships', () => { + let db: Awaited>['db'] + let cleanup: () => Promise + + const insertUser = async (id: string, email: string) => { + const now = new Date() + await db.insert(user).values({ + id, + name: 'Test User', + email, + emailVerified: true, + isNew: true, + createdAt: now, + updatedAt: now, + }) + } + + const insertSharedWorkspace = async (id: string): Promise => { + await db.insert(workspacesTable).values({ + id, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + } + + beforeEach(async () => { + const testEnv = await createTestDb() + db = testEnv.db + cleanup = testEnv.cleanup + }) + + afterEach(async () => { + await cleanup() + }) + + it('is a no-op for users with no matching pending memberships', async () => { + await insertUser('u1', 'u1@test.com') + await promotePendingMemberships(db, 'u1', 'u1@test.com', 'Test User') + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.userId, 'u1')) + expect(memberships).toHaveLength(0) + }) + + it('promotes pending memberships matching the user email into membership rows', async () => { + await insertUser('admin1', 'admin1@test.com') + const sharedWorkspaceId = uuidv7() + await insertSharedWorkspace(sharedWorkspaceId) + + await db.insert(workspacePendingMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedWorkspaceId, + email: 'newcomer@test.com', + role: 'member', + invitedByUserId: 'admin1', + }) + + await insertUser('newcomer', 'newcomer@test.com') + await promotePendingMemberships(db, 'newcomer', 'newcomer@test.com', 'Test User') + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.userId, 'newcomer')) + expect(memberships).toHaveLength(1) + expect(memberships[0].workspaceId).toBe(sharedWorkspaceId) + expect(memberships[0].role).toBe('member') + + const remainingPending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.email, 'newcomer@test.com')) + expect(remainingPending).toHaveLength(0) + }) + + it('normalizes the email before matching pending memberships', async () => { + await insertUser('admin2', 'admin2@test.com') + const sharedWorkspaceId = uuidv7() + await insertSharedWorkspace(sharedWorkspaceId) + + await db.insert(workspacePendingMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedWorkspaceId, + email: 'mixedcase@test.com', + role: 'admin', + invitedByUserId: 'admin2', + }) + + await insertUser('mixed', 'mixedcase@test.com') + // Mixed-case input — `promotePendingMemberships` normalizes before matching. + await promotePendingMemberships(db, 'mixed', 'MixedCase@TEST.com', 'Test User') + + const promoted = await db + .select() + .from(workspaceMembershipsTable) + .where( + and( + eq(workspaceMembershipsTable.userId, 'mixed'), + eq(workspaceMembershipsTable.workspaceId, sharedWorkspaceId), + ), + ) + expect(promoted).toHaveLength(1) + expect(promoted[0].role).toBe('admin') + }) + + it('promotes multiple pending invites for the same email atomically', async () => { + await insertUser('admin3', 'admin3@test.com') + const ws1 = uuidv7() + const ws2 = uuidv7() + await insertSharedWorkspace(ws1) + await insertSharedWorkspace(ws2) + + await db.insert(workspacePendingMembershipsTable).values([ + { + id: uuidv7(), + workspaceId: ws1, + email: 'multi@test.com', + role: 'member', + invitedByUserId: 'admin3', + }, + { + id: uuidv7(), + workspaceId: ws2, + email: 'multi@test.com', + role: 'admin', + invitedByUserId: 'admin3', + }, + ]) + + await insertUser('multi', 'multi@test.com') + await promotePendingMemberships(db, 'multi', 'multi@test.com', 'Test User') + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.userId, 'multi')) + expect(memberships).toHaveLength(2) + + const remainingPending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.email, 'multi@test.com')) + expect(remainingPending).toHaveLength(0) + }) +}) + +describe('syncMembershipDisplayInfo', () => { + let db: Awaited>['db'] + let cleanup: () => Promise + + const insertUser = async (id: string, email: string, name = 'Original Name') => { + const now = new Date() + await db.insert(user).values({ + id, + name, + email, + emailVerified: true, + isNew: true, + createdAt: now, + updatedAt: now, + }) + } + + const insertSharedWorkspace = async (id: string): Promise => { + await db.insert(workspacesTable).values({ id, name: 'Shared', isPersonal: false, ownerUserId: null }) + } + + beforeEach(async () => { + const testEnv = await createTestDb() + db = testEnv.db + cleanup = testEnv.cleanup + }) + + afterEach(async () => { + await cleanup() + }) + + it('updates user_name and user_email on every membership row for the user', async () => { + await insertUser('alice', 'old@test.com', 'Old Name') + const ws1 = uuidv7() + const ws2 = uuidv7() + await insertSharedWorkspace(ws1) + await insertSharedWorkspace(ws2) + + await db.insert(workspaceMembershipsTable).values([ + { + id: uuidv7(), + workspaceId: ws1, + userId: 'alice', + role: 'admin', + userName: 'Old Name', + userEmail: 'old@test.com', + }, + { + id: uuidv7(), + workspaceId: ws2, + userId: 'alice', + role: 'member', + userName: 'Old Name', + userEmail: 'old@test.com', + }, + ]) + + await syncMembershipDisplayInfo(db, 'alice', 'New Name', 'new@test.com') + + const rows = await db.select().from(workspaceMembershipsTable).where(eq(workspaceMembershipsTable.userId, 'alice')) + expect(rows).toHaveLength(2) + for (const row of rows) { + expect(row.userName).toBe('New Name') + expect(row.userEmail).toBe('new@test.com') + } + }) + + it('leaves rows for other users untouched', async () => { + await insertUser('alice', 'alice@test.com', 'Alice') + await insertUser('bob', 'bob@test.com', 'Bob') + const ws = uuidv7() + await insertSharedWorkspace(ws) + + await db.insert(workspaceMembershipsTable).values([ + { id: uuidv7(), workspaceId: ws, userId: 'alice', role: 'admin', userName: 'Alice', userEmail: 'alice@test.com' }, + { id: uuidv7(), workspaceId: ws, userId: 'bob', role: 'member', userName: 'Bob', userEmail: 'bob@test.com' }, + ]) + + await syncMembershipDisplayInfo(db, 'alice', 'Alice Updated', 'alice-new@test.com') + + const bobRow = await db + .select() + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.workspaceId, ws), eq(workspaceMembershipsTable.userId, 'bob'))) + expect(bobRow[0].userName).toBe('Bob') + expect(bobRow[0].userEmail).toBe('bob@test.com') + }) + + it('is a no-op when the user has no memberships', async () => { + await insertUser('lonely', 'lonely@test.com', 'Lonely') + + await syncMembershipDisplayInfo(db, 'lonely', 'New', 'new@test.com') + + const rows = await db.select().from(workspaceMembershipsTable).where(eq(workspaceMembershipsTable.userId, 'lonely')) + expect(rows).toHaveLength(0) + }) +}) diff --git a/backend/src/dal/workspaces.ts b/backend/src/dal/workspaces.ts new file mode 100644 index 000000000..3510aa72d --- /dev/null +++ b/backend/src/dal/workspaces.ts @@ -0,0 +1,620 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { db as DbType } from '@/db/client' +import { + workspaceMembershipsTable, + workspacePendingMembershipsTable, + workspacePermissionsTable, + workspacesTable, +} from '@/db/powersync-schema' +import { normalizeEmail } from '@/lib/email' +import { and, count, eq, ne } from 'drizzle-orm' +import { v7 as uuidv7 } from 'uuid' + +/** + * Promotes any pending memberships matching this user's email into real + * membership rows, atomically with the user creation. + * + * Called from the Better Auth post-user-create hook. The personal workspace + * itself is FE-created (uploaded via PowerSync with a deterministic id from + * `shared/workspaces.ts`) — the BE no longer creates one here. Pending + * promotion stays server-side because a brand-new user isn't a member of any + * workspace yet, so no FE client can see (or act on) the pending invite at + * signup time. + * + * Skipped for anonymous users — anon never receives pending invites. + */ +export const promotePendingMemberships = async ( + database: typeof DbType, + userId: string, + email: string, + name: string, +): Promise => { + const normalizedEmail = normalizeEmail(email) + + await database.transaction(async (tx) => { + const txDb = tx as unknown as typeof database + + const pending = await txDb + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.email, normalizedEmail)) + + if (pending.length === 0) { + return + } + + await txDb + .insert(workspaceMembershipsTable) + .values( + pending.map((row) => ({ + id: uuidv7(), + workspaceId: row.workspaceId, + userId, + role: row.role, + userName: name, + userEmail: normalizedEmail, + })), + ) + .onConflictDoNothing({ + target: [workspaceMembershipsTable.workspaceId, workspaceMembershipsTable.userId], + }) + + await txDb + .delete(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.email, normalizedEmail)) + }) +} + +export type WorkspaceRow = { + id: string + isPersonal: boolean + ownerUserId: string | null +} + +export type MembershipRow = { + id: string + workspaceId: string + userId: string + role: 'admin' | 'member' +} + +export type PendingRow = { + id: string + workspaceId: string +} + +export type PermissionRow = { + id: string + workspaceId: string +} + +/** Fetches the workspace row for membership/personal checks. Returns `null` if missing. */ +export const getWorkspaceById = async (database: typeof DbType, workspaceId: string): Promise => { + const rows = await database + .select({ + id: workspacesTable.id, + isPersonal: workspacesTable.isPersonal, + ownerUserId: workspacesTable.ownerUserId, + }) + .from(workspacesTable) + .where(eq(workspacesTable.id, workspaceId)) + .limit(1) + return rows[0] ?? null +} + +export const isPersonalWorkspace = async (database: typeof DbType, workspaceId: string): Promise => { + const row = await getWorkspaceById(database, workspaceId) + return row?.isPersonal === true +} + +export const isWorkspaceMember = async ( + database: typeof DbType, + workspaceId: string, + userId: string, +): Promise => { + const rows = await database + .select({ id: workspaceMembershipsTable.id }) + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.workspaceId, workspaceId), eq(workspaceMembershipsTable.userId, userId))) + .limit(1) + return rows.length > 0 +} + +export const isWorkspaceAdmin = async ( + database: typeof DbType, + workspaceId: string, + userId: string, +): Promise => { + const rows = await database + .select({ id: workspaceMembershipsTable.id }) + .from(workspaceMembershipsTable) + .where( + and( + eq(workspaceMembershipsTable.workspaceId, workspaceId), + eq(workspaceMembershipsTable.userId, userId), + eq(workspaceMembershipsTable.role, 'admin'), + ), + ) + .limit(1) + return rows.length > 0 +} + +/** + * Returns the user's `role` ('admin' | 'member') for the workspace, or `null` + * if they have no membership row. Used by upload handlers to evaluate + * `workspace_permissions.required_role` against the caller's actual role. + */ +export const getUserRoleInWorkspace = async ( + database: typeof DbType, + workspaceId: string, + userId: string, +): Promise => { + const rows = await database + .select({ role: workspaceMembershipsTable.role }) + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.workspaceId, workspaceId), eq(workspaceMembershipsTable.userId, userId))) + .limit(1) + const row = rows[0] + if (!row) { + return null + } + return row.role as Role +} + +/** + * Reads `workspace_permissions.required_role` for `(workspaceId, permissionKey)`. + * Returns `null` when no row exists yet; callers default to `'admin'` + * (Decision 11 — the safe default for any new key). + */ +export const getRequiredRoleForPermission = async ( + database: typeof DbType, + workspaceId: string, + permissionKey: WorkspacePermissionKey, +): Promise => { + const rows = await database + .select({ requiredRole: workspacePermissionsTable.requiredRole }) + .from(workspacePermissionsTable) + .where( + and( + eq(workspacePermissionsTable.workspaceId, workspaceId), + eq(workspacePermissionsTable.permissionKey, permissionKey), + ), + ) + .limit(1) + const row = rows[0] + if (!row) { + return null + } + return row.requiredRole as Role +} + +/** True when the user is an admin of any workspace (used to gate shared-workspace creation). */ +export const isAdminOfAnyWorkspace = async (database: typeof DbType, userId: string): Promise => { + const rows = await database + .select({ id: workspaceMembershipsTable.id }) + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.userId, userId), eq(workspaceMembershipsTable.role, 'admin'))) + .limit(1) + return rows.length > 0 +} + +/** + * Counts admin memberships in a workspace, optionally excluding one membership id. + * Used for last-admin protection: callers count after the delete inside the same tx + * and reject when the result would be zero. + */ +/** + * Counts memberships in a workspace. Used by the bootstrap-admin exception in + * the membership upload handler: it allows a single admin self-claim for a + * personal workspace only when the workspace currently has zero memberships. + */ +export const countWorkspaceMemberships = async (database: typeof DbType, workspaceId: string): Promise => { + const rows = await database + .select({ value: count() }) + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, workspaceId)) + return Number(rows[0]?.value ?? 0) +} + +export const countWorkspaceAdmins = async ( + database: typeof DbType, + workspaceId: string, + excludeMembershipId?: string, +): Promise => { + const baseFilter = and( + eq(workspaceMembershipsTable.workspaceId, workspaceId), + eq(workspaceMembershipsTable.role, 'admin'), + ) + const where = excludeMembershipId + ? and(baseFilter, ne(workspaceMembershipsTable.id, excludeMembershipId)) + : baseFilter + const rows = await database.select({ value: count() }).from(workspaceMembershipsTable).where(where) + return Number(rows[0]?.value ?? 0) +} + +export const getMembershipById = async ( + database: typeof DbType, + membershipId: string, +): Promise => { + const rows = await database + .select({ + id: workspaceMembershipsTable.id, + workspaceId: workspaceMembershipsTable.workspaceId, + userId: workspaceMembershipsTable.userId, + role: workspaceMembershipsTable.role, + }) + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.id, membershipId)) + .limit(1) + return rows[0] ?? null +} + +/** + * Look up a membership by `(workspace_id, user_id)` — the unique constraint + * that `upsertMembership` collides on. Upload-handler validation uses this to + * detect when a PUT would effectively change an existing role (treated as a + * PATCH for auth purposes). + */ +export const getMembershipByWorkspaceAndUser = async ( + database: typeof DbType, + workspaceId: string, + userId: string, +): Promise => { + const rows = await database + .select({ + id: workspaceMembershipsTable.id, + workspaceId: workspaceMembershipsTable.workspaceId, + userId: workspaceMembershipsTable.userId, + role: workspaceMembershipsTable.role, + }) + .from(workspaceMembershipsTable) + .where(and(eq(workspaceMembershipsTable.workspaceId, workspaceId), eq(workspaceMembershipsTable.userId, userId))) + .limit(1) + return rows[0] ?? null +} + +export const getPendingMembershipById = async (database: typeof DbType, id: string): Promise => { + const rows = await database + .select({ + id: workspacePendingMembershipsTable.id, + workspaceId: workspacePendingMembershipsTable.workspaceId, + }) + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, id)) + .limit(1) + return rows[0] ?? null +} + +export const getWorkspacePermissionById = async ( + database: typeof DbType, + id: string, +): Promise => { + const rows = await database + .select({ + id: workspacePermissionsTable.id, + workspaceId: workspacePermissionsTable.workspaceId, + }) + .from(workspacePermissionsTable) + .where(eq(workspacePermissionsTable.id, id)) + .limit(1) + return rows[0] ?? null +} + +import type { WorkspacePermissionKey } from '@shared/workspaces' + +export type Role = 'admin' | 'member' +export type { WorkspacePermissionKey } + +export type UpsertWorkspaceInput = { + id: string + name: string + isPersonal: boolean + /** Required when `isPersonal` is `true`; null/omitted for shared. */ + ownerUserId?: string | null + /** Optional slug. Shared-only; personal workspaces never carry one. */ + slug?: string | null + /** Optional icon (emoji or base64 image). Either workspace kind may set it. */ + icon?: string | null +} + +/** + * Upserts a shared workspace row. Conflict target is the PK; on conflict the + * mutable fields are refreshed and `updated_at` bumped — covers the admin- + * rename-via-PUT path even though FE renames now flow through PATCH. + * + * Use `insertPersonalWorkspaceIfMissing` for personal workspaces instead — the + * "do nothing on conflict" semantics avoid clobbering a user rename when a + * second device runs its idempotent bootstrap PUT. + */ +export const upsertWorkspace = async (database: typeof DbType, input: UpsertWorkspaceInput): Promise => { + await database + .insert(workspacesTable) + .values({ + id: input.id, + name: input.name, + slug: input.slug ?? null, + icon: input.icon ?? null, + isPersonal: input.isPersonal, + ownerUserId: input.ownerUserId ?? null, + }) + .onConflictDoUpdate({ + target: workspacesTable.id, + set: { + name: input.name, + ...(input.slug !== undefined ? { slug: input.slug } : {}), + ...(input.icon !== undefined ? { icon: input.icon } : {}), + updatedAt: new Date(), + }, + }) +} + +/** + * Insert a personal workspace row if no row with this id exists. Multi-device + * safe: device A creates and renames the workspace; device B running its own + * `ensurePersonalWorkspace` bootstrap re-uploads the canonical PUT with the + * default name. `ON CONFLICT DO NOTHING` preserves the renamed name on the BE. + * + * `slug` is intentionally absent — personal workspaces don't appear in URLs + * (see THU-551 URL deviation) so the column stays null. `icon` is optional and + * persisted on first insert only. + */ +export const insertPersonalWorkspaceIfMissing = async ( + database: typeof DbType, + input: { id: string; name: string; ownerUserId: string; icon?: string | null }, +): Promise => { + await database + .insert(workspacesTable) + .values({ + id: input.id, + name: input.name, + icon: input.icon ?? null, + isPersonal: true, + ownerUserId: input.ownerUserId, + }) + .onConflictDoNothing({ target: workspacesTable.id }) +} + +/** + * Updates a workspace's mutable fields. The upload handler is the only caller + * and gates writes on admin-of-the-workspace — this just persists the patch. + * + * Returns the affected row count so callers can map 0 → ROW_NOT_FOUND. + */ +export const updateWorkspace = async ( + database: typeof DbType, + id: string, + patch: { name?: string; slug?: string | null; icon?: string | null }, +): Promise => { + const setClause: Record = { updatedAt: new Date() } + if (patch.name !== undefined) { + setClause.name = patch.name + } + if (patch.slug !== undefined) { + setClause.slug = patch.slug + } + if (patch.icon !== undefined) { + setClause.icon = patch.icon + } + const rows = await database.update(workspacesTable).set(setClause).where(eq(workspacesTable.id, id)).returning() + return rows.length +} + +export type MembershipInput = { + id: string + workspaceId: string + userId: string + role: Role + /** Denormalized from `auth.user`. Synced down so the Members page can render + * display info without a `users` projection table (PowerSync sync rules + * can't follow `user_id` across buckets). */ + userName?: string | null + userEmail?: string | null +} + +/** + * Upserts a workspace membership. Conflict target is the natural key + * `(workspace_id, user_id)`; on conflict the role is refreshed. Display info + * (`user_name`, `user_email`) is refreshed too so a stale denormalized row + * heals the next time the upload handler runs against it. + */ +export const upsertMembership = async (database: typeof DbType, input: MembershipInput): Promise => { + await database + .insert(workspaceMembershipsTable) + .values(input) + .onConflictDoUpdate({ + target: [workspaceMembershipsTable.workspaceId, workspaceMembershipsTable.userId], + set: { + role: input.role, + ...(input.userName !== undefined ? { userName: input.userName } : {}), + ...(input.userEmail !== undefined ? { userEmail: input.userEmail } : {}), + }, + }) +} + +/** + * Insert a membership row only if no row with the same `(workspace_id, user_id)` + * already exists. Used by the promote-on-insert path in the pending-membership + * upload handler: an invite for an email that already belongs to a member must + * not overwrite that member's existing role (otherwise an invite for an admin's + * own email would downgrade them to whatever role the invite carried). Mirrors + * the `promotePendingMemberships` DO-NOTHING semantics for the signup path. + */ +export const insertMembershipIfMissing = async (database: typeof DbType, input: MembershipInput): Promise => { + await database + .insert(workspaceMembershipsTable) + .values(input) + .onConflictDoNothing({ + target: [workspaceMembershipsTable.workspaceId, workspaceMembershipsTable.userId], + }) +} + +/** + * Mirrors a user's current display info onto every one of their membership rows. + * Called from the Better Auth `update.after` hook so name/email changes propagate + * to co-members on the next sync round-trip. Idempotent — safe to call on every + * user update regardless of whether name/email actually changed. + */ +export const syncMembershipDisplayInfo = async ( + database: typeof DbType, + userId: string, + name: string, + email: string, +): Promise => { + await database + .update(workspaceMembershipsTable) + .set({ userName: name, userEmail: email }) + .where(eq(workspaceMembershipsTable.userId, userId)) +} + +export const updateMembership = async ( + database: typeof DbType, + id: string, + patch: { role?: Role }, +): Promise => { + if (patch.role === undefined) { + return 0 + } + const rows = await database + .update(workspaceMembershipsTable) + .set({ role: patch.role }) + .where(eq(workspaceMembershipsTable.id, id)) + .returning() + return rows.length +} + +export const deleteMembership = async (database: typeof DbType, id: string): Promise => { + const rows = await database.delete(workspaceMembershipsTable).where(eq(workspaceMembershipsTable.id, id)).returning() + return rows.length +} + +export type PendingMembershipInput = { + id: string + workspaceId: string + email: string + role: Role + invitedByUserId: string +} + +/** + * Upserts a pending membership row. Email is normalized server-side so case / + * whitespace variants land on the same record as the Better Auth `before` hook's + * normalized `user.email`. Conflict target is `(workspace_id, email)`; on conflict + * the role and inviter are refreshed. + */ +export const upsertPendingMembership = async ( + database: typeof DbType, + input: PendingMembershipInput, +): Promise => { + const email = normalizeEmail(input.email) + await database + .insert(workspacePendingMembershipsTable) + .values({ ...input, email }) + .onConflictDoUpdate({ + target: [workspacePendingMembershipsTable.workspaceId, workspacePendingMembershipsTable.email], + set: { role: input.role, invitedByUserId: input.invitedByUserId }, + }) +} + +export const updatePendingMembership = async ( + database: typeof DbType, + id: string, + patch: { email?: string; role?: Role; invitedByUserId?: string }, +): Promise => { + const setClause: Record = {} + if (patch.email !== undefined) { + setClause.email = normalizeEmail(patch.email) + } + if (patch.role !== undefined) { + setClause.role = patch.role + } + if (patch.invitedByUserId !== undefined) { + setClause.invitedByUserId = patch.invitedByUserId + } + if (Object.keys(setClause).length === 0) { + return 0 + } + const rows = await database + .update(workspacePendingMembershipsTable) + .set(setClause) + .where(eq(workspacePendingMembershipsTable.id, id)) + .returning() + return rows.length +} + +export const deletePendingMembership = async (database: typeof DbType, id: string): Promise => { + const rows = await database + .delete(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, id)) + .returning() + return rows.length +} + +/** + * Deletes the pending row for `(workspace_id, email)`. Used by the + * promote-on-insert path in the upload handler: when `upsertPendingMembership` + * conflicts on the `(workspace_id, email)` unique constraint, Postgres keeps + * the existing row's id, so a delete keyed on the upload's `op.id` would no-op + * and leave a stale pending invite behind for someone who is now a real + * member. Email is normalized to match `upsertPendingMembership`'s storage. + */ +export const deletePendingMembershipByWorkspaceAndEmail = async ( + database: typeof DbType, + workspaceId: string, + email: string, +): Promise => { + const normalizedEmail = normalizeEmail(email) + const rows = await database + .delete(workspacePendingMembershipsTable) + .where( + and( + eq(workspacePendingMembershipsTable.workspaceId, workspaceId), + eq(workspacePendingMembershipsTable.email, normalizedEmail), + ), + ) + .returning() + return rows.length +} + +export type WorkspacePermissionInput = { + id: string + workspaceId: string + permissionKey: WorkspacePermissionKey + requiredRole: Role +} + +export const upsertWorkspacePermission = async ( + database: typeof DbType, + input: WorkspacePermissionInput, +): Promise => { + await database + .insert(workspacePermissionsTable) + .values(input) + .onConflictDoUpdate({ + target: [workspacePermissionsTable.workspaceId, workspacePermissionsTable.permissionKey], + set: { requiredRole: input.requiredRole }, + }) +} + +export const updateWorkspacePermission = async ( + database: typeof DbType, + id: string, + patch: { requiredRole?: Role }, +): Promise => { + if (patch.requiredRole === undefined) { + return 0 + } + const rows = await database + .update(workspacePermissionsTable) + .set({ requiredRole: patch.requiredRole }) + .where(eq(workspacePermissionsTable.id, id)) + .returning() + return rows.length +} + +export const deleteWorkspacePermission = async (database: typeof DbType, id: string): Promise => { + const rows = await database.delete(workspacePermissionsTable).where(eq(workspacePermissionsTable.id, id)).returning() + return rows.length +} diff --git a/backend/src/db/powersync-schema.ts b/backend/src/db/powersync-schema.ts index 3f4fc6e04..a98b28c2c 100644 --- a/backend/src/db/powersync-schema.ts +++ b/backend/src/db/powersync-schema.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { PowerSyncTableName } from '@shared/powersync-tables' +import { workspacePermissionKeys, workspacePermissionRoles } from '@shared/workspaces' import { type AnyPgColumn, type AnyPgTable, @@ -14,8 +15,9 @@ import { real, text, timestamp, + uniqueIndex, } from 'drizzle-orm/pg-core' -import { getTableColumns } from 'drizzle-orm' +import { getTableColumns, sql } from 'drizzle-orm' import { user } from './auth-schema' /** @@ -25,6 +27,122 @@ import { user } from './auth-schema' const powersyncSchema = pgSchema('powersync') +/** + * Workspace entity. Every real user gets one personal workspace (`is_personal = true`) + * created by the Better Auth post-create hook; shared workspaces are created later via + * PowerSync upload from the FE (gated by `allowWorkspaceCreationBy*` flags). + * + * `owner_user_id` defines who a personal workspace belongs to (NOT an access-control + * "owner" role — roles live in `workspace_memberships`). The partial unique index + * enforces "one personal workspace per user". + */ +export const workspacesTable = powersyncSchema.table( + 'workspaces', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug'), + icon: text('icon'), + isPersonal: boolean('is_personal').notNull().default(false), + ownerUserId: text('owner_user_id').references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('idx_workspaces_personal_per_owner') + .on(table.ownerUserId) + .where(sql`${table.isPersonal} = true`), + index('idx_workspaces_owner_user_id').on(table.ownerUserId), + uniqueIndex('idx_workspaces_slug') + .on(table.slug) + .where(sql`${table.slug} IS NOT NULL`), + ], +) + +/** + * Workspace membership and role assignment. The natural key per spec §3.7 is + * `(workspace_id, user_id)`; PowerSync requires a single `id` column for row tracking, + * so the natural key is enforced as a unique constraint instead of a composite PK. + * + * Roles are `admin` | `member` only (Decision 9 — no `owner`). Last-admin protection + * lives in the upload handler factory. + * + * `user_name` / `user_email` are denormalized from `auth.user` so the Members + * page can render display info without a synced `users` table — PowerSync sync + * rules can't follow a `user_id` foreign key across buckets. The upload handler + * fills them at insert time; the Better Auth `after('updateUser')` hook keeps + * them in step when a user later edits their name or email. + */ +export const workspaceMembershipsTable = powersyncSchema.table( + 'workspace_memberships', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + role: text('role', { enum: ['admin', 'member'] }).notNull(), + userName: text('user_name'), + userEmail: text('user_email'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('idx_workspace_memberships_workspace_user').on(table.workspaceId, table.userId), + index('idx_workspace_memberships_user').on(table.userId), + index('idx_workspace_memberships_workspace').on(table.workspaceId), + ], +) + +/** + * Pending memberships: admin invites an email that doesn't yet have an account. + * On signup, the Better Auth post-create hook promotes any matching rows into + * `workspace_memberships` and deletes them here. Emails are stored normalized + * (lower-cased + trimmed) to match the `before` hook's normalization of `user.email`. + */ +export const workspacePendingMembershipsTable = powersyncSchema.table( + 'workspace_pending_memberships', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + role: text('role', { enum: ['admin', 'member'] }).notNull(), + invitedByUserId: text('invited_by_user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('idx_workspace_pending_memberships_workspace_email').on(table.workspaceId, table.email), + index('idx_workspace_pending_memberships_email').on(table.email), + index('idx_workspace_pending_memberships_workspace').on(table.workspaceId), + ], +) + +/** + * Per-workspace permission policy (Decision 10). The enum lists every + * configurable action the workspace exposes; the source of truth lives in + * `shared/workspaces.ts` so FE/BE schemas + types stay in lockstep. + */ +export const workspacePermissionsTable = powersyncSchema.table( + 'workspace_permissions', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), + permissionKey: text('permission_key', { enum: [...workspacePermissionKeys] }).notNull(), + requiredRole: text('required_role', { enum: [...workspacePermissionRoles] }).notNull(), + }, + (table) => [ + uniqueIndex('idx_workspace_permissions_workspace_key').on(table.workspaceId, table.permissionKey), + index('idx_workspace_permissions_workspace').on(table.workspaceId), + ], +) + export const settingsTable = powersyncSchema.table( 'settings', { @@ -53,11 +171,19 @@ export const chatThreadsTable = powersyncSchema.table( acpSessionId: text('acp_session_id'), agentId: text('agent_id'), deletedAt: timestamp('deleted_at'), + // User-private within a workspace — the row's author is the only valid reader, + // so deleting the user cascades the row away (no other member can ever see it). userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [index('idx_chat_threads_user_id').on(table.userId)], + (table) => [ + index('idx_chat_threads_user_id').on(table.userId), + index('idx_chat_threads_workspace_id').on(table.workspaceId), + ], ) export const chatMessagesTable = powersyncSchema.table( @@ -76,8 +202,14 @@ export const chatMessagesTable = powersyncSchema.table( userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [index('idx_chat_messages_user_id').on(table.userId)], + (table) => [ + index('idx_chat_messages_user_id').on(table.userId), + index('idx_chat_messages_workspace_id').on(table.workspaceId), + ], ) export const tasksTable = powersyncSchema.table( @@ -92,8 +224,16 @@ export const tasksTable = powersyncSchema.table( userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_tasks_user_id').on(table.userId)], + (table) => [ + // Composite PK on (id, workspace_id) lets default-data rows repeat across workspaces. + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_tasks_user_id').on(table.userId), + index('idx_tasks_workspace_id').on(table.workspaceId), + ], ) export const modelsTable = powersyncSchema.table( @@ -117,11 +257,16 @@ export const modelsTable = powersyncSchema.table( defaultHash: text('default_hash'), vendor: text('vendor'), description: text('description'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_models_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_models_user_id').on(table.userId), + index('idx_models_workspace_id').on(table.workspaceId), + ], ) export const promptsTable = powersyncSchema.table( @@ -133,11 +278,16 @@ export const promptsTable = powersyncSchema.table( modelId: text('model_id'), deletedAt: timestamp('deleted_at'), defaultHash: text('default_hash'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_prompts_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_prompts_user_id').on(table.userId), + index('idx_prompts_workspace_id').on(table.workspaceId), + ], ) export const skillsTable = powersyncSchema.table( @@ -151,11 +301,16 @@ export const skillsTable = powersyncSchema.table( pinnedOrder: integer('pinned_order'), deletedAt: timestamp('deleted_at'), defaultHash: text('default_hash'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_skills_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_skills_user_id').on(table.userId), + index('idx_skills_workspace_id').on(table.workspaceId), + ], ) export const triggersTable = powersyncSchema.table( @@ -167,11 +322,12 @@ export const triggersTable = powersyncSchema.table( promptId: text('prompt_id'), isEnabled: integer('is_enabled').default(1), deletedAt: timestamp('deleted_at'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [index('idx_triggers_user_id').on(table.userId)], + (table) => [index('idx_triggers_user_id').on(table.userId), index('idx_triggers_workspace_id').on(table.workspaceId)], ) export const modesTable = powersyncSchema.table( @@ -186,11 +342,16 @@ export const modesTable = powersyncSchema.table( order: integer('order').default(0), defaultHash: text('default_hash'), deletedAt: timestamp('deleted_at'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_modes_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_modes_user_id').on(table.userId), + index('idx_modes_workspace_id').on(table.workspaceId), + ], ) export const modelProfilesTable = powersyncSchema.table( @@ -218,11 +379,16 @@ export const modelProfilesTable = powersyncSchema.table( providerOptions: text('provider_options'), defaultHash: text('default_hash'), deletedAt: timestamp('deleted_at'), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_model_profiles_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_model_profiles_user_id').on(table.userId), + index('idx_model_profiles_workspace_id').on(table.workspaceId), + ], ) /** Synced via PowerSync. Device list, status, and public key for encryption. */ @@ -246,14 +412,20 @@ export const devicesTable = powersyncSchema.table( (table) => [index('idx_devices_user_id').on(table.userId)], ) -/** Synced via PowerSync. User-created ACP agents only. System agents are not rows. */ +/** + * Synced via PowerSync. User-created ACP agents only. System agents are not rows. + * Workspace-scoped, shared with all members — same shape as `mcp_servers`. The + * addendum predates this table being added in THU-547, but the conceptual model + * (external service connection configs) belongs to a workspace, not a single user. + */ export const agentsTable = powersyncSchema.table( 'agents', { id: text('id').notNull(), - userId: text('user_id') + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + workspaceId: text('workspace_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => workspacesTable.id, { onDelete: 'cascade' }), name: text('name').notNull(), type: text('type', { enum: ['remote-acp', 'managed-acp'] }).notNull(), transport: text('transport', { enum: ['websocket'] }).notNull(), @@ -263,7 +435,11 @@ export const agentsTable = powersyncSchema.table( enabled: integer('enabled').default(1).notNull(), deletedAt: timestamp('deleted_at'), }, - (table) => [primaryKey({ columns: [table.id, table.userId] }), index('idx_agents_user_id').on(table.userId)], + (table) => [ + primaryKey({ columns: [table.id, table.workspaceId] }), + index('idx_agents_user_id').on(table.userId), + index('idx_agents_workspace_id').on(table.workspaceId), + ], ) /** @@ -283,6 +459,10 @@ export const powersyncTablesByName = { model_profiles: modelProfilesTable, devices: devicesTable, agents: agentsTable, + workspaces: workspacesTable, + workspace_memberships: workspaceMembershipsTable, + workspace_pending_memberships: workspacePendingMembershipsTable, + workspace_permissions: workspacePermissionsTable, } satisfies Record /** @@ -312,6 +492,10 @@ export const powersyncPkColumn: Record = { model_profiles: modelProfilesTable.id, devices: devicesTable.id, agents: agentsTable.id, + workspaces: workspacesTable.id, + workspace_memberships: workspaceMembershipsTable.id, + workspace_pending_memberships: workspacePendingMembershipsTable.id, + workspace_permissions: workspacePermissionsTable.id, } /** @@ -323,13 +507,17 @@ export const powersyncConflictTarget: Record settings: [settingsTable.key, settingsTable.userId], chat_threads: [chatThreadsTable.id], chat_messages: [chatMessagesTable.id], - tasks: [tasksTable.id, tasksTable.userId], - models: [modelsTable.id, modelsTable.userId], - prompts: [promptsTable.id, promptsTable.userId], - skills: [skillsTable.id, skillsTable.userId], + tasks: [tasksTable.id, tasksTable.workspaceId], + models: [modelsTable.id, modelsTable.workspaceId], + prompts: [promptsTable.id, promptsTable.workspaceId], + skills: [skillsTable.id, skillsTable.workspaceId], triggers: [triggersTable.id], - modes: [modesTable.id, modesTable.userId], - model_profiles: [modelProfilesTable.id, modelProfilesTable.userId], + modes: [modesTable.id, modesTable.workspaceId], + model_profiles: [modelProfilesTable.id, modelProfilesTable.workspaceId], devices: [devicesTable.id], - agents: [agentsTable.id, agentsTable.userId], + agents: [agentsTable.id, agentsTable.workspaceId], + workspaces: [workspacesTable.id], + workspace_memberships: [workspaceMembershipsTable.id], + workspace_pending_memberships: [workspacePendingMembershipsTable.id], + workspace_permissions: [workspacePermissionsTable.id], } diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 82c5ba810..fdba49d95 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -8,3 +8,13 @@ * - Trims whitespace */ export const normalizeEmail = (email: string) => email.toLowerCase().trim() + +/** + * Format check (same regex as the FE's `isValidEmailFormat`) — guards + * upload-handler inserts against junk strings before they hit the DB. Run + * against the normalized form so case/whitespace don't sneak past. + */ +const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/ + +export const isValidEmailFormat = (email: string): boolean => emailRegex.test(normalizeEmail(email)) diff --git a/backend/src/powersync/upload-handlers/helpers.ts b/backend/src/powersync/upload-handlers/helpers.ts new file mode 100644 index 000000000..42f44bb9b --- /dev/null +++ b/backend/src/powersync/upload-handlers/helpers.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getRequiredRoleForPermission, getUserRoleInWorkspace } from '@/dal/workspaces' +import { permissionAllows, type WorkspacePermissionKey } from '@shared/workspaces' +import type { HandlerResult, UploadTx } from './types' + +/** Column names Drizzle declares as `timestamp(...)`; JSON sends them as ISO strings. */ +const timestampDbColumns = new Set(['deleted_at', 'last_seen', 'created_at', 'revoked_at', 'updated_at']) + +/** + * Map a `{ db_column_name: value }` payload from PowerSync into a Drizzle-ready + * `{ schemaKey: value }` shape, dropping unknown columns and converting ISO date + * strings on `timestamp` columns into `Date` instances. + */ +export const toSchemaRecord = ( + dbRecord: Record, + validDbNames: Set, + dbNameToKey: Record, +): Record => { + const out: Record = {} + for (const [dbName, value] of Object.entries(dbRecord)) { + if (!validDbNames.has(dbName)) { + continue + } + const schemaKey = dbNameToKey[dbName] + if (schemaKey && value !== undefined) { + let mapped = value + if (timestampDbColumns.has(dbName) && typeof value === 'string') { + const d = new Date(value) + mapped = Number.isNaN(d.getTime()) ? value : d + } + out[schemaKey] = mapped + } + } + return out +} + +/** Shorthand result constructors so handler bodies stay terse. */ +export const allow = (): HandlerResult => ({ kind: 'apply' }) +export const reject = (rejectionClass: 'permanent' | 'transient', code: string): HandlerResult => ({ + kind: 'reject', + class: rejectionClass, + code, +}) + +/** + * Resolves the caller's role + the configured permission's required role and + * returns whether the op is allowed. Defaults `required_role` to `'admin'` + * when no `workspace_permissions` row exists for the key (Decision 11) so an + * unconfigured workspace stays admin-only. Shared by every handler that gates + * writes on `workspace_permissions` — keep the lookup in one place so the + * default-to-admin policy can't drift between tables. + */ +export const callerSatisfiesPermission = async ( + tx: UploadTx, + workspaceId: string, + userId: string, + permissionKey: WorkspacePermissionKey, +): Promise => { + const required = (await getRequiredRoleForPermission(tx, workspaceId, permissionKey)) ?? 'admin' + const userRole = await getUserRoleInWorkspace(tx, workspaceId, userId) + return permissionAllows(userRole, required) +} diff --git a/backend/src/powersync/upload-handlers/index.ts b/backend/src/powersync/upload-handlers/index.ts new file mode 100644 index 000000000..9aeffdd75 --- /dev/null +++ b/backend/src/powersync/upload-handlers/index.ts @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export { applyUploadBatch, handlers, type BatchResult } from './registry' +export type { HandlerResult, UploadCtx, UploadHandler, UploadOp, UploadTx } from './types' +export { UploadRejection } from './types' diff --git a/backend/src/powersync/upload-handlers/registry.test.ts b/backend/src/powersync/upload-handlers/registry.test.ts new file mode 100644 index 000000000..f8284fabe --- /dev/null +++ b/backend/src/powersync/upload-handlers/registry.test.ts @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { powersyncTableNames } from '@shared/powersync-tables' +import { describe, expect, it } from 'bun:test' +import { handlers } from './registry' + +/** + * Schema-drift test (addendum §3.9): the upload handler registry must cover every + * synced table. The `Record` constraint already + * catches this at compile time — this runtime assertion is a defensive check that + * the registry never gets accidentally narrowed (e.g. via casts). + */ +describe('upload handler registry', () => { + it('has a handler for every synced table', () => { + const missing = powersyncTableNames.filter((name) => !(name in handlers)) + expect(missing).toEqual([]) + }) + + it('does not expose handlers for unknown table names', () => { + const extra = Object.keys(handlers).filter((name) => !powersyncTableNames.includes(name as never)) + expect(extra).toEqual([]) + }) + + it('every handler exposes validate and apply functions', () => { + for (const name of powersyncTableNames) { + const handler = handlers[name] + expect(typeof handler.validate).toBe('function') + expect(typeof handler.apply).toBe('function') + } + }) +}) diff --git a/backend/src/powersync/upload-handlers/registry.ts b/backend/src/powersync/upload-handlers/registry.ts new file mode 100644 index 000000000..bee96ea0e --- /dev/null +++ b/backend/src/powersync/upload-handlers/registry.ts @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { db as DbType } from '@/db/client' +import { type PowerSyncTableName } from '@shared/powersync-tables' +import { createUserScopedHandler } from './user-scoped' +import { createWorkspaceScopedHandler } from './workspace-scoped' +import { UploadRejection, type RejectedOp, type UploadCtx, type UploadHandler, type UploadOp } from './types' +import { workspacesHandler } from './workspaces' +import { workspaceMembershipsHandler } from './workspace-memberships' +import { workspacePendingMembershipsHandler } from './workspace-pending-memberships' +import { workspacePermissionsHandler } from './workspace-permissions' + +/** + * Per-table upload handler registry. The `Record` shape + * is the schema-drift pin (addendum §3.9): adding a new synced table to + * `shared/powersync-tables.ts` without a matching handler here fails `tsc`. + * + * The user-scoped factory covers tables whose rows are owned by a single user; + * the workspace tables have bespoke handlers because their permission model is + * row-relational rather than row-owned. + */ +export const handlers: Record = { + // Account-level (user-scoped, not workspace-scoped). + settings: createUserScopedHandler({ tableName: 'settings' }), + // Devices are partially writable: server-managed columns are stripped, DELETE + // goes through the dedicated revoke API (`/api/account/devices/:id`). + devices: createUserScopedHandler({ + tableName: 'devices', + denyColumns: ['revoked_at', 'trusted', 'public_key', 'mlkem_public_key', 'approval_pending', 'app_version'], + denyDelete: true, + }), + + // Workspace-scoped, user-private (only the row's author may read/write). + chat_threads: createWorkspaceScopedHandler({ tableName: 'chat_threads', userPrivate: true }), + chat_messages: createWorkspaceScopedHandler({ tableName: 'chat_messages', userPrivate: true }), + tasks: createWorkspaceScopedHandler({ tableName: 'tasks', userPrivate: true }), + + // Workspace-scoped, shared (any member of the workspace may read/write). + models: createWorkspaceScopedHandler({ + tableName: 'models', + userPrivate: false, + addPermissionKey: 'add_models', + removePermissionKey: 'remove_models', + softDeleteColumn: 'deleted_at', + }), + mcp_servers: createWorkspaceScopedHandler({ + tableName: 'mcp_servers', + userPrivate: false, + addPermissionKey: 'add_mcp_servers', + removePermissionKey: 'remove_mcp_servers', + softDeleteColumn: 'deleted_at', + }), + prompts: createWorkspaceScopedHandler({ tableName: 'prompts', userPrivate: false }), + skills: createWorkspaceScopedHandler({ + tableName: 'skills', + userPrivate: false, + 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 }), + agents: createWorkspaceScopedHandler({ + tableName: 'agents', + userPrivate: false, + addPermissionKey: 'add_agents', + removePermissionKey: 'remove_agents', + softDeleteColumn: 'deleted_at', + }), + + // Workspace registry tables — bespoke handlers (commit 2). + workspaces: workspacesHandler, + workspace_memberships: workspaceMembershipsHandler, + workspace_pending_memberships: workspacePendingMembershipsHandler, + workspace_permissions: workspacePermissionsHandler, +} + +export type BatchResult = + /** + * The batch completed without any transient failures. Applied ops are committed; + * `rejected` lists every op that was permanently rejected (its savepoint rolled + * back individually so it never landed in the DB). Empty list = full success. + */ + | { ok: true; rejected: RejectedOp[] } + /** + * At least one op (or the outer transaction itself) hit a transient failure; + * the entire batch rolled back. The caller maps this to a 5xx so PowerSync + * retries the batch. + */ + | { ok: false; code: string; op?: UploadOp } + +/** + * Runs the upload batch with per-op savepoints inside a single outer transaction. + * + * - Permanent rejection → that op's savepoint is rolled back; the op is added to + * the `rejected` list and the loop continues. Earlier applied ops remain visible + * to later ops (last-admin protection still observes cumulative state). + * - Transient rejection (or any non-`UploadRejection` throw) → bubble out of the + * outer transaction, rolling everything back. Caller returns 5xx so PowerSync + * retries the batch. + * + * Per the addendum's failure classification (§3.9): permanent = ack-reject and + * discard, transient = retry. The all-or-nothing tx around the batch keeps + * cross-op invariants atomic; per-op savepoints let permanent rejections coexist + * with applied ops in the same response. + */ +export const applyUploadBatch = async ( + database: typeof DbType, + operations: UploadOp[], + ctx: UploadCtx, +): Promise => { + const rejected: RejectedOp[] = [] + // Mutated inside the async tx callback — TS won't narrow across that boundary, + // so the outer catch reads these fields directly. + let transientOp: UploadOp | null = null + let transientCode: string | null = null + + const recordPermanent = (op: UploadOp, code: string): void => { + rejected.push({ op, code }) + } + + try { + await database.transaction(async (outerTx) => { + const outerDb = outerTx as unknown as typeof database + + for (const op of operations) { + const handler = handlers[op.type] + if (!handler) { + recordPermanent(op, 'UNKNOWN_TABLE') + continue + } + + try { + // Nested transaction uses a Postgres savepoint; rolling back the inner + // throw doesn't abort the outer tx. See Drizzle's transaction docs. + await outerDb.transaction(async (innerTx) => { + const innerDb = innerTx as unknown as typeof database + const result = await handler.validate(op, ctx, innerDb) + if (result.kind === 'reject') { + throw new UploadRejection(result.class, result.code) + } + await handler.apply(op, ctx, innerDb) + }) + } catch (err) { + if (err instanceof UploadRejection && err.rejectionClass === 'permanent') { + recordPermanent(op, err.code) + continue + } + // Either a transient UploadRejection or an uncategorized error (raw DB + // failure, deadlock, etc.) — abort the whole batch so PowerSync retries. + transientOp = op + transientCode = + err instanceof UploadRejection ? err.code : err instanceof Error ? err.message : 'UNKNOWN_ERROR' + throw err + } + } + }) + } catch (err) { + if (transientOp && transientCode) { + return { ok: false, code: transientCode, op: transientOp } + } + // Outer tx itself failed (commit error, connection drop, etc.) — surface as transient. + return { + ok: false, + code: err instanceof Error ? err.message : 'UNKNOWN_ERROR', + } + } + + return { ok: true, rejected } +} diff --git a/backend/src/powersync/upload-handlers/types.ts b/backend/src/powersync/upload-handlers/types.ts new file mode 100644 index 000000000..1d309bcb3 --- /dev/null +++ b/backend/src/powersync/upload-handlers/types.ts @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { Settings } from '@/config/settings' +import type { db as DbType } from '@/db/client' +import type { PowerSyncTableName } from '@shared/powersync-tables' + +export type UploadOp = { + op: 'PUT' | 'PATCH' | 'DELETE' + type: PowerSyncTableName + id: string + data?: Record +} + +export type UploadCtx = { + userId: string + settings: Settings +} + +export type HandlerResult = { kind: 'apply' } | { kind: 'reject'; class: 'permanent' | 'transient'; code: string } + +/** + * Drizzle's transaction callback receives an opaque `tx` object; the rest of the + * codebase casts it to `typeof db` so all the usual query builders compose. We + * follow the same idiom — see `backend/src/api/account.ts` / `encryption.ts`. + */ +export type UploadTx = typeof DbType + +export type UploadHandler = { + /** + * Pre-write policy check. Pure read or constant-time logic where possible — + * anything that needs the row state (last-admin protection, etc.) should run + * inside `apply` so it shares the same transactional snapshot as the write. + */ + validate: (op: UploadOp, ctx: UploadCtx, tx: UploadTx) => Promise + /** + * Performs the write. Throw `UploadRejection` to abort the batch with a + * permanent or transient error; any other throw is treated as transient + * (DB-level error) by the dispatcher. + */ + apply: (op: UploadOp, ctx: UploadCtx, tx: UploadTx) => Promise +} + +/** + * Thrown by handlers to abort the upload batch with a structured rejection. + * Distinct from generic `Error` so the dispatcher can classify the failure + * without inspecting the message. + */ +export class UploadRejection extends Error { + constructor( + public readonly rejectionClass: 'permanent' | 'transient', + public readonly code: string, + ) { + super(`upload ${rejectionClass}: ${code}`) + this.name = 'UploadRejection' + } +} + +/** A permanently rejected op accumulated during batch dispatch. */ +export type RejectedOp = { + op: UploadOp + code: string +} diff --git a/backend/src/powersync/upload-handlers/user-scoped.ts b/backend/src/powersync/upload-handlers/user-scoped.ts new file mode 100644 index 000000000..423806570 --- /dev/null +++ b/backend/src/powersync/upload-handlers/user-scoped.ts @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + powersyncConflictTarget, + powersyncDbNameToSchemaKey, + powersyncPkColumn, + powersyncTablesByName, +} from '@/db/powersync-schema' +import type { PowerSyncTableName } from '@shared/powersync-tables' +import { and, eq } from 'drizzle-orm' +import type { AnyPgColumn, AnyPgTable } from 'drizzle-orm/pg-core' +import { allow, reject, toSchemaRecord } from './helpers' +import { UploadRejection, type UploadHandler } from './types' + +export type UserScopedConfig = { + tableName: PowerSyncTableName + /** Columns the client may not set; stripped from PUT/PATCH payloads. */ + denyColumns?: readonly string[] + /** When true, DELETE ops are permanently rejected. */ + denyDelete?: boolean +} + +/** + * Builds an `UploadHandler` for a table whose rows are owned by a single user + * (the row's `user_id` column always equals the authenticated user). Covers + * every pre-workspace synced table; the workspace tables get their own handlers. + * + * Behavior matches the pre-factory `applyOperation` so this commit is a pure + * refactor for the existing tables. Workspace-id scoping is layered in commit 3. + */ +export const createUserScopedHandler = (cfg: UserScopedConfig): UploadHandler => { + const { tableName, denyColumns = [], denyDelete = false } = cfg + + return { + validate: async (op) => { + if (op.op === 'DELETE' && denyDelete) { + return reject('permanent', 'DELETE_NOT_ALLOWED') + } + return allow() + }, + apply: async (op, ctx, tx) => { + const table = powersyncTablesByName[tableName] + const dbNameToKey = powersyncDbNameToSchemaKey[tableName] + const pkColumn = powersyncPkColumn[tableName] + const conflictTarget = powersyncConflictTarget[tableName] + + const validDbNames = new Set(Object.keys(dbNameToKey)) + // All user-scoped tables carry a `user_id` column for ownership isolation. + const tableWithUserId = table as AnyPgTable & { userId: AnyPgColumn } + + switch (op.op) { + case 'PUT': { + const payload = { ...(op.data ?? {}) } as Record + delete payload.id + delete payload.user_id + for (const col of denyColumns) { + delete payload[col] + } + const rawData: Record = { ...payload, id: op.id, user_id: ctx.userId } + const schemaValues = toSchemaRecord(rawData, validDbNames, dbNameToKey) + if (Object.keys(schemaValues).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + const updateSet = { ...schemaValues } + delete updateSet.id + delete updateSet.key + delete updateSet.userId + + const insertQuery = tx.insert(table).values(schemaValues as never) + if (Object.keys(updateSet).length > 0) { + await insertQuery.onConflictDoUpdate({ + target: conflictTarget, + set: updateSet as never, + setWhere: eq(tableWithUserId.userId, ctx.userId), + }) + } else { + await insertQuery.onConflictDoNothing({ target: conflictTarget }) + } + return + } + case 'PATCH': { + if (!op.data || Object.keys(op.data).length === 0) { + return + } + const patchPayload = { ...op.data } as Record + delete patchPayload.id + delete patchPayload.user_id + for (const col of denyColumns) { + delete patchPayload[col] + } + const schemaPatch = toSchemaRecord(patchPayload, validDbNames, dbNameToKey) + if (Object.keys(schemaPatch).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + const patched = await tx + .update(table) + .set(schemaPatch as never) + .where(and(eq(pkColumn, op.id), eq(tableWithUserId.userId, ctx.userId))) + .returning() + + if (patched.length === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const deleted = await tx + .delete(table) + .where(and(eq(pkColumn, op.id), eq(tableWithUserId.userId, ctx.userId))) + .returning() + + if (deleted.length === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, + } +} diff --git a/backend/src/powersync/upload-handlers/workspace-handlers.test.ts b/backend/src/powersync/upload-handlers/workspace-handlers.test.ts new file mode 100644 index 000000000..eb0f5d6d5 --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-handlers.test.ts @@ -0,0 +1,2005 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { user } from '@/db/auth-schema' +import { + agentsTable, + mcpServersTable, + modelsTable, + skillsTable, + workspaceMembershipsTable, + workspacePendingMembershipsTable, + workspacePermissionsTable, + workspacesTable, +} from '@/db/powersync-schema' +import { createTestDb } from '@/test-utils/db' +import { createTestSettings } from '@/test-utils/settings' +import { computePersonalAdminMembershipId, computePersonalWorkspaceId } from '@shared/workspaces' +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { v7 as uuidv7 } from 'uuid' +import { applyUploadBatch } from './registry' +import type { UploadCtx, UploadOp } from './types' + +describe('workspace upload handlers', () => { + let db: Awaited>['db'] + let cleanup: () => Promise + + const insertUser = async (id: string, email: string) => { + const now = new Date() + await db.insert(user).values({ + id, + name: 'Test User', + email, + emailVerified: true, + isNew: true, + createdAt: now, + updatedAt: now, + }) + } + + const ctxFor = (userId: string, overrides: Partial = {}): UploadCtx => ({ + userId, + settings: createTestSettings(), + ...overrides, + }) + + /** + * Drives a personal-workspace bootstrap through the real upload handler — the + * same code path the FE exercises on first sign-in. Verifies the canonical + * acceptance path and gives later tests a personal-workspace fixture to + * exercise the immutability rules against. + */ + const bootstrapPersonalViaUpload = async (userId: string): Promise => { + const workspaceId = computePersonalWorkspaceId(userId) + const membershipId = computePersonalAdminMembershipId(userId) + const ops: UploadOp[] = [ + { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: userId, name: 'Personal' }, + }, + { + op: 'PUT', + type: 'workspace_memberships', + id: membershipId, + data: { workspace_id: workspaceId, user_id: userId, role: 'admin' }, + }, + ] + const result = await applyUploadBatch(db, ops, ctxFor(userId)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + return workspaceId + } + + const expectPermanentReject = (result: Awaited>, code: string): void => { + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(1) + expect(result.rejected[0].code).toBe(code) + } + } + + beforeEach(async () => { + const env = await createTestDb() + db = env.db + cleanup = env.cleanup + }) + + afterEach(async () => { + await cleanup() + }) + + describe('workspaces — personal', () => { + it('accepts a personal workspace PUT with canonical id and matching owner', async () => { + await insertUser('owner1', 'owner1@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner1') + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored).toHaveLength(1) + expect(stored[0].isPersonal).toBe(true) + expect(stored[0].ownerUserId).toBe('owner1') + expect(stored[0].name).toBe('Personal') + }) + + it('rejects a personal workspace PUT with non-canonical id', async () => { + await insertUser('owner2', 'owner2@test.com') + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { is_personal: true, owner_user_id: 'owner2', name: 'Personal' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('owner2')) + expectPermanentReject(result, 'PERSONAL_WORKSPACE_ID_NOT_CANONICAL') + }) + + it('rejects a personal workspace PUT claiming someone else as owner', async () => { + await insertUser('attacker', 'attacker@test.com') + // Attacker tries to upload a personal workspace under victim's canonical id. + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: computePersonalWorkspaceId('victim'), + data: { is_personal: true, owner_user_id: 'victim', name: 'Personal' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('attacker')) + // Canonical-id check fires first since the attacker's userId doesn't hash + // to the same workspace id; the owner-mismatch branch covers the case + // where someone supplies a matching id but a different owner field. + expectPermanentReject(result, 'PERSONAL_WORKSPACE_ID_NOT_CANONICAL') + }) + + it('is idempotent on re-upload — multiple devices uploading the same row', async () => { + await insertUser('mdev', 'mdev@test.com') + const workspaceId = await bootstrapPersonalViaUpload('mdev') + + // Simulate device B uploading the same canonical row. + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'mdev', name: 'Personal' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('mdev')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored).toHaveLength(1) + }) + + it('allows PATCH-rename on a personal workspace by its owner-admin', async () => { + await insertUser('owner3', 'owner3@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner3') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { name: 'Home base' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('owner3')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].name).toBe('Home base') + }) + + it('rejects PATCH on a personal workspace from a non-owner', async () => { + await insertUser('owner3b', 'owner3b@test.com') + await insertUser('attacker3', 'attacker3@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner3b') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { name: 'Hijacked' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('attacker3')) + expectPermanentReject(result, 'NOT_WORKSPACE_ADMIN') + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].name).toBe('Personal') + }) + + it('preserves a prior rename when a second device re-uploads the bootstrap PUT', async () => { + await insertUser('mdev2', 'mdev2@test.com') + const workspaceId = await bootstrapPersonalViaUpload('mdev2') + + // User renames on device A. + const renameOp: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { name: 'Renamed' }, + } + const renameResult = await applyUploadBatch(db, [renameOp], ctxFor('mdev2')) + expect(renameResult.ok).toBe(true) + + // Device B's idempotent bootstrap re-uploads the canonical PUT with the + // default name — must not clobber the rename. + const reBootstrap: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'mdev2', name: 'Default' }, + } + const reResult = await applyUploadBatch(db, [reBootstrap], ctxFor('mdev2')) + expect(reResult.ok).toBe(true) + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].name).toBe('Renamed') + }) + + it('rejects DELETE on workspaces (v1 deferred)', async () => { + await insertUser('owner4', 'owner4@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner4') + + const op: UploadOp = { op: 'DELETE', type: 'workspaces', id: workspaceId } + const result = await applyUploadBatch(db, [op], ctxFor('owner4')) + expectPermanentReject(result, 'WORKSPACE_DELETE_DISABLED') + }) + }) + + describe('workspaces — shared', () => { + it('rejects member-initiated creation when the policy flag is off', async () => { + await insertUser('member', 'member@test.com') + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { name: 'Forbidden' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('member')) + expectPermanentReject(result, 'WORKSPACE_CREATION_DISABLED') + }) + + it('allows member-initiated creation when the policy flag is on', async () => { + await insertUser('member2', 'm2@test.com') + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { name: 'Shared with everyone' }, + } + const result = await applyUploadBatch( + db, + [op], + ctxFor('member2', { + settings: createTestSettings({ allowWorkspaceCreationByMembers: true }), + }), + ) + expect(result.ok).toBe(true) + + const inserted = await db.select().from(workspacesTable).where(eq(workspacesTable.id, op.id)) + expect(inserted).toHaveLength(1) + expect(inserted[0].name).toBe('Shared with everyone') + expect(inserted[0].isPersonal).toBe(false) + }) + + it('lets a user who admins their own personal workspace create shared workspaces with the flag off', async () => { + await insertUser('admin', 'a@test.com') + await bootstrapPersonalViaUpload('admin') + + const op: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { name: 'Admin-created' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin')) + expect(result.ok).toBe(true) + }) + + it('rejects updates by non-admins of a shared workspace', async () => { + await insertUser('a1', 'a1@test.com') + await insertUser('b1', 'b1@test.com') + await bootstrapPersonalViaUpload('a1') + + const create: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { name: 'Original' }, + } + const createResult = await applyUploadBatch(db, [create], ctxFor('a1')) + expect(createResult.ok).toBe(true) + + const rename: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: create.id, + data: { name: 'Hijacked' }, + } + const renameResult = await applyUploadBatch(db, [rename], ctxFor('b1')) + expectPermanentReject(renameResult, 'NOT_WORKSPACE_ADMIN') + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, create.id)) + expect(stored[0].name).toBe('Original') + }) + }) + + describe('workspaces — slug + icon', () => { + const createSharedAs = async (userId: string, name = 'Acme'): Promise => { + const id = uuidv7() + await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id, data: { name } }], + ctxFor(userId, { settings: createTestSettings({ allowWorkspaceCreationByMembers: true }) }), + ) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId: id, userId, role: 'admin' }) + return id + } + + it('PATCH applies slug + icon on a shared workspace by its admin', async () => { + await insertUser('slugadmin', 'slugadmin@test.com') + const workspaceId = await createSharedAs('slugadmin', 'Original') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { slug: 'engineering', icon: '🛠️' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('slugadmin')) + expect(result.ok).toBe(true) + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].slug).toBe('engineering') + expect(stored[0].icon).toBe('🛠️') + }) + + it('PATCH rejects slug on a personal workspace', async () => { + await insertUser('personal_slug', 'personal_slug@test.com') + const workspaceId = await bootstrapPersonalViaUpload('personal_slug') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { slug: 'nope' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('personal_slug')) + expectPermanentReject(result, 'PERSONAL_WORKSPACE_SLUG_FORBIDDEN') + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].slug).toBeNull() + }) + + it('PATCH allows icon-only update on a personal workspace', async () => { + await insertUser('personal_icon', 'personal_icon@test.com') + const workspaceId = await bootstrapPersonalViaUpload('personal_icon') + + const op: UploadOp = { + op: 'PATCH', + type: 'workspaces', + id: workspaceId, + data: { icon: '🏠' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('personal_icon')) + expect(result.ok).toBe(true) + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].icon).toBe('🏠') + expect(stored[0].slug).toBeNull() + }) + + it('PUT does not clear server-side slug/icon when payload omits them', async () => { + await insertUser('keepfields', 'keepfields@test.com') + const workspaceId = await createSharedAs('keepfields', 'Initial') + + // Set slug + icon via PATCH. + const patchResult = await applyUploadBatch( + db, + [{ op: 'PATCH', type: 'workspaces', id: workspaceId, data: { slug: 'kept-slug', icon: '🎯' } }], + ctxFor('keepfields'), + ) + expect(patchResult.ok).toBe(true) + + // Now PUT with only `name` — slug + icon must survive. + const putResult = await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id: workspaceId, data: { name: 'Renamed' } }], + ctxFor('keepfields'), + ) + expect(putResult.ok).toBe(true) + + const stored = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + expect(stored[0].name).toBe('Renamed') + expect(stored[0].slug).toBe('kept-slug') + expect(stored[0].icon).toBe('🎯') + }) + + it('PUT rejects shared workspace with a slug already taken', async () => { + await insertUser('first', 'first@test.com') + await insertUser('second', 'second@test.com') + await bootstrapPersonalViaUpload('first') + await bootstrapPersonalViaUpload('second') + + const firstId = uuidv7() + const firstResult = await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id: firstId, data: { name: 'First', slug: 'shared-slug' } }], + ctxFor('first'), + ) + expect(firstResult.ok).toBe(true) + + const secondId = uuidv7() + const secondResult = await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id: secondId, data: { name: 'Second', slug: 'shared-slug' } }], + ctxFor('second'), + ) + expectPermanentReject(secondResult, 'WORKSPACE_SLUG_TAKEN') + }) + }) + + describe('workspace_memberships', () => { + it('accepts the bootstrap admin membership for own personal workspace', async () => { + await insertUser('owner5', 'owner5@test.com') + // The bootstrapPersonalViaUpload helper exercises this in one batch; here + // we split it into two batches to verify the membership exception fires + // even when uploaded separately. + const workspaceId = computePersonalWorkspaceId('owner5') + const wsOp: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'owner5', name: 'Personal' }, + } + const wsResult = await applyUploadBatch(db, [wsOp], ctxFor('owner5')) + expect(wsResult.ok).toBe(true) + + const memOp: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: computePersonalAdminMembershipId('owner5'), + data: { workspace_id: workspaceId, user_id: 'owner5', role: 'admin' }, + } + const memResult = await applyUploadBatch(db, [memOp], ctxFor('owner5')) + expect(memResult.ok).toBe(true) + + const stored = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, workspaceId)) + expect(stored).toHaveLength(1) + expect(stored[0].role).toBe('admin') + // Display info is denormalized from auth.user by the upload handler so the + // FE Members page can render without a synced users projection. + expect(stored[0].userName).toBe('Test User') + expect(stored[0].userEmail).toBe('owner5@test.com') + }) + + it('rejects an admin-role membership PUT from invite_users-only caller (escalation guard)', async () => { + // Direct `workspace_memberships` PUT with `role: 'admin'` must require + // `change_roles` in addition to `invite_users` — same shape as the + // pending-membership escalation guard. + await insertUser('admin-ws', 'admin-ws@test.com') + await bootstrapPersonalViaUpload('admin-ws') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ + id: sharedId, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-ws', + role: 'admin', + }) + const memberId = 'ws-member-invite-only' + await insertUser(memberId, 'ws-member-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + const newUserId = 'new-admin-target' + await insertUser(newUserId, 'new-admin-target@test.com') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, user_id: newUserId, role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('rejects PUT that demotes an existing admin without change_roles', async () => { + // `upsertMembership` does ON CONFLICT DO UPDATE SET role on + // `(workspace_id, user_id)`. A caller with `invite_users` alone could + // otherwise PUT `role: 'member'` at an existing admin's pair and + // demote them without `change_roles` — same effective action as a + // PATCH demote, which DOES require `change_roles`. + await insertUser('demote-admin', 'demote-admin@test.com') + await bootstrapPersonalViaUpload('demote-admin') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ + id: sharedId, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'demote-admin', + role: 'admin', + }) + const targetAdminId = 'demote-target-admin' + await insertUser(targetAdminId, 'demote-target-admin@test.com') + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: targetAdminId, + role: 'admin', + }) + const memberId = 'demote-actor-invite-only' + await insertUser(memberId, 'demote-actor-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + // PUT with a fresh op.id (so the lookup must hit by `(workspace_id, user_id)`) + // and the demoted role on the existing admin's pair. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, user_id: targetAdminId, role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + + // Existing admin row is untouched. + const stored = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.id, targetMembershipId)) + expect(stored[0].role).toBe('admin') + }) + + it('rejects PUT that would demote the last admin (last-admin protection)', async () => { + // PUT's apply path upserts via `ON CONFLICT DO UPDATE SET role`. Without + // a last-admin guard in apply, a caller satisfying `change_roles` could + // demote the workspace's only admin to member by PUT — leaving zero + // admins, while PATCH/DELETE would reject with LAST_ADMIN_PROTECTED. + await insertUser('lone-admin', 'lone-admin@test.com') + await bootstrapPersonalViaUpload('lone-admin') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ + id: sharedId, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + const adminMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: adminMembershipId, + workspaceId: sharedId, + userId: 'lone-admin', + role: 'admin', + }) + const memberId = 'member-with-cr-demote' + await insertUser(memberId, 'member-with-cr-demote@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + // Grant both keys so PUT validate passes — the test exercises the apply + // layer's last-admin guard. + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'change_roles', + requiredRole: 'member', + }) + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, user_id: 'lone-admin', role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'LAST_ADMIN_PROTECTED') + + // The admin row is unchanged. + const stored = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.id, adminMembershipId)) + expect(stored[0].role).toBe('admin') + }) + + it('rejects a second membership write to a personal workspace (immutable)', async () => { + await insertUser('owner6', 'owner6@test.com') + await insertUser('victim', 'victim@test.com') + const workspaceId = await bootstrapPersonalViaUpload('owner6') + + // owner6 tries to add another member to their personal workspace. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: workspaceId, user_id: 'victim', role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('owner6')) + expectPermanentReject(result, 'PERSONAL_WORKSPACE_IMMUTABLE') + }) + + it('rejects bootstrap admin membership for another user', async () => { + await insertUser('badguy', 'badguy@test.com') + await insertUser('target', 'target@test.com') + const targetWorkspaceId = await bootstrapPersonalViaUpload('target') + + // badguy tries to claim admin in target's personal workspace. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: targetWorkspaceId, user_id: 'badguy', role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('badguy')) + // Personal-workspace immutability kicks in. + expectPermanentReject(result, 'PERSONAL_WORKSPACE_IMMUTABLE') + }) + + it('protects the last admin of a shared workspace from deletion', async () => { + await insertUser('a4', 'a4@test.com') + await bootstrapPersonalViaUpload('a4') + + const sharedId = uuidv7() + await applyUploadBatch( + db, + [{ op: 'PUT', type: 'workspaces', id: sharedId, data: { name: 'Shared' } }], + ctxFor('a4'), + ) + + // Seed an admin membership directly so we can exercise last-admin protection; + // the creator-admin flow lands later when shared-workspace creation also + // creates the admin membership. + const a4MembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: a4MembershipId, + workspaceId: sharedId, + userId: 'a4', + role: 'admin', + }) + + const op: UploadOp = { + op: 'DELETE', + type: 'workspace_memberships', + id: a4MembershipId, + } + const result = await applyUploadBatch(db, [op], ctxFor('a4')) + expectPermanentReject(result, 'LAST_ADMIN_PROTECTED') + + const stillThere = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.id, a4MembershipId)) + expect(stillThere).toHaveLength(1) + }) + + it('PATCH role: allowed when caller has change_roles=member', async () => { + await insertUser('admin-cr-ok', 'admin-cr-ok@test.com') + await insertUser('target-cr-ok', 'target-cr-ok@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'Shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-cr-ok', + role: 'admin', + }) + const memberId = 'member-with-cr' + await insertUser(memberId, 'member-with-cr@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: 'target-cr-ok', + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'change_roles', + requiredRole: 'member', + }) + + const op: UploadOp = { + op: 'PATCH', + type: 'workspace_memberships', + id: targetMembershipId, + data: { role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expect(result.ok).toBe(true) + }) + + it('PATCH role: rejected when caller lacks change_roles', async () => { + await insertUser('admin-cr-no', 'admin-cr-no@test.com') + await insertUser('target-cr-no', 'target-cr-no@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'Shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-cr-no', + role: 'admin', + }) + const memberId = 'member-no-cr' + await insertUser(memberId, 'member-no-cr@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: 'target-cr-no', + role: 'member', + }) + // No workspace_permissions row → change_roles defaults to admin. + + const op: UploadOp = { + op: 'PATCH', + type: 'workspace_memberships', + id: targetMembershipId, + data: { role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('DELETE: allowed when caller has remove_users=member', async () => { + await insertUser('admin-ru-ok', 'admin-ru-ok@test.com') + await insertUser('target-ru-ok', 'target-ru-ok@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'Shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-ru-ok', + role: 'admin', + }) + const memberId = 'member-with-ru' + await insertUser(memberId, 'member-with-ru@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: 'target-ru-ok', + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'remove_users', + requiredRole: 'member', + }) + + const op: UploadOp = { + op: 'DELETE', + type: 'workspace_memberships', + id: targetMembershipId, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expect(result.ok).toBe(true) + }) + + it('DELETE: rejected when caller lacks remove_users', async () => { + await insertUser('admin-ru-no', 'admin-ru-no@test.com') + await insertUser('target-ru-no', 'target-ru-no@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'Shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-ru-no', + role: 'admin', + }) + const memberId = 'member-no-ru' + await insertUser(memberId, 'member-no-ru@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + const targetMembershipId = uuidv7() + await db.insert(workspaceMembershipsTable).values({ + id: targetMembershipId, + workspaceId: sharedId, + userId: 'target-ru-no', + role: 'member', + }) + + const op: UploadOp = { + op: 'DELETE', + type: 'workspace_memberships', + id: targetMembershipId, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('rejects a PUT adding another user when e2eeEnabled is true (THU-593)', async () => { + await insertUser('admin-e1', 'admin-e1@test.com') + await insertUser('invitee-e1', 'invitee-e1@test.com') + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ id: sharedId, name: 'E2EE shared', isPersonal: false }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'admin-e1', + role: 'admin', + }) + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, user_id: 'invitee-e1', role: 'member' }, + } + const result = await applyUploadBatch( + db, + [op], + ctxFor('admin-e1', { settings: createTestSettings({ e2eeEnabled: true }) }), + ) + expectPermanentReject(result, 'E2EE_MEMBERSHIPS_DISABLED') + }) + + it('still allows the self-bootstrap PUT when e2eeEnabled is true', async () => { + await insertUser('owner-e1', 'owner-e1@test.com') + const workspaceId = computePersonalWorkspaceId('owner-e1') + const ops: UploadOp[] = [ + { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'owner-e1', name: 'Personal' }, + }, + { + op: 'PUT', + type: 'workspace_memberships', + id: computePersonalAdminMembershipId('owner-e1'), + data: { workspace_id: workspaceId, user_id: 'owner-e1', role: 'admin' }, + }, + ] + const result = await applyUploadBatch( + db, + ops, + ctxFor('owner-e1', { settings: createTestSettings({ e2eeEnabled: true }) }), + ) + expect(result.ok).toBe(true) + }) + }) + + describe('workspace_pending_memberships', () => { + const seedSharedAsAdmin = async (adminUserId: string): Promise => { + const sharedId = uuidv7() + await db.insert(workspacesTable).values({ + id: sharedId, + name: 'Shared', + isPersonal: false, + ownerUserId: null, + }) + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: adminUserId, + role: 'admin', + }) + return sharedId + } + + it('allows a member to invite when invite_users is granted to member role', async () => { + await insertUser('a-perm', 'a-perm@test.com') + await insertUser('b-perm', 'b-perm@test.com') + await bootstrapPersonalViaUpload('a-perm') + const sharedId = await seedSharedAsAdmin('a-perm') + + // b-perm becomes a member of the shared workspace. + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'b-perm', + role: 'member', + }) + + // Workspace grants `invite_users` to the `member` role. + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + // b-perm (member) sends a pending invite — should succeed. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'newcomer@test.com', + role: 'member', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('b-perm')) + expect(result.ok).toBe(true) + }) + + it('rejects an admin-role pending invite from invite_users-only caller (escalation guard)', async () => { + // `invite_users` alone must not be enough to invite `role: 'admin'` — + // otherwise the signup-promote path would mint a new admin and bypass + // the `change_roles` gate that protects existing-member promotions. + await insertUser('inviter-only', 'inviter-only@test.com') + await bootstrapPersonalViaUpload('inviter-only') + const sharedId = await seedSharedAsAdmin('inviter-only') + const memberId = 'member-with-invite-only' + await insertUser(memberId, 'member-with-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, email: 'pending-admin@test.com', role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('rejects PATCH that promotes a pending invite to admin without change_roles', async () => { + // PATCH-to-admin must hit the same escalation guard as PUT-to-admin — + // otherwise an inviter could quietly elevate a pending invite they + // shouldn't be able to promote. + await insertUser('patch-inviter', 'patch-inviter@test.com') + await bootstrapPersonalViaUpload('patch-inviter') + const sharedId = await seedSharedAsAdmin('patch-inviter') + const memberId = 'patch-member-invite-only' + await insertUser(memberId, 'patch-member-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + // Seed a pending invite at role=member, then try to PATCH to admin. + const pendingId = uuidv7() + await db.insert(workspacePendingMembershipsTable).values({ + id: pendingId, + workspaceId: sharedId, + email: 'pending-target@test.com', + role: 'member', + invitedByUserId: 'patch-inviter', + }) + + const op: UploadOp = { + op: 'PATCH', + type: 'workspace_pending_memberships', + id: pendingId, + data: { role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('allows PATCH demoting a pending admin invite to member from invite_users-only caller', async () => { + // The escalation guard is intentionally one-way: promotions to admin + // require `change_roles`, but demotions stay gated on `invite_users` + // alone. Demoting a pending admin invite is tampering, not escalation, + // and matching the broader "any role change" rule from the memberships + // handler would cost an extra DB read for a non-security concern. + await insertUser('demote-inviter', 'demote-inviter@test.com') + await bootstrapPersonalViaUpload('demote-inviter') + const sharedId = await seedSharedAsAdmin('demote-inviter') + const memberId = 'demote-member-invite-only' + await insertUser(memberId, 'demote-member-invite-only@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: 'invite_users', + requiredRole: 'member', + }) + + const pendingId = uuidv7() + await db.insert(workspacePendingMembershipsTable).values({ + id: pendingId, + workspaceId: sharedId, + email: 'pending-admin-to-demote@test.com', + role: 'admin', + invitedByUserId: 'demote-inviter', + }) + + const op: UploadOp = { + op: 'PATCH', + type: 'workspace_pending_memberships', + id: pendingId, + data: { role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expect(result.ok).toBe(true) + }) + + it('allows an admin-role pending invite when caller has BOTH invite_users and change_roles', async () => { + await insertUser('inviter-both', 'inviter-both@test.com') + await bootstrapPersonalViaUpload('inviter-both') + const sharedId = await seedSharedAsAdmin('inviter-both') + const memberId = 'member-with-both' + await insertUser(memberId, 'member-with-both@test.com') + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: memberId, + role: 'member', + }) + for (const key of ['invite_users', 'change_roles'] as const) { + await db.insert(workspacePermissionsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + permissionKey: key, + requiredRole: 'member', + }) + } + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, email: 'pending-admin-allowed@test.com', role: 'admin' }, + } + const result = await applyUploadBatch(db, [op], ctxFor(memberId)) + expect(result.ok).toBe(true) + }) + + it('rejects writes by non-admins of the target workspace', async () => { + await insertUser('a5', 'a5@test.com') + await insertUser('b5', 'b5@test.com') + await bootstrapPersonalViaUpload('a5') + const sharedId = await seedSharedAsAdmin('a5') + + // b5 (no membership) tries to invite an email. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'invitee@test.com', + role: 'member', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('b5')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('normalizes email on insert', async () => { + await insertUser('admin5', 'admin5@test.com') + await bootstrapPersonalViaUpload('admin5') + const sharedId = await seedSharedAsAdmin('admin5') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: ' MixedCase@TEST.com ', + role: 'member', + invited_by_user_id: 'admin5', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin5')) + expect(result.ok).toBe(true) + + const stored = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(stored[0].email).toBe('mixedcase@test.com') + }) + + it('promotes to membership + deletes pending row when invited email matches an existing user', async () => { + await insertUser('admin6', 'admin6@test.com') + await insertUser('invitee1', 'invitee1@test.com') + await bootstrapPersonalViaUpload('admin6') + const sharedId = await seedSharedAsAdmin('admin6') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'invitee1@test.com', + role: 'member', + invited_by_user_id: 'admin6', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin6')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const pending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(pending).toHaveLength(0) + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, sharedId)) + // admin6 (from seedSharedAsAdmin) + invitee1 (promoted) + expect(memberships).toHaveLength(2) + const invitee = memberships.find((m) => m.userId === 'invitee1') + expect(invitee).toBeDefined() + expect(invitee?.role).toBe('member') + // Promotion carries the matched user's display info onto the membership row. + expect(invitee?.userName).toBe('Test User') + expect(invitee?.userEmail).toBe('invitee1@test.com') + }) + + it('promotes via normalized email match (case + whitespace)', async () => { + await insertUser('admin7', 'admin7@test.com') + await insertUser('invitee2', 'invitee2@test.com') + await bootstrapPersonalViaUpload('admin7') + const sharedId = await seedSharedAsAdmin('admin7') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: ' Invitee2@TEST.com ', + role: 'admin', + invited_by_user_id: 'admin7', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin7')) + expect(result.ok).toBe(true) + + const pending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(pending).toHaveLength(0) + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, sharedId)) + const invitee = memberships.find((m) => m.userId === 'invitee2') + expect(invitee?.role).toBe('admin') + }) + + it('deletes the actual pending row on promote-on-insert even after unique-key conflict', async () => { + await insertUser('admin10', 'admin10@test.com') + await insertUser('invitee3', 'invitee3@test.com') + await bootstrapPersonalViaUpload('admin10') + const sharedId = await seedSharedAsAdmin('admin10') + + // Seed an existing pending row with id=X. + const originalPendingId = uuidv7() + await db.insert(workspacePendingMembershipsTable).values({ + id: originalPendingId, + workspaceId: sharedId, + email: 'invitee3@test.com', + role: 'admin', + invitedByUserId: 'admin10', + }) + + // Now upload a NEW pending op (id=Y) for the same workspace+email. + const newOpId = uuidv7() + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: newOpId, + data: { + workspace_id: sharedId, + email: 'invitee3@test.com', + role: 'member', + invited_by_user_id: 'admin10', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin10')) + expect(result.ok).toBe(true) + + // Both the id=X row (the actual one in DB) and the id=Y delete target + // must result in zero pending rows for (workspace_id, email). + const pending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.workspaceId, sharedId)) + expect(pending).toHaveLength(0) + }) + + it('preserves an existing membership role on promote-on-insert (no downgrade)', async () => { + await insertUser('admin9', 'admin9@test.com') + await insertUser('coadmin', 'coadmin@test.com') + await bootstrapPersonalViaUpload('admin9') + const sharedId = await seedSharedAsAdmin('admin9') + + // Make coadmin an admin of the same workspace directly. + await db.insert(workspaceMembershipsTable).values({ + id: uuidv7(), + workspaceId: sharedId, + userId: 'coadmin', + role: 'admin', + userName: 'Test User', + userEmail: 'coadmin@test.com', + }) + + // Invite coadmin's email with role='member' — should NOT downgrade. + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'coadmin@test.com', + role: 'member', + invited_by_user_id: 'admin9', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin9')) + expect(result.ok).toBe(true) + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, sharedId)) + const coadminRow = memberships.find((m) => m.userId === 'coadmin') + expect(coadminRow?.role).toBe('admin') + }) + + it('rejects malformed email on pending PUT with INVALID_EMAIL', async () => { + await insertUser('admin-email', 'admin-email@test.com') + await bootstrapPersonalViaUpload('admin-email') + const sharedId = await seedSharedAsAdmin('admin-email') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { workspace_id: sharedId, email: 'not-an-email', role: 'member' }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin-email')) + expectPermanentReject(result, 'INVALID_EMAIL') + }) + + it('overrides client-supplied invited_by_user_id with ctx.userId', async () => { + await insertUser('inviter', 'inviter@test.com') + await bootstrapPersonalViaUpload('inviter') + const sharedId = await seedSharedAsAdmin('inviter') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'pending@test.com', + role: 'member', + // Attempt to attribute the invite to someone else. + invited_by_user_id: 'someone-else', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('inviter')) + expect(result.ok).toBe(true) + + const stored = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(stored[0]?.invitedByUserId).toBe('inviter') + }) + + it('keeps pending row when invited email does not match any user', async () => { + await insertUser('admin8', 'admin8@test.com') + await bootstrapPersonalViaUpload('admin8') + const sharedId = await seedSharedAsAdmin('admin8') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'nobody@test.com', + role: 'member', + invited_by_user_id: 'admin8', + }, + } + const result = await applyUploadBatch(db, [op], ctxFor('admin8')) + expect(result.ok).toBe(true) + + const pending = await db + .select() + .from(workspacePendingMembershipsTable) + .where(eq(workspacePendingMembershipsTable.id, op.id)) + expect(pending).toHaveLength(1) + expect(pending[0].email).toBe('nobody@test.com') + + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, sharedId)) + // only the seed admin — no promotion + expect(memberships).toHaveLength(1) + }) + + it('rejects a pending PUT when e2eeEnabled is true (THU-593)', async () => { + await insertUser('admin-e2', 'admin-e2@test.com') + const sharedId = await seedSharedAsAdmin('admin-e2') + + const op: UploadOp = { + op: 'PUT', + type: 'workspace_pending_memberships', + id: uuidv7(), + data: { + workspace_id: sharedId, + email: 'invitee@test.com', + role: 'member', + invited_by_user_id: 'admin-e2', + }, + } + const result = await applyUploadBatch( + db, + [op], + ctxFor('admin-e2', { settings: createTestSettings({ e2eeEnabled: true }) }), + ) + expectPermanentReject(result, 'E2EE_MEMBERSHIPS_DISABLED') + }) + }) + + describe('batch accumulation', () => { + it('accepts a workspace + admin-membership batch atomically (FE first sign-in)', async () => { + await insertUser('combo', 'combo@test.com') + const workspaceId = computePersonalWorkspaceId('combo') + const membershipId = computePersonalAdminMembershipId('combo') + const ops: UploadOp[] = [ + { + op: 'PUT', + type: 'workspaces', + id: workspaceId, + data: { is_personal: true, owner_user_id: 'combo', name: 'Personal' }, + }, + { + op: 'PUT', + type: 'workspace_memberships', + id: membershipId, + data: { workspace_id: workspaceId, user_id: 'combo', role: 'admin' }, + }, + ] + const result = await applyUploadBatch(db, ops, ctxFor('combo')) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(0) + } + + const ws = await db.select().from(workspacesTable).where(eq(workspacesTable.id, workspaceId)) + const memberships = await db + .select() + .from(workspaceMembershipsTable) + .where(eq(workspaceMembershipsTable.workspaceId, workspaceId)) + expect(ws).toHaveLength(1) + expect(memberships).toHaveLength(1) + }) + + it('accumulates multiple permanent rejections in one response', async () => { + await insertUser('owner7', 'owner7@test.com') + const personalId = await bootstrapPersonalViaUpload('owner7') + + const ownerMismatch: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: personalId, + data: { is_personal: true, owner_user_id: 'somebody-else', name: 'Sneaky' }, + } + const wrongIdPersonal: UploadOp = { + op: 'PUT', + type: 'workspaces', + id: uuidv7(), + data: { is_personal: true, owner_user_id: 'owner7', name: 'Sneaky' }, + } + const result = await applyUploadBatch( + db, + [ownerMismatch, wrongIdPersonal], + ctxFor('owner7', { + settings: createTestSettings({ allowWorkspaceCreationByMembers: true }), + }), + ) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.rejected).toHaveLength(2) + expect(result.rejected[0].code).toBe('PERSONAL_WORKSPACE_OWNER_MISMATCH') + expect(result.rejected[1].code).toBe('PERSONAL_WORKSPACE_ID_NOT_CANONICAL') + } + }) + }) + + describe('agents — workspace permission gating (add_agents / remove_agents)', () => { + /** + * Sets up a shared workspace with `adminId` as admin and `memberId` as member. + * Both users must have already been inserted via `insertUser`. Returns the + * workspace id so the test can target it. + */ + const seedSharedWithAdminAndMember = async (adminId: string, memberId: string): Promise => { + const workspaceId = uuidv7() + await db.insert(workspacesTable).values({ id: workspaceId, isPersonal: false, name: 'Acme' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: adminId, role: 'admin' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: memberId, role: 'member' }) + return workspaceId + } + + const setRequiredRole = async ( + workspaceId: string, + key: 'add_agents' | 'remove_agents', + requiredRole: 'admin' | 'member', + ): Promise => { + await db.insert(workspacePermissionsTable).values({ id: uuidv7(), workspaceId, permissionKey: key, requiredRole }) + } + + const agentPut = (workspaceId: string, id = uuidv7()): UploadOp => ({ + op: 'PUT', + type: 'agents', + id, + data: { + workspace_id: workspaceId, + name: 'Test agent', + type: 'remote-acp', + transport: 'websocket', + url: 'wss://example.invalid/acp', + }, + }) + + it('PUT agent: admin always succeeds (default required_role = admin)', async () => { + await insertUser('agAdmin1', 'agadmin1@test.com') + await insertUser('agMember1', 'agmember1@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin1', 'agMember1') + + const op = agentPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('agAdmin1')) + expect(result.ok).toBe(true) + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('PUT agent: member rejected when add_agents required_role = admin (default)', async () => { + await insertUser('agAdmin2', 'agadmin2@test.com') + await insertUser('agMember2', 'agmember2@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin2', 'agMember2') + + const op = agentPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('agMember2')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, op.id)) + expect(stored).toHaveLength(0) + }) + + it('PUT agent: member allowed when add_agents required_role = member', async () => { + await insertUser('agAdmin3', 'agadmin3@test.com') + await insertUser('agMember3', 'agmember3@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin3', 'agMember3') + await setRequiredRole(workspaceId, 'add_agents', 'member') + + const op = agentPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('agMember3')) + expect(result.ok).toBe(true) + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('DELETE agent: member rejected when remove_agents required_role = admin (default)', async () => { + await insertUser('agAdmin4', 'agadmin4@test.com') + await insertUser('agMember4', 'agmember4@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin4', 'agMember4') + // Admin seeds the agent so the row exists. + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin4')) + expect(putResult.ok).toBe(true) + + const deleteOp: UploadOp = { op: 'DELETE', type: 'agents', id: putOp.id } + const result = await applyUploadBatch(db, [deleteOp], ctxFor('agMember4')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, putOp.id)) + expect(stored).toHaveLength(1) + }) + + it('DELETE agent: member allowed when remove_agents required_role = member', async () => { + await insertUser('agAdmin5', 'agadmin5@test.com') + await insertUser('agMember5', 'agmember5@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin5', 'agMember5') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin5')) + expect(putResult.ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_agents', 'member') + + const deleteOp: UploadOp = { op: 'DELETE', type: 'agents', id: putOp.id } + const result = await applyUploadBatch(db, [deleteOp], ctxFor('agMember5')) + expect(result.ok).toBe(true) + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, putOp.id)) + expect(stored).toHaveLength(0) + }) + + it('add_agents = member does not unlock DELETE (remove_agents still defaults to admin)', async () => { + await insertUser('agAdmin6', 'agadmin6@test.com') + await insertUser('agMember6', 'agmember6@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin6', 'agMember6') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin6')) + expect(putResult.ok).toBe(true) + // Member can add but the remove permission is still admin. + await setRequiredRole(workspaceId, 'add_agents', 'member') + + const deleteOp: UploadOp = { op: 'DELETE', type: 'agents', id: putOp.id } + const result = await applyUploadBatch(db, [deleteOp], ctxFor('agMember6')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + // FE DAL soft-deletes via PATCH(deleted_at = now), not DELETE. The handler + // classifies that PATCH as a remove and gates it on `remove_agents`. + it('soft-delete via PATCH(deleted_at) gates on remove_agents, not add_agents', async () => { + await insertUser('agAdmin7', 'agadmin7@test.com') + await insertUser('agMember7', 'agmember7@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin7', 'agMember7') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin7')) + expect(putResult.ok).toBe(true) + // Member can edit (add) but not soft-delete (remove). + await setRequiredRole(workspaceId, 'add_agents', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'agents', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('agMember7')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('soft-delete via PATCH(deleted_at) is allowed when remove_agents = member', async () => { + await insertUser('agAdmin8', 'agadmin8@test.com') + await insertUser('agMember8', 'agmember8@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin8', 'agMember8') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin8')) + expect(putResult.ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_agents', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'agents', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('agMember8')) + expect(result.ok).toBe(true) + const stored = await db.select().from(agentsTable).where(eq(agentsTable.id, putOp.id)) + expect(stored[0].deletedAt).not.toBeNull() + }) + + // Edit PATCH (no deleted_at) still gates on add_agents, not remove_agents. + it('non-delete PATCH gates on add_agents even when remove_agents = member', async () => { + await insertUser('agAdmin9', 'agadmin9@test.com') + await insertUser('agMember9', 'agmember9@test.com') + const workspaceId = await seedSharedWithAdminAndMember('agAdmin9', 'agMember9') + const putOp = agentPut(workspaceId) + const putResult = await applyUploadBatch(db, [putOp], ctxFor('agAdmin9')) + expect(putResult.ok).toBe(true) + // Member has remove but not add; an edit (no deleted_at) should still reject. + await setRequiredRole(workspaceId, 'remove_agents', 'member') + + const editOp: UploadOp = { + op: 'PATCH', + type: 'agents', + id: putOp.id, + data: { name: 'Renamed by member' }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('agMember9')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + }) + + describe('skills — workspace permission gating (add_skills / remove_skills)', () => { + const seedSharedWithAdminAndMember = async (adminId: string, memberId: string): Promise => { + const workspaceId = uuidv7() + await db.insert(workspacesTable).values({ id: workspaceId, isPersonal: false, name: 'Acme' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: adminId, role: 'admin' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: memberId, role: 'member' }) + return workspaceId + } + + const setRequiredRole = async ( + workspaceId: string, + key: 'add_skills' | 'remove_skills', + requiredRole: 'admin' | 'member', + ): Promise => { + await db.insert(workspacePermissionsTable).values({ id: uuidv7(), workspaceId, permissionKey: key, requiredRole }) + } + + const skillPut = (workspaceId: string, id = uuidv7()): UploadOp => ({ + op: 'PUT', + type: 'skills', + id, + data: { + workspace_id: workspaceId, + name: 'Test skill', + description: 'Test', + instruction: 'Do the thing', + enabled: 1, + }, + }) + + it('PUT skill: member rejected when add_skills required_role = admin (default)', async () => { + await insertUser('skAdmin1', 'skadmin1@test.com') + await insertUser('skMember1', 'skmember1@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin1', 'skMember1') + + const op = skillPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('skMember1')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('PUT skill: member allowed when add_skills required_role = member', async () => { + await insertUser('skAdmin2', 'skadmin2@test.com') + await insertUser('skMember2', 'skmember2@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin2', 'skMember2') + await setRequiredRole(workspaceId, 'add_skills', 'member') + + const op = skillPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('skMember2')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('soft-delete via PATCH(deleted_at) gates on remove_skills, not add_skills', async () => { + await insertUser('skAdmin3', 'skadmin3@test.com') + await insertUser('skMember3', 'skmember3@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin3', 'skMember3') + const putOp = skillPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('skAdmin3'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'add_skills', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('skMember3')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('soft-delete via PATCH(deleted_at) is allowed when remove_skills = member', async () => { + await insertUser('skAdmin4', 'skadmin4@test.com') + await insertUser('skMember4', 'skmember4@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin4', 'skMember4') + const putOp = skillPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('skAdmin4'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_skills', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('skMember4')) + expect(result.ok).toBe(true) + const stored = await db.select().from(skillsTable).where(eq(skillsTable.id, putOp.id)) + expect(stored[0].deletedAt).not.toBeNull() + }) + + it('edit PATCH (no deleted_at) gates on add_skills, not remove_skills', async () => { + await insertUser('skAdmin5', 'skadmin5@test.com') + await insertUser('skMember5', 'skmember5@test.com') + const workspaceId = await seedSharedWithAdminAndMember('skAdmin5', 'skMember5') + const putOp = skillPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('skAdmin5'))).ok).toBe(true) + // Member has remove but not add — toggling `enabled` is an edit and must reject. + await setRequiredRole(workspaceId, 'remove_skills', 'member') + + const editOp: UploadOp = { + op: 'PATCH', + type: 'skills', + id: putOp.id, + data: { enabled: 0 }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('skMember5')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + }) + + describe('models — workspace permission gating (add_models / remove_models)', () => { + const seedSharedWithAdminAndMember = async (adminId: string, memberId: string): Promise => { + const workspaceId = uuidv7() + await db.insert(workspacesTable).values({ id: workspaceId, isPersonal: false, name: 'Acme' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: adminId, role: 'admin' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: memberId, role: 'member' }) + return workspaceId + } + + const setRequiredRole = async ( + workspaceId: string, + key: 'add_models' | 'remove_models', + requiredRole: 'admin' | 'member', + ): Promise => { + await db.insert(workspacePermissionsTable).values({ id: uuidv7(), workspaceId, permissionKey: key, requiredRole }) + } + + const modelPut = (workspaceId: string, id = uuidv7()): UploadOp => ({ + op: 'PUT', + type: 'models', + id, + data: { + workspace_id: workspaceId, + provider: 'openai', + name: 'Test model', + model: 'gpt-test', + enabled: 1, + }, + }) + + it('PUT model: member rejected when add_models required_role = admin (default)', async () => { + await insertUser('mdAdmin1', 'mdadmin1@test.com') + await insertUser('mdMember1', 'mdmember1@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin1', 'mdMember1') + + const op = modelPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('mdMember1')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('PUT model: member allowed when add_models required_role = member', async () => { + await insertUser('mdAdmin2', 'mdadmin2@test.com') + await insertUser('mdMember2', 'mdmember2@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin2', 'mdMember2') + await setRequiredRole(workspaceId, 'add_models', 'member') + + const op = modelPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('mdMember2')) + expect(result.ok).toBe(true) + const stored = await db.select().from(modelsTable).where(eq(modelsTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('soft-delete via PATCH(deleted_at) gates on remove_models, not add_models', async () => { + await insertUser('mdAdmin3', 'mdadmin3@test.com') + await insertUser('mdMember3', 'mdmember3@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin3', 'mdMember3') + const putOp = modelPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mdAdmin3'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'add_models', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'models', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('mdMember3')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('soft-delete via PATCH(deleted_at) is allowed when remove_models = member', async () => { + await insertUser('mdAdmin4', 'mdadmin4@test.com') + await insertUser('mdMember4', 'mdmember4@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin4', 'mdMember4') + const putOp = modelPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mdAdmin4'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_models', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'models', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('mdMember4')) + expect(result.ok).toBe(true) + const stored = await db.select().from(modelsTable).where(eq(modelsTable.id, putOp.id)) + expect(stored[0].deletedAt).not.toBeNull() + }) + + it('edit PATCH (toggle enabled) gates on add_models, not remove_models', async () => { + await insertUser('mdAdmin5', 'mdadmin5@test.com') + await insertUser('mdMember5', 'mdmember5@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mdAdmin5', 'mdMember5') + const putOp = modelPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mdAdmin5'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_models', 'member') + + const editOp: UploadOp = { + op: 'PATCH', + type: 'models', + id: putOp.id, + data: { enabled: 0 }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('mdMember5')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + }) + + describe('mcp_servers — workspace permission gating (add_mcp_servers / remove_mcp_servers)', () => { + const seedSharedWithAdminAndMember = async (adminId: string, memberId: string): Promise => { + const workspaceId = uuidv7() + await db.insert(workspacesTable).values({ id: workspaceId, isPersonal: false, name: 'Acme' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: adminId, role: 'admin' }) + await db.insert(workspaceMembershipsTable).values({ id: uuidv7(), workspaceId, userId: memberId, role: 'member' }) + return workspaceId + } + + const setRequiredRole = async ( + workspaceId: string, + key: 'add_mcp_servers' | 'remove_mcp_servers', + requiredRole: 'admin' | 'member', + ): Promise => { + await db.insert(workspacePermissionsTable).values({ id: uuidv7(), workspaceId, permissionKey: key, requiredRole }) + } + + const mcpPut = (workspaceId: string, id = uuidv7()): UploadOp => ({ + op: 'PUT', + type: 'mcp_servers', + id, + data: { + workspace_id: workspaceId, + name: 'Test MCP', + type: 'http', + url: 'https://example.invalid/mcp', + enabled: 1, + }, + }) + + it('PUT mcp_server: member rejected when add_mcp_servers required_role = admin (default)', async () => { + await insertUser('mcpAdmin1', 'mcpadmin1@test.com') + await insertUser('mcpMember1', 'mcpmember1@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mcpAdmin1', 'mcpMember1') + + const op = mcpPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('mcpMember1')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('PUT mcp_server: member allowed when add_mcp_servers required_role = member', async () => { + await insertUser('mcpAdmin2', 'mcpadmin2@test.com') + await insertUser('mcpMember2', 'mcpmember2@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mcpAdmin2', 'mcpMember2') + await setRequiredRole(workspaceId, 'add_mcp_servers', 'member') + + const op = mcpPut(workspaceId) + const result = await applyUploadBatch(db, [op], ctxFor('mcpMember2')) + expect(result.ok).toBe(true) + const stored = await db.select().from(mcpServersTable).where(eq(mcpServersTable.id, op.id)) + expect(stored).toHaveLength(1) + }) + + it('soft-delete via PATCH(deleted_at) gates on remove_mcp_servers, not add_mcp_servers', async () => { + await insertUser('mcpAdmin3', 'mcpadmin3@test.com') + await insertUser('mcpMember3', 'mcpmember3@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mcpAdmin3', 'mcpMember3') + const putOp = mcpPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mcpAdmin3'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'add_mcp_servers', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'mcp_servers', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('mcpMember3')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + + it('soft-delete via PATCH(deleted_at) is allowed when remove_mcp_servers = member', async () => { + await insertUser('mcpAdmin4', 'mcpadmin4@test.com') + await insertUser('mcpMember4', 'mcpmember4@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mcpAdmin4', 'mcpMember4') + const putOp = mcpPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mcpAdmin4'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_mcp_servers', 'member') + + const softDeleteOp: UploadOp = { + op: 'PATCH', + type: 'mcp_servers', + id: putOp.id, + data: { deleted_at: new Date().toISOString() }, + } + const result = await applyUploadBatch(db, [softDeleteOp], ctxFor('mcpMember4')) + expect(result.ok).toBe(true) + const stored = await db.select().from(mcpServersTable).where(eq(mcpServersTable.id, putOp.id)) + expect(stored[0].deletedAt).not.toBeNull() + }) + + it('edit PATCH (toggle enabled) gates on add_mcp_servers, not remove_mcp_servers', async () => { + await insertUser('mcpAdmin5', 'mcpadmin5@test.com') + await insertUser('mcpMember5', 'mcpmember5@test.com') + const workspaceId = await seedSharedWithAdminAndMember('mcpAdmin5', 'mcpMember5') + const putOp = mcpPut(workspaceId) + expect((await applyUploadBatch(db, [putOp], ctxFor('mcpAdmin5'))).ok).toBe(true) + await setRequiredRole(workspaceId, 'remove_mcp_servers', 'member') + + const editOp: UploadOp = { + op: 'PATCH', + type: 'mcp_servers', + id: putOp.id, + data: { enabled: 0 }, + } + const result = await applyUploadBatch(db, [editOp], ctxFor('mcpMember5')) + expectPermanentReject(result, 'INSUFFICIENT_PERMISSION') + }) + }) +}) diff --git a/backend/src/powersync/upload-handlers/workspace-memberships.ts b/backend/src/powersync/upload-handlers/workspace-memberships.ts new file mode 100644 index 000000000..00f4a7faf --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-memberships.ts @@ -0,0 +1,306 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + countWorkspaceAdmins, + countWorkspaceMemberships, + deleteMembership, + getMembershipById, + getMembershipByWorkspaceAndUser, + getWorkspaceById, + isPersonalWorkspace, + type Role, + updateMembership, + upsertMembership, +} from '@/dal/workspaces' +import { getUserById } from '@/dal/users' +import { computePersonalAdminMembershipId, computePersonalWorkspaceId } from '@shared/workspaces' +import { allow, callerSatisfiesPermission, reject } from './helpers' +import { UploadRejection, type UploadHandler, type UploadTx } from './types' + +const isRole = (v: unknown): v is Role => v === 'admin' || v === 'member' + +/** + * Personal workspaces are created by the FE (uploaded with a deterministic id); + * the very first admin membership for that workspace has to land via upload + * too. The handler's general rule — "must be admin of the target workspace to + * write a membership" — would reject this initial claim because no admin exists + * yet. This narrow exception unlocks exactly one row: + * + * - target workspace is personal + * - workspace's owner is the caller + * - membership id matches the canonical admin-self id for the caller + * - membership references the caller as the user + * - role is admin + * - no memberships exist on the workspace yet + * + * Once the row lands the workspace is back in the immutable state — any + * subsequent membership write for a personal workspace is rejected as before. + */ +const isPersonalAdminBootstrap = async ( + tx: UploadTx, + ctx: { userId: string }, + membershipId: string, + data: Record | undefined, +): Promise => { + if (membershipId !== computePersonalAdminMembershipId(ctx.userId)) { + return false + } + const targetWorkspaceId = typeof data?.workspace_id === 'string' ? data.workspace_id : null + if (targetWorkspaceId !== computePersonalWorkspaceId(ctx.userId)) { + return false + } + const targetUserId = typeof data?.user_id === 'string' ? data.user_id : null + if (targetUserId !== ctx.userId) { + return false + } + if (data?.role !== 'admin') { + return false + } + const workspace = await getWorkspaceById(tx, targetWorkspaceId) + if (!workspace || !workspace.isPersonal || workspace.ownerUserId !== ctx.userId) { + return false + } + const existingMemberships = await countWorkspaceMemberships(tx, targetWorkspaceId) + return existingMemberships === 0 +} + +/** + * Upload handler for `workspace_memberships`. Enforces: + * + * - Each op gates on a `workspace_permissions` key — `invite_users` for PUT, + * `change_roles` for PATCH, `remove_users` for DELETE — defaulting to + * admin-only when the permission row is absent (Decision 11). + * - PUT that would effectively change an existing membership's role + * additionally requires `change_roles`. PUT applies via `upsertMembership` + * (ON CONFLICT DO UPDATE SET role), so a payload demoting an admin to + * member at the same `(workspace_id, user_id)` would otherwise bypass + * PATCH's `change_roles` gate. + * - Adding a brand-new membership with `role: 'admin'` requires + * `change_roles` for the same reason — `invite_users` alone could + * otherwise mint new admins (also via the pending-invite signup-promote + * path). + * - Personal workspaces are immutable past the FE-driven admin bootstrap + * (`isPersonalAdminBootstrap`), which lands exactly one admin row for the + * owner the first time the workspace appears server-side. + * - The first admin membership for a freshly-created shared workspace is + * allowed by `isSharedWorkspaceAdminBootstrap` (caller is the row's user, + * role is admin, workspace has zero members yet). + * - DELETE that would leave zero remaining admins is permanently rejected. + * The count is taken inside the same transaction so concurrent revokes + * can't both pass the check. + */ +/** + * Shared workspace creator bootstrap: the FE creates a shared workspace and its + * own admin membership in the same upload batch. When the membership arrives the + * workspace exists but has zero members, so the general "must be admin" check + * would reject it. This exception allows exactly one initial claim: + * + * - target workspace exists and is NOT personal + * - membership's user_id equals the caller (no impersonation) + * - role is admin + * - no memberships exist on the workspace yet + */ +const isSharedWorkspaceAdminBootstrap = async ( + tx: UploadTx, + ctx: { userId: string }, + data: Record | undefined, +): Promise => { + const targetWorkspaceId = typeof data?.workspace_id === 'string' ? data.workspace_id : null + if (!targetWorkspaceId) { + return false + } + const targetUserId = typeof data?.user_id === 'string' ? data.user_id : null + if (targetUserId !== ctx.userId) { + return false + } + if (data?.role !== 'admin') { + return false + } + const workspace = await getWorkspaceById(tx, targetWorkspaceId) + if (!workspace || workspace.isPersonal) { + return false + } + const existingMemberships = await countWorkspaceMemberships(tx, targetWorkspaceId) + return existingMemberships === 0 +} + +export const workspaceMembershipsHandler: UploadHandler = { + validate: async (op, ctx, tx) => { + if (op.op === 'PUT' && (await isPersonalAdminBootstrap(tx, ctx, op.id, op.data))) { + return allow() + } + if (op.op === 'PUT' && (await isSharedWorkspaceAdminBootstrap(tx, ctx, op.data))) { + return allow() + } + + // @todo Revisit when the E2EE pipeline supports multi-recipient envelopes + // and is workspace-aware. Until then, adding a membership for another user + // on an E2EE-enabled server would produce data they can't decrypt. Self-row + // bootstraps are exempt (covered above) — the local user already owns the + // key for their own data. See THU-593. + if (op.op === 'PUT' && ctx.settings.e2eeEnabled) { + const targetUserId = typeof op.data?.user_id === 'string' ? op.data.user_id : null + if (targetUserId && targetUserId !== ctx.userId) { + return reject('permanent', 'E2EE_MEMBERSHIPS_DISABLED') + } + } + + const targetWorkspaceId = + op.op === 'PUT' ? (typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : null) : null + + // Per-op permission keys read from `workspace_permissions.required_role`. + // Defaults to admin when the row is absent (Decision 11). Aligned with what + // the FE Members UI checks via `useWorkspacePermission` so a workspace that + // grants `member` the permission can actually exercise it on upload. + const newRole = isRole(op.data?.role) ? op.data.role : null + const targetsAdminRole = newRole === 'admin' + if (op.op === 'PUT' && !targetWorkspaceId) { + const existing = await getMembershipById(tx, op.id) + if (!existing) { + return reject('permanent', 'WORKSPACE_ID_REQUIRED') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + // Effective role change (either direction) requires `change_roles`. + // `upsertMembership` overwrites `role` on conflict, so a PUT that + // changes role would otherwise bypass PATCH's gate. + const wouldChangeRole = newRole !== null && existing.role !== newRole + if ( + (wouldChangeRole || targetsAdminRole) && + !(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'change_roles')) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + + if (op.op === 'PUT') { + // Insert-or-update path: target workspace is whatever the payload carries. + if (await isPersonalWorkspace(tx, targetWorkspaceId!)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, targetWorkspaceId!, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + // Resolve the existing row at the conflict target `(workspace_id, user_id)` + // — that's what `upsertMembership` updates, not the row at `op.id`. + // A PUT changing an existing role (either direction) requires + // `change_roles`; a fresh insert with `role: 'admin'` also requires it. + const payloadUserId = typeof op.data?.user_id === 'string' ? op.data.user_id : null + const existing = + payloadUserId !== null ? await getMembershipByWorkspaceAndUser(tx, targetWorkspaceId!, payloadUserId) : null + const wouldChangeRole = existing !== null && newRole !== null && existing.role !== newRole + const wouldMintNewAdmin = existing === null && targetsAdminRole + if ( + (wouldChangeRole || wouldMintNewAdmin) && + !(await callerSatisfiesPermission(tx, targetWorkspaceId!, ctx.userId, 'change_roles')) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + + // PATCH / DELETE both target an existing membership row. + const existing = await getMembershipById(tx, op.id) + if (!existing) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + const permissionKey: 'change_roles' | 'remove_users' = op.op === 'PATCH' ? 'change_roles' : 'remove_users' + if (!(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, permissionKey))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + }, + + apply: async (op, _ctx, tx) => { + switch (op.op) { + case 'PUT': { + const workspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : null + const userId = typeof op.data?.user_id === 'string' ? op.data.user_id : null + const role = isRole(op.data?.role) ? op.data.role : null + if (!workspaceId || !userId || !role) { + throw new UploadRejection('permanent', 'MEMBERSHIP_FIELDS_REQUIRED') + } + // Last-admin protection mirrors PATCH/DELETE. `upsertMembership` does + // ON CONFLICT DO UPDATE SET role on `(workspace_id, user_id)`, so a + // PUT demoting the workspace's only admin to member would otherwise + // bypass the guard those paths enforce. + const existing = await getMembershipByWorkspaceAndUser(tx, workspaceId, userId) + if (existing && existing.role === 'admin' && role !== 'admin') { + const remainingAdmins = await countWorkspaceAdmins(tx, workspaceId, existing.id) + if (remainingAdmins === 0) { + throw new UploadRejection('permanent', 'LAST_ADMIN_PROTECTED') + } + } + // Enrich the row with the canonical name/email from `auth.user` so the + // FE Members page has display info without a synced `users` table. The + // FE never gets to set these fields directly — the BE is the only + // source of truth. + const targetUser = await getUserById(tx, userId) + if (!targetUser) { + throw new UploadRejection('permanent', 'USER_NOT_FOUND') + } + await upsertMembership(tx, { + id: op.id, + workspaceId, + userId, + role, + userName: targetUser.name, + userEmail: targetUser.email, + }) + return + } + case 'PATCH': { + const role = isRole(op.data?.role) ? op.data.role : undefined + if (role === undefined) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + // Demoting the last admin would leave the workspace orphaned; capture the + // existing row inside the tx and reject before applying when applicable. + const before = await getMembershipById(tx, op.id) + if (!before) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + if (before.role === 'admin' && role !== 'admin') { + const remainingAdmins = await countWorkspaceAdmins(tx, before.workspaceId, before.id) + if (remainingAdmins === 0) { + throw new UploadRejection('permanent', 'LAST_ADMIN_PROTECTED') + } + } + + const affected = await updateMembership(tx, op.id, { role }) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const before = await getMembershipById(tx, op.id) + if (!before) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + if (before.role === 'admin') { + const remainingAdmins = await countWorkspaceAdmins(tx, before.workspaceId, before.id) + if (remainingAdmins === 0) { + throw new UploadRejection('permanent', 'LAST_ADMIN_PROTECTED') + } + } + const affected = await deleteMembership(tx, op.id) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, +} diff --git a/backend/src/powersync/upload-handlers/workspace-pending-memberships.ts b/backend/src/powersync/upload-handlers/workspace-pending-memberships.ts new file mode 100644 index 000000000..f4aa2bf27 --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-pending-memberships.ts @@ -0,0 +1,190 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + deletePendingMembership, + deletePendingMembershipByWorkspaceAndEmail, + getPendingMembershipById, + insertMembershipIfMissing, + isPersonalWorkspace, + type Role, + updatePendingMembership, + upsertPendingMembership, +} from '@/dal/workspaces' +import { getUserByEmail } from '@/dal/users' +import { isValidEmailFormat, normalizeEmail } from '@/lib/email' +import { allow, callerSatisfiesPermission, reject } from './helpers' +import { UploadRejection, type UploadHandler } from './types' + +const isRole = (v: unknown): v is Role => v === 'admin' || v === 'member' + +/** + * Upload handler for `workspace_pending_memberships`. Every write gates on the + * `invite_users` permission (admin by default, Decision 11). Promoting/editing + * a pending invite to `role: 'admin'` additionally requires `change_roles` — + * see the inline guard. Personal workspaces cannot have pending memberships. + * Email is normalized server-side by the DAL to match the Better Auth `before` hook. + */ +export const workspacePendingMembershipsHandler: UploadHandler = { + validate: async (op, ctx, tx) => { + // @todo Revisit when the E2EE pipeline supports multi-recipient envelopes + // and is workspace-aware. Pending memberships are by definition for someone + // else, so any insert on an E2EE-enabled server would produce data the + // invitee can't decrypt. See THU-593. + if (op.op === 'PUT' && ctx.settings.e2eeEnabled) { + return reject('permanent', 'E2EE_MEMBERSHIPS_DISABLED') + } + + // All pending-membership writes (create / edit / cancel an invite) gate on + // `invite_users` so a workspace that grants `member` the permission can + // exercise it on upload. Defaults to admin via Decision 11. + // + // Inviting (or editing the invite to) `role: 'admin'` additionally requires + // `change_roles` — otherwise `invite_users` alone could mint new admins + // via the signup-promote path, bypassing the gate that protects existing + // members from being promoted by non-role-managers. + const targetsAdminRole = op.data?.role === 'admin' + if (op.op === 'PUT') { + const targetWorkspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : undefined + if (!targetWorkspaceId) { + const existing = await getPendingMembershipById(tx, op.id) + if (!existing) { + return reject('permanent', 'WORKSPACE_ID_REQUIRED') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + if ( + targetsAdminRole && + !(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'change_roles')) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + if (await isPersonalWorkspace(tx, targetWorkspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, targetWorkspaceId, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + if (targetsAdminRole && !(await callerSatisfiesPermission(tx, targetWorkspaceId, ctx.userId, 'change_roles'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + + const existing = await getPendingMembershipById(tx, op.id) + if (!existing) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'invite_users'))) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + // Guard fires only on promotions (role becoming admin). Demoting an + // existing pending admin invite to `member` stays gated on `invite_users` + // alone — it's tampering, not escalation, and matching the broader + // "any role change requires change_roles" rule from the memberships + // handler would cost an extra DB read (load existing pending row) for + // a non-security concern. + if ( + op.op === 'PATCH' && + targetsAdminRole && + !(await callerSatisfiesPermission(tx, existing.workspaceId, ctx.userId, 'change_roles')) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + }, + + apply: async (op, ctx, tx) => { + switch (op.op) { + case 'PUT': { + const workspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : null + const email = typeof op.data?.email === 'string' ? op.data.email : null + const role = isRole(op.data?.role) ? op.data.role : null + if (!workspaceId || !email || !role) { + throw new UploadRejection('permanent', 'PENDING_FIELDS_REQUIRED') + } + if (!isValidEmailFormat(email)) { + throw new UploadRejection('permanent', 'INVALID_EMAIL') + } + // `invitedByUserId` is server-truth — the caller is the inviter, full + // stop. Trusting the payload would let a client attribute the invite + // to someone else. + await upsertPendingMembership(tx, { + id: op.id, + workspaceId, + email, + role, + invitedByUserId: ctx.userId, + }) + + // Promote-on-insert: if the invited email already belongs to a real + // user on this server, write a membership row + delete the pending + // row in the same transaction. PostgreSQL emits both ops in WAL order + // so PowerSync ships the insert+delete back to the originating FE, + // which removes its optimistic local pending row organically. The + // signup hook (`promotePendingMemberships`) covers the unknown-email + // path when the invitee later signs up. + // + // DO-NOTHING semantics: if the user is already a member of this + // workspace, the invite must NOT overwrite their current role — + // inviting an existing admin's email would otherwise downgrade them + // to whatever role the invite carried. Mirrors + // `promotePendingMemberships` (the signup-time bulk-promote path). + const matched = await getUserByEmail(tx, normalizeEmail(email)) + if (matched) { + await insertMembershipIfMissing(tx, { + id: crypto.randomUUID(), + workspaceId, + userId: matched.id, + role, + userName: matched.name, + userEmail: matched.email, + }) + // Delete by `(workspace_id, email)` rather than `op.id` — the upsert + // above hit the `(workspace_id, email)` unique constraint when the + // pending row already existed, in which case Postgres kept the + // original id and the upload's `op.id` no longer matches. Keying on + // workspace+email always lands on the actual row. + await deletePendingMembershipByWorkspaceAndEmail(tx, workspaceId, email) + } + return + } + case 'PATCH': { + // `invited_by_user_id` is server-truth and not patchable by the + // client — silently drop it from the payload rather than rewriting + // it to an attacker-supplied value. + const email = typeof op.data?.email === 'string' ? op.data.email : undefined + const role = isRole(op.data?.role) ? op.data.role : undefined + + if (email === undefined && role === undefined) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + if (email !== undefined && !isValidEmailFormat(email)) { + throw new UploadRejection('permanent', 'INVALID_EMAIL') + } + const affected = await updatePendingMembership(tx, op.id, { email, role }) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const affected = await deletePendingMembership(tx, op.id) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, +} diff --git a/backend/src/powersync/upload-handlers/workspace-permissions.ts b/backend/src/powersync/upload-handlers/workspace-permissions.ts new file mode 100644 index 000000000..670a57fbb --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-permissions.ts @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + deleteWorkspacePermission, + getWorkspacePermissionById, + isPersonalWorkspace, + isWorkspaceAdmin, + type Role, + updateWorkspacePermission, + upsertWorkspacePermission, +} from '@/dal/workspaces' +import { isWorkspacePermissionKey } from '@shared/workspaces' +import { allow, reject } from './helpers' +import { UploadRejection, type UploadHandler } from './types' + +const isRole = (v: unknown): v is Role => v === 'admin' || v === 'member' + +/** + * Upload handler for `workspace_permissions`. All operations require admin role + * in the target workspace; personal workspaces have no configurable permissions + * in v1 (Decision 11). + */ +export const workspacePermissionsHandler: UploadHandler = { + validate: async (op, ctx, tx) => { + if (op.op === 'PUT') { + const targetWorkspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : undefined + if (!targetWorkspaceId) { + const existing = await getWorkspacePermissionById(tx, op.id) + if (!existing) { + return reject('permanent', 'WORKSPACE_ID_REQUIRED') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await isWorkspaceAdmin(tx, existing.workspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + return allow() + } + if (await isPersonalWorkspace(tx, targetWorkspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await isWorkspaceAdmin(tx, targetWorkspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + return allow() + } + + const existing = await getWorkspacePermissionById(tx, op.id) + if (!existing) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (await isPersonalWorkspace(tx, existing.workspaceId)) { + return reject('permanent', 'PERSONAL_WORKSPACE_IMMUTABLE') + } + if (!(await isWorkspaceAdmin(tx, existing.workspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + return allow() + }, + + apply: async (op, _ctx, tx) => { + switch (op.op) { + case 'PUT': { + const workspaceId = typeof op.data?.workspace_id === 'string' ? op.data.workspace_id : null + const permissionKey = isWorkspacePermissionKey(op.data?.permission_key) ? op.data.permission_key : null + const requiredRole = isRole(op.data?.required_role) ? op.data.required_role : null + if (!workspaceId || !permissionKey || !requiredRole) { + throw new UploadRejection('permanent', 'PERMISSION_FIELDS_REQUIRED') + } + await upsertWorkspacePermission(tx, { + id: op.id, + workspaceId, + permissionKey, + requiredRole, + }) + return + } + case 'PATCH': { + const requiredRole = isRole(op.data?.required_role) ? op.data.required_role : undefined + if (requiredRole === undefined) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + const affected = await updateWorkspacePermission(tx, op.id, { requiredRole }) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const affected = await deleteWorkspacePermission(tx, op.id) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, +} diff --git a/backend/src/powersync/upload-handlers/workspace-scoped.ts b/backend/src/powersync/upload-handlers/workspace-scoped.ts new file mode 100644 index 000000000..95b421964 --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspace-scoped.ts @@ -0,0 +1,281 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { isWorkspaceMember } from '@/dal/workspaces' +import { + powersyncConflictTarget, + powersyncDbNameToSchemaKey, + powersyncPkColumn, + powersyncTablesByName, +} from '@/db/powersync-schema' +import type { PowerSyncTableName } from '@shared/powersync-tables' +import { type WorkspacePermissionKey } from '@shared/workspaces' +import { and, eq } from 'drizzle-orm' +import type { AnyPgColumn, AnyPgTable } from 'drizzle-orm/pg-core' +import { allow, callerSatisfiesPermission, reject, toSchemaRecord } from './helpers' +import { UploadRejection, type UploadHandler, type UploadTx } from './types' + +export type WorkspaceScopedConfig = { + tableName: PowerSyncTableName + /** + * When true, the row's `user_id` column must equal the caller. Applied to + * chat_threads / chat_messages / tasks per spec §3.7: those rows are user-private + * within a shared workspace. When false (models, modes, prompts, skills, triggers, + * mcp_servers, model_profiles) any workspace member may write the row. + */ + userPrivate: boolean + /** Columns the client may not set; stripped from PUT/PATCH payloads. */ + denyColumns?: readonly string[] + /** + * Permission key gating PUT and "edit" PATCHes. When set, the handler reads + * `workspace_permissions.required_role` for this key and rejects the op + * unless the caller's role satisfies it. Default (no key) keeps the "any + * workspace member may write" behaviour. + */ + addPermissionKey?: WorkspacePermissionKey + /** + * Permission key gating DELETE and "soft-delete" PATCHes (see + * `softDeleteColumn`). Same lookup semantics as `addPermissionKey`. + */ + removePermissionKey?: WorkspacePermissionKey + /** + * Column name (in PowerSync upload's snake_case form) used by the table for + * soft-delete tombstones. When set, a PATCH that writes this column to a + * non-null value is treated as a remove and gates on `removePermissionKey` + * instead of `addPermissionKey`. PATCHes that don't touch the column — or + * that set it back to null (restore) — continue to gate as adds. + * + * Required for the four resource tables (agents, skills, models, + * mcp_servers) whose FE DAL soft-deletes via UPDATE rather than DELETE. + */ + softDeleteColumn?: string +} + +const isString = (v: unknown): v is string => typeof v === 'string' + +/** + * 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. + */ +const fetchRowScope = async ( + tx: UploadTx, + tableName: PowerSyncTableName, + rowId: string, +): Promise<{ workspaceId: string; userId: string | null } | null> => { + const table = powersyncTablesByName[tableName] as AnyPgTable & { + workspaceId: AnyPgColumn + userId: 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 row = rows[0] + if (!row) { + return null + } + return { + workspaceId: row.workspaceId as string, + userId: (row.userId as string | null) ?? null, + } +} + +/** + * Builds an `UploadHandler` for a workspace-scoped synced table. Enforces: + * + * - Every row write requires the caller to be a member of the target workspace. + * For PUT, the target is `op.data.workspace_id`; for PATCH/DELETE it's read from + * the row's current `workspace_id`. + * - When `userPrivate` is true, PATCH/DELETE additionally require the row's + * `user_id` to equal the caller — protecting one member's chat threads from + * being edited by another member of the same workspace. + * - PUT forces `user_id = ctx.userId` so a client cannot impersonate another + * member when authoring rows. PATCH/DELETE silently drop any payload `user_id`. + * - PATCH/DELETE silently drop any payload `workspace_id` so a row cannot be + * moved between workspaces via upload. + */ +export const createWorkspaceScopedHandler = (cfg: WorkspaceScopedConfig): UploadHandler => { + const { tableName, userPrivate, denyColumns = [], addPermissionKey, removePermissionKey, softDeleteColumn } = cfg + + /** + * Classifies the op as 'add' (PUT, PATCH-edit, PATCH-restore) or 'remove' + * (DELETE, PATCH that sets `softDeleteColumn` to a truthy value). Drives + * which permission key gates the write. + */ + const opIntent = (op: { op: 'PUT' | 'PATCH' | 'DELETE'; data?: Record }): 'add' | 'remove' => { + if (op.op === 'DELETE') { + return 'remove' + } + if ( + op.op === 'PATCH' && + softDeleteColumn !== undefined && + op.data && + Object.prototype.hasOwnProperty.call(op.data, softDeleteColumn) && + op.data[softDeleteColumn] != null + ) { + return 'remove' + } + return 'add' + } + + return { + validate: async (op, ctx, tx) => { + if (op.op === 'PUT') { + 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 + if (!resolvedWorkspaceId) { + return reject('permanent', 'WORKSPACE_ID_REQUIRED') + } + if (!(await isWorkspaceMember(tx, resolvedWorkspaceId, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_MEMBER') + } + if ( + addPermissionKey && + !(await callerSatisfiesPermission(tx, resolvedWorkspaceId, ctx.userId, addPermissionKey)) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + } + + const scope = await fetchRowScope(tx, tableName, op.id) + 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) { + return reject('permanent', 'NOT_ROW_OWNER') + } + const requiredPermissionKey = opIntent(op) === 'remove' ? removePermissionKey : addPermissionKey + if ( + requiredPermissionKey && + !(await callerSatisfiesPermission(tx, scope.workspaceId, ctx.userId, requiredPermissionKey)) + ) { + return reject('permanent', 'INSUFFICIENT_PERMISSION') + } + return allow() + }, + + apply: async (op, ctx, tx) => { + const table = powersyncTablesByName[tableName] as AnyPgTable & { + workspaceId: AnyPgColumn + userId: AnyPgColumn + } + const dbNameToKey = powersyncDbNameToSchemaKey[tableName] + const pkColumn = powersyncPkColumn[tableName] + const conflictTarget = powersyncConflictTarget[tableName] + const validDbNames = new Set(Object.keys(dbNameToKey)) + + switch (op.op) { + case 'PUT': { + const targetWorkspaceId = isString(op.data?.workspace_id) ? op.data.workspace_id : null + const resolvedWorkspaceId = targetWorkspaceId ?? (await fetchRowScope(tx, tableName, op.id))?.workspaceId + if (!resolvedWorkspaceId) { + throw new UploadRejection('permanent', 'WORKSPACE_ID_REQUIRED') + } + + const payload = { ...(op.data ?? {}) } as Record + delete payload.id + delete payload.user_id + delete payload.workspace_id + for (const col of denyColumns) { + delete payload[col] + } + + const rawData: Record = { + ...payload, + id: op.id, + workspace_id: resolvedWorkspaceId, + user_id: ctx.userId, + } + const schemaValues = toSchemaRecord(rawData, validDbNames, dbNameToKey) + if (Object.keys(schemaValues).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + const updateSet = { ...schemaValues } + delete updateSet.id + delete updateSet.key + delete updateSet.workspaceId + // Preserve the row's original `user_id` on update so co-members editing a + // shared row don't rewrite authorship. + delete updateSet.userId + + const insertQuery = tx.insert(table).values(schemaValues as never) + if (Object.keys(updateSet).length > 0) { + await insertQuery.onConflictDoUpdate({ + target: conflictTarget, + set: updateSet as never, + setWhere: eq(table.workspaceId, resolvedWorkspaceId), + }) + } else { + await insertQuery.onConflictDoNothing({ target: conflictTarget }) + } + return + } + case 'PATCH': { + if (!op.data || Object.keys(op.data).length === 0) { + return + } + const patchPayload = { ...op.data } as Record + delete patchPayload.id + delete patchPayload.user_id + delete patchPayload.workspace_id + for (const col of denyColumns) { + delete patchPayload[col] + } + const schemaPatch = toSchemaRecord(patchPayload, validDbNames, dbNameToKey) + if (Object.keys(schemaPatch).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + + // Re-fetch scope to pin workspace_id in the WHERE clause. Composite PK + // (id, workspace_id) means WHERE id alone could touch rows across workspaces. + const patchScope = await fetchRowScope(tx, tableName, op.id) + if (!patchScope) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + + const patched = await tx + .update(table) + .set(schemaPatch as never) + .where(and(eq(pkColumn, op.id), eq(table.workspaceId, patchScope.workspaceId))) + .returning() + + if (patched.length === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + const deleteScope = await fetchRowScope(tx, tableName, op.id) + if (!deleteScope) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + + const deleted = await tx + .delete(table) + .where(and(eq(pkColumn, op.id), eq(table.workspaceId, deleteScope.workspaceId))) + .returning() + + if (deleted.length === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + } + }, + } +} diff --git a/backend/src/powersync/upload-handlers/workspaces.ts b/backend/src/powersync/upload-handlers/workspaces.ts new file mode 100644 index 000000000..aee4ec592 --- /dev/null +++ b/backend/src/powersync/upload-handlers/workspaces.ts @@ -0,0 +1,204 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + getWorkspaceById, + insertPersonalWorkspaceIfMissing, + isAdminOfAnyWorkspace, + isWorkspaceAdmin, + updateWorkspace, + upsertWorkspace, +} from '@/dal/workspaces' +import { computePersonalWorkspaceId } from '@shared/workspaces' +import { allow, reject } from './helpers' +import { UploadRejection, type UploadHandler } from './types' + +/** + * Upload handler for the `workspaces` table. Enforces: + * + * - **Personal workspace PUT**: allowed iff the row id matches the canonical + * `computePersonalWorkspaceId(ctx.userId)` AND `owner_user_id === ctx.userId`. + * First-write wins on the name + icon — if the row already exists, the PUT + * is a no-op so a second device's idempotent bootstrap doesn't clobber data + * the user changed elsewhere. `slug` always stays null on personal (personal + * workspaces don't carry URL slugs). + * - **Personal workspace PATCH**: name and icon mutable; slug is rejected + * (`PERSONAL_WORKSPACE_SLUG_FORBIDDEN`). Gated on admin of the workspace. + * - **Shared workspace PUT**: gated by `allowWorkspaceCreationByMembers`. + * Members may create only when the flag is on; users who already admin some + * workspace can always create regardless of the flag. + * - **Shared workspace PATCH**: requires the caller to be admin of the target. + * name, slug, and icon are mutable. + * - **Duplicate slug**: Postgres `UNIQUE INDEX idx_workspaces_slug` rejects + * the write; the apply layer catches it and surfaces `WORKSPACE_SLUG_TAKEN` + * as a permanent rejection. + * - **DELETE**: out of scope for v1. + */ +export const workspacesHandler: UploadHandler = { + validate: async (op, ctx, tx) => { + if (op.op === 'DELETE') { + return reject('permanent', 'WORKSPACE_DELETE_DISABLED') + } + + const existing = await getWorkspaceById(tx, op.id) + + if (op.op === 'PATCH') { + if (!existing) { + return reject('permanent', 'ROW_NOT_FOUND') + } + if (!(await isWorkspaceAdmin(tx, op.id, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + if (existing.isPersonal && op.data?.slug !== undefined) { + return reject('permanent', 'PERSONAL_WORKSPACE_SLUG_FORBIDDEN') + } + return allow() + } + + // PUT — split by personal vs shared via the payload + id checks. + const payloadIsPersonal = op.data?.is_personal === true + const canonicalPersonalId = computePersonalWorkspaceId(ctx.userId) + const idMatchesCanonical = op.id === canonicalPersonalId + + if (payloadIsPersonal || idMatchesCanonical || existing?.isPersonal) { + // This is a personal-workspace PUT. Accept only if both the canonical id + // and the ownership claim match the caller. Anything else — wrong id, + // someone else's owner_user_id — is rejected. + if (!idMatchesCanonical) { + return reject('permanent', 'PERSONAL_WORKSPACE_ID_NOT_CANONICAL') + } + const claimedOwner = typeof op.data?.owner_user_id === 'string' ? op.data.owner_user_id : undefined + if (claimedOwner !== undefined && claimedOwner !== ctx.userId) { + return reject('permanent', 'PERSONAL_WORKSPACE_OWNER_MISMATCH') + } + if (existing && !existing.isPersonal) { + // The canonical id is already used by a non-personal workspace — + // structurally impossible if the canonical hashing is correct, but + // refuse anyway rather than silently mutate a shared workspace. + return reject('permanent', 'PERSONAL_WORKSPACE_ID_COLLISION') + } + return allow() + } + + // Shared workspace PUT. + if (existing) { + // Updating an existing shared workspace via PUT. + if (!(await isWorkspaceAdmin(tx, op.id, ctx.userId))) { + return reject('permanent', 'NOT_WORKSPACE_ADMIN') + } + return allow() + } + + const memberMayCreate = + ctx.settings.allowWorkspaceCreationByMembers || (await isAdminOfAnyWorkspace(tx, ctx.userId)) + if (!memberMayCreate) { + return reject('permanent', 'WORKSPACE_CREATION_DISABLED') + } + + return allow() + }, + + apply: async (op, ctx, tx) => { + switch (op.op) { + case 'PUT': { + const canonicalPersonalId = computePersonalWorkspaceId(ctx.userId) + const isPersonalPut = op.data?.is_personal === true || op.id === canonicalPersonalId + const incomingName = typeof op.data?.name === 'string' ? op.data.name : null + // Personal workspaces default to "Default" if the client omitted the name + // (defensive — current clients always send one). Shared workspaces require it. + const name = incomingName ?? (isPersonalPut ? 'Default' : null) + if (!name) { + throw new UploadRejection('permanent', 'WORKSPACE_NAME_REQUIRED') + } + // Distinguish "key omitted from payload" (undefined) from "explicitly + // null" so an admin's idempotent PUT that doesn't carry slug/icon + // doesn't clobber values already on the server. `upsertWorkspace`'s + // ON CONFLICT only writes columns whose input value is `!== undefined`. + const slug = op.data?.slug === undefined ? undefined : typeof op.data.slug === 'string' ? op.data.slug : null + const icon = op.data?.icon === undefined ? undefined : typeof op.data.icon === 'string' ? op.data.icon : null + if (isPersonalPut) { + // `DO NOTHING` on conflict — preserves any later changes if a second + // device's bootstrap PUT lands after the user already mutated the row. + await insertPersonalWorkspaceIfMissing(tx, { + id: op.id, + name, + icon: icon ?? null, + ownerUserId: ctx.userId, + }) + return + } + await runWithSlugViolationGuard(() => + upsertWorkspace(tx, { + id: op.id, + name, + slug, + icon, + isPersonal: false, + ownerUserId: null, + }), + ) + return + } + case 'PATCH': { + const patch: { name?: string; slug?: string | null; icon?: string | null } = {} + if (typeof op.data?.name === 'string') { + patch.name = op.data.name + } + if (op.data?.slug !== undefined) { + patch.slug = typeof op.data.slug === 'string' ? op.data.slug : null + } + if (op.data?.icon !== undefined) { + patch.icon = typeof op.data.icon === 'string' ? op.data.icon : null + } + if (Object.keys(patch).length === 0) { + throw new UploadRejection('permanent', 'EMPTY_PAYLOAD') + } + const affected = await runWithSlugViolationGuard(() => updateWorkspace(tx, op.id, patch)) + if (affected === 0) { + throw new UploadRejection('permanent', 'ROW_NOT_FOUND') + } + return + } + case 'DELETE': { + throw new UploadRejection('permanent', 'WORKSPACE_DELETE_DISABLED') + } + } + }, +} + +/** + * Wrap a write that may collide with the partial-unique slug index. Postgres + * raises `unique_violation` (SQLSTATE 23505) which would otherwise bubble out + * as a transient retry — we want a permanent `WORKSPACE_SLUG_TAKEN` instead. + */ +const runWithSlugViolationGuard = async (write: () => Promise): Promise => { + try { + return await write() + } catch (err) { + if (isUniqueViolationOnSlugIndex(err)) { + throw new UploadRejection('permanent', 'WORKSPACE_SLUG_TAKEN') + } + throw err + } +} + +const isUniqueViolationOnSlugIndex = (err: unknown): boolean => { + // Drizzle wraps the underlying postgres-js error in `.cause`; check both the + // outer object and its cause. + for (const candidate of [err, (err as { cause?: unknown } | null)?.cause]) { + if (!candidate || typeof candidate !== 'object') { + continue + } + const e = candidate as { code?: unknown; constraint_name?: unknown; constraint?: unknown } + if (e.code !== '23505') { + continue + } + const constraint = + typeof e.constraint === 'string' ? e.constraint : typeof e.constraint_name === 'string' ? e.constraint_name : null + if (constraint && constraint.includes('idx_workspaces_slug')) { + return true + } + } + return false +} diff --git a/backend/src/test-utils/db.ts b/backend/src/test-utils/db.ts index 0d17b66ca..17e6bbc3c 100644 --- a/backend/src/test-utils/db.ts +++ b/backend/src/test-utils/db.ts @@ -3,7 +3,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { PGlite } from '@electric-sql/pglite' -import { sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/pglite' import { migrate } from 'drizzle-orm/pglite/migrator' import { resolve } from 'path' @@ -42,22 +41,57 @@ class TestDbManager { } /** - * Create a test database instance with transaction isolation + * Create a test database instance with transaction isolation. + * + * Opens a Drizzle transaction and exposes the Transaction object as `db`. + * Why a Drizzle transaction (not raw `BEGIN`/`ROLLBACK` SQL): production + * upload handlers wrap operations in `db.transaction(...)`. When that runs + * against a Drizzle Database/Session, PGlite issues raw `BEGIN`/`COMMIT` + * SQL — a nested `BEGIN` is a no-op (Postgres warning), but the matching + * `COMMIT` ends the outer test transaction, breaking isolation. + * + * Against a Drizzle Transaction, `db.transaction(...)` uses `SAVEPOINT` + * instead, which nests cleanly and rolls back with the outer test + * transaction on cleanup. + * + * The outer transaction is opened via a deferred promise so we can hand + * the Transaction object to the caller and hold it open until cleanup. + * Throwing a sentinel from inside the transaction callback triggers + * Drizzle's `ROLLBACK`; the catch outside swallows the sentinel. */ async createTestDb() { if (!this.initialized) { await this.initialize() } - // Start a transaction using Drizzle's API - await this.db!.execute(sql`BEGIN`) + const rollbackSentinel = new Error('__test_cleanup_rollback__') + + let resolveTx!: (tx: typeof DbType) => void + let signalRollback!: () => void + + const txReady = new Promise((resolve) => { + resolveTx = resolve + }) + + const txDone = this.db!.transaction(async (tx) => { + resolveTx(tx as unknown as typeof DbType) + await new Promise((_, reject) => { + signalRollback = () => reject(rollbackSentinel) + }) + }).catch((err) => { + if (err !== rollbackSentinel) { + throw err + } + }) + + const tx = await txReady return { client: this.client!, - db: this.db!, - // Cleanup function to roll back the transaction + db: tx, cleanup: async () => { - await this.db!.execute(sql`ROLLBACK`) + signalRollback() + await txDone }, } } diff --git a/backend/src/test-utils/settings.ts b/backend/src/test-utils/settings.ts index f4a0b9662..dedaa0f71 100644 --- a/backend/src/test-utils/settings.ts +++ b/backend/src/test-utils/settings.ts @@ -10,6 +10,7 @@ import type { Settings } from '@/config/settings' * matching the schema in `@/config/settings`. */ export const createTestSettings = (overrides: Partial = {}): Settings => ({ + serverId: '00000000-0000-0000-0000-000000000000', fireworksApiKey: '', mistralApiKey: '', anthropicApiKey: '', @@ -23,6 +24,8 @@ export const createTestSettings = (overrides: Partial = {}): Settings microsoftClientSecret: '', authMode: 'consumer' as const, authAllowAnonymous: false, + allowWorkspaceCreationByAnon: false, + allowWorkspaceCreationByMembers: false, oidcClientId: '', oidcClientSecret: '', oidcIssuer: '', diff --git a/backend/src/test-utils/test-setup.ts b/backend/src/test-utils/test-setup.ts index 24a747fd2..b73ef43f0 100644 --- a/backend/src/test-utils/test-setup.ts +++ b/backend/src/test-utils/test-setup.ts @@ -25,6 +25,10 @@ process.env.RATE_LIMIT_ENABLED = 'false' // so that test signToken() helpers produce matching signatures process.env.BETTER_AUTH_SECRET = 'better-auth-secret-12345678901234567890' +// Required by settingsSchema (z.string().uuid(), no default). Tests that call +// clearSettingsCache() re-parse from env — without this they throw a ZodError. +process.env.SERVER_ID = '00000000-0000-0000-0000-000000000000' + // Initialize the database before any tests run console.log('🔧 Initializing test database...') await testDbManager.initialize() diff --git a/bun.lock b/bun.lock index 9f92e9dff..057243d87 100644 --- a/bun.lock +++ b/bun.lock @@ -63,6 +63,7 @@ "dayjs": "^1.11.13", "drizzle-orm": "^0.45.2", "framer-motion": "^12.23.12", + "frimousse": "^0.3.0", "input-otp": "^1.4.2", "katex": "^0.16.0", "lucide-react": "^1.8.0", @@ -1335,6 +1336,8 @@ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "frimousse": ["frimousse@0.3.0", "", { "peerDependencies": { "react": "^18 || ^19", "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], diff --git a/deploy/config/powersync-config.yaml b/deploy/config/powersync-config.yaml index 8af7dfecf..be11fa24c 100644 --- a/deploy/config/powersync-config.yaml +++ b/deploy/config/powersync-config.yaml @@ -14,27 +14,60 @@ port: 8080 sync_rules: content: | bucket_definitions: - user_essentials: + # Account-level data — one bucket per user. Device list and user-scoped + # settings. Workspaces and permissions are joined via the workspace bucket + # below (PowerSync sync rules don't support IN (SELECT ...) subqueries). + user_account: priority: 1 parameters: SELECT request.user_id() as user_id data: - SELECT * FROM powersync.settings WHERE user_id = bucket.user_id - - SELECT * FROM powersync.models WHERE user_id = bucket.user_id - - SELECT * FROM powersync.modes WHERE user_id = bucket.user_id - - SELECT * FROM powersync.model_profiles WHERE user_id = bucket.user_id - SELECT * FROM powersync.devices WHERE user_id = bucket.user_id - - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - user_data: - priority: 2 + + # User-private workspace-scoped tables — parameterized by user_id so we can + # filter without referencing request.user_id() inside the data query (which + # PowerSync rejects). Rows for every workspace the user is a member of land + # in the local DB; the FE narrows to the active workspace at query time. + user_private: + priority: 1 parameters: SELECT request.user_id() as user_id data: + - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - SELECT * FROM powersync.chat_messages WHERE user_id = bucket.user_id - SELECT * FROM powersync.tasks WHERE user_id = bucket.user_id - - SELECT * FROM powersync.mcp_servers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.prompts WHERE user_id = bucket.user_id - - SELECT * FROM powersync.skills WHERE user_id = bucket.user_id - - SELECT * FROM powersync.triggers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.agents WHERE user_id = bucket.user_id + + # Workspace essentials — one bucket per workspace the user is a member of. + # Carries the workspace row itself and the permission policy. Promoted to + # priority 1 so the workspace selector / sidebar and permission-gated UI + # render on first paint without waiting for the heavier shared config. + workspace_essentials: + priority: 1 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspaces WHERE id = bucket.workspace_id + - SELECT * FROM powersync.workspace_permissions WHERE workspace_id = bucket.workspace_id + + # Workspace shared data — full member list, pending invites, and shared + # workspace-scoped config. Pending memberships sync to every member (not + # just admins) because `invite_users`/`change_roles`/`remove_users` are + # per-key permissions that can be granted to `member` role; without the + # row syncing down, a permitted non-admin would see an empty pending list. + # Write authorization is enforced by the upload handlers, not by the + # sync rule. + 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 client_auth: supabase: false diff --git a/deploy/k8s/templates/configmaps.yaml b/deploy/k8s/templates/configmaps.yaml index 25ba358a1..c44164ca7 100644 --- a/deploy/k8s/templates/configmaps.yaml +++ b/deploy/k8s/templates/configmaps.yaml @@ -22,27 +22,60 @@ data: sync_rules: content: | bucket_definitions: - user_essentials: + # Account-level data — one bucket per user. Device list and user-scoped + # settings. Workspaces and permissions are joined via the workspace bucket + # below (PowerSync sync rules don't support IN (SELECT ...) subqueries). + user_account: priority: 1 parameters: SELECT request.user_id() as user_id data: - SELECT * FROM powersync.settings WHERE user_id = bucket.user_id - - SELECT * FROM powersync.models WHERE user_id = bucket.user_id - - SELECT * FROM powersync.modes WHERE user_id = bucket.user_id - - SELECT * FROM powersync.model_profiles WHERE user_id = bucket.user_id - SELECT * FROM powersync.devices WHERE user_id = bucket.user_id - - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - user_data: - priority: 2 + + # User-private workspace-scoped tables — parameterized by user_id so we can + # filter without referencing request.user_id() inside the data query (which + # PowerSync rejects). Rows for every workspace the user is a member of land + # in the local DB; the FE narrows to the active workspace at query time. + user_private: + priority: 1 parameters: SELECT request.user_id() as user_id data: + - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - SELECT * FROM powersync.chat_messages WHERE user_id = bucket.user_id - SELECT * FROM powersync.tasks WHERE user_id = bucket.user_id - - SELECT * FROM powersync.mcp_servers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.prompts WHERE user_id = bucket.user_id - - SELECT * FROM powersync.skills WHERE user_id = bucket.user_id - - SELECT * FROM powersync.triggers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.agents WHERE user_id = bucket.user_id + + # Workspace essentials — one bucket per workspace the user is a member of. + # Carries the workspace row itself and the permission policy. Promoted to + # priority 1 so the workspace selector / sidebar and permission-gated UI + # render on first paint without waiting for the heavier shared config. + workspace_essentials: + priority: 1 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspaces WHERE id = bucket.workspace_id + - SELECT * FROM powersync.workspace_permissions WHERE workspace_id = bucket.workspace_id + + # Workspace shared data — full member list, pending invites, and shared + # workspace-scoped config. Pending memberships sync to every member (not + # just admins) because `invite_users`/`change_roles`/`remove_users` are + # per-key permissions that can be granted to `member` role; without the + # row syncing down, a permitted non-admin would see an empty pending list. + # Write authorization is enforced by the upload handlers, not by the + # sync rule. + 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 client_auth: supabase: false diff --git a/package.json b/package.json index 9251bbe78..af6401e43 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "dayjs": "^1.11.13", "drizzle-orm": "^0.45.2", "framer-motion": "^12.23.12", + "frimousse": "^0.3.0", "input-otp": "^1.4.2", "katex": "^0.16.0", "lucide-react": "^1.8.0", diff --git a/playwright.config.ts b/playwright.config.ts index 5efdcef47..202f9e4f3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -92,6 +92,11 @@ export default defineConfig({ timeout: 120_000, env: { PORT: String(oidcBackendPort), + // Stable per-deployment UUID — required by the settings schema (no default). Locally, + // `backend/.env` supplies one via `make doctor`, but CI inherits no such file, so we + // pin a deterministic fixture here. Distinct from the SAML backend's id so the two + // e2e backends model independent trust domains. + SERVER_ID: 'e2e0e2e0-e2e0-4e2e-8e2e-e2e0e2e00001', AUTH_MODE: 'oidc', OIDC_CLIENT_ID: 'thunderbolt-app', OIDC_CLIENT_SECRET: 'thunderbolt-dev-secret', @@ -127,6 +132,8 @@ export default defineConfig({ timeout: 120_000, env: { PORT: String(samlBackendPort), + // Distinct from the OIDC backend's id — see comment there for why we pin a fixture. + SERVER_ID: 'e2e0e2e0-e2e0-4e2e-8e2e-e2e0e2e00002', AUTH_MODE: 'saml', SAML_ENTRY_POINT: `http://localhost:${mockSamlPort}/saml/sso`, SAML_ENTITY_ID: 'e2e-saml-sp', diff --git a/powersync-service/config/config.yaml b/powersync-service/config/config.yaml index a81ab5278..2ff8a7fd7 100644 --- a/powersync-service/config/config.yaml +++ b/powersync-service/config/config.yaml @@ -14,26 +14,59 @@ port: 8080 sync_rules: content: | bucket_definitions: - user_essentials: + # Account-level data — one bucket per user. Device list and user-scoped + # settings. Workspaces and permissions are joined via the workspace bucket + # below (PowerSync sync rules don't support IN (SELECT ...) subqueries). + user_account: priority: 1 parameters: SELECT request.user_id() as user_id data: - SELECT * FROM powersync.settings WHERE user_id = bucket.user_id - - SELECT * FROM powersync.models WHERE user_id = bucket.user_id - - SELECT * FROM powersync.modes WHERE user_id = bucket.user_id - - SELECT * FROM powersync.model_profiles WHERE user_id = bucket.user_id - SELECT * FROM powersync.devices WHERE user_id = bucket.user_id - - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - user_data: - priority: 2 + + # User-private workspace-scoped tables — parameterized by user_id so we can + # filter without referencing request.user_id() inside the data query (which + # PowerSync rejects). Rows for every workspace the user is a member of land + # in the local DB; the FE narrows to the active workspace at query time. + user_private: + priority: 1 parameters: SELECT request.user_id() as user_id data: + - SELECT * FROM powersync.chat_threads WHERE user_id = bucket.user_id - SELECT * FROM powersync.chat_messages WHERE user_id = bucket.user_id - SELECT * FROM powersync.tasks WHERE user_id = bucket.user_id - - SELECT * FROM powersync.prompts WHERE user_id = bucket.user_id - - SELECT * FROM powersync.skills WHERE user_id = bucket.user_id - - SELECT * FROM powersync.triggers WHERE user_id = bucket.user_id - - SELECT * FROM powersync.agents WHERE user_id = bucket.user_id + + # Workspace essentials — one bucket per workspace the user is a member of. + # Carries the workspace row itself and the permission policy. Promoted to + # priority 1 so the workspace selector / sidebar and permission-gated UI + # render on first paint without waiting for the heavier shared config. + workspace_essentials: + priority: 1 + parameters: SELECT workspace_id FROM powersync.workspace_memberships WHERE user_id = request.user_id() + data: + - SELECT * FROM powersync.workspaces WHERE id = bucket.workspace_id + - SELECT * FROM powersync.workspace_permissions WHERE workspace_id = bucket.workspace_id + + # Workspace shared data — full member list, pending invites, and shared + # workspace-scoped config. Pending memberships sync to every member (not + # just admins) because `invite_users`/`change_roles`/`remove_users` are + # per-key permissions that can be granted to `member` role; without the + # row syncing down, a permitted non-admin would see an empty pending list. + # Write authorization is enforced by the upload handlers, not by the + # sync rule. + 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.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 client_auth: supabase: false diff --git a/scripts/thunderdoctor.sh b/scripts/thunderdoctor.sh index 2a5325361..fe05b6661 100755 --- a/scripts/thunderdoctor.sh +++ b/scripts/thunderdoctor.sh @@ -220,6 +220,41 @@ if [ -f "$PROJECT_ROOT/backend/.env" ]; then echo -e " ${PASS} BETTER_AUTH_SECRET is set" fi + # SERVER_ID: stable per-deployment UUID returned by GET /v1/config. Required at boot; + # the frontend namespaces its trust-domain registry by it. Auto-generate for local dev + # following the same TTY / THUNDERDOCTOR_AUTOFIX gate as BETTER_AUTH_SECRET above. + current_server_id=$(grep -E '^SERVER_ID=' "$PROJECT_ROOT/backend/.env" | head -1 | sed -E 's/^SERVER_ID=//; s/^"(.*)"$/\1/' || true) + if ! echo "$current_server_id" | grep -Eqi '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then + if [ ! -t 1 ] && [ "${THUNDERDOCTOR_AUTOFIX:-}" != "true" ]; then + has_critical_failure=true + echo -e " ${FAIL} SERVER_ID is empty/invalid — won't auto-fix from a non-interactive shell. Run \`make doctor\` from a terminal, or set THUNDERDOCTOR_AUTOFIX=true to opt in." + else + if command -v uuidgen >/dev/null 2>&1; then + new_server_id=$(uuidgen | tr '[:upper:]' '[:lower:]') + elif [ -r /proc/sys/kernel/random/uuid ]; then + new_server_id=$(cat /proc/sys/kernel/random/uuid) + else + new_server_id="" + fi + if [ -n "$new_server_id" ]; then + echo -e " ${YELLOW}→${NC} Writing a fresh SERVER_ID to backend/.env..." + tmp=$(mktemp) + NEW_SERVER_ID="$new_server_id" awk ' + BEGIN { val = ENVIRON["NEW_SERVER_ID"]; replaced = 0 } + /^SERVER_ID=/ { print "SERVER_ID=" val; replaced = 1; next } + { print } + END { if (!replaced) print "SERVER_ID=" val } + ' "$PROJECT_ROOT/backend/.env" > "$tmp" && mv "$tmp" "$PROJECT_ROOT/backend/.env" + echo -e " ${PASS} SERVER_ID generated and written to backend/.env" + else + has_critical_failure=true + echo -e " ${FAIL} SERVER_ID is empty/invalid and neither uuidgen nor /proc/sys/kernel/random/uuid is available — set it manually to a UUID" + fi + fi + elif [ "$QUIET" = false ]; then + echo -e " ${PASS} SERVER_ID is set" + fi + # POWERSYNC_URL is required when DATABASE_DRIVER=postgres; the backend silently 503s without it. driver=$(grep -E '^DATABASE_DRIVER=' "$PROJECT_ROOT/backend/.env" | head -1 | sed -E 's/^DATABASE_DRIVER=//; s/^"(.*)"$/\1/' || true) if [ "$driver" = "postgres" ]; then diff --git a/shared/powersync-tables.ts b/shared/powersync-tables.ts index 89e3cd733..44738e9dd 100644 --- a/shared/powersync-tables.ts +++ b/shared/powersync-tables.ts @@ -22,6 +22,10 @@ export const powersyncTableNames = [ 'model_profiles', 'devices', 'agents', + 'workspaces', + 'workspace_memberships', + 'workspace_pending_memberships', + 'workspace_permissions', ] as const export type PowerSyncTableName = (typeof powersyncTableNames)[number] @@ -46,4 +50,8 @@ export const powersyncTableToQueryKeys: { model_profiles: [['modelProfiles']], devices: [['devices']], agents: [['agents']], + workspaces: [['workspaces']], + workspace_memberships: [['workspaceMemberships']], + workspace_pending_memberships: [['workspacePendingMemberships']], + workspace_permissions: [['workspacePermissions']], } diff --git a/shared/workspaces.test.ts b/shared/workspaces.test.ts new file mode 100644 index 000000000..43e6e03af --- /dev/null +++ b/shared/workspaces.test.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { + isWorkspacePermissionKey, + isWorkspacePermissionRole, + permissionAllows, + workspacePermissionKeys, +} from './workspaces' + +describe('permissionAllows', () => { + it('admin satisfies admin requirements', () => { + expect(permissionAllows('admin', 'admin')).toBe(true) + }) + + it('admin satisfies member requirements', () => { + expect(permissionAllows('admin', 'member')).toBe(true) + }) + + it('member satisfies member requirements', () => { + expect(permissionAllows('member', 'member')).toBe(true) + }) + + it('member does not satisfy admin requirements', () => { + expect(permissionAllows('member', 'admin')).toBe(false) + }) + + it('null/undefined user roles never satisfy anything', () => { + expect(permissionAllows(null, 'admin')).toBe(false) + expect(permissionAllows(null, 'member')).toBe(false) + expect(permissionAllows(undefined, 'admin')).toBe(false) + expect(permissionAllows(undefined, 'member')).toBe(false) + }) +}) + +describe('isWorkspacePermissionKey', () => { + it('accepts every key declared in workspacePermissionKeys', () => { + for (const key of workspacePermissionKeys) { + expect(isWorkspacePermissionKey(key)).toBe(true) + } + }) + + it('rejects unknown strings and non-strings', () => { + expect(isWorkspacePermissionKey('unknown_key')).toBe(false) + expect(isWorkspacePermissionKey('')).toBe(false) + expect(isWorkspacePermissionKey(123)).toBe(false) + expect(isWorkspacePermissionKey(null)).toBe(false) + expect(isWorkspacePermissionKey(undefined)).toBe(false) + }) +}) + +describe('isWorkspacePermissionRole', () => { + it('accepts admin and member', () => { + expect(isWorkspacePermissionRole('admin')).toBe(true) + expect(isWorkspacePermissionRole('member')).toBe(true) + }) + + it('rejects anything else', () => { + expect(isWorkspacePermissionRole('owner')).toBe(false) + expect(isWorkspacePermissionRole('')).toBe(false) + expect(isWorkspacePermissionRole(null)).toBe(false) + }) +}) diff --git a/shared/workspaces.ts b/shared/workspaces.ts new file mode 100644 index 000000000..19f465e1e --- /dev/null +++ b/shared/workspaces.ts @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { v5 as uuidv5 } from 'uuid' + +/** + * Fixed UUID namespace for deriving deterministic personal workspace and + * admin-membership ids from a user id. Do not change — changing this constant + * orphans every existing personal workspace. + */ +const PERSONAL_WORKSPACE_NAMESPACE = 'e2c4f9e0-b3a1-4a5c-9e8f-1d3a5c7e9f1b' + +/** + * Derive the canonical personal workspace id for a user. + * + * The personal workspace is FE-created on first sign-in and uploaded via + * PowerSync. Multiple devices that sign in for the same account independently + * compute the same id and upload the same row, so concurrent first-sign-ins + * become idempotent upserts on the BE rather than a partial-unique-index race. + * + * Used as the canonical anchor in the BE upload handler — the personal + * workspace PUT is accepted only when `op.id === computePersonalWorkspaceId(ctx.userId)`. + */ +export const computePersonalWorkspaceId = (userId: string): string => + uuidv5(`personal:${userId}`, PERSONAL_WORKSPACE_NAMESPACE) + +/** + * Derive the canonical admin-membership id for a user's personal workspace. + * + * Same rationale as the workspace id — two devices uploading the bootstrap + * admin membership for the same user end up with the same row id, so the + * upload becomes an upsert no-op rather than two rows pointing at the same + * `(workspace_id, user_id)` natural key (which the unique constraint would + * reject for the second device). + */ +export const computePersonalAdminMembershipId = (userId: string): string => + uuidv5(`personal-admin:${userId}`, PERSONAL_WORKSPACE_NAMESPACE) + +/** + * Single source of truth for the keys that may appear in the + * `workspace_permissions.permission_key` column. Order matches the rendering + * order of the Permissions settings page (lifecycle → capabilities → admin). + * + * `manage_members` is a legacy key superseded by per-operation gates + * (`invite_users`/`change_roles`/`remove_users`). Kept in the union so older + * `workspace_permissions` rows still type-check; not surfaced on the + * Permissions page and not consulted by any route guard or upload handler. + * + * Add a new key here, then the FE/BE schemas, the FE/BE types, and the upload + * handler's runtime check all stay in sync via this constant. + */ +export const workspacePermissionKeys = [ + 'manage_members', + 'join_workspace', + 'invite_users', + 'change_roles', + 'remove_users', + 'add_agents', + 'remove_agents', + 'add_skills', + 'remove_skills', + 'add_models', + 'remove_models', + 'add_mcp_servers', + 'remove_mcp_servers', + 'change_general_settings', + 'change_permissions', + 'delete_workspace', +] as const + +export type WorkspacePermissionKey = (typeof workspacePermissionKeys)[number] + +/** The two role buckets used by both `workspace_memberships.role` and `workspace_permissions.required_role`. */ +export const workspacePermissionRoles = ['admin', 'member'] as const + +export type WorkspacePermissionRole = (typeof workspacePermissionRoles)[number] + +/** Runtime narrowing for upload-handler payloads — checks `v` against `workspacePermissionKeys`. */ +export const isWorkspacePermissionKey = (v: unknown): v is WorkspacePermissionKey => + typeof v === 'string' && (workspacePermissionKeys as readonly string[]).includes(v) + +/** Runtime narrowing for upload-handler payloads — checks `v` against `workspacePermissionRoles`. */ +export const isWorkspacePermissionRole = (v: unknown): v is WorkspacePermissionRole => + typeof v === 'string' && (workspacePermissionRoles as readonly string[]).includes(v) + +/** + * Whether `userRole` satisfies a `requiredRole` policy. Admins always satisfy; + * a `member` requirement is satisfied by both admins and members. + * + * Shared between FE (`useWorkspacePermission`) and BE (upload handler authz) + * so the same predicate gates UI affordances and write enforcement. + */ +export const permissionAllows = ( + userRole: WorkspacePermissionRole | null | undefined, + requiredRole: WorkspacePermissionRole, +): boolean => { + if (!userRole) { + return false + } + if (userRole === 'admin') { + return true + } + return requiredRole === 'member' +} diff --git a/src/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index dca112df4..03da12b70 100644 --- a/src/acp/transports/index.test.ts +++ b/src/acp/transports/index.test.ts @@ -23,7 +23,7 @@ import '@/testing-library' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { wsTargetPrefix } from '@shared/proxy-protocol' import { encodeWsBearer } from '@shared/ws-bearer' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' import { openTransport } from './index' import { type WebSocketEventMap } from './websocket' @@ -73,7 +73,11 @@ beforeEach(() => { // Cast through unknown — FakeBrowserSocket only implements the surface the // transport uses, not the full DOM `WebSocket` interface. globalThis.WebSocket = FakeBrowserSocket as unknown as typeof WebSocket - useLocalSettingsStore.setState({ cloudUrl: 'http://cloud.test/v1' }) + const fixtureServerId = '00000000-0000-0000-0000-0000000000ac' + useTrustDomainRegistry.setState({ + servers: { [fixtureServerId]: { serverId: fixtureServerId, cloudUrl: 'http://cloud.test/v1' } }, + activeTrustDomain: { kind: 'server', serverId: fixtureServerId }, + }) }) afterEach(() => { diff --git a/src/acp/transports/index.ts b/src/acp/transports/index.ts index 782c22b12..b05bc1325 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -32,7 +32,7 @@ import { getAuthToken } from '@/lib/auth-token' import type { HttpClient } from '@/lib/http' import { isTauri } from '@/lib/platform' import { computeEffectiveProxyEnabled, createProxyWebSocket } from '@/lib/proxy-fetch' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { getActiveCloudUrl } from '@/stores/trust-domain-registry' import type { AgentType } from '@shared/acp-types' import { encodeWsBearer, wsBearerSubprotocolPrefix, wsCarrierSubprotocol } from '@shared/ws-bearer' import type { AcpTransport } from '../types' @@ -62,7 +62,7 @@ export type OpenTransportInputs = { getAuthToken?: () => string | null } -const cloudWsUrl = (): string => useLocalSettingsStore.getState().cloudUrl +const cloudWsUrl = (): string => getActiveCloudUrl() ?? '' /** Decide if the transport should use the native (Standalone) path or the * cloud-proxy path. Mirrors `computeEffectiveProxyEnabled` exactly — when the diff --git a/src/ai/eval/debug-single.ts b/src/ai/eval/debug-single.ts index 8ed981b61..2f4fd3c8f 100644 --- a/src/ai/eval/debug-single.ts +++ b/src/ai/eval/debug-single.ts @@ -8,7 +8,7 @@ */ import { aiFetchStreamingResponse } from '@/ai/fetch' import { setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' -import { getLocalSetting } from '@/stores/local-settings-store' +import { seedTestTrustDomain } from '@/test-utils/powersync-reactivity-test' import { defaultModelOpus48 } from '@/defaults/models' import { defaultModeChat } from '@/defaults/modes' import { isSsoMode } from '@/lib/auth-mode' @@ -24,6 +24,7 @@ const run = async () => { console.log('[1/5] Setting up database...') await setupTestDatabase() + seedTestTrustDomain() console.log('[1/5] Database ready.\n') const modelId = defaultModelOpus48.id @@ -38,7 +39,9 @@ const run = async () => { console.log(`[2/5] Mode: ${defaultModeChat.name}`) console.log(`[2/5] Prompt: "${prompt}"\n`) - const cloudUrl = getLocalSetting('cloudUrl') + // CLI script — no boot, no trust-domain registry to read from. Source the URL from + // the env var directly with the same localhost fallback the boot resolver uses. + const cloudUrl = import.meta.env.VITE_THUNDERBOLT_CLOUD_URL || 'http://localhost:8000/v1' const httpClient = createAuthenticatedClient(cloudUrl, getAuthToken, { credentials: isSsoMode() ? 'include' : undefined, }) diff --git a/src/ai/eval/run.ts b/src/ai/eval/run.ts index c1258b872..5abe22b8d 100644 --- a/src/ai/eval/run.ts +++ b/src/ai/eval/run.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' +import { seedTestTrustDomain } from '@/test-utils/powersync-reactivity-test' import { generateReport } from './report' import { runPool } from './runner' import { getScenarios } from './scenarios' @@ -27,6 +28,9 @@ const main = async () => { // Set up a single shared database for all scenarios (read-only for evals) await setupTestDatabase() + // Seed the trust-domain registry so requireActiveWorkspaceId can resolve + // the personal workspace from the test DB (getActiveUserId() reads from here). + seedTestTrustDomain() // Suppress noisy console output from fetch.ts unless --verbose if (!verbose) { diff --git a/src/ai/eval/runner.ts b/src/ai/eval/runner.ts index 2ccec4bae..8948d72e7 100644 --- a/src/ai/eval/runner.ts +++ b/src/ai/eval/runner.ts @@ -7,8 +7,8 @@ import { createPrompt } from '@/ai/prompt' import { getSettings } from '@/dal' import { getModel } from '@/dal/models' import { getModelProfile } from '@/dal/model-profiles' +import { wsId as evalWorkspaceId } from '@/dal/test-utils' import { getDb } from '@/db/database' -import { getLocalSetting } from '@/stores/local-settings-store' import { isSsoMode } from '@/lib/auth-mode' import { getAuthToken } from '@/lib/auth-token' import { createAuthenticatedClient } from '@/lib/http' @@ -21,12 +21,15 @@ import type { EvalResult, EvalScenario } from './types' const timeout = parseInt(process.env.EVAL_timeout ?? '120000') +// CLI eval — no boot, no trust-domain registry to read from. The env var fallback +// matches the boot resolver's default so dev and eval point at the same backend. +const evalCloudUrl = import.meta.env.VITE_THUNDERBOLT_CLOUD_URL || 'http://localhost:8000/v1' + let _evalHttpClientPromise: Promise | null = null const getEvalHttpClient = () => { if (!_evalHttpClientPromise) { _evalHttpClientPromise = (async () => { - const cloudUrl = getLocalSetting('cloudUrl') - return createAuthenticatedClient(cloudUrl, getAuthToken, { + return createAuthenticatedClient(evalCloudUrl, getAuthToken, { credentials: isSsoMode() ? 'include' : undefined, }) })() @@ -47,7 +50,10 @@ const logVerbosePrompt = async (scenario: EvalScenario, modeSystemPrompt: string const db = getDb() const modelId = getModelId(scenario.modelName) - const [model, profile] = await Promise.all([getModel(db, modelId), getModelProfile(db, modelId)]) + const [model, profile] = await Promise.all([ + getModel(db, evalWorkspaceId, modelId), + getModelProfile(db, evalWorkspaceId, modelId), + ]) const settings = await getSettings(db, { preferred_name: '', location_name: '', @@ -131,9 +137,8 @@ export const runScenario = async (scenario: EvalScenario): Promise = const httpClient = await getEvalHttpClient() // Eval runs in Node, not a browser — no React tree, no `ProxyFetchProvider`. - // Build the proxy fetch directly from the same cloudUrl the HTTP client uses. - const cloudUrl = getLocalSetting('cloudUrl') - const proxyFetch = createProxyFetch({ cloudUrl }) + // Build the proxy fetch from the same env cloudUrl the HTTP client uses. + const proxyFetch = createProxyFetch({ cloudUrl: evalCloudUrl }) // Call the actual AI pipeline with a timeout const response = await Promise.race([ diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index e709b2fc3..161f2dc9d 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -13,11 +13,12 @@ import { shouldRetry, } from '@/ai/step-logic' import { getAllSkills, getIntegrationStatus, getModel, getModelProfile, getSettings } from '@/dal' +import { requireActiveWorkspaceId } from '@/lib/active-workspace' import { getMessage } from '@/dal/chat-messages' import { extractLastUserText, resolveSkillTokenInstructions } from '@/skills/resolve-skill-system-messages' import { collectAskEntriesFromCache, formatAskResponsesNote } from '@/widgets/ask/lib' import { getDb } from '@/db/database' -import { getLocalSetting } from '@/stores/local-settings-store' +import { getActiveCloudUrl } from '@/stores/trust-domain-registry' import { isSsoMode } from '@/lib/auth-mode' import { getAuthToken } from '@/lib/auth-token' import { fetch as baseFetch } from '@/lib/fetch' @@ -93,7 +94,11 @@ let userTinfoilClient: SecureClient | null = null export const getSystemTinfoilClient = async (): Promise => { // cloudUrl already ends in /v1 (shared with the OpenAI chat baseURL). - const cloudUrl = getLocalSetting('cloudUrl').replace(/\/$/, '') + const activeCloudUrl = getActiveCloudUrl() + if (!activeCloudUrl) { + throw new Error('Cannot use the system Tinfoil client without an active server trust domain') + } + const cloudUrl = activeCloudUrl.replace(/\/$/, '') let client = systemTinfoilClients.get(cloudUrl) if (!client) { const { SecureClient } = await import('tinfoil') @@ -258,7 +263,10 @@ export const createModel = async (modelConfig: Model, getProxyFetch: () => Fetch // (e.g. cloudUrl, proxy_enabled toggle) is picked up. switch (modelConfig.provider) { case 'thunderbolt': { - const cloudUrl = getLocalSetting('cloudUrl') + const cloudUrl = getActiveCloudUrl() + if (!cloudUrl) { + throw new Error('Cannot use the thunderbolt provider without an active server trust domain') + } const token = getAuthToken() || 'thunderbolt' // SSO web flow authenticates via session cookies — the SSO callback is a // browser redirect, not an XHR, so `set-auth-token` never reaches the @@ -433,6 +441,7 @@ export const aiFetchStreamingResponse = async ({ // reach this function the user turn is already persisted. const db = getDb() + const workspaceId = await requireActiveWorkspaceId(db) // Fetch all settings in a single query (returns camelCase by default) const settings = await getSettings(db, { @@ -450,13 +459,13 @@ export const aiFetchStreamingResponse = async ({ const integrationStatus = await getIntegrationStatus(db) - const model = await getModel(db, modelId) + const model = await getModel(db, workspaceId, modelId) if (!model) { throw new Error('Model not found') } - const profile = await getModelProfile(db, modelId) + const profile = await getModelProfile(db, workspaceId, modelId) const supportsTools = model.toolUsage !== 0 @@ -646,7 +655,7 @@ export const aiFetchStreamingResponse = async ({ // the context-overflow estimate so the budget and the actual prepend // stay in lockstep. const lastUserText = extractLastUserText(messages) - const allSkills = await getAllSkills(db) + const allSkills = await getAllSkills(db, workspaceId) const instructionBySlug = new Map() for (const skill of allSkills) { if (skill.enabled === 1 && skill.name && skill.instruction) { diff --git a/src/ai/prompt.test.ts b/src/ai/prompt.test.ts index 54cb999a5..8fb9fcbf7 100644 --- a/src/ai/prompt.test.ts +++ b/src/ai/prompt.test.ts @@ -30,6 +30,7 @@ const createStubProfile = (overrides: Partial = {}): ModelProfile defaultHash: null, deletedAt: null, userId: null, + workspaceId: null, ...overrides, }) diff --git a/src/ai/step-logic.test.ts b/src/ai/step-logic.test.ts index c53c3add5..108174010 100644 --- a/src/ai/step-logic.test.ts +++ b/src/ai/step-logic.test.ts @@ -40,6 +40,7 @@ const createStubProfile = (overrides: Partial = {}): ModelProfile defaultHash: null, deletedAt: null, userId: null, + workspaceId: null, ...overrides, }) diff --git a/src/api/config-store.ts b/src/api/config-store.ts index 7597391e4..2cfc9af06 100644 --- a/src/api/config-store.ts +++ b/src/api/config-store.ts @@ -6,7 +6,21 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' export type AppConfig = { + /** Stable per-deployment UUID. Returned by `GET /v1/config`; required in + * server trust domains (the FE uses it to namespace auth token / device ID / + * encryption keys / DB filename). Optional on this type because an offline + * boot may have no cached config yet — boot code MUST treat its absence as + * "no server reachable", never as "any server is fine". */ + serverId?: string e2eeEnabled?: boolean + /** Server-side anonymous-sessions flag. When false, the BE rejects + * `/sign-in/anonymous`. Mirrors the BE `AUTH_ALLOW_ANONYMOUS`. */ + allowAnonUsers?: boolean + /** Workspace creation policy. Enforced by the BE upload-handler factory; UI + * hides the "Create workspace" affordance when both gates evaluate to false + * for the active user. */ + allowWorkspaceCreationByAnon?: boolean + allowWorkspaceCreationByMembers?: boolean /** Deployment-level UI capability flags from `GET /config`. Optional so an * empty/offline config (standalone mode) reads as "default behavior": * built-in agent shown, custom agents allowed. */ diff --git a/src/api/encryption.test.ts b/src/api/encryption.test.ts index 14dc98d8a..67331e609 100644 --- a/src/api/encryption.test.ts +++ b/src/api/encryption.test.ts @@ -3,13 +3,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' +import { testServerId } from '@/testing-library' import { type HttpClient } from '@/contexts' import { getAuthToken } from '@/lib/auth-token' import { createAuthenticatedClient } from '@/lib/http' import { registerDevice, storeEnvelope, fetchMyEnvelope, fetchCanary } from './encryption' -const deviceIdKey = 'thunderbolt_device_id' -const authTokenKey = 'thunderbolt_auth_token' +const deviceIdKey = `thunderbolt_device_id__${testServerId}` +const authTokenKey = `thunderbolt_auth_token__${testServerId}` type CapturedRequest = { url: string; method: string; body: Record | null; headers: Headers } diff --git a/src/app.tsx b/src/app.tsx index f082cd2c5..d9303eaca 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -37,11 +37,17 @@ import { useViewportLock } from '@/hooks/use-viewport-lock' import { useMcpSync } from '@/hooks/use-mcp-sync' import { PostHogProvider } from '@/lib/posthog' import { ThemeProvider } from '@/lib/theme-provider' +import { AppErrorBoundary } from './components/app-error-boundary' import { AppErrorScreen } from './components/app-error-screen' +import { ModePicker } from './components/boot/mode-picker' import { UpgradeRequired } from './components/upgrade-required' import { useConfigStore } from '@/api/config-store' import { compareSemver } from '@/lib/compare-semver' import { AuthGate } from './components/auth-gate' +import { WorkspaceGate } from './components/workspace-gate' +import { WorkspaceMembershipGate } from './components/workspace-membership-gate' +import { WorkspaceSettingsGate } from './settings/workspace/gate' +import { RequireWorkspaceAdmin } from './settings/workspace/require-permission' import { OnboardingDialog } from './components/onboarding/onboarding-dialog' import { WelcomeDialog } from './components/welcome-dialog' import { PendingDeviceModal } from './components/pending-device-modal' @@ -62,7 +68,7 @@ import { isSsoMode, isWaitlistBypassed } from './lib/auth-mode' import { isTauri } from './lib/platform' import { getPowerSyncInstance } from './db/powersync/sync-state' import { refreshSystemAgents } from '@/db/seeding/seed-agents' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useActiveCloudUrl } from '@/stores/trust-domain-registry' import { type ComponentProps, Suspense, lazy, useEffect, useState } from 'react' import { markAppMounted } from '@/lib/init-timing' import { LazyMotion } from 'framer-motion' @@ -83,6 +89,9 @@ const McpServersPage = lazy(() => import('@/settings/mcp-servers')) const SkillsPage = lazy(() => import('@/settings/skills')) const AgentsSettingsPage = lazy(() => import('@/routes/settings/agents')) const IntegrationsPage = lazy(() => import('@/settings/integrations')) +const WorkspaceGeneralPage = lazy(() => import('@/settings/workspace/general')) +const WorkspaceMembersPage = lazy(() => import('@/settings/workspace/members')) +const WorkspacePermissionsPage = lazy(() => import('@/settings/workspace/permissions')) // Lazily import SSO components so non-enterprise deployments don't pay // for the extra bundle size and attack surface. @@ -95,6 +104,46 @@ const MessageSimulatorPage = import.meta.env.DEV ? lazy(() => import('./devtools const queryClient = new QueryClient() +/** + * Shared route sub-tree mounted under both the personal workspace (unprefixed) + * and `/w/:workspaceId` (shared, membership-gated). Paths are relative so each + * mount resolves them against its parent — `chats/new` becomes either + * `/chats/new` or `/w//chats/new`. + */ +const renderWorkspaceRoutes = ({ experimentalFeatureTasks }: { experimentalFeatureTasks: boolean }) => ( + <> + {/* Home routes with HomeLayout */} + }> + } /> + } /> + {experimentalFeatureTasks && } />} + {import.meta.env.DEV && } />} + + + {/* Settings routes with SettingsLayout */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + + } /> + }> + } /> + + + {import.meta.env.DEV && } />} + + +) + /** * Hydrate the local-only `agents_system` table from the backend's `/agents` * discovery endpoint when the user has a real (non-anonymous) session. @@ -110,7 +159,7 @@ const useBootstrapSystemAgents = () => { const httpClient = useHttpClient() const authClient = useAuth() const { data: session } = authClient.useSession() - const cloudUrl = useLocalSettingsStore((s) => s.cloudUrl) + const cloudUrl = useActiveCloudUrl() const isRealUser = !!session?.user && session.user.isAnonymous !== true @@ -130,11 +179,13 @@ const AppContent = ({ initData }: { initData: InitData }) => { useSafeAreaInset() return ( - - - - - + + + + + + + ) } @@ -169,37 +220,45 @@ const AppRoutes = ({ initData }: { initData: InitData }) => { )} {/* Main app routes - authenticated only. The gate decides redirect - targets internally from VITE_AUTH_MODE + VITE_AUTH_ENABLE_ANONYMOUS. */} + targets internally from VITE_AUTH_MODE + VITE_AUTH_ENABLE_ANONYMOUS. + `WorkspaceGate` then holds the routes until `runPostAuthBootstrap` + has resolved the active workspace — keeps DAL inserts from firing + with a null workspace id between authentication and sync landing. + + The shared sub-tree (chat / settings / dev surfaces) is mounted + twice: unprefixed for the personal workspace (canonical) and under + `/w/:workspaceId/` for shared workspaces (membership-gated). Index + and child paths are relative so the same JSX resolves to either + `/chats/new` or `/w/:workspaceId/chats/new` based on its parent. */} }> - - - - - - } - > - {/* Home routes with HomeLayout */} - }> - } /> - } /> - {experimentalFeatureTasks.value && } />} - {import.meta.env.DEV && } />} + }> + {/* Personal workspace — unprefixed canonical URLs. */} + + + + + + } + > + {renderWorkspaceRoutes({ experimentalFeatureTasks: experimentalFeatureTasks.value })} - {/* Settings routes with SettingsLayout */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {import.meta.env.DEV && } />} + {/* Shared workspaces — `/w/:workspaceId/...`, membership-gated. */} + }> + + + + + + } + > + {renderWorkspaceRoutes({ experimentalFeatureTasks: experimentalFeatureTasks.value })} + @@ -240,6 +299,10 @@ export const App = () => { if (upgradeRequired) { return } + + if (initError?.code === 'NO_TRUST_DOMAIN') { + return + } if (initError) { if (initError.code === 'STORAGE_UNAVAILABLE') { return diff --git a/src/automations/use-trigger-scheduler.ts b/src/automations/use-trigger-scheduler.ts index 3cf93e36a..2c120af04 100644 --- a/src/automations/use-trigger-scheduler.ts +++ b/src/automations/use-trigger-scheduler.ts @@ -5,9 +5,11 @@ import { getDb } from '@/db/database' import { useSettings } from '@/hooks/use-settings' import { getAllEnabledTriggers, runAutomation } from '@/dal' +import { useActiveWorkspaceId } from '@/lib/active-workspace' import { useEffect, useRef } from 'react' export const useTriggerScheduler = () => { + const workspaceId = useActiveWorkspaceId() const { isTriggersEnabled } = useSettings({ is_triggers_enabled: false, }) @@ -15,7 +17,7 @@ export const useTriggerScheduler = () => { useEffect(() => { const plan = async () => { - if (!isTriggersEnabled.value) { + if (!isTriggersEnabled.value || !workspaceId) { return } @@ -23,7 +25,7 @@ export const useTriggerScheduler = () => { timers.current = [] const db = getDb() - const triggers = await getAllEnabledTriggers(db) + const triggers = await getAllEnabledTriggers(db, workspaceId) triggers.forEach((t) => { if (t.triggerTime) { @@ -35,13 +37,16 @@ export const useTriggerScheduler = () => { } const delay = next.getTime() - Date.now() timers.current.push( - setTimeout(() => runAutomation(getDb(), t.promptId).catch(console.error), delay) as unknown as number, + setTimeout( + () => runAutomation(getDb(), workspaceId, t.promptId).catch(console.error), + delay, + ) as unknown as number, ) } }) } - if (!isTriggersEnabled.value) { + if (!isTriggersEnabled.value || !workspaceId) { return } @@ -53,5 +58,5 @@ export const useTriggerScheduler = () => { clearInterval(id) timers.current.forEach(clearTimeout) } - }, [isTriggersEnabled.value]) + }, [isTriggersEnabled.value, workspaceId]) } diff --git a/src/chats/agent-routing.test.ts b/src/chats/agent-routing.test.ts index 60f8137b6..8afd7d09b 100644 --- a/src/chats/agent-routing.test.ts +++ b/src/chats/agent-routing.test.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { wsId } from '@/dal/test-utils' + /** * `createAgentRoutingFetch` dispatch tests. Verifies the `customFetch` the AI * SDK consumes correctly routes each `chat.sendMessage(...)` to the cached @@ -95,6 +97,7 @@ const hydrateSessionWith = (id: string, agent: Agent, chatThread: ChatThread | n connectionStatus: 'idle', connectionError: null, id, + workspaceId: wsId, pendingPermission: null, retryCount: 0, retriesExhausted: false, @@ -119,7 +122,7 @@ describe('createAgentRoutingFetch', () => { const connectToAgent = mock(async (_agent: Agent) => adapter) hydrateSessionWith('t-built-in', builtInAgent) - const customFetch = createAgentRoutingFetch('t-built-in', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-built-in', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -138,7 +141,7 @@ describe('createAgentRoutingFetch', () => { const connectToAgent = mock(async (_agent: Agent) => adapter) hydrateSessionWith('t-remote', remoteAgent) - const customFetch = createAgentRoutingFetch('t-remote', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-remote', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -155,7 +158,7 @@ describe('createAgentRoutingFetch', () => { const connectToAgent = mock(async (_agent: Agent) => adapter) hydrateSessionWith('t-cache', remoteAgent) - const customFetch = createAgentRoutingFetch('t-cache', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-cache', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -176,7 +179,7 @@ describe('createAgentRoutingFetch', () => { hydrateSessionWith('t-switch', remoteAgent) - const customFetch = createAgentRoutingFetch('t-switch', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-switch', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -225,7 +228,7 @@ describe('createAgentRoutingFetch', () => { hydrateSessionWith('thread-77', remoteAgent, chatThread) - const customFetch = createAgentRoutingFetch('thread-77', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('thread-77', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: updateChatThread as never, getDb: (() => fakeDb) as never, @@ -239,10 +242,11 @@ describe('createAgentRoutingFetch', () => { await capturedOnAcpSessionId!('acp-sess-xyz') expect(updateChatThread).toHaveBeenCalledTimes(1) - const call = updateChatThread.mock.calls[0] as unknown as [unknown, string, { acpSessionId: string }] + const call = updateChatThread.mock.calls[0] as unknown as [unknown, string, string, { acpSessionId: string }] expect(call[0]).toBe(fakeDb) - expect(call[1]).toBe('thread-77') - expect(call[2]).toMatchObject({ acpSessionId: 'acp-sess-xyz' }) + expect(call[1]).toBe(wsId) + expect(call[2]).toBe('thread-77') + expect(call[3]).toMatchObject({ acpSessionId: 'acp-sess-xyz' }) }) it('saves the user message before invoking the adapter (built-in agent)', async () => { @@ -271,7 +275,7 @@ describe('createAgentRoutingFetch', () => { parts: [{ type: 'text', text: 'Hello world' }], } - const customFetch = createAgentRoutingFetch('t-save-builtin', saveMessagesSpy, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-save-builtin', wsId, saveMessagesSpy, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -298,7 +302,7 @@ describe('createAgentRoutingFetch', () => { parts: [{ type: 'text', text: 'ACP question' }], } - const customFetch = createAgentRoutingFetch('t-save-remote', saveMessagesSpy, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-save-remote', wsId, saveMessagesSpy, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, }) @@ -336,7 +340,7 @@ describe('createAgentRoutingFetch', () => { hydrateSessionWith('t-no-thread', remoteAgent, null) - const customFetch = createAgentRoutingFetch('t-no-thread', saveMessages, httpClient, getProxyFetch, { + const customFetch = createAgentRoutingFetch('t-no-thread', wsId, saveMessages, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: updateChatThread as never, getDb: (() => fakeDb) as never, diff --git a/src/chats/chat-instance-permission.test.ts b/src/chats/chat-instance-permission.test.ts index 130e845f0..a54f59e24 100644 --- a/src/chats/chat-instance-permission.test.ts +++ b/src/chats/chat-instance-permission.test.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { wsId } from '@/dal/test-utils' + /** * Tests that the routing fetch's permission bridge stashes pending requests * on the store and that `resolvePendingPermission` completes the adapter's @@ -62,7 +64,7 @@ describe('requestPermission bridge', () => { const connectToAgent = mock(async () => adapter) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, diff --git a/src/chats/chat-instance.test.ts b/src/chats/chat-instance.test.ts index 473b95652..06756c556 100644 --- a/src/chats/chat-instance.test.ts +++ b/src/chats/chat-instance.test.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { wsId } from '@/dal/test-utils' + /** * `createAgentRoutingFetch` connection-status tests. Verifies the customFetch * factory writes `connectionStatus` transitions into the chat-store around @@ -58,7 +60,7 @@ describe('createAgentRoutingFetch — connection status', () => { return makeAdapter(agent) }) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, @@ -76,7 +78,7 @@ describe('createAgentRoutingFetch — connection status', () => { throw new Error('boom') }) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, @@ -92,7 +94,7 @@ describe('createAgentRoutingFetch — connection status', () => { it('only re-connects when the agent identity changes (cache hit stays ready)', async () => { const connectToAgent = mock(async (agent: Agent) => makeAdapter(agent)) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, @@ -109,7 +111,7 @@ describe('createAgentRoutingFetch — connection status', () => { const altAgent: Agent = { ...builtInAgent, id: 'alt' } const connectToAgent = mock(async (agent: Agent) => makeAdapter(agent)) - const fetch = createAgentRoutingFetch(sessionId, async () => {}, httpClient, getProxyFetch, { + const fetch = createAgentRoutingFetch(sessionId, wsId, async () => {}, httpClient, getProxyFetch, { connectToAgent: connectToAgent as never, updateChatThread: (async () => {}) as never, getDb: (() => ({})) as never, diff --git a/src/chats/chat-instance.ts b/src/chats/chat-instance.ts index b2ffd514f..bd9e6ccee 100644 --- a/src/chats/chat-instance.ts +++ b/src/chats/chat-instance.ts @@ -104,6 +104,7 @@ export type CreateChatInstanceDeps = { */ export const createAgentRoutingFetch = ( id: string, + workspaceId: string, saveMessages: SaveMessagesFunction, httpClient: HttpClient, getProxyFetch: () => FetchFn, @@ -174,7 +175,7 @@ export const createAgentRoutingFetch = ( if (!chatThread) { return } - await updateChatThread(getDb(), chatThread.id, { acpSessionId: newSessionId }) + await updateChatThread(getDb(), workspaceId, chatThread.id, { acpSessionId: newSessionId }) } // Surface `connecting` only when routing to a different agent than this @@ -230,13 +231,14 @@ export const createAgentRoutingFetch = ( export const createChatInstance = ( id: string, + workspaceId: string, messages: ThunderboltUIMessage[], saveMessages: SaveMessagesFunction, httpClient: HttpClient, getProxyFetch: () => FetchFn, deps: CreateChatInstanceDeps = {}, ) => { - const customFetch = createAgentRoutingFetch(id, saveMessages, httpClient, getProxyFetch, deps) + const customFetch = createAgentRoutingFetch(id, workspaceId, saveMessages, httpClient, getProxyFetch, deps) let retryCount = 0 let retryTimeout: ReturnType | null = null diff --git a/src/chats/chat-store.test.ts b/src/chats/chat-store.test.ts index 399d9e9aa..8a4ef67ad 100644 --- a/src/chats/chat-store.test.ts +++ b/src/chats/chat-store.test.ts @@ -4,7 +4,7 @@ import { getSettings } from '@/dal' import { createChatThread, getChatThread } from '@/dal/chat-threads' -import { setupTestDatabase, teardownTestDatabase, resetTestDatabase } from '@/dal/test-utils' +import { setupTestDatabase, teardownTestDatabase, resetTestDatabase, wsId } from '@/dal/test-utils' import { getDb } from '@/db/database' import { builtInAgent } from '@/defaults/agents' import type { Mode } from '@/types' @@ -119,6 +119,7 @@ describe('chat-store', () => { connectionStatus: 'idle' as const, connectionError: null, id: 'test-id', + workspaceId: wsId, pendingPermission: null, selectedAgent: builtInAgent, selectedMode: null as unknown as Mode, @@ -367,6 +368,7 @@ describe('chat-store', () => { await createChatThread( getDb(), + wsId, { id: chatThread.id, title: 'x', contextSize: null, triggeredBy: null, wasTriggeredByAutomation: 0 }, model, ) @@ -383,7 +385,7 @@ describe('chat-store', () => { await useChatStore.getState().setSelectedAgent(chatThread.id, customAgent) - const stored = await getChatThread(getDb(), chatThread.id) + const stored = await getChatThread(getDb(), wsId, chatThread.id) expect(stored?.agentId).toBe(customAgent.id) }) @@ -410,7 +412,7 @@ describe('chat-store', () => { expect(session?.selectedAgent.id).toBe(customAgent.id) // Verify no row was created behind the scenes. - const stored = await getChatThread(getDb(), 'thread-no-row') + const stored = await getChatThread(getDb(), wsId, 'thread-no-row') expect(stored).toBeNull() }) diff --git a/src/chats/chat-store.ts b/src/chats/chat-store.ts index 6e2a84c9a..4db68548a 100644 --- a/src/chats/chat-store.ts +++ b/src/chats/chat-store.ts @@ -34,6 +34,7 @@ export type ChatSession = { connectionStatus: ConnectionStatus connectionError: Error | null id: string + workspaceId: string pendingPermission: PendingPermission | null retryCount: number retriesExhausted: boolean @@ -170,8 +171,12 @@ export const useChatStore = create()((set, get) => ({ const db = getDb() + // `session.workspaceId` is the source of truth — the in-memory thread row + // may have been hydrated before the workspace_id column was added or from + // a partial row, but the session carries the workspaceId as a required + // field. The thread is the gate (need its id to PATCH). if (session.chatThread) { - await updateChatThread(db, session.chatThread.id, { agentId: agent.id }) + await updateChatThread(db, session.workspaceId, session.chatThread.id, { agentId: agent.id }) } // Persist the global last-used agent so new chats default to it (mirrors diff --git a/src/chats/detail.tsx b/src/chats/detail.tsx index f65b46b82..8b68146f7 100644 --- a/src/chats/detail.tsx +++ b/src/chats/detail.tsx @@ -16,14 +16,13 @@ type ChatHydrateHandlerProps = PropsWithChildren<{ }> const ChatHydrateHandler = ({ children, id, isNew }: ChatHydrateHandlerProps) => { - const { hydrateChatStore, isReady, saveMessages } = useHydrateChatStore({ id, isNew }) + const { hydrateChatStore, isReady, saveMessages, workspaceId } = useHydrateChatStore({ id, isNew }) - useHandleIntegrationCompletion({ saveMessages }) + useHandleIntegrationCompletion({ saveMessages, workspaceId }) useEffect(() => { - hydrateChatStore() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]) + void hydrateChatStore() + }, [id, workspaceId]) // eslint-disable-line react-hooks/exhaustive-deps if (!isReady) { return null diff --git a/src/chats/use-hydrate-chat-store.test.tsx b/src/chats/use-hydrate-chat-store.test.tsx index 69b711f72..7c63ce831 100644 --- a/src/chats/use-hydrate-chat-store.test.tsx +++ b/src/chats/use-hydrate-chat-store.test.tsx @@ -2,8 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { setupTestDatabase, teardownTestDatabase, resetTestDatabase } from '@/dal/test-utils' +import { setupTestDatabase, teardownTestDatabase, resetTestDatabase, wsId } from '@/dal/test-utils' import { getCurrentSession, resetStore } from '@/test-utils/chat-store-mocks' +import { resetTestTrustDomain, seedTestTrustDomain } from '@/test-utils/powersync-reactivity-test' import { createQueryTestWrapper } from '@/test-utils/react-query' import { act, cleanup, renderHook } from '@testing-library/react' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' @@ -38,6 +39,7 @@ const createDefaultMode = async () => { order: 0, deletedAt: null, defaultHash: null, + workspaceId: wsId, }) return 'mode-chat' @@ -64,6 +66,7 @@ const createSystemModel = async () => { deletedAt: null, url: null, defaultHash: null, + workspaceId: wsId, }) return modelId @@ -90,6 +93,7 @@ const createTestModel = async () => { deletedAt: null, url: null, defaultHash: null, + workspaceId: wsId, }) return modelId @@ -99,13 +103,14 @@ const createTestModel = async () => { * Helper function to create a test thread */ const createTestThread = async (modelId: string, title: string = 'Test Thread') => { - const model = await getModel(getDb(), modelId) + const model = await getModel(getDb(), wsId, modelId) if (!model) { throw new Error('Test setup failed') } const threadId = uuidv7() await createChatThread( getDb(), + wsId, { id: threadId, title, @@ -150,6 +155,10 @@ describe('useHydrateChatStore', () => { }) beforeEach(async () => { + // Seed the trust-domain registry with a standalone user so `useActiveWorkspaceId` + // resolves to `wsId` (the personal workspace seeded by setupTestDatabase). The + // hook early-returns null without this, so hydrateChatStore would no-op. + seedTestTrustDomain() // Reset store state before each test resetStore() await resetTestDatabase() @@ -163,6 +172,7 @@ describe('useHydrateChatStore', () => { cleanup() // Reset store state after each test resetStore() + resetTestTrustDomain() await resetTestDatabase() }) @@ -227,6 +237,29 @@ describe('useHydrateChatStore', () => { expect(session?.triggerData).toBeDefined() }) + it('handles concurrent hydration calls for the same id without throwing', async () => { + // Regression: when `[id, workspaceId]` flips twice in quick succession + // (e.g. landing on `/w//chats/new` right after workspace creation), + // two `hydrateChatStore()` calls race past the early dedup and both reach + // `createSession`. The store's `createSession` throws on duplicate id, so + // the second invocation used to crash with "Session already exists". + const systemModelId = await createSystemModel() + const threadId = await createTestThread(systemModelId) + + const { result } = renderHook(() => useHydrateChatStore({ id: threadId, isNew: false }), { + wrapper: TestWrapper, + }) + + // Kick off two concurrent calls; both must resolve without throwing. + await act(async () => { + await Promise.all([result.current.hydrateChatStore(), result.current.hydrateChatStore()]) + }) + + const storeState = useChatStore.getState() + expect(storeState.sessions.has(threadId)).toBe(true) + expect(storeState.currentSessionId).toBe(threadId) + }) + it('should reset store before hydrating', async () => { const systemModelId = await createSystemModel() const threadId1 = await createTestThread(systemModelId, 'Thread 1') @@ -269,7 +302,7 @@ describe('useHydrateChatStore', () => { createTestMessage({ role: 'assistant', parts: [{ type: 'text', text: 'Hi there' }] }), ] - await saveMessagesWithContextUpdate(getDb(), threadId, messages) + await saveMessagesWithContextUpdate(getDb(), wsId, threadId, messages) const { result } = renderHook(() => useHydrateChatStore({ id: threadId, isNew: false }), { wrapper: TestWrapper, @@ -307,7 +340,7 @@ describe('useHydrateChatStore', () => { // A new chat has no `chat_threads` row, so the agent resolves from the // global `selected_agent` setting (the user's last pick). It must win over // `allAgents[0]`, which is always the built-in. - await createAgent(getDb(), { + await createAgent(getDb(), wsId, { id: 'custom-last-used', name: 'Last Used Agent', type: 'remote-acp', @@ -429,7 +462,7 @@ describe('useHydrateChatStore', () => { }) // The newly-created thread row should carry the agent the user picked. - const stored = await getThread(getDb(), threadId) + const stored = await getThread(getDb(), wsId, threadId) expect(stored?.agentId).toBe('haystack-rag') }) diff --git a/src/chats/use-hydrate-chat-store.ts b/src/chats/use-hydrate-chat-store.ts index 7f9775481..bab63c08e 100644 --- a/src/chats/use-hydrate-chat-store.ts +++ b/src/chats/use-hydrate-chat-store.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useDatabase, useHttpClient } from '@/contexts' +import { getActiveWorkspaceId, useActiveWorkspaceId } from '@/lib/active-workspace' import { useProxyFetchGetter } from '@/lib/proxy-fetch-context' import { composeAllAgents, @@ -30,6 +31,7 @@ import { convertDbChatMessageToUIMessage } from '@/lib/utils' import type { SaveMessagesFunction, ThunderboltUIMessage } from '@/types' import { useState } from 'react' import { useNavigate } from 'react-router' +import { useWorkspaceNavigate } from '@/lib/active-workspace' import { useChatStore } from './chat-store' import { createChatInstance } from './chat-instance' @@ -51,15 +53,17 @@ const trackChatReadyOnce = () => { export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => { const db = useDatabase() + const workspaceId = useActiveWorkspaceId() const httpClient = useHttpClient() const getProxyFetch = useProxyFetchGetter() const navigate = useNavigate() + const workspaceNavigate = useWorkspaceNavigate() const [isReady, setIsReady] = useState(false) const { getEnabledClients, reconnectClient } = useMCP() - const updateThreadTitle = async (messages: ThunderboltUIMessage[], threadId: string) => { + const updateThreadTitle = async (messages: ThunderboltUIMessage[], threadId: string, workspaceId: string) => { const firstUserMessage = messages.find((msg) => msg.role === 'user') if (!firstUserMessage) { return @@ -75,10 +79,19 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => } const title = await generateTitle(textContent) - await updateChatThread(db, threadId, { title }) + await updateChatThread(db, workspaceId, threadId, { title }) } const saveMessages: SaveMessagesFunction = async ({ id, messages }) => { + // Resolve workspaceId at call time rather than relying on the hook's + // closure-captured value, which may be stale on the first render before + // `useActiveWorkspaceId`'s React Query has resolved. The async getter + // reads the trust-domain registry + DB synchronously w.r.t. React's + // render lifecycle, so it's deterministic in tests. + const workspaceId = await getActiveWorkspaceId(db) + if (!workspaceId) { + throw new Error('No active workspace') + } const { sessions, updateSession } = useChatStore.getState() const session = sessions.get(id) @@ -91,50 +104,74 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => // Pass `selectedAgent.id` so a brand-new thread is created with the user's // currently-selected agent — otherwise the row would default to `null` // and a reload would silently fall back to the built-in agent. - const thread = await getOrCreateChatThread(db, id, session.selectedModel.id, session.selectedAgent.id) + const thread = await getOrCreateChatThread(db, workspaceId, id, session.selectedModel.id, session.selectedAgent.id) // Save messages and update context size using DAL - await saveMessagesWithContextUpdate(db, id, messages) + await saveMessagesWithContextUpdate(db, workspaceId, id, messages) // Generate title in background if needed if (thread?.title === 'New Chat') { - updateThreadTitle(messages, id) + updateThreadTitle(messages, id, workspaceId) } if (!session.chatThread) { updateSession(id, { chatThread: thread }) - navigate(`/chats/${id}`, { relative: 'path' }) + workspaceNavigate(`/chats/${id}`) } } const hydrateChatStore = async () => { + // Resolve workspaceId at call time rather than relying on the hook's + // closure-captured value. `useActiveWorkspaceId` may briefly return null + // on the first render (race between WorkspaceGate and React Query's + // resolution). The async getter reads the trust-domain registry + DB + // directly, so it's deterministic regardless of React render timing — + // which also means tests don't need to flush React Query before calling. + const workspaceId = await getActiveWorkspaceId(db) + if (!workspaceId) { + return + } const { createSession, sessions, setCurrentSessionId, setGetMcpClients, setReconnectClient, setModes, setModels } = useChatStore.getState() // Check if this ID belongs to a deleted chat - redirect to 404 if so - const isDeleted = await isChatThreadDeleted(db, id) + const isDeleted = await isChatThreadDeleted(db, workspaceId, id) if (isDeleted) { navigate('/not-found', { replace: true }) return } - // If the session already exists, set the current session id and update the mcp clients and models - if (sessions.has(id)) { - setCurrentSessionId(id) - - const [modes, models] = await Promise.all([getAllModes(db), getAvailableModels(db)]) - - // Store the provider's getter (not a snapshot) so each send reads the - // current connected clients, including any swapped in by a reconnect. - setGetMcpClients(getEnabledClients) - setReconnectClient(reconnectClient) - setModes(modes) - setModels(models) - - setIsReady(true) - trackChatReadyOnce() - - return + // If the session already exists, reuse it — unless it was built for a different + // workspace (workspace switch). In that case, evict it so the full create path + // rebuilds the closures with the new workspaceId. + const existingSession = sessions.get(id) + if (existingSession) { + if (existingSession.workspaceId !== workspaceId) { + // Drop `isReady` before we evict so consumers don't render against a + // session we've just removed during the async rebuild below — they'd + // see `isReady=true` with no matching session entry and throw + // missing-session errors. + setIsReady(false) + const nextSessions = new Map(sessions) + nextSessions.delete(id) + useChatStore.setState({ sessions: nextSessions }) + // fall through to full create path + } else { + setCurrentSessionId(id) + + const [modes, models] = await Promise.all([getAllModes(db, workspaceId), getAvailableModels(db, workspaceId)]) + + // Store the provider's getter (not a snapshot) so each send reads the + // current connected clients, including any swapped in by a reconnect. + setGetMcpClients(getEnabledClients) + setReconnectClient(reconnectClient) + setModes(modes) + setModels(models) + + setIsReady(true) + trackChatReadyOnce() + return + } } // If the session does not exist, create it below @@ -151,14 +188,14 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => customAgentRows, systemAgentRows, ] = await Promise.all([ - getDefaultModelForThread(db, id, settings.selectedModel ?? undefined), - getSelectedMode(db), - getChatThread(db, id), - getChatMessages(db, id), - getAllModes(db), - getAvailableModels(db), - getTriggerPromptForThread(db, id), - getAllAgents(db), + getDefaultModelForThread(db, workspaceId, id, settings.selectedModel ?? undefined), + getSelectedMode(db, workspaceId), + getChatThread(db, workspaceId, id), + getChatMessages(db, workspaceId, id), + getAllModes(db, workspaceId), + getAvailableModels(db, workspaceId), + getTriggerPromptForThread(db, workspaceId, id), + getAllAgents(db, workspaceId), getAllSystemAgents(db), ]) @@ -213,8 +250,26 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => return } + // Re-read the session map immediately before the write. The top-of-function + // existing-session check (above) is separated from `createSession` by the + // big Promise.all, so two concurrent hydrations for the same `id` can both + // pass the early dedup and race to `createSession` — which throws when + // both reach it. Surfaces when `[id, workspaceId]` flips twice in quick + // succession (e.g. landing on `/w//chats/new` right after workspace + // creation, where `useActiveWorkspaceId()` is briefly null then resolves). + if (useChatStore.getState().sessions.has(id)) { + setCurrentSessionId(id) + setGetMcpClients(getEnabledClients) + setReconnectClient(reconnectClient) + setModes(modes) + setModels(models) + setIsReady(true) + return + } + const chatInstance = createChatInstance( id, + workspaceId, initialMessages.map(convertDbChatMessageToUIMessage) as ThunderboltUIMessage[], saveMessages, httpClient, @@ -227,6 +282,7 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => connectionStatus: 'idle', connectionError: null, id, + workspaceId, pendingPermission: null, retryCount: 0, retriesExhausted: false, @@ -251,5 +307,5 @@ export const useHydrateChatStore = ({ id, isNew }: UseHydrateChatStoreParams) => trackChatReadyOnce() } - return { hydrateChatStore, isReady, saveMessages } + return { hydrateChatStore, isReady, saveMessages, workspaceId } } diff --git a/src/components/account-deleted.test.tsx b/src/components/account-deleted.test.tsx index 66a3f7b30..bbc1d33bd 100644 --- a/src/components/account-deleted.test.tsx +++ b/src/components/account-deleted.test.tsx @@ -12,6 +12,7 @@ const mockReplace = mock() Object.defineProperty(window, 'location', { value: { replace: mockReplace }, writable: true, + configurable: true, }) describe('AccountDeleted', () => { diff --git a/src/components/app-error-boundary.test.tsx b/src/components/app-error-boundary.test.tsx new file mode 100644 index 000000000..12cd34e92 --- /dev/null +++ b/src/components/app-error-boundary.test.tsx @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { setupConsoleSpy, type ConsoleSpies } from '@/test-utils/console-spies' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { AppErrorBoundary } from './app-error-boundary' + +const Boom = ({ message }: { message: string }) => { + throw new Error(message) +} + +describe('AppErrorBoundary', () => { + let consoleSpies: ConsoleSpies + + beforeEach(() => { + consoleSpies = setupConsoleSpy() + }) + + afterEach(() => { + consoleSpies.restore() + }) + + it('renders the children when no error is thrown', () => { + render( + + OK + , + ) + expect(screen.getByText('OK')).toBeInTheDocument() + }) + + it('renders AppErrorScreen with the caught message when a child throws', () => { + render( + + + , + ) + expect(screen.getByText('Failed to initialize app')).toBeInTheDocument() + expect(screen.getByText('bootstrap failed')).toBeInTheDocument() + }) +}) diff --git a/src/components/app-error-boundary.tsx b/src/components/app-error-boundary.tsx new file mode 100644 index 000000000..fdab92176 --- /dev/null +++ b/src/components/app-error-boundary.tsx @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { AppErrorScreen } from './app-error-screen' + +type AppErrorBoundaryState = { + error: Error | null +} + +/** + * Catches uncaught render-time errors so a failed bootstrap (e.g. the + * `SessionToWorkspaceBootstrap` throw in `auth-context.tsx`) lands on an + * actionable error screen instead of a blank page. Mounted just above + * `BrowserRouter` so the boundary scope covers every route. + */ +export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErrorBoundaryState> { + state: AppErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): AppErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[AppErrorBoundary] Uncaught render error:', error, info.componentStack) + } + + render(): ReactNode { + const { error } = this.state + if (error) { + return ( + window.location.reload()} + /> + ) + } + return this.props.children + } +} diff --git a/src/components/boot/mode-picker.test.tsx b/src/components/boot/mode-picker.test.tsx new file mode 100644 index 000000000..e4d62f4e7 --- /dev/null +++ b/src/components/boot/mode-picker.test.tsx @@ -0,0 +1,326 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getClock } from '@/testing-library' +import '@testing-library/jest-dom' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' +import { ModePicker, type ValidateServerUrlFn, type ValidationResult } from './mode-picker' + +// --- Injected validator (DI) --- +// +// The `validate` prop replaces the previous `mock.module('@/lib/http', ...)` +// shim. That global module mock leaked across test files because it only +// shipped `createClient`/`HttpError` — downstream tests that imported other +// `@/lib/http` exports (or expected a fully-featured HttpClient back from +// `createClient`) crashed. DI keeps the mock scoped to this file. +const mockValidate = mock(async () => ({ ok: false, message: 'not configured' })) + +const renderModePicker = () => render() + +// --- window.location.reload mock --- + +const mockReload = mock() + +beforeEach(() => { + // @ts-expect-error — jsdom does not allow overwriting location directly + delete window.location + // @ts-expect-error — jsdom does not allow overwriting location directly + window.location = { ...window.location, reload: mockReload } + + mockValidate.mockClear() + // Default to a failing validation so accidental real-network calls don't blow up. + mockValidate.mockImplementation(async () => ({ ok: false, message: 'not configured' })) + mockReload.mockClear() + + useTrustDomainRegistry.setState({ servers: {}, activeTrustDomain: undefined }) +}) + +afterEach(() => { + useTrustDomainRegistry.setState({ servers: {}, activeTrustDomain: undefined }) +}) + +const okValidation = (overrides: Partial> = {}) => ({ + ok: true as const, + serverId: 'test-server-id', + cloudUrl: 'http://localhost:8000/v1', + ...overrides, +}) + +const errorValidation = (message: string): ValidationResult => ({ ok: false, message }) + +// Helper: flush all pending async work (promises + fake timers) inside act +const flush = async () => { + await act(async () => { + await getClock().runAllAsync() + }) +} + +describe('ModePicker', () => { + describe('initial render', () => { + it('shows both option cards', () => { + renderModePicker() + + expect(screen.getByText('Set up an on-device agent')).toBeInTheDocument() + expect(screen.getByText('Connect to AI server')).toBeInTheDocument() + }) + + it('Continue (arrow) is disabled on initial render (server selected, URL empty)', () => { + renderModePicker() + + const buttons = screen.getAllByRole('button') + const arrowBtn = buttons[buttons.length - 1] + expect(arrowBtn).toBeDisabled() + }) + + it('shows URL input on initial render (server is the default selection)', () => { + renderModePicker() + + expect(screen.getByPlaceholderText('app.thunderbolt.io/')).toBeInTheDocument() + }) + }) + + describe('standalone selection (Skip-only flow)', () => { + // The "Set up an on-device agent" card is a disabled placeholder in the + // current UI — the only path into standalone mode is the Skip button. + it('Skip writes activateStandalone and reloads', () => { + renderModePicker() + + fireEvent.click(screen.getByRole('button', { name: /skip/i })) + + expect(useTrustDomainRegistry.getState().activeTrustDomain).toEqual({ kind: 'standalone' }) + expect(mockReload).toHaveBeenCalledTimes(1) + }) + + it('standalone card is disabled (placeholder for future on-device agent flow)', () => { + renderModePicker() + + const standaloneCard = screen.getByText('Set up an on-device agent').closest('button') + expect(standaloneCard).toBeDisabled() + }) + }) + + describe('server selection', () => { + it('reveals URL input when server card is clicked', () => { + renderModePicker() + + fireEvent.click(screen.getByText('Connect to AI server')) + + expect(screen.getByPlaceholderText('app.thunderbolt.io/')).toBeInTheDocument() + expect(screen.getByText("Enter Server's URL:")).toBeInTheDocument() + }) + + it('Continue is disabled when server is selected but URL is empty', () => { + renderModePicker() + + fireEvent.click(screen.getByText('Connect to AI server')) + + const buttons = screen.getAllByRole('button') + expect(buttons[buttons.length - 1]).toBeDisabled() + }) + + it('Continue enables once a URL is typed', () => { + renderModePicker() + + fireEvent.click(screen.getByText('Connect to AI server')) + fireEvent.change(screen.getByPlaceholderText('app.thunderbolt.io/'), { + target: { value: 'http://localhost:8000' }, + }) + + const buttons = screen.getAllByRole('button') + expect(buttons[buttons.length - 1]).not.toBeDisabled() + }) + }) + + describe('blur validation', () => { + it('shows a checkmark on successful blur validation', async () => { + mockValidate.mockResolvedValue(okValidation()) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://localhost:8000' } }) + fireEvent.blur(input) + + await flush() + + // A Check icon should appear inside the input wrapper + expect(input.parentElement?.querySelector('svg')).toBeInTheDocument() + }) + + it('shows "Couldn\'t reach" error on validate failure during blur', async () => { + mockValidate.mockResolvedValue(errorValidation("Couldn't reach this server")) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://unreachable.local' } }) + fireEvent.blur(input) + + await flush() + + expect(screen.getByText("Couldn't reach this server")).toBeInTheDocument() + }) + + it('shows "doesn\'t look like" error on validate failure during blur', async () => { + mockValidate.mockResolvedValue(errorValidation("This URL doesn't look like a Thunderbolt server")) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://not-thunderbolt.local' } }) + fireEvent.blur(input) + + await flush() + + expect(screen.getByText("This URL doesn't look like a Thunderbolt server")).toBeInTheDocument() + }) + }) + + describe('Continue with server (submit-time validation)', () => { + it('writes activateServer and reloads on valid validate result', async () => { + const serverId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + mockValidate.mockResolvedValue(okValidation({ serverId, cloudUrl: 'http://localhost:8000/v1' })) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + fireEvent.change(screen.getByPlaceholderText('app.thunderbolt.io/'), { + target: { value: 'http://localhost:8000' }, + }) + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[buttons.length - 1]) + + await flush() + + expect(mockReload).toHaveBeenCalledTimes(1) + const registry = useTrustDomainRegistry.getState() + expect(registry.activeTrustDomain).toEqual({ kind: 'server', serverId }) + expect(registry.servers[serverId]?.cloudUrl).toBe('http://localhost:8000/v1') + }) + + it('passes the raw user URL through to validate (URL normalization is its responsibility)', async () => { + mockValidate.mockResolvedValue(okValidation()) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + fireEvent.change(screen.getByPlaceholderText('app.thunderbolt.io/'), { + target: { value: 'http://localhost:8000/v1' }, + }) + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[buttons.length - 1]) + + await flush() + + // Submit-time: handleContinue forwards the raw URL to validate; tests of + // normalizeBaseUrl behavior live alongside `validateServerUrl` directly. + expect(mockValidate).toHaveBeenCalledWith('http://localhost:8000/v1') + }) + + it('does not leave Continue stuck disabled after a stale blur + edit', async () => { + let resolveValidate: (r: ValidationResult) => void = () => {} + mockValidate.mockImplementation( + () => + new Promise((resolve) => { + resolveValidate = resolve + }), + ) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://stale.local' } }) + fireEvent.blur(input) + // Edit before validate() resolves — isValidating must clear so Continue + // enables once a non-empty URL is in the field. + fireEvent.change(input, { target: { value: 'http://fresh.local' } }) + + resolveValidate(okValidation()) + await flush() + + const buttons = screen.getAllByRole('button') + expect((buttons[buttons.length - 1] as HTMLButtonElement).disabled).toBe(false) + }) + + it('drops a stale blur result when the user edits the field during validation', async () => { + // Hold validate() pending so we can interleave a SET_URL between + // dispatch(VALIDATE_START) and the result. + let resolveValidate: (r: ValidationResult) => void = () => {} + mockValidate.mockImplementation( + () => + new Promise((resolve) => { + resolveValidate = resolve + }), + ) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://stale.local' } }) + fireEvent.blur(input) + // No flush yet — validate() is still pending. + + // User edits the field while validate is in flight. + fireEvent.change(input, { target: { value: 'http://fresh.local' } }) + + // Stale validate finally resolves with success for the OLD URL. + resolveValidate(okValidation()) + await flush() + + // Checkmark must NOT appear — the success was for stale text. + expect(input.parentElement?.querySelector('svg')).not.toBeInTheDocument() + }) + + it('reuses the blur-time validation when Continue submits the same URL', async () => { + const serverId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + mockValidate.mockResolvedValue(okValidation({ serverId })) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + + const input = screen.getByPlaceholderText('app.thunderbolt.io/') + fireEvent.change(input, { target: { value: 'http://localhost:8000' } }) + fireEvent.blur(input) + await flush() + + // Blur fires one validate call. + expect(mockValidate).toHaveBeenCalledTimes(1) + + // Continue against the SAME URL must not re-validate. + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[buttons.length - 1]) + await flush() + + expect(mockValidate).toHaveBeenCalledTimes(1) + expect(useTrustDomainRegistry.getState().activeTrustDomain).toEqual({ kind: 'server', serverId }) + }) + + it('shows inline error and returns to picker on validation failure', async () => { + mockValidate.mockResolvedValue(errorValidation("Couldn't reach this server")) + + renderModePicker() + fireEvent.click(screen.getByText('Connect to AI server')) + fireEvent.change(screen.getByPlaceholderText('app.thunderbolt.io/'), { + target: { value: 'http://unreachable.local' }, + }) + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[buttons.length - 1]) + + await flush() + + expect(screen.getByText("Couldn't reach this server")).toBeInTheDocument() + expect(mockReload).not.toHaveBeenCalled() + // Picker heading should still be visible (not the connecting screen) + expect(screen.getByText('How would you like to use Thunderbolt?')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/boot/mode-picker.tsx b/src/components/boot/mode-picker.tsx new file mode 100644 index 000000000..3b0d77899 --- /dev/null +++ b/src/components/boot/mode-picker.tsx @@ -0,0 +1,320 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useReducer, useRef } from 'react' +import { ArrowRight, Bot, Check, Server } from 'lucide-react' +import { AppLogo } from '@/components/app-logo' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import type { AppConfig } from '@/api/config-store' +import { createClient, HttpError } from '@/lib/http' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' + +type Mode = 'standalone' | 'server' + +type State = { + selection: Mode | null + serverUrl: string + serverUrlError: string | null + isValidating: boolean + isUrlValidated: boolean + stage: 'picker' | 'connecting' +} + +type Action = + | { type: 'SELECT'; mode: Mode } + | { type: 'SET_URL'; url: string } + | { type: 'VALIDATE_START' } + | { type: 'VALIDATE_SUCCESS' } + | { type: 'VALIDATE_ERROR'; message: string } + | { type: 'CONNECT' } + +const initialState: State = { + selection: 'server', + serverUrl: '', + serverUrlError: null, + isValidating: false, + isUrlValidated: false, + stage: 'picker', +} + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'SELECT': + return { ...state, selection: action.mode, serverUrlError: null, isUrlValidated: false } + case 'SET_URL': + // Reset `isValidating` too: if a blur kicked off `validate()` and the + // user then edits the field, the in-flight result is stale and gets + // dropped without dispatching success/error — without this reset the + // flag would stick at true and the Continue button would never enable. + return { ...state, serverUrl: action.url, serverUrlError: null, isUrlValidated: false, isValidating: false } + case 'VALIDATE_START': + return { ...state, isValidating: true, serverUrlError: null } + case 'VALIDATE_SUCCESS': + return { ...state, isValidating: false, isUrlValidated: true } + case 'VALIDATE_ERROR': + return { ...state, isValidating: false, isUrlValidated: false, serverUrlError: action.message, stage: 'picker' } + case 'CONNECT': + return { ...state, stage: 'connecting' } + } +} + +// Normalize whatever the user typed into a bare base URL (no trailing slash, no /v1). +// Accepted forms: "app.thunderbolt.io", "app.thunderbolt.io/", "http://...", "https://.../v1" +const normalizeBaseUrl = (url: string): string => { + let s = url.trim().replace(/\/+$/, '') + if (s.endsWith('/v1')) { + s = s.slice(0, -3) + } + if (!s.startsWith('http://') && !s.startsWith('https://')) { + const isLocal = s.startsWith('localhost') || s.startsWith('127.0.0.1') + s = `${isLocal ? 'http' : 'https'}://${s}` + } + return s +} + +export type ValidationResult = { ok: true; serverId: string; cloudUrl: string } | { ok: false; message: string } + +export type ValidateServerUrlFn = (userUrl: string) => Promise + +export const validateServerUrl: ValidateServerUrlFn = async (userUrl) => { + const base = normalizeBaseUrl(userUrl) + const client = createClient({ prefixUrl: `${base}/v1` }) + try { + const config = await client.get('config', { timeout: 5_000 }).json() + if (!config?.serverId) { + return { ok: false, message: "This URL doesn't look like a Thunderbolt server" } + } + return { ok: true, serverId: config.serverId, cloudUrl: `${base}/v1` } + } catch (err) { + return err instanceof HttpError + ? { ok: false, message: "This URL doesn't look like a Thunderbolt server" } + : { ok: false, message: "Couldn't reach this server" } + } +} + +type ModePickerProps = { + /** + * Server URL validator. Optional override for tests — defaults to the real + * `validateServerUrl` which hits `GET /v1/config`. Tests pass a mock + * here instead of `mock.module('@/lib/http', ...)` so the global module + * stays intact for other test files. + */ + validate?: ValidateServerUrlFn +} + +export const ModePicker = ({ validate = validateServerUrl }: ModePickerProps = {}) => { + const [state, dispatch] = useReducer(reducer, initialState) + + // Mirror of the live `serverUrl` so async validate callbacks can detect that + // the user typed something else while the request was in flight, and bail + // out of applying a result that no longer matches. Updated on every render + // AND synchronously in the input's `onChange` — the render-time write alone + // races with a validate Promise that resolves before React commits the + // SET_URL re-render, since the closure's `urlAtCall` and the (stale) ref + // both still equal the previous value at that moment. + const serverUrlRef = useRef(state.serverUrl) + serverUrlRef.current = state.serverUrl + + // Cache of the last validate() resolution by URL. Reused when Continue + // submits the same URL the user just blurred, so we don't pay the + // /v1/config round-trip twice (and don't visually "re-validate" a URL the + // user already saw a checkmark on). + const lastValidationRef = useRef<{ url: string; result: ValidationResult } | null>(null) + + const isServerMode = state.selection === 'server' + // Dots: left = initial pick, right = server URL step + const activeDot = isServerMode ? 1 : 0 + + const canContinue = + state.selection !== null && !state.isValidating && (state.selection !== 'server' || state.serverUrl.trim() !== '') + + const handleSkip = () => { + useTrustDomainRegistry.getState().activateStandalone() + window.location.reload() + } + + const handleBlur = async () => { + if (!isServerMode || !state.serverUrl.trim()) { + return + } + const urlAtCall = state.serverUrl + // Skip if we already have a cached result for this exact URL — the second + // blur after Continue's pre-validation would otherwise re-fire the network. + if (lastValidationRef.current?.url === urlAtCall) { + const cached = lastValidationRef.current.result + dispatch(cached.ok ? { type: 'VALIDATE_SUCCESS' } : { type: 'VALIDATE_ERROR', message: cached.message }) + return + } + dispatch({ type: 'VALIDATE_START' }) + const result = await validate(urlAtCall) + // Drop the result if the input has changed since we started — applying it + // would either show a checkmark for the new text (when the old URL passed) + // or show an error for text the user already replaced. + if (urlAtCall !== serverUrlRef.current) { + return + } + lastValidationRef.current = { url: urlAtCall, result } + dispatch(result.ok ? { type: 'VALIDATE_SUCCESS' } : { type: 'VALIDATE_ERROR', message: result.message }) + } + + const handleContinue = async () => { + if (state.selection === 'standalone') { + useTrustDomainRegistry.getState().activateStandalone() + window.location.reload() + return + } + + if (state.selection === 'server') { + // Reuse the blur-time result when the URL hasn't changed since — avoids + // a second round-trip and the click-while-blur-pending double-tap UX. + const cached = lastValidationRef.current + if (cached && cached.url === state.serverUrl && cached.result.ok) { + dispatch({ type: 'CONNECT' }) + useTrustDomainRegistry + .getState() + .activateServer({ serverId: cached.result.serverId, cloudUrl: cached.result.cloudUrl }) + window.location.reload() + return + } + + dispatch({ type: 'CONNECT' }) + const urlAtCall = state.serverUrl + const result = await validate(urlAtCall) + if (urlAtCall !== serverUrlRef.current) { + // User edited the field during the network call — bail rather than + // surface a result for stale text. Reset the stage so we don't strand + // the user on the "Connecting to Server" screen with no recovery path. + dispatch({ type: 'VALIDATE_ERROR', message: '' }) + return + } + lastValidationRef.current = { url: urlAtCall, result } + if (!result.ok) { + dispatch({ type: 'VALIDATE_ERROR', message: result.message }) + return + } + useTrustDomainRegistry.getState().activateServer({ serverId: result.serverId, cloudUrl: result.cloudUrl }) + window.location.reload() + } + } + + if (state.stage === 'connecting') { + return ( +
+ + Connecting to Server +
+ ) + } + + return ( +
+ {/* Branding */} +
+ + Thunderbolt +
+ +
+

How would you like to use Thunderbolt?

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

{state.serverUrlError}

+ )} +
+ )} +
+ + {/* Footer: Skip — dots — Continue */} +
+ + +
+ {[0, 1].map((i) => ( +
+ ))} +
+ + +
+
+ ) +} diff --git a/src/components/chat/chat-model-picker.test.tsx b/src/components/chat/chat-model-picker.test.tsx index 3b90abd73..261b40e52 100644 --- a/src/components/chat/chat-model-picker.test.tsx +++ b/src/components/chat/chat-model-picker.test.tsx @@ -21,6 +21,13 @@ import { MemoryRouter } from 'react-router' import type { ReactNode } from 'react' import { ChatModelPicker } from './chat-model-picker' +const fakeUseWorkspacePermission = (isAllowed: boolean) => + (() => ({ + requiredRole: 'admin' as const, + isAllowed, + isResolved: true, + })) as unknown as typeof import('@/hooks/use-workspace-permission').useWorkspacePermission + const remoteAcpAgent: Agent = { id: 'remote-1', name: 'Remote Agent', @@ -131,6 +138,32 @@ describe('ChatModelPicker', () => { expect(container.firstChild).toBeNull() }) + it('renders the "Add Models" footer when the user has add_models permission', async () => { + setupWithAgent(builtInAgent) + + render(, { wrapper: TestWrapper }) + + await act(async () => { + fireEvent.click(screen.getByText('GPT-4')) + }) + + expect(await screen.findByText('Add Models')).toBeInTheDocument() + }) + + it('hides the "Add Models" footer when the user lacks add_models permission', async () => { + setupWithAgent(builtInAgent) + + render(, { wrapper: TestWrapper }) + + await act(async () => { + fireEvent.click(screen.getByText('GPT-4')) + }) + + // Wait for the dropdown to settle, then assert the footer is absent. + await screen.findByText('GPT-5') + expect(screen.queryByText('Add Models')).not.toBeInTheDocument() + }) + it('changes the selected model in the store on click', async () => { setupWithAgent(builtInAgent) diff --git a/src/components/chat/chat-model-picker.tsx b/src/components/chat/chat-model-picker.tsx index 786d3721d..c50e2bef2 100644 --- a/src/components/chat/chat-model-picker.tsx +++ b/src/components/chat/chat-model-picker.tsx @@ -5,7 +5,8 @@ import { useChatStore, useCurrentChatSession } from '@/chats/chat-store' import { ModelSelector } from '@/components/ui/model-selector' import { useIsMobile } from '@/hooks/use-mobile' -import { useNavigate } from 'react-router' +import { useWorkspacePermission as useWorkspacePermission_default } from '@/hooks/use-workspace-permission' +import { useWorkspaceNavigate } from '@/lib/active-workspace' /** * Model picker for the chat composer. Renders to the immediate right of the @@ -17,12 +18,23 @@ import { useNavigate } from 'react-router' * ModeSelector sitting beside it, and mirrors its open direction (down on * desktop, up on mobile) so the two dropdowns stay consistent. */ -export const ChatModelPicker = () => { +type ChatModelPickerProps = { + /** Test seam — defaults to the real hook. Pages exercise this via tests + * that inject a fake to assert the gated-affordance is hidden. */ + useWorkspacePermission?: typeof useWorkspacePermission_default +} + +export const ChatModelPicker = ({ + useWorkspacePermission = useWorkspacePermission_default, +}: ChatModelPickerProps = {}) => { const models = useChatStore((state) => state.models) const setSelectedModel = useChatStore((state) => state.setSelectedModel) - const navigate = useNavigate() + const navigate = useWorkspaceNavigate() const { isMobile } = useIsMobile() const { id: chatThreadId, selectedAgent, selectedModel, chatThread } = useCurrentChatSession() + // Suppress the "Add Models" footer when the user can't add — they'd land on + // a settings page where the affordance is also hidden. + const { isAllowed: canAddModels } = useWorkspacePermission('add_models') if (selectedAgent.type !== 'built-in' || models.length === 0) { return null @@ -39,7 +51,7 @@ export const ChatModelPicker = () => { selectedModel={selectedModel ?? null} chatThread={chatThread ?? null} onModelChange={handleModelChange} - onAddModels={() => navigate('/settings/models')} + onAddModels={canAddModels ? () => navigate('/settings/models') : undefined} side={isMobile ? 'top' : 'bottom'} align="start" /> diff --git a/src/components/chat/chat-skills-bar.test.tsx b/src/components/chat/chat-skills-bar.test.tsx index 27fc4dc98..74d27e96c 100644 --- a/src/components/chat/chat-skills-bar.test.tsx +++ b/src/components/chat/chat-skills-bar.test.tsx @@ -20,6 +20,7 @@ const skill = (id: string, name: string): Skill => ({ deletedAt: null, defaultHash: null, userId: null, + workspaceId: null, }) const fakeUsePinnedSkills = (overrides?: { @@ -49,6 +50,13 @@ const fakeUseEnabledSkills = (enabledIds: ReadonlySet) => setEnabled: async () => undefined, })) as unknown as typeof import('@/skills/use-skills').useEnabledSkills +const fakeUseWorkspacePermission = (isAllowed: boolean) => + (() => ({ + requiredRole: 'admin' as const, + isAllowed, + isResolved: true, + })) as unknown as typeof import('@/hooks/use-workspace-permission').useWorkspacePermission + const renderBar = (props: Partial[0]> = {}) => { return render( @@ -59,6 +67,7 @@ const renderBar = (props: Partial[0]> = {}) => usePinnedSkills={props.usePinnedSkills ?? fakeUsePinnedSkills({ pinned: [] })} useLibrarySkills={props.useLibrarySkills ?? fakeUseLibrarySkills([])} useEnabledSkills={props.useEnabledSkills ?? fakeUseEnabledSkills(new Set())} + useWorkspacePermission={props.useWorkspacePermission ?? fakeUseWorkspacePermission(true)} /> , @@ -124,4 +133,42 @@ describe('ChatSkillsBar', () => { // The chip's click → onAddToChat path is exercised end-to-end at the // composer level; here we trust Radix's primitives. + + describe('permission gating (add_skills)', () => { + it('hides the "+ Pin a skill" trigger when the user lacks add_skills', () => { + const a = skill('a', 'daily-brief') + renderBar({ + usePinnedSkills: fakeUsePinnedSkills({ pinned: [] }), + useLibrarySkills: fakeUseLibrarySkills([a]), + useEnabledSkills: fakeUseEnabledSkills(new Set(['a'])), + useWorkspacePermission: fakeUseWorkspacePermission(false), + }) + + expect(screen.queryByLabelText('Pin a skill')).not.toBeInTheDocument() + }) + + it('renders nothing when the user lacks add_skills and has no pinned chips, regardless of candidates', () => { + const a = skill('a', 'daily-brief') + const { container } = renderBar({ + usePinnedSkills: fakeUsePinnedSkills({ pinned: [] }), + useLibrarySkills: fakeUseLibrarySkills([a]), + useEnabledSkills: fakeUseEnabledSkills(new Set(['a'])), + useWorkspacePermission: fakeUseWorkspacePermission(false), + }) + expect(container.firstChild).toBeNull() + }) + + it('still renders pinned chips when the user lacks add_skills (read-only chips)', () => { + const a = skill('a', 'daily-brief') + renderBar({ + usePinnedSkills: fakeUsePinnedSkills({ pinned: [a] }), + useLibrarySkills: fakeUseLibrarySkills([a]), + useEnabledSkills: fakeUseEnabledSkills(new Set(['a'])), + useWorkspacePermission: fakeUseWorkspacePermission(false), + }) + + expect(screen.getByText('/daily-brief')).toBeTruthy() + expect(screen.queryByLabelText('Pin a skill')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/chat/chat-skills-bar.tsx b/src/components/chat/chat-skills-bar.tsx index 544d54d1e..d14ea176d 100644 --- a/src/components/chat/chat-skills-bar.tsx +++ b/src/components/chat/chat-skills-bar.tsx @@ -11,6 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { maxPinnedSkills } from '@/dal' import { useIsMobile } from '@/hooks/use-mobile' +import { useWorkspacePermission as useWorkspacePermission_default } from '@/hooks/use-workspace-permission' import { ReorderPanel } from '@/skills/reorder-panel' import { SuggestionChip } from '@/skills/suggestion-chip' import { useSkillTelemetry } from '@/skills/telemetry' @@ -35,6 +36,7 @@ type ChatSkillsBarProps = { usePinnedSkills?: typeof usePinnedSkills_default useLibrarySkills?: typeof useLibrarySkills_default useEnabledSkills?: typeof useEnabledSkills_default + useWorkspacePermission?: typeof useWorkspacePermission_default } /** @@ -54,12 +56,17 @@ export const ChatSkillsBar = ({ usePinnedSkills = usePinnedSkills_default, useLibrarySkills = useLibrarySkills_default, useEnabledSkills = useEnabledSkills_default, + useWorkspacePermission = useWorkspacePermission_default, }: ChatSkillsBarProps) => { const { pinned, pinnedSet, reorderPins, togglePin } = usePinnedSkills() const { skills: library } = useLibrarySkills() const { isEnabled } = useEnabledSkills() const { isMobile } = useIsMobile() const trackSkillEvent = useSkillTelemetry() + // Pin / unpin / reorder all PATCH the `skills` row — the BE gates them + // on `add_skills`. Hide the affordances when the user can't satisfy that + // permission so we don't surface actions that round-trip-fail. + const { isAllowed: canEditSkills } = useWorkspacePermission('add_skills') const [openChipId, setOpenChipId] = useState(null) const [reorderMode, setReorderMode] = useState(false) @@ -115,10 +122,12 @@ export const ChatSkillsBar = ({ ? 'No more skills to pin' : 'Pin a skill' - // Hide the whole bar only when there's nothing to display *and* nothing - // to add. If the user has zero pins but unpinned skills exist, we still - // show the `+` button so they can pin one. - if (pinned.length === 0 && pinnable.length === 0) { + // Hide the whole bar when there's nothing to display *and* nothing the user + // can act on. Zero pins + unpinned candidates still warrants the `+` button + // — but only when the user can actually pin (`canEditSkills`); otherwise + // both the chips row and the trigger are empty and the strip would render + // as a thin blank line above the composer. + if (pinned.length === 0 && (pinnable.length === 0 || !canEditSkills)) { return null } @@ -131,6 +140,7 @@ export const ChatSkillsBar = ({ key={skill.id} label={skill.name} dimmed={openChipId !== null && openChipId !== skill.id} + canEdit={canEditSkills} onClick={() => onAddToChat(skill.name)} onOpenChange={(open) => setOpenChipId(open ? skill.id : null)} onAddInstruction={() => onAddInstruction(skill.instruction)} @@ -147,26 +157,27 @@ export const ChatSkillsBar = ({ }} /> ))} - - - - - - - - {addTooltip} - - {/* + > + + + + + {addTooltip} + + {/* `collisionPadding={16}` keeps the popover 16px off the viewport edges. On mobile the content is sized to `calc(100vw-2rem)` (32px narrower than the viewport), so collision avoidance pins it to a @@ -175,49 +186,52 @@ export const ChatSkillsBar = ({ leaves room, so the padding never shifts the `align="start"` anchor off the `+` button. */} - -
    - {pinnable.map((skill) => ( -
  • - -
  • - ))} -
-
-
+ {skill.description && ( + + {skill.description} + + )} + + + ))} + + + + )}
) diff --git a/src/components/logout-modal.test.tsx b/src/components/logout-modal.test.tsx index 6019d002a..08a928768 100644 --- a/src/components/logout-modal.test.tsx +++ b/src/components/logout-modal.test.tsx @@ -13,16 +13,15 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' import { LogoutModal } from './logout-modal' -const mockClearLocalData = mock(() => Promise.resolve()) - -const env = import.meta.env as Record +const mockSignOutAndWipe = mock(async ({ onComplete }: { signOut?: () => Promise; onComplete: () => void }) => { + onComplete() +}) -// Mock window.location -const mockReload = mock() const mockReplace = mock() Object.defineProperty(window, 'location', { - value: { reload: mockReload, replace: mockReplace }, + value: { replace: mockReplace }, writable: true, + configurable: true, }) describe('LogoutModal', () => { @@ -44,14 +43,12 @@ describe('LogoutModal', () => { await resetTestDatabase() mockOnOpenChange = mock() mockSignOut = mock(() => Promise.resolve()) - mockClearLocalData.mockClear() - mockReload.mockClear() + mockSignOutAndWipe.mockClear() mockReplace.mockClear() }) afterEach(() => { mockOnOpenChange.mockClear() - delete env.VITE_AUTH_MODE }) const renderModal = (props: Partial<{ open: boolean; onOpenChange: (open: boolean) => void }> = {}) => { @@ -59,7 +56,7 @@ describe('LogoutModal', () => { signOut: mockSignOut, }) return render( - , + , { wrapper: createTestProvider({ authClient }), }, @@ -67,10 +64,12 @@ describe('LogoutModal', () => { } describe('rendering', () => { - it('renders when open', () => { + it('renders title and wipe-warning description when open', () => { renderModal({ open: true }) - // Check for the dialog title specifically expect(screen.getByRole('heading', { name: 'Log out' })).toBeInTheDocument() + expect( + screen.getByText('Signing out will remove all chats, settings, and cached data from this device.'), + ).toBeInTheDocument() }) it('does not render content when closed', () => { @@ -78,305 +77,85 @@ describe('LogoutModal', () => { expect(screen.queryByText('Log out')).not.toBeInTheDocument() }) - it('displays description text', () => { - renderModal() - expect(screen.getByText('What would you like to do with your local data?')).toBeInTheDocument() - }) - - it('displays both data options', () => { - renderModal() - expect(screen.getByText('Leave data on device')).toBeInTheDocument() - expect(screen.getByText('Delete data from device')).toBeInTheDocument() - }) - it('displays cancel and logout buttons', () => { renderModal() expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Log out' })).toBeInTheDocument() }) - }) - - describe('option selection', () => { - it('has "keep" option selected by default', () => { - renderModal() - const keepOption = screen.getByText('Leave data on device').closest('button') - // Check the radio indicator is styled as selected (has the inner dot) - expect(keepOption?.querySelector('.bg-primary')).toBeInTheDocument() - }) - - it('selects "delete" option when clicked', () => { - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - - fireEvent.click(deleteOption) - - // Check the delete option is now selected - expect(deleteOption.querySelector('.bg-destructive')).toBeInTheDocument() - }) - - it('allows switching between options', () => { - renderModal() - const keepOption = screen.getByText('Leave data on device').closest('button')! - const deleteOption = screen.getByText('Delete data from device').closest('button')! - - // Select delete - fireEvent.click(deleteOption) - expect(deleteOption.querySelector('.bg-destructive')).toBeInTheDocument() - - // Switch back to keep - fireEvent.click(keepOption) - expect(keepOption.querySelector('.bg-primary')).toBeInTheDocument() - }) - }) - - describe('logout flow with keep data', () => { - it('calls signOut and reloads when logging out with keep option', async () => { - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockSignOut).toHaveBeenCalled() - expect(mockClearLocalData).toHaveBeenCalledWith({ clearDatabase: false }) - expect(mockReload).toHaveBeenCalled() - }) - - it('shows loading state during logout', async () => { - let resolveSignOut: (value?: unknown) => void - mockSignOut.mockReturnValue( - new Promise((resolve) => { - resolveSignOut = resolve - }), - ) - - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().tickAsync(0) - }) - - expect(screen.getByText('Logging out...')).toBeInTheDocument() - - // Clean up - resolveSignOut!() - await act(async () => { - await getClock().runAllAsync() - }) - }) - }) - - describe('logout flow with delete data', () => { - it('calls signOut, clearLocalData with clearDatabase, and reloads when deleting data', async () => { - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(deleteOption) - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockSignOut).toHaveBeenCalled() - expect(mockClearLocalData).toHaveBeenCalledWith({ clearDatabase: true }) - expect(mockReload).toHaveBeenCalled() - }) - - it('shows delete-specific loading text', async () => { - let resolveSignOut: (value?: unknown) => void - mockSignOut.mockReturnValue( - new Promise((resolve) => { - resolveSignOut = resolve - }), - ) - - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(deleteOption) - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().tickAsync(0) - }) - - expect(screen.getByText('Deleting...')).toBeInTheDocument() - - // Clean up - resolveSignOut!() - await act(async () => { - await getClock().runAllAsync() - }) - }) - it('uses destructive button variant when delete is selected', () => { + it('does not offer a "keep my data" affordance', () => { renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(deleteOption) - - // Check for destructive variant class - expect(logoutButton.className).toContain('destructive') - }) - }) - - describe('error handling', () => { - it('continues to reload even if signOut fails', async () => { - mockSignOut.mockRejectedValue(new Error('Network error')) - - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockReload).toHaveBeenCalled() + expect(screen.queryByText(/keep data/i)).not.toBeInTheDocument() + expect(screen.queryByText(/leave data/i)).not.toBeInTheDocument() }) - it('continues to reload even if clearLocalData fails', async () => { - mockClearLocalData.mockRejectedValueOnce(new Error('Cleanup error')) - + it('styles the logout button as destructive', () => { renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(deleteOption) - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockReload).toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Log out' }).className).toContain('destructive') }) }) - describe('SSO mode logout', () => { - beforeEach(() => { + describe('logout flow', () => { + it('passes the Better Auth signOut callback and an SSO-aware onComplete to signOutAndWipe', async () => { + const env = import.meta.env as Record env.VITE_AUTH_MODE = 'sso' - }) - - it('navigates to /signed-out instead of reloading in SSO mode', async () => { - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().runAllAsync() + try { + renderModal() + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) + + await act(async () => { + await getClock().runAllAsync() + }) + + expect(mockSignOutAndWipe).toHaveBeenCalledTimes(1) + const arg = mockSignOutAndWipe.mock.calls[0][0] + expect(typeof arg.signOut).toBe('function') + await arg.signOut?.() + expect(mockSignOut).toHaveBeenCalled() + // SSO mode → onComplete lands on /signed-out via replace(). + expect(mockReplace).toHaveBeenCalledWith('/signed-out') + } finally { + delete env.VITE_AUTH_MODE + } + }) + + it('reloads instead of redirecting in consumer mode', async () => { + // Default test env has no VITE_AUTH_MODE set → isSsoMode() === false. + const mockReload = mock() + Object.defineProperty(window, 'location', { + value: { replace: mockReplace, reload: mockReload }, + writable: true, + configurable: true, }) - expect(mockReplace).toHaveBeenCalledWith('/signed-out') - expect(mockReload).not.toHaveBeenCalled() - }) - - it('navigates to /signed-out even if signOut fails', async () => { - mockSignOut.mockRejectedValue(new Error('Network error')) - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) await act(async () => { await getClock().runAllAsync() }) - expect(mockReplace).toHaveBeenCalledWith('/signed-out') - expect(mockReload).not.toHaveBeenCalled() + expect(mockReload).toHaveBeenCalled() + expect(mockReplace).not.toHaveBeenCalled() }) }) - describe('modal behavior during logout', () => { - it('prevents closing while logging out', async () => { - let resolveSignOut: (value?: unknown) => void - mockSignOut.mockReturnValue( - new Promise((resolve) => { - resolveSignOut = resolve - }), - ) - + describe('double-click guard', () => { + it('only fires signOutAndWipe once when Log out is clicked rapidly', () => { renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().tickAsync(0) - }) - - // Try to cancel - should be disabled - const cancelButton = screen.getByRole('button', { name: 'Cancel' }) - expect(cancelButton).toBeDisabled() - - // Clean up - resolveSignOut!() - await act(async () => { - await getClock().runAllAsync() - }) - }) - - it('disables logout button while logging out', async () => { - let resolveSignOut: (value?: unknown) => void - mockSignOut.mockReturnValue( - new Promise((resolve) => { - resolveSignOut = resolve - }), - ) - - renderModal() - const logoutButton = screen.getByRole('button', { name: 'Log out' }) - - fireEvent.click(logoutButton) - - await act(async () => { - await getClock().tickAsync(0) - }) - - // The button text changes and becomes disabled - expect(screen.getByRole('button', { name: /Logging out|Deleting/i })).toBeDisabled() - - // Clean up - resolveSignOut!() - await act(async () => { - await getClock().runAllAsync() - }) + const button = screen.getByRole('button', { name: 'Log out' }) + fireEvent.click(button) + fireEvent.click(button) + fireEvent.click(button) + expect(mockSignOutAndWipe).toHaveBeenCalledTimes(1) }) }) describe('cancel behavior', () => { - it('calls onOpenChange(false) when cancel is clicked', () => { + it('closes the dialog when cancel is clicked', () => { renderModal() - const cancelButton = screen.getByRole('button', { name: 'Cancel' }) - - fireEvent.click(cancelButton) - + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(mockOnOpenChange).toHaveBeenCalledWith(false) }) - - it('resets option selection when modal is closed', () => { - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - - // Select delete option - fireEvent.click(deleteOption) - - // Close and reopen - selection should reset via onOpenChange handler - // The component resets selectedOption to 'keep' when newOpen is false - expect(mockOnOpenChange).not.toHaveBeenCalled() - }) }) }) diff --git a/src/components/logout-modal.tsx b/src/components/logout-modal.tsx index d110974c3..467b21110 100644 --- a/src/components/logout-modal.tsx +++ b/src/components/logout-modal.tsx @@ -2,116 +2,98 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { HardDrive, Loader2, Trash2 } from 'lucide-react' -import { useState } from 'react' - -import { Button } from '@/components/ui/button' +import { useRef, useState } from 'react' import { - ResponsiveModal, - ResponsiveModalContent, - ResponsiveModalDescription, - ResponsiveModalFooter, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from '@/components/ui/responsive-modal' -import { SelectableCard, type DataOption } from '@/components/ui/selectable-card' + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { useAuth } from '@/contexts' import { isSsoMode } from '@/lib/auth-mode' -import { clearLocalData as defaultClearLocalData } from '@/lib/cleanup' +import { signOutAndWipe as defaultSignOutAndWipe } from '@/lib/cleanup' type LogoutModalProps = { open: boolean onOpenChange: (open: boolean) => void /** - * Injectable for tests. Defaults to the real {@link clearLocalData} — the - * shipped UI never overrides this. Lets the test pass a stub instead of + * Injectable for tests. Defaults to the real {@link signOutAndWipe} — the shipped + * UI never overrides this. Lets the test pass a stub instead of * `mock.module('@/lib/cleanup', ...)`, which would leak across files (see * `docs/development/testing.md` §65). */ - clearLocalData?: typeof defaultClearLocalData + signOutAndWipe?: typeof defaultSignOutAndWipe } -export const LogoutModal = ({ open, onOpenChange, clearLocalData = defaultClearLocalData }: LogoutModalProps) => { +export const LogoutModal = ({ open, onOpenChange, signOutAndWipe = defaultSignOutAndWipe }: LogoutModalProps) => { const authClient = useAuth() - const [selectedOption, setSelectedOption] = useState('keep') const [isLoggingOut, setIsLoggingOut] = useState(false) + // Ref mirrors the state for synchronous reads: Radix fires onOpenToggle in the same + // click-handler tick as our onClick, before React commits the setState above. + const isLoggingOutRef = useRef(false) - const handleLogout = async () => { - setIsLoggingOut(true) - - try { - await authClient.signOut() - } catch (error) { - console.error('Failed to sign out:', error) - } - - try { - await clearLocalData({ clearDatabase: selectedOption === 'delete' }) - } catch (error) { - console.error('Failed to clear local data:', error) - } - - if (isSsoMode()) { - window.location.replace('/signed-out') - } else { - window.location.reload() + // Per addendum decision #16 (THU-549 §5): signing out always wipes the active trust + // domain's local data. The previous "keep my data" affordance was incompatible with + // per-trust-domain SQLite files — leftover data without a matching auth token has no + // path back to the user. + const handleLogout = () => { + // Ref-guard at entry: the button's `disabled={isLoggingOut}` only applies + // after React commits the setState below. A rapid double-click (or a + // queued click event firing in the same tick as the first) would + // otherwise launch a second signOutAndWipe concurrent with the first. + if (isLoggingOutRef.current) { + return } + isLoggingOutRef.current = true + setIsLoggingOut(true) + // SSO lands on `/signed-out` because IdP-bounce-back would silently re-auth the + // user on reload; consumer mode reloads so the user re-enters the normal unauth + // landing (sign-in or waitlist). + void signOutAndWipe({ + signOut: async () => { + await authClient.signOut() + }, + onComplete: () => { + if (isSsoMode()) { + window.location.replace('/signed-out') + } else { + window.location.reload() + } + }, + }) } const handleOpenChange = (newOpen: boolean) => { - if (isLoggingOut) { + if (isLoggingOutRef.current) { return } - if (!newOpen) { - setSelectedOption('keep') - } onOpenChange(newOpen) } return ( - - - Log out - What would you like to do with your local data? - - - - setSelectedOption('keep')} - icon={} - title="Leave data on device" - description="Your chats and settings will remain on this device for next time." - /> - setSelectedOption('delete')} - icon={} - title="Delete data from device" - description="Remove all chats, settings, and cached data from this device." - variant="destructive" - /> - - - - - - - + + + + Log out + + Signing out will remove all chats, settings, and cached data from this device. + + + + Cancel + + {isLoggingOut ? 'Logging out…' : 'Log out'} + + + + ) } diff --git a/src/components/magic-link-verify.tsx b/src/components/magic-link-verify.tsx index 3a8721737..7aa490800 100644 --- a/src/components/magic-link-verify.tsx +++ b/src/components/magic-link-verify.tsx @@ -12,6 +12,7 @@ import { challengeTokenHeader } from '@/lib/constants' import { useAuth } from '@/contexts' import { getOtpErrorMessage } from '@/lib/otp-error-messages' import { useSettings } from '@/hooks/use-settings' +import { runPostAuthBootstrap } from '@/lib/post-auth-bootstrap' type VerifyState = { status: 'verifying' } | { status: 'success' } | { status: 'error'; message: string } @@ -72,6 +73,22 @@ export const MagicLinkVerify = () => { // This ensures the sidebar and other components see the new session immediately await refetchSession() + // Post-auth pipeline: connect sync, resolve personal workspace, reconcile. + // Idempotent + deduped vs. the AuthProvider observer. + if (result.data?.user?.id) { + try { + await runPostAuthBootstrap({ + kind: 'server', + userId: result.data.user.id, + isAnonymous: result.data.user.isAnonymous === true, + }) + } catch (bootstrapError) { + console.error('Post-auth bootstrap failed:', bootstrapError) + setState({ status: 'error', message: 'Could not sync your account. Please retry.' }) + return + } + } + setState({ status: 'success' }) } catch { setState({ status: 'error', message: 'Something went wrong. Please try again.' }) diff --git a/src/components/pending-device-modal.test.tsx b/src/components/pending-device-modal.test.tsx index 096734cd3..7c32882df 100644 --- a/src/components/pending-device-modal.test.tsx +++ b/src/components/pending-device-modal.test.tsx @@ -13,14 +13,15 @@ import { getClock } from '@/testing-library' import '@testing-library/jest-dom' import { act, cleanup, fireEvent, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' +import { testServerId } from '@/testing-library' import { v7 as uuidv7 } from 'uuid' import type { ReactNode } from 'react' const currentDeviceId = uuidv7() const pendingDeviceId1 = uuidv7() -const deviceIdKey = 'thunderbolt_device_id' -const authTokenKey = 'thunderbolt_auth_token' +const deviceIdKey = `thunderbolt_device_id__${testServerId}` +const authTokenKey = `thunderbolt_auth_token__${testServerId}` const sessionStorageKey = 'pending_device_dismissed_ids' // Defend against bleed from other test files that fully mock '@/db/powersync/sync-state'. diff --git a/src/components/revoked-device-modal.test.tsx b/src/components/revoked-device-modal.test.tsx index 785d5d038..d9e111bca 100644 --- a/src/components/revoked-device-modal.test.tsx +++ b/src/components/revoked-device-modal.test.tsx @@ -10,15 +10,15 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' import { RevokedDeviceModal } from './revoked-device-modal' -const mockClearLocalData = mock(() => Promise.resolve()) -mock.module('@/lib/cleanup', () => ({ - clearLocalData: mockClearLocalData, -})) +const mockSignOutAndWipe = mock(async ({ onComplete }: { signOut?: () => Promise; onComplete: () => void }) => { + onComplete() +}) const mockReplace = mock() Object.defineProperty(window, 'location', { value: { replace: mockReplace }, writable: true, + configurable: true, }) describe('RevokedDeviceModal', () => { @@ -32,21 +32,23 @@ describe('RevokedDeviceModal', () => { beforeEach(async () => { await resetTestDatabase() - mockClearLocalData.mockClear() + mockSignOutAndWipe.mockClear() mockReplace.mockClear() }) const renderModal = (props: Partial<{ open: boolean }> = {}) => - render(, { + render(, { wrapper: createTestProvider(), }) describe('rendering', () => { - it('renders when open', () => { + it('renders title and wipe-warning description when open', () => { renderModal({ open: true }) expect(screen.getByRole('heading', { name: 'Device access revoked' })).toBeInTheDocument() expect( - screen.getByText('This device has been signed out remotely. Choose what to do with your local data.'), + screen.getByText( + 'This device has been signed out remotely. Your local chats, settings, and cached data will be removed from this device.', + ), ).toBeInTheDocument() }) @@ -55,52 +57,26 @@ describe('RevokedDeviceModal', () => { expect(screen.queryByRole('heading', { name: 'Device access revoked' })).not.toBeInTheDocument() }) - it('displays both data options', () => { + it('displays a single destructive confirm button', () => { renderModal() - expect(screen.getByText('Keep data on device')).toBeInTheDocument() - expect(screen.getByText('Delete data from device')).toBeInTheDocument() + const button = screen.getByRole('button', { name: 'Confirm' }) + expect(button).toBeInTheDocument() + expect(button.className).toContain('destructive') }) - it('displays confirm button', () => { + it('does not offer a "keep my data" affordance', () => { renderModal() - expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument() + expect(screen.queryByText(/keep data/i)).not.toBeInTheDocument() }) - it('does not show close button', () => { + it('has no cancel button (revocation is non-optional)', () => { renderModal() - expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() - }) - }) - - describe('option selection', () => { - it('has "keep" option selected by default', () => { - renderModal() - const keepOption = screen.getByText('Keep data on device').closest('button') - expect(keepOption?.querySelector('.bg-primary')).toBeInTheDocument() - }) - - it('selects "delete" option when clicked', () => { - renderModal() - const deleteOption = screen.getByText('Delete data from device').closest('button')! - fireEvent.click(deleteOption) - expect(deleteOption.querySelector('.bg-destructive')).toBeInTheDocument() - }) - - it('allows switching between options', () => { - renderModal() - const keepOption = screen.getByText('Keep data on device').closest('button')! - const deleteOption = screen.getByText('Delete data from device').closest('button')! - - fireEvent.click(deleteOption) - expect(deleteOption.querySelector('.bg-destructive')).toBeInTheDocument() - - fireEvent.click(keepOption) - expect(keepOption.querySelector('.bg-primary')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() }) }) - describe('confirm flow with keep data', () => { - it('calls window.location.replace("/") when confirming with keep option', async () => { + describe('confirm flow', () => { + it('invokes signOutAndWipe with no signOut and an onComplete that replaces to /', async () => { renderModal() fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) @@ -108,61 +84,12 @@ describe('RevokedDeviceModal', () => { await getClock().runAllAsync() }) - expect(mockClearLocalData).toHaveBeenCalledWith({ clearDatabase: false }) + expect(mockSignOutAndWipe).toHaveBeenCalledTimes(1) + const arg = mockSignOutAndWipe.mock.calls[0][0] + expect(arg.signOut).toBeUndefined() + expect(typeof arg.onComplete).toBe('function') + // The mock invokes onComplete itself; assert the side-effect that landed. expect(mockReplace).toHaveBeenCalledWith('/') }) - - it('shows loading state during confirm', async () => { - renderModal() - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) - - await act(async () => { - await getClock().tickAsync(0) - }) - - expect(screen.getByText('Signing out...')).toBeInTheDocument() - - await act(async () => { - await getClock().runAllAsync() - }) - }) - }) - - describe('confirm flow with delete data', () => { - it('calls clearLocalData with clearDatabase and window.location.replace when confirming with delete option', async () => { - renderModal() - fireEvent.click(screen.getByText('Delete data from device').closest('button')!) - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(mockClearLocalData).toHaveBeenCalledWith({ clearDatabase: true }) - expect(mockReplace).toHaveBeenCalledWith('/') - }) - - it('shows delete-specific loading text', async () => { - renderModal() - fireEvent.click(screen.getByText('Delete data from device').closest('button')!) - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) - - await act(async () => { - await getClock().tickAsync(0) - }) - - expect(screen.getByText('Deleting...')).toBeInTheDocument() - - await act(async () => { - await getClock().runAllAsync() - }) - }) - - it('uses destructive button variant when delete is selected', () => { - renderModal() - fireEvent.click(screen.getByText('Delete data from device').closest('button')!) - const confirmButton = screen.getByRole('button', { name: 'Confirm' }) - expect(confirmButton.className).toContain('destructive') - }) }) }) diff --git a/src/components/revoked-device-modal.tsx b/src/components/revoked-device-modal.tsx index d5d89ebec..6cae22c52 100644 --- a/src/components/revoked-device-modal.tsx +++ b/src/components/revoked-device-modal.tsx @@ -2,84 +2,63 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { HardDrive, Loader2, Trash2 } from 'lucide-react' -import { useState } from 'react' - -import { Button } from '@/components/ui/button' +import { useRef } from 'react' import { - ResponsiveModal, - ResponsiveModalContent, - ResponsiveModalDescription, - ResponsiveModalFooter, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from '@/components/ui/responsive-modal' -import { SelectableCard, type DataOption } from '@/components/ui/selectable-card' -import { clearLocalData } from '@/lib/cleanup' + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { signOutAndWipe as defaultSignOutAndWipe } from '@/lib/cleanup' type RevokedDeviceModalProps = { open: boolean + /** + * Injectable for tests (mirrors the LogoutModal pattern). Avoids a + * `mock.module('@/lib/cleanup', ...)` here that would leak across files + * (see `docs/development/testing.md` §65). The shipped UI never overrides. + */ + signOutAndWipe?: typeof defaultSignOutAndWipe } -export const RevokedDeviceModal = ({ open }: RevokedDeviceModalProps) => { - const [selectedOption, setSelectedOption] = useState('keep') - const [isProcessing, setIsProcessing] = useState(false) +export const RevokedDeviceModal = ({ open, signOutAndWipe = defaultSignOutAndWipe }: RevokedDeviceModalProps) => { + const wipingRef = useRef(false) - const handleConfirm = async () => { - setIsProcessing(true) - await clearLocalData({ clearDatabase: selectedOption === 'delete' }) - window.location.replace('/') + // Server kicked this device — the active server's auth token is gone, encryption + // keys are useless without it, and the per-trust-domain DB file has no path back + // to the user. Per the THU-549 wipe model (addendum decision #16), revocation + // takes the same path as voluntary logout (minus the Better Auth signOut call — + // the server already invalidated the session). The user has no opt-out — there's + // no Cancel button: confirm is the only path. + const handleConfirm = () => { + if (wipingRef.current) { + return + } + wipingRef.current = true + void signOutAndWipe({ + onComplete: () => window.location.replace('/'), + }) } return ( - {}} - showCloseButton={false} - onInteractOutside={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > - - Device access revoked - - This device has been signed out remotely. Choose what to do with your local data. - - - - - setSelectedOption('keep')} - icon={} - title="Keep data on device" - description="Your chats and settings will remain on this device for offline use." - /> - setSelectedOption('delete')} - icon={} - title="Delete data from device" - description="Remove all chats, settings, and cached data from this device." - variant="destructive" - /> - - - - - - + + e.preventDefault()}> + + Device access revoked + + This device has been signed out remotely. Your local chats, settings, and cached data will be removed from + this device. + + + + + Confirm + + + + ) } diff --git a/src/components/settings/agents/agent-list.test.tsx b/src/components/settings/agents/agent-list.test.tsx index 5c136bd58..8e43d3de8 100644 --- a/src/components/settings/agents/agent-list.test.tsx +++ b/src/components/settings/agents/agent-list.test.tsx @@ -64,6 +64,14 @@ describe('canDeleteAgent', () => { it('returns false when no user is signed in', () => { expect(canDeleteAgent(customAgent, null)).toBe(false) }) + + it('returns false when the workspace `remove_agents` permission denies the user', () => { + expect(canDeleteAgent(customAgent, 'user-42', false)).toBe(false) + }) + + it('returns true when canRemoveAgents defaults (omitted), preserving prior behaviour', () => { + expect(canDeleteAgent(customAgent, 'user-42')).toBe(true) + }) }) describe('canEditAgent', () => { @@ -173,6 +181,40 @@ describe('AgentList', () => { expect(screen.getByTestId(`agent-toggle-${customAgent.id}`)).not.toBeDisabled() }) + + it('hides the Remove affordance on every row when canRemoveAgents is false', () => { + const onToggle = mock(() => {}) + const onDelete = mock(() => {}) + + render( + , + ) + + expect(screen.queryByTestId(`agent-delete-${customAgent.id}`)).not.toBeInTheDocument() + }) + + it('disables the row toggle when canEditAgents is false (even for an owned custom)', () => { + const onToggle = mock(() => {}) + const onDelete = mock(() => {}) + + render( + , + ) + + expect(screen.getByTestId(`agent-toggle-${customAgent.id}`)).toBeDisabled() + }) }) describe('agentToggleDisabled', () => { diff --git a/src/components/settings/agents/agent-list.tsx b/src/components/settings/agents/agent-list.tsx index 788b7cf86..904289c6e 100644 --- a/src/components/settings/agents/agent-list.tsx +++ b/src/components/settings/agents/agent-list.tsx @@ -8,6 +8,11 @@ import type { Agent } from '@/types/acp' type AgentListProps = { agents: Agent[] currentUserId: string | null + /** Defaults to true. Mirrors `add_agents`; gates the row enable/disable toggle. */ + canEditAgents?: boolean + /** Defaults to true — when false the Remove affordance is hidden on every row. + * Mirrors the workspace `remove_agents` permission; the BE is authoritative. */ + canRemoveAgents?: boolean onToggle: (agent: Agent, enabled: boolean) => void onEdit: (agent: Agent) => void onDelete: (agent: Agent) => void @@ -16,13 +21,23 @@ type AgentListProps = { /** Renders the unified agent list returned by `useAllAgents` (built-in first, * then system, then user customs). Composition lives in the DAL — this * component is purely visual + event-dispatching so it stays trivial to test. */ -export const AgentList = ({ agents, currentUserId, onToggle, onEdit, onDelete }: AgentListProps) => ( +export const AgentList = ({ + agents, + currentUserId, + canEditAgents = true, + canRemoveAgents = true, + onToggle, + onEdit, + onDelete, +}: AgentListProps) => (
{agents.map((agent) => ( { /** Predicate for the delete action's visibility. Customs the current user owns * can be soft-deleted; built-in and system agents are managed externally and * must not be removable from the UI. Exported for unit testing without - * rendering the full row tree. */ -export const canDeleteAgent = (agent: Agent, currentUserId: string | null): boolean => { + * rendering the full row tree. + * + * `canRemoveAgents` reflects the workspace `remove_agents` permission — when + * false, no row is removable regardless of ownership. Defaults to true so + * existing callers keep working. + */ +export const canDeleteAgent = ( + agent: Agent, + currentUserId: string | null, + canRemoveAgents: boolean = true, +): boolean => { if (agent.type === 'built-in') { return false } @@ -47,6 +56,9 @@ export const canDeleteAgent = (agent: Agent, currentUserId: string | null): bool if (!currentUserId) { return false } + if (!canRemoveAgents) { + return false + } return agent.userId === currentUserId } @@ -73,17 +85,32 @@ export const agentToggleDisabled = (agent: Agent): { disabled: boolean; disabled type AgentRowProps = { agent: Agent currentUserId: string | null + /** Defaults to true. Mirrors the workspace `add_agents` permission — also + * used for the enable/disable toggle since toggling is a PATCH the BE + * gates on `add_agents`. */ + canEditAgents?: boolean + /** Defaults to true. Mirrors the workspace `remove_agents` permission. */ + canRemoveAgents?: boolean onToggle: (agent: Agent, enabled: boolean) => void onEdit: (agent: Agent) => void onDelete: (agent: Agent) => void } -export const AgentRow = ({ agent, currentUserId, onToggle, onEdit, onDelete }: AgentRowProps) => { +export const AgentRow = ({ + agent, + currentUserId, + canEditAgents = true, + canRemoveAgents = true, + onToggle, + onEdit, + onDelete, +}: AgentRowProps) => { const Icon = iconForAgent(agent) const badge = badgeForAgent(agent) const showEdit = canEditAgent(agent, currentUserId) - const showDelete = canDeleteAgent(agent, currentUserId) + const showDelete = canDeleteAgent(agent, currentUserId, canRemoveAgents) const { disabled: toggleDisabled, disabledTooltip } = agentToggleDisabled(agent) + const finalToggleDisabled = toggleDisabled || !canEditAgents const isEnabled = agent.enabled === 1 const [deleteOpen, setDeleteOpen] = useState(false) @@ -120,7 +147,7 @@ export const AgentRow = ({ agent, currentUserId, onToggle, onEdit, onDelete }: A onToggle(agent, checked)} />
diff --git a/src/components/sidebar-footer.tsx b/src/components/sidebar-footer.tsx index bf26f0506..84c441617 100644 --- a/src/components/sidebar-footer.tsx +++ b/src/components/sidebar-footer.tsx @@ -19,6 +19,7 @@ import { useSidebar, } from '@/components/ui/sidebar' import { useAuth, useSignInModal } from '@/contexts' +import { useWorkspaceUrl } from '@/lib/active-workspace' import { getDownloadUrl } from '@/lib/download-links' import { isWebDesktopPlatform, isTauri } from '@/lib/platform' import { edgeSpacing, mobileSidebarWidthRatio } from '@/lib/constants' @@ -79,6 +80,8 @@ export const SidebarFooter = ({ className }: SidebarFooterProps) => { const { openSignInModal } = useSignInModal() const [logoutModalOpen, setLogoutModalOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false) + const devSettingsUrl = useWorkspaceUrl('/settings/dev-settings') + const messageSimulatorUrl = useWorkspaceUrl('/message-simulator') // On mobile, always treat the sidebar as expanded when it's open const isExpanded = isMobile || state === 'expanded' @@ -258,13 +261,13 @@ export const SidebarFooter = ({ className }: SidebarFooterProps) => { } label="Dev Settings" - to="/settings/dev-settings" + to={devSettingsUrl} onNavigate={handleMenuNavigate} /> } label="Message Simulator" - to="/message-simulator" + to={messageSimulatorUrl} onNavigate={handleMenuNavigate} /> diff --git a/src/components/sign-in-modal.test.tsx b/src/components/sign-in-modal.test.tsx index a2e3c1510..b4e2f2e44 100644 --- a/src/components/sign-in-modal.test.tsx +++ b/src/components/sign-in-modal.test.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { resetTestDatabase, setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useTrustDomainRegistry } from '@/stores/trust-domain-registry' import type { ConsoleSpies } from '@/test-utils/console-spies' import { setupConsoleSpy } from '@/test-utils/console-spies' import { createTestProvider } from '@/test-utils/test-provider' @@ -87,12 +87,15 @@ describe('SignInModal', () => { const { httpClient, fetchSpy } = createSpyHttpClient(undefined, waitlistResponse) mockHttpClient = httpClient mockFetchSpy = fetchSpy - // Pin `cloudUrl` to a localhost value so the post-submit assertion in - // `shows sent state after successful submission` is deterministic. The - // store *defaults* to localhost, but `VITE_THUNDERBOLT_CLOUD_URL` - // overrides that default in CI — leaving the test order-dependent - // under `--randomize`. See THU-561. - useLocalSettingsStore.setState({ cloudUrl: 'http://localhost:8000/v1' }) + // Pin the active server's cloudUrl to a localhost value so the post-submit assertion + // in `shows sent state after successful submission` is deterministic. The env + // `VITE_THUNDERBOLT_CLOUD_URL` overrides the default in CI — leaving the test + // order-dependent under `--randomize` without this fixture. See THU-561. + const fixtureServerId = '00000000-0000-0000-0000-000000000001' + useTrustDomainRegistry.setState({ + servers: { [fixtureServerId]: { serverId: fixtureServerId, cloudUrl: 'http://localhost:8000/v1' } }, + activeTrustDomain: { kind: 'server', serverId: fixtureServerId }, + }) }) afterEach(async () => { diff --git a/src/components/sign-in/sign-in-form.tsx b/src/components/sign-in/sign-in-form.tsx index b447433be..71a69c6a2 100644 --- a/src/components/sign-in/sign-in-form.tsx +++ b/src/components/sign-in/sign-in-form.tsx @@ -4,7 +4,7 @@ import { useAuth, useHttpClient } from '@/contexts' import { useSettings } from '@/hooks/use-settings' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useActiveCloudUrl } from '@/stores/trust-domain-registry' import { isLocalhostUrl } from '@/lib/utils' import { type ReactNode, type RefObject, useCallback, useEffect } from 'react' import { SignInEmailStep } from './sign-in-email-step' @@ -79,7 +79,7 @@ export const SignInForm = ({ }: SignInFormProps) => { const authClient = useAuth() const httpClient = useHttpClient() - const cloudUrl = useLocalSettingsStore((s) => s.cloudUrl) + const cloudUrl = useActiveCloudUrl() ?? '' const { preferredName } = useSettings({ preferred_name: '' }) const isLocalhost = isLocalhostUrl(cloudUrl) const displayName = preferredName.value as string diff --git a/src/components/sign-in/use-sign-in-form-state.ts b/src/components/sign-in/use-sign-in-form-state.ts index b2fe3cafd..1adeaebb3 100644 --- a/src/components/sign-in/use-sign-in-form-state.ts +++ b/src/components/sign-in/use-sign-in-form-state.ts @@ -9,6 +9,7 @@ import { HttpError, type HttpClient } from '@/lib/http' import { getOtpErrorMessage } from '@/lib/otp-error-messages' import { updateSettings } from '@/dal' import { getDb, getDatabaseInstance } from '@/db/database' +import { runPostAuthBootstrap } from '@/lib/post-auth-bootstrap' import { isValidEmailFormat } from '@/lib/utils' import { useReducer, type FormEvent } from 'react' @@ -238,6 +239,24 @@ export const useSignInFormState = ({ analytics.onPromotionSuccess(result.data.user.id) } + // Post-auth pipeline: connect sync, await the personal workspace, reconcile + // defaults. Fires here so the success step doesn't appear until the user can + // actually use the app. The dedupe in `runPostAuthBootstrap` keeps this + // safe even if `SessionToWorkspaceBootstrap` is firing in parallel. + if (result.data?.user?.id) { + try { + await runPostAuthBootstrap({ + kind: 'server', + userId: result.data.user.id, + isAnonymous: result.data.user.isAnonymous === true, + }) + } catch (bootstrapError) { + console.error('Post-auth bootstrap failed:', bootstrapError) + dispatch({ type: 'VERIFY_ERROR', payload: 'Could not sync your account. Please retry.' }) + return + } + } + // Sign-in successful - show success state dispatch({ type: 'VERIFY_SUCCESS' }) } catch (error) { diff --git a/src/components/sso-redirect.tsx b/src/components/sso-redirect.tsx index ea2cb2654..f266b3597 100644 --- a/src/components/sso-redirect.tsx +++ b/src/components/sso-redirect.tsx @@ -8,7 +8,7 @@ import { setAuthToken } from '@/lib/auth-token' import { isSafeUrl } from '@/lib/url-utils' import { isTauri } from '@/lib/platform' import { startSsoFlowLoopback } from '@/lib/sso-loopback' -import { useLocalSettingsStore } from '@/stores/local-settings-store' +import { useActiveCloudUrl } from '@/stores/trust-domain-registry' import { useAnonymousPromotionAnalytics } from '@/lib/analytics/use-anonymous-promotion-analytics' import { useAuth } from '@/contexts' import Loading from '@/loading' @@ -21,7 +21,7 @@ import Loading from '@/loading' * of navigating the webview (WKWebView drops cookies during cross-origin redirects). */ const SsoRedirect = () => { - const cloudUrl = useLocalSettingsStore((s) => s.cloudUrl) + const cloudUrl = useActiveCloudUrl() ?? '' const authClient = useAuth() const analytics = useAnonymousPromotionAnalytics() const [error, setError] = useState(false) diff --git a/src/components/ui/chat-nav-button.tsx b/src/components/ui/chat-nav-button.tsx index 4eab648f4..207a467c4 100644 --- a/src/components/ui/chat-nav-button.tsx +++ b/src/components/ui/chat-nav-button.tsx @@ -19,7 +19,7 @@ import { cn } from '@/lib/utils' import { Slot } from '@radix-ui/react-slot' import { Ellipsis, Trash2 } from 'lucide-react' import { type HTMLAttributes } from 'react' -import { useNavigate } from 'react-router' +import { useWorkspaceNavigate } from '@/lib/active-workspace' type ChatNavButtonProps = HTMLAttributes & { chatTitle: string @@ -29,7 +29,7 @@ type ChatNavButtonProps = HTMLAttributes & { export const ChatNavButton = ({ chatTitle, threadId, className, asChild = false, ...props }: ChatNavButtonProps) => { const Comp = asChild ? Slot : 'div' - const navigate = useNavigate() + const navigate = useWorkspaceNavigate() const handleButtonClick = () => { navigate(`/chats/${threadId}`) diff --git a/src/components/ui/email-chip-input.test.tsx b/src/components/ui/email-chip-input.test.tsx new file mode 100644 index 000000000..901152b5c --- /dev/null +++ b/src/components/ui/email-chip-input.test.tsx @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import '@testing-library/jest-dom' +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { EmailChipInput } from './email-chip-input' + +const placeholderText = 'Enter emails…' + +const Harness = ({ initial = [] as string[] }: { initial?: string[] }) => { + const [value, setValue] = useState(initial) + return ( + <> + +
{JSON.stringify(value)}
+ + ) +} + +const findInput = (initial: string[] = []): HTMLInputElement => { + // Placeholder only renders when value is empty; fall back to the email + // autocomplete attribute (the input always has it) when we seeded chips. + if (initial.length === 0) { + return screen.getByPlaceholderText(placeholderText) as HTMLInputElement + } + return screen.getByDisplayValue('') as HTMLInputElement +} + +const typeAndCommit = (input: HTMLInputElement, text: string, commitKey: 'Enter' | ',' | ' ') => { + fireEvent.change(input, { target: { value: text } }) + fireEvent.keyDown(input, { key: commitKey }) +} + +describe('EmailChipInput', () => { + afterEach(() => { + cleanup() + }) + + it('commits a valid email as a chip on Enter', () => { + render() + const input = findInput() + typeAndCommit(input, 'alice@test.com', 'Enter') + expect(screen.getByTestId('email-chip-alice@test.com')).toBeInTheDocument() + expect(screen.getByTestId('value-json')).toHaveTextContent('["alice@test.com"]') + expect(input).toHaveValue('') + }) + + it('commits on comma', () => { + render() + typeAndCommit(findInput(), 'a@test.com', ',') + expect(screen.getByTestId('email-chip-a@test.com')).toBeInTheDocument() + }) + + it('commits on space', () => { + render() + typeAndCommit(findInput(), 'b@test.com', ' ') + expect(screen.getByTestId('email-chip-b@test.com')).toBeInTheDocument() + }) + + it('normalizes to lowercase + trim', () => { + render() + typeAndCommit(findInput(), ' Mixed@TEST.com ', 'Enter') + expect(screen.getByTestId('email-chip-mixed@test.com')).toBeInTheDocument() + expect(screen.getByTestId('value-json')).toHaveTextContent('["mixed@test.com"]') + }) + + it('does not commit invalid email; shows error; leaves text in input', () => { + render() + const input = findInput() + typeAndCommit(input, 'not-an-email', 'Enter') + expect(screen.queryByTestId(/email-chip-/)).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent(/not a valid email/i) + expect(input).toHaveValue('not-an-email') + }) + + it('clears error when the user types again', () => { + render() + const input = findInput() + typeAndCommit(input, 'bad', 'Enter') + expect(screen.getByRole('alert')).toBeInTheDocument() + fireEvent.change(input, { target: { value: 'badx' } }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('dedupes within the chip list', () => { + render() + typeAndCommit(findInput(['alice@test.com']), 'alice@test.com', 'Enter') + expect(screen.getAllByTestId(/email-chip-/)).toHaveLength(1) + }) + + it('backspace on empty input removes the last chip', () => { + render() + fireEvent.keyDown(findInput(['a@test.com', 'b@test.com']), { key: 'Backspace' }) + expect(screen.queryByTestId('email-chip-b@test.com')).not.toBeInTheDocument() + expect(screen.getByTestId('email-chip-a@test.com')).toBeInTheDocument() + }) + + it('does not remove chips when backspacing with text in input', () => { + render() + const input = findInput(['a@test.com', 'b@test.com']) + fireEvent.change(input, { target: { value: 'x' } }) + fireEvent.keyDown(input, { key: 'Backspace' }) + expect(screen.getByTestId('email-chip-a@test.com')).toBeInTheDocument() + expect(screen.getByTestId('email-chip-b@test.com')).toBeInTheDocument() + }) + + it('clicking the chip × removes that chip', () => { + render() + fireEvent.click(screen.getByLabelText('Remove a@test.com')) + expect(screen.queryByTestId('email-chip-a@test.com')).not.toBeInTheDocument() + expect(screen.getByTestId('email-chip-b@test.com')).toBeInTheDocument() + }) + + it('paste of comma-separated emails commits all valid + reports invalid', () => { + render() + const input = findInput() + fireEvent.paste(input, { + clipboardData: { + getData: () => 'one@test.com, two@test.com; not-email three@test.com', + }, + }) + expect(screen.getByTestId('email-chip-one@test.com')).toBeInTheDocument() + expect(screen.getByTestId('email-chip-two@test.com')).toBeInTheDocument() + expect(screen.getByTestId('email-chip-three@test.com')).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent(/not-email/) + }) + + it('commits the current draft on blur', () => { + render() + const input = findInput() + fireEvent.change(input, { target: { value: 'blur@test.com' } }) + fireEvent.blur(input) + expect(screen.getByTestId('email-chip-blur@test.com')).toBeInTheDocument() + }) +}) diff --git a/src/components/ui/email-chip-input.tsx b/src/components/ui/email-chip-input.tsx new file mode 100644 index 000000000..0d9e98db8 --- /dev/null +++ b/src/components/ui/email-chip-input.tsx @@ -0,0 +1,206 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { cn, isValidEmailFormat } from '@/lib/utils' +import { X } from 'lucide-react' +import { type ClipboardEvent, type KeyboardEvent, useId, useRef, useState } from 'react' + +const separatorRegex = /[\s,;]+/ + +const normalize = (email: string): string => email.toLowerCase().trim() + +type EmailChipInputProps = { + value: string[] + onChange: (next: string[]) => void + placeholder?: string + disabled?: boolean + className?: string + /** Optional id for the visible input (use to associate a