From b9a80dcf226c50c8778a70443e41557980376a03 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:44:19 -0500 Subject: [PATCH] feat: implement multi-user access and invite flows (#3784) ## Thinking Path > - Paperclip is the control plane for autonomous AI companies. > - V1 needs to stay local-first while also supporting shared, authenticated deployments. > - Human operators need real identities, company membership, invite flows, profile surfaces, and company-scoped access controls. > - Agents and operators also need the existing issue, inbox, workspace, approval, and plugin flows to keep working under those authenticated boundaries. > - This branch accumulated the multi-user implementation, follow-up QA fixes, workspace/runtime refinements, invite UX improvements, release-branch conflict resolution, and review hardening. > - This pull request consolidates that branch onto the current `master` branch as a single reviewable PR. > - The benefit is a complete multi-user implementation path with tests and docs carried forward without dropping existing branch work. ## What Changed - Added authenticated human-user access surfaces: auth/session routes, company user directory, profile settings, company access/member management, join requests, and invite management. - Added invite creation, invite landing, onboarding, logo/branding, invite grants, deduped join requests, and authenticated multi-user E2E coverage. - Tightened company-scoped and instance-admin authorization across board, plugin, adapter, access, issue, and workspace routes. - Added profile-image URL validation hardening, avatar preservation on name-only profile updates, and join-request uniqueness migration cleanup for pending human requests. - Added an atomic member role/status/grants update path so Company Access saves no longer leave partially updated permissions. - Improved issue chat, inbox, assignee identity rendering, sidebar/account/company navigation, workspace routing, and execution workspace reuse behavior for multi-user operation. - Added and updated server/UI tests covering auth, invites, membership, issue workspace inheritance, plugin authz, inbox/chat behavior, and multi-user flows. - Merged current `public-gh/master` into this branch, resolved all conflicts, and verified no `pnpm-lock.yaml` change is included in this PR diff. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts ui/src/components/IssueChatThread.test.tsx ui/src/pages/Inbox.test.tsx` - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/workspace-runtime-service-authz.test.ts server/src/__tests__/access-validators.test.ts` - `pnpm exec vitest run server/src/__tests__/authz-company-access.test.ts server/src/__tests__/routines-routes.test.ts server/src/__tests__/sidebar-preferences-routes.test.ts server/src/__tests__/approval-routes-idempotency.test.ts server/src/__tests__/openclaw-invite-prompt-route.test.ts server/src/__tests__/agent-cross-tenant-authz-routes.test.ts server/src/__tests__/routines-e2e.test.ts` - `pnpm exec vitest run server/src/__tests__/auth-routes.test.ts ui/src/pages/CompanyAccess.test.tsx` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm db:generate` - `npx playwright test --config tests/e2e/playwright.config.ts --list` - Confirmed branch has no uncommitted changes and is `0` commits behind `public-gh/master` before PR creation. - Confirmed no `pnpm-lock.yaml` change is staged or present in the PR diff. ## Risks - High review surface area: this PR contains the accumulated multi-user branch plus follow-up fixes, so reviewers should focus especially on company-boundary enforcement and authenticated-vs-local deployment behavior. - UI behavior changed across invites, inbox, issue chat, access settings, and sidebar navigation; no browser screenshots are included in this branch-consolidation PR. - Plugin install, upgrade, and lifecycle/config mutations now require instance-admin access, which is intentional but may change expectations for non-admin board users. - A join-request dedupe migration rejects duplicate pending human requests before creating unique indexes; deployments with unusual historical duplicates should review the migration behavior. - Company member role/status/grant saves now use a new combined endpoint; older separate endpoints remain for compatibility. - Full production build was not run locally in this heartbeat; CI should cover the full matrix. ## Model Used - OpenAI Codex coding agent, GPT-5-based model, CLI/tool-use environment. Exact deployed model identifier and context window were not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Note on screenshots: this is a branch-consolidation PR for an already-developed multi-user branch, and no browser screenshots were captured during this heartbeat. --------- Co-authored-by: dotta Co-authored-by: Paperclip Co-authored-by: Claude Opus 4.6 --- .gitignore | 3 + cli/README.md | 2 +- cli/src/__tests__/worktree.test.ts | 93 + doc/DEPLOYMENT-MODES.md | 1 + doc/PUBLISHING.md | 32 - doc/spec/invite-flow.md | 299 + docs/docs.json | 2 + package.json | 1 + .../scripts/create-auth-bootstrap-invite.ts | 84 + .../migrations/0057_tidy_join_requests.sql | 57 + .../db/src/migrations/meta/0057_snapshot.json | 13432 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 9 +- packages/db/src/schema/join_requests.ts | 7 + packages/shared/src/constants.ts | 24 + packages/shared/src/index.ts | 36 +- packages/shared/src/project-mentions.test.ts | 11 + packages/shared/src/project-mentions.ts | 40 + packages/shared/src/types/access.ts | 80 + packages/shared/src/types/index.ts | 10 + packages/shared/src/types/project.ts | 2 +- packages/shared/src/validators/access.ts | 86 + .../src/validators/execution-workspace.ts | 1 - packages/shared/src/validators/index.ts | 14 + releases/v2026.414.0.md | 107 + scripts/dev-runner.ts | 4 +- scripts/paperclip-commit-metrics.ts | 13 +- scripts/provision-worktree.sh | 42 +- server/src/__tests__/access-service.test.ts | 99 + .../src/__tests__/access-validators.test.ts | 33 + server/src/__tests__/adapter-routes.test.ts | 17 +- .../src/__tests__/agent-skills-routes.test.ts | 8 - server/src/__tests__/auth-routes.test.ts | 170 + .../src/__tests__/auth-session-route.test.ts | 61 + .../__tests__/authz-company-access.test.ts | 157 + .../__tests__/claude-local-execute.test.ts | 4 - .../src/__tests__/codex-local-execute.test.ts | 1 - .../company-user-directory-route.test.ts | 132 + .../execution-workspaces-service.test.ts | 134 - server/src/__tests__/health.test.ts | 10 +- .../heartbeat-process-recovery.test.ts | 3 +- .../heartbeat-workspace-session.test.ts | 38 - .../instance-settings-routes.test.ts | 16 + .../invite-accept-existing-member.test.ts | 126 + .../src/__tests__/invite-create-route.test.ts | 126 + server/src/__tests__/invite-expiry.test.ts | 4 +- .../src/__tests__/invite-join-grants.test.ts | 66 +- .../src/__tests__/invite-list-route.test.ts | 164 + .../src/__tests__/invite-logo-route.test.ts | 146 + .../__tests__/invite-summary-route.test.ts | 270 + .../__tests__/issue-feedback-routes.test.ts | 1 - server/src/__tests__/issues-service.test.ts | 278 +- .../src/__tests__/join-request-dedupe.test.ts | 104 + .../openclaw-invite-prompt-route.test.ts | 29 +- .../src/__tests__/plugin-routes-authz.test.ts | 29 + .../__tests__/shared-telemetry-events.test.ts | 73 + server/src/__tests__/worktree-config.test.ts | 107 + server/src/adapters/utils.ts | 1 - server/src/app.ts | 19 +- server/src/lib/join-request-dedupe.ts | 88 + server/src/middleware/auth.ts | 21 +- server/src/routes/access.ts | 1163 +- server/src/routes/adapters.ts | 20 +- server/src/routes/auth.ts | 100 + server/src/routes/authz.ts | 31 +- server/src/routes/health.ts | 6 +- server/src/routes/instance-settings.ts | 16 +- server/src/routes/plugins.ts | 48 +- server/src/routes/sidebar-badges.ts | 27 +- server/src/services/access.ts | 181 +- server/src/services/board-auth.ts | 11 +- server/src/services/company-member-roles.ts | 59 + server/src/services/invite-grants.ts | 68 + server/src/types/express.d.ts | 7 + server/src/worktree-config.ts | 14 +- tests/e2e/multi-user-authenticated.spec.ts | 326 + tests/e2e/multi-user.spec.ts | 518 + ...aywright-multiuser-authenticated.config.ts | 28 + tests/e2e/playwright-multiuser.config.ts | 26 + tests/e2e/playwright.config.ts | 3 + ui/src/App.test.tsx | 146 + ui/src/App.tsx | 84 +- ui/src/api/access.ts | 236 +- ui/src/api/auth.ts | 114 +- ui/src/components/ActivityRow.tsx | 11 +- ui/src/components/CloudAccessGate.tsx | 114 + ui/src/components/CompanyPatternIcon.tsx | 7 +- .../CompanySettingsSidebar.test.tsx | 137 + ui/src/components/CompanySettingsSidebar.tsx | 69 + .../components/ExecutionParticipantPicker.tsx | 39 +- .../ExecutionWorkspaceCloseDialog.tsx | 74 +- ui/src/components/InstanceSidebar.tsx | 4 +- ui/src/components/IssueChatThread.test.tsx | 36 +- ui/src/components/IssueChatThread.tsx | 269 +- ui/src/components/IssueColumns.tsx | 15 +- ui/src/components/IssueProperties.tsx | 60 +- ui/src/components/IssuesList.test.tsx | 57 + ui/src/components/IssuesList.tsx | 46 +- ui/src/components/Layout.test.tsx | 249 + ui/src/components/Layout.tsx | 126 +- ui/src/components/MarkdownBody.test.tsx | 8 +- ui/src/components/MarkdownBody.tsx | 2 + ui/src/components/MarkdownEditor.tsx | 33 +- ui/src/components/NewIssueDialog.tsx | 40 +- ui/src/components/NewProjectDialog.tsx | 28 +- ui/src/components/Sidebar.tsx | 11 +- ui/src/components/SidebarAccountMenu.test.tsx | 117 + ui/src/components/SidebarAccountMenu.tsx | 227 + ui/src/components/SidebarCompanyMenu.test.tsx | 125 + ui/src/components/SidebarCompanyMenu.tsx | 102 + .../access/CompanySettingsNav.test.tsx | 99 + .../components/access/CompanySettingsNav.tsx | 46 + ui/src/components/access/ModeBadge.tsx | 19 + ui/src/components/agent-config-primitives.tsx | 20 +- ui/src/context/LiveUpdatesProvider.test.ts | 52 - ui/src/context/LiveUpdatesProvider.tsx | 16 +- ui/src/hooks/useInboxBadge.ts | 9 +- ui/src/lib/activity-format.ts | 30 +- ui/src/lib/assignees.ts | 7 + ui/src/lib/company-members.test.ts | 105 + ui/src/lib/company-members.ts | 116 + ui/src/lib/company-routes.test.ts | 32 - ui/src/lib/inbox.test.ts | 83 +- ui/src/lib/inbox.ts | 21 +- ui/src/lib/invite-memory.ts | 36 + ui/src/lib/issue-chat-messages.test.ts | 21 + ui/src/lib/issue-chat-messages.ts | 27 +- ui/src/lib/mention-chips.ts | 15 +- ui/src/lib/project-workspaces-tab.ts | 112 +- ui/src/lib/queryKeys.ts | 7 + ui/src/pages/Activity.tsx | 14 + ui/src/pages/Auth.tsx | 6 +- ui/src/pages/CompanyAccess.test.tsx | 219 + ui/src/pages/CompanyAccess.tsx | 476 + ui/src/pages/CompanyInvites.test.tsx | 267 + ui/src/pages/CompanyInvites.tsx | 374 + ui/src/pages/Dashboard.tsx | 14 + ui/src/pages/Inbox.test.tsx | 67 +- ui/src/pages/Inbox.tsx | 78 +- ui/src/pages/InstanceAccess.tsx | 245 + ui/src/pages/InstanceGeneralSettings.tsx | 49 + ui/src/pages/InviteLanding.test.tsx | 657 + ui/src/pages/InviteLanding.tsx | 896 +- ui/src/pages/InviteUxLab.test.tsx | 52 + ui/src/pages/InviteUxLab.tsx | 927 ++ ui/src/pages/IssueDetail.tsx | 70 +- ui/src/pages/JoinRequestQueue.tsx | 194 + ui/src/pages/ProfileSettings.test.tsx | 133 + ui/src/pages/ProfileSettings.tsx | 273 + ui/src/pages/ProjectDetail.tsx | 1 - ui/src/pages/Routines.tsx | 11 +- 150 files changed, 26872 insertions(+), 1289 deletions(-) create mode 100644 doc/spec/invite-flow.md create mode 100644 packages/db/scripts/create-auth-bootstrap-invite.ts create mode 100644 packages/db/src/migrations/0057_tidy_join_requests.sql create mode 100644 packages/db/src/migrations/meta/0057_snapshot.json create mode 100644 releases/v2026.414.0.md create mode 100644 server/src/__tests__/access-service.test.ts create mode 100644 server/src/__tests__/access-validators.test.ts create mode 100644 server/src/__tests__/auth-routes.test.ts create mode 100644 server/src/__tests__/auth-session-route.test.ts create mode 100644 server/src/__tests__/authz-company-access.test.ts create mode 100644 server/src/__tests__/company-user-directory-route.test.ts create mode 100644 server/src/__tests__/invite-accept-existing-member.test.ts create mode 100644 server/src/__tests__/invite-create-route.test.ts create mode 100644 server/src/__tests__/invite-list-route.test.ts create mode 100644 server/src/__tests__/invite-logo-route.test.ts create mode 100644 server/src/__tests__/invite-summary-route.test.ts create mode 100644 server/src/__tests__/join-request-dedupe.test.ts create mode 100644 server/src/__tests__/shared-telemetry-events.test.ts create mode 100644 server/src/lib/join-request-dedupe.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/services/company-member-roles.ts create mode 100644 server/src/services/invite-grants.ts create mode 100644 tests/e2e/multi-user-authenticated.spec.ts create mode 100644 tests/e2e/multi-user.spec.ts create mode 100644 tests/e2e/playwright-multiuser-authenticated.config.ts create mode 100644 tests/e2e/playwright-multiuser.config.ts create mode 100644 ui/src/App.test.tsx create mode 100644 ui/src/components/CloudAccessGate.tsx create mode 100644 ui/src/components/CompanySettingsSidebar.test.tsx create mode 100644 ui/src/components/CompanySettingsSidebar.tsx create mode 100644 ui/src/components/Layout.test.tsx create mode 100644 ui/src/components/SidebarAccountMenu.test.tsx create mode 100644 ui/src/components/SidebarAccountMenu.tsx create mode 100644 ui/src/components/SidebarCompanyMenu.test.tsx create mode 100644 ui/src/components/SidebarCompanyMenu.tsx create mode 100644 ui/src/components/access/CompanySettingsNav.test.tsx create mode 100644 ui/src/components/access/CompanySettingsNav.tsx create mode 100644 ui/src/components/access/ModeBadge.tsx create mode 100644 ui/src/lib/company-members.test.ts create mode 100644 ui/src/lib/company-members.ts create mode 100644 ui/src/lib/invite-memory.ts create mode 100644 ui/src/pages/CompanyAccess.test.tsx create mode 100644 ui/src/pages/CompanyAccess.tsx create mode 100644 ui/src/pages/CompanyInvites.test.tsx create mode 100644 ui/src/pages/CompanyInvites.tsx create mode 100644 ui/src/pages/InstanceAccess.tsx create mode 100644 ui/src/pages/InviteLanding.test.tsx create mode 100644 ui/src/pages/InviteUxLab.test.tsx create mode 100644 ui/src/pages/InviteUxLab.tsx create mode 100644 ui/src/pages/JoinRequestQueue.tsx create mode 100644 ui/src/pages/ProfileSettings.test.tsx create mode 100644 ui/src/pages/ProfileSettings.tsx diff --git a/.gitignore b/.gitignore index f685aca695..cdeb446edc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ +node_modules node_modules/ +**/node_modules +**/node_modules/ dist/ .env *.tsbuildinfo diff --git a/cli/README.md b/cli/README.md index db92a53983..be390d19a0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -12,7 +12,7 @@

MIT License Stars - Discord + Discord


diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index b552bd283e..ce0f85ad53 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import { agents, + authUsers, companies, createDb, projects, @@ -47,6 +48,7 @@ import { const ORIGINAL_CWD = process.cwd(); const ORIGINAL_ENV = { ...process.env }; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip; const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { @@ -373,6 +375,97 @@ describe("worktree helpers", () => { } }); + itEmbeddedPostgres( + "seeds authenticated users into minimally cloned worktree instances", + async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); + const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed"); + const sourceHome = path.join(tempRoot, "source-home"); + const sourceConfigDir = path.join(sourceHome, "instances", "source"); + const sourceConfigPath = path.join(sourceConfigDir, "config.json"); + const sourceEnvPath = path.join(sourceConfigDir, ".env"); + const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key"); + const worktreeHome = path.join(tempRoot, ".paperclip-worktrees"); + const originalCwd = process.cwd(); + const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-"); + + try { + const sourceDbClient = createDb(sourceDb.connectionString); + await sourceDbClient.insert(authUsers).values({ + id: "user-existing", + email: "existing@paperclip.ing", + name: "Existing User", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); + fs.mkdirSync(worktreeRoot, { recursive: true }); + + const sourceConfig = buildSourceConfig(); + sourceConfig.database = { + mode: "postgres", + embeddedPostgresDataDir: path.join(sourceConfigDir, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(sourceConfigDir, "backups"), + }, + connectionString: sourceDb.connectionString, + }; + sourceConfig.logging.logDir = path.join(sourceConfigDir, "logs"); + sourceConfig.storage.localDisk.baseDir = path.join(sourceConfigDir, "storage"); + sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; + + fs.writeFileSync(sourceConfigPath, JSON.stringify(sourceConfig, null, 2) + "\n", "utf8"); + fs.writeFileSync(sourceEnvPath, "", "utf8"); + fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); + + process.chdir(worktreeRoot); + await worktreeInitCommand({ + name: "PAP-999-auth-seed", + home: worktreeHome, + fromConfig: sourceConfigPath, + force: true, + }); + + const targetConfig = JSON.parse( + fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"), + ) as PaperclipConfig; + const { default: EmbeddedPostgres } = await import("embedded-postgres"); + const targetPg = new EmbeddedPostgres({ + databaseDir: targetConfig.database.embeddedPostgresDataDir, + user: "paperclip", + password: "paperclip", + port: targetConfig.database.embeddedPostgresPort, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + await targetPg.start(); + try { + const targetDb = createDb( + `postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`, + ); + const seededUsers = await targetDb.select().from(authUsers); + expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true); + } finally { + await targetPg.stop(); + } + } finally { + process.chdir(originalCwd); + await sourceDb.cleanup(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, + 20000, + ); + it("avoids ports already claimed by sibling worktree instance configs", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-")); const repoRoot = path.join(tempRoot, "repo"); diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index a7b8d7fb0a..bf8dbdd4c7 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -142,3 +142,4 @@ This prevents lockout when a user migrates from long-running local trusted usage - implementation plan: `doc/plans/deployment-auth-mode-consolidation.md` - V1 contract: `doc/SPEC-implementation.md` - operator workflows: `doc/DEVELOPING.md` and `doc/CLI.md` +- invite/join state map: `doc/spec/invite-flow.md` diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index db56540dca..83a6c4a7be 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -115,38 +115,6 @@ If the first real publish returns npm `E404`, check npm-side prerequisites befor - The initial publish must include `--access public` for a public scoped package. - npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA. -### Manual first publish for `@paperclipai/mcp-server` - -If you need to publish only the MCP server package once by hand, use: - -- `@paperclipai/mcp-server` - -Recommended flow from the repo root: - -```bash -# optional sanity check: this 404s until the first publish exists -npm view @paperclipai/mcp-server version - -# make sure the build output is fresh -pnpm --filter @paperclipai/mcp-server build - -# confirm your local npm auth before the real publish -npm whoami - -# safe preview of the exact publish payload -cd packages/mcp-server -pnpm publish --dry-run --no-git-checks --access public - -# real publish -pnpm publish --no-git-checks --access public -``` - -Notes: - -- Publish from `packages/mcp-server/`, not the repo root. -- If `npm view @paperclipai/mcp-server version` already returns the same version that is in [`packages/mcp-server/package.json`](../packages/mcp-server/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh). -- The same npm-side prerequisites apply as above: valid npm auth, permission to publish to the `@paperclipai` scope, `--access public`, and the required publish auth/2FA policy. - ## Version formats Paperclip uses calendar versions: diff --git a/doc/spec/invite-flow.md b/doc/spec/invite-flow.md new file mode 100644 index 0000000000..504744b038 --- /dev/null +++ b/doc/spec/invite-flow.md @@ -0,0 +1,299 @@ +# Invite Flow State Map + +Status: Current implementation map +Date: 2026-04-13 + +This document maps the current invite creation and acceptance states implemented in: + +- `ui/src/pages/CompanyInvites.tsx` +- `ui/src/pages/CompanySettings.tsx` +- `ui/src/pages/InviteLanding.tsx` +- `server/src/routes/access.ts` +- `server/src/lib/join-request-dedupe.ts` + +## State Legend + +- Invite state: `active`, `revoked`, `accepted`, `expired` +- Join request status: `pending_approval`, `approved`, `rejected` +- Claim secret state for agent joins: `available`, `consumed`, `expired` +- Invite type: `company_join` or `bootstrap_ceo` +- Join type: `human`, `agent`, or `both` + +## Entity Lifecycle + +```mermaid +flowchart TD + Board[Board user on invite screen] + HumanInvite[Create human company invite] + OpenClawInvite[Generate OpenClaw invite prompt] + Active[Invite state: active] + Revoked[Invite state: revoked] + Expired[Invite state: expired] + Accepted[Invite state: accepted] + BootstrapDone[Bootstrap accepted
no join request] + HumanReuse{Matching human join request
already exists for same user/email?} + HumanPending[Join request
pending_approval] + HumanApproved[Join request
approved] + HumanRejected[Join request
rejected] + AgentPending[Agent join request
pending_approval
+ optional claim secret] + AgentApproved[Agent join request
approved] + AgentRejected[Agent join request
rejected] + ClaimAvailable[Claim secret available] + ClaimConsumed[Claim secret consumed] + ClaimExpired[Claim secret expired] + OpenClawReplay[Special replay path:
accepted invite can be POSTed again
for openclaw_gateway only] + + Board --> HumanInvite --> Active + Board --> OpenClawInvite --> Active + Active --> Revoked: revoke + Active --> Expired: expiresAt passes + + Active --> BootstrapDone: bootstrap_ceo accept + BootstrapDone --> Accepted + + Active --> HumanReuse: human accept + HumanReuse --> HumanPending: reuse existing pending request + HumanReuse --> HumanApproved: reuse existing approved request + HumanReuse --> HumanPending: no reusable request
create new request + HumanPending --> HumanApproved: board approves + HumanPending --> HumanRejected: board rejects + HumanPending --> Accepted + HumanApproved --> Accepted + + Active --> AgentPending: agent accept + AgentPending --> Accepted + AgentPending --> AgentApproved: board approves + AgentPending --> AgentRejected: board rejects + AgentApproved --> ClaimAvailable: createdAgentId + claimSecretHash + ClaimAvailable --> ClaimConsumed: POST claim-api-key succeeds + ClaimAvailable --> ClaimExpired: secret expires + + Accepted --> OpenClawReplay + OpenClawReplay --> AgentPending + OpenClawReplay --> AgentApproved +``` + +## Board-Side Screen States + +```mermaid +stateDiagram-v2 + [*] --> CompanySelection + + CompanySelection --> NoCompany: no company selected + CompanySelection --> LoadingHistory: selectedCompanyId present + LoadingHistory --> HistoryError: listInvites failed + LoadingHistory --> Ready: listInvites succeeded + + state Ready { + [*] --> EmptyHistory + EmptyHistory --> PopulatedHistory: invites exist + PopulatedHistory --> LoadingMore: View more + LoadingMore --> PopulatedHistory: next page loaded + + PopulatedHistory --> RevokePending: Revoke active invite + RevokePending --> PopulatedHistory: revoke succeeded + RevokePending --> PopulatedHistory: revoke failed + + EmptyHistory --> CreatePending: Create invite + PopulatedHistory --> CreatePending: Create invite + CreatePending --> LatestInviteVisible: create succeeded + CreatePending --> Ready: create failed + LatestInviteVisible --> CopiedToast: clipboard copy succeeded + LatestInviteVisible --> Ready: navigate away or refresh + } + + CompanySelection --> OpenClawPromptReady: Company settings prompt generator + OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt + OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated + OpenClawPromptPending --> OpenClawPromptReady: generation failed +``` + +## Invite Landing Screen States + +```mermaid +stateDiagram-v2 + [*] --> TokenGate + + TokenGate --> InvalidToken: token missing + TokenGate --> Loading: token present + Loading --> InviteUnavailable: invite fetch failed or invite not returned + Loading --> CheckingAccess: signed-in session and invite.companyId + Loading --> InviteResolved: invite loaded without membership check + Loading --> AcceptedInviteSummary: invite already consumed
but linked join request still exists + + CheckingAccess --> RedirectToBoard: current user already belongs to company + CheckingAccess --> InviteResolved: membership check finished and no join-request summary state is active + CheckingAccess --> AcceptedInviteSummary: membership check finished and invite has joinRequestStatus + + state InviteResolved { + [*] --> Branch + Branch --> AgentForm: company_join + allowedJoinTypes=agent + Branch --> InlineAuth: authenticated mode + no session + join is not agent-only + Branch --> AcceptReady: bootstrap invite or human-ready session/local_trusted + + InlineAuth --> InlineAuth: toggle sign-up/sign-in + InlineAuth --> InlineAuth: auth validation or auth error message + InlineAuth --> RedirectToBoard: auth succeeded and company membership already exists + InlineAuth --> AcceptPending: auth succeeded and invite still needs acceptance + + AgentForm --> AcceptPending: submit request + AgentForm --> AgentForm: validation or accept error + + AcceptReady --> AcceptPending: Accept invite + AcceptReady --> AcceptReady: accept error + } + + AcceptPending --> BootstrapComplete: bootstrapAccepted=true + AcceptPending --> RedirectToBoard: join status=approved + AcceptPending --> PendingApprovalResult: join status=pending_approval + AcceptPending --> RejectedResult: join status=rejected + + state AcceptedInviteSummary { + [*] --> SummaryBranch + SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval + SummaryBranch --> OpeningCompany: joinRequestStatus=approved
and human invite user is now a member + SummaryBranch --> RejectedReload: joinRequestStatus=rejected + SummaryBranch --> ConsumedReload: approved agent invite or other consumed state + } + + PendingApprovalResult --> PendingApprovalReload: reload after submit + RejectedResult --> RejectedReload: reload after board rejects + RedirectToBoard --> OpeningCompany: brief pre-navigation render when approved membership is detected + OpeningCompany --> RedirectToBoard: navigate to board +``` + +## Sequence Diagrams + +### Human Invite Creation And First Acceptance + +```mermaid +sequenceDiagram + autonumber + actor Board as Board user + participant Settings as Company Invites UI + participant API as Access routes + participant Invites as invites table + actor Invitee as Invite recipient + participant Landing as Invite landing UI + participant Auth as Auth session + participant Join as join_requests table + + Board->>Settings: Choose role and click Create invite + Settings->>API: POST /api/companies/:companyId/invites + API->>Invites: Insert active invite + API-->>Settings: inviteUrl + metadata + + Invitee->>Landing: Open invite URL + Landing->>API: GET /api/invites/:token + API->>Invites: Load active invite + API-->>Landing: Invite summary + + alt Authenticated mode and no session + Landing->>Auth: Sign up or sign in + Auth-->>Landing: Session established + end + + Landing->>API: POST /api/invites/:token/accept (requestType=human) + API->>Join: Look for reusable human join request + alt Reusable pending or approved request exists + API->>Invites: Mark invite accepted + API-->>Landing: Existing join request status + else No reusable request exists + API->>Invites: Mark invite accepted + API->>Join: Insert pending_approval join request + API-->>Landing: New pending_approval join request + end +``` + +### Human Approval And Reload Path + +```mermaid +sequenceDiagram + autonumber + actor Invitee as Invite recipient + participant Landing as Invite landing UI + participant API as Access routes + participant Join as join_requests table + actor Approver as Company admin + participant Queue as Access queue UI + participant Membership as company_memberships + grants + + Invitee->>Landing: Reload consumed invite URL + Landing->>API: GET /api/invites/:token + API->>Join: Load join request by inviteId + API-->>Landing: joinRequestStatus + joinRequestType + + alt joinRequestStatus = pending_approval + Landing-->>Invitee: Show waiting-for-approval panel + Approver->>Queue: Review request in Company Settings -> Access + Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve + API->>Membership: Ensure membership and grants + API->>Join: Mark join request approved + Invitee->>Landing: Refresh after approval + Landing->>API: GET /api/invites/:token + API->>Join: Reload approved join request + API-->>Landing: approved status + Landing-->>Invitee: Opening company and redirect + else joinRequestStatus = rejected + Landing-->>Invitee: Show rejected error panel + else joinRequestStatus = approved but membership missing + Landing-->>Invitee: Fall through to consumed/unavailable state + end +``` + +### Agent Invite Approval, Claim, And Replay + +```mermaid +sequenceDiagram + autonumber + actor Board as Board user + participant Settings as Company Settings UI + participant API as Access routes + participant Invites as invites table + actor Gateway as OpenClaw gateway agent + participant Join as join_requests table + actor Approver as Company admin + participant Agents as agents table + participant Keys as agent_api_keys table + + Board->>Settings: Generate OpenClaw invite prompt + Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt + API->>Invites: Insert active agent invite + API-->>Settings: Prompt text + invite token + + Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway) + API->>Invites: Mark invite accepted + API->>Join: Insert pending_approval join request + claimSecretHash + API-->>Gateway: requestId + claimSecret + claimApiKeyPath + + Approver->>API: POST /companies/:companyId/join-requests/:requestId/approve + API->>Agents: Create agent + membership + grants + API->>Join: Mark request approved and store createdAgentId + + Gateway->>API: POST /api/join-requests/:requestId/claim-api-key (claimSecret) + API->>Keys: Create initial API key + API->>Join: Mark claim secret consumed + API-->>Gateway: Plaintext Paperclip API key + + opt Replay accepted invite for updated gateway defaults + Gateway->>API: POST /api/invites/:token/accept again + API->>Join: Reuse existing approved or pending request + API->>Agents: Update approved agent adapter config when applicable + API-->>Gateway: Updated join request payload + end +``` + +## Notes + +- `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`. +- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email. +- The landing page has two layers of post-accept UI: + - immediate mutation-result UI from `POST /api/invites/:token/accept` + - reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed +- Reload behavior for accepted company invites is now status-sensitive: + - `pending_approval` re-renders the waiting-for-approval panel + - `rejected` renders the "This join request was not approved." error panel + - `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state +- `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`. +- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`. +- `bootstrap_ceo` invites are one-time and do not create join requests. diff --git a/docs/docs.json b/docs/docs.json index be48cc8e9e..54dfb003eb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -48,6 +48,8 @@ "guides/board-operator/managing-tasks", "guides/board-operator/execution-workspaces-and-runtime-services", "guides/board-operator/delegation", + "guides/board-operator/execution-workspaces-and-runtime-services", + "guides/board-operator/delegation", "guides/board-operator/approvals", "guides/board-operator/costs-and-budgets", "guides/board-operator/activity-log", diff --git a/package.json b/package.json index ea356f0b49..ce666bfaf3 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", + "test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts", "evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval", "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed", diff --git a/packages/db/scripts/create-auth-bootstrap-invite.ts b/packages/db/scripts/create-auth-bootstrap-invite.ts new file mode 100644 index 0000000000..7af8a7d34f --- /dev/null +++ b/packages/db/scripts/create-auth-bootstrap-invite.ts @@ -0,0 +1,84 @@ +import { createHash, randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { createDb } from "../src/client.js"; +import { invites } from "../src/schema/index.js"; + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +function createInviteToken() { + return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; +} + +function readArg(flag: string) { + const index = process.argv.indexOf(flag); + if (index === -1) return null; + return process.argv[index + 1] ?? null; +} + +async function main() { + const configPath = readArg("--config"); + const baseUrl = readArg("--base-url"); + + if (!configPath || !baseUrl) { + throw new Error("Usage: tsx create-auth-bootstrap-invite.ts --config --base-url "); + } + + const config = JSON.parse(readFileSync(path.resolve(configPath), "utf8")) as { + database?: { + mode?: string; + embeddedPostgresPort?: number; + connectionString?: string; + }; + }; + const dbUrl = + config.database?.mode === "postgres" + ? config.database.connectionString + : `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`; + if (!dbUrl) { + throw new Error(`Could not resolve database connection from ${configPath}`); + } + + const db = createDb(dbUrl); + const closableDb = db as typeof db & { + $client?: { + end?: (options?: { timeout?: number }) => Promise; + }; + }; + + try { + const now = new Date(); + await db + .update(invites) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(invites.inviteType, "bootstrap_ceo"), + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now) + ) + ); + + const token = createInviteToken(); + await db.insert(invites).values({ + inviteType: "bootstrap_ceo", + tokenHash: hashToken(token), + allowedJoinTypes: "human", + expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000), + invitedByUserId: "system", + }); + + process.stdout.write(`${baseUrl.replace(/\/+$/, "")}/invite/${token}\n`); + } finally { + await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/packages/db/src/migrations/0057_tidy_join_requests.sql b/packages/db/src/migrations/0057_tidy_join_requests.sql new file mode 100644 index 0000000000..8ab27b410b --- /dev/null +++ b/packages/db/src/migrations/0057_tidy_join_requests.sql @@ -0,0 +1,57 @@ +WITH ranked_user_requests AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY company_id, requesting_user_id + ORDER BY created_at ASC, id ASC + ) AS rank + FROM join_requests + WHERE request_type = 'human' + AND status = 'pending_approval' + AND requesting_user_id IS NOT NULL +) +UPDATE join_requests +SET + status = 'rejected', + rejected_at = COALESCE(rejected_at, now()), + updated_at = now() +WHERE id IN ( + SELECT id + FROM ranked_user_requests + WHERE rank > 1 +); +--> statement-breakpoint +WITH ranked_email_requests AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY company_id, lower(request_email_snapshot) + ORDER BY created_at ASC, id ASC + ) AS rank + FROM join_requests + WHERE request_type = 'human' + AND status = 'pending_approval' + AND request_email_snapshot IS NOT NULL +) +UPDATE join_requests +SET + status = 'rejected', + rejected_at = COALESCE(rejected_at, now()), + updated_at = now() +WHERE id IN ( + SELECT id + FROM ranked_email_requests + WHERE rank > 1 +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_user_uq" +ON "join_requests" USING btree ("company_id", "requesting_user_id") +WHERE "request_type" = 'human' + AND "status" = 'pending_approval' + AND "requesting_user_id" IS NOT NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_email_uq" +ON "join_requests" USING btree ("company_id", lower("request_email_snapshot")) +WHERE "request_type" = 'human' + AND "status" = 'pending_approval' + AND "request_email_snapshot" IS NOT NULL; diff --git a/packages/db/src/migrations/meta/0057_snapshot.json b/packages/db/src/migrations/meta/0057_snapshot.json new file mode 100644 index 0000000000..f57e05e33d --- /dev/null +++ b/packages/db/src/migrations/meta/0057_snapshot.json @@ -0,0 +1,13432 @@ +{ + "id": "c13b1dd5-1860-4d0b-aeb2-5bb197766983", + "prevId": "5b9211ec-73c0-4825-bdf7-08ba60b6915c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "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 with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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 with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "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 + } + }, + "indexes": {}, + "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": {}, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_api_keys": { + "name": "board_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "board_api_keys_key_hash_idx": { + "name": "board_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_api_keys_user_idx": { + "name": "board_api_keys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "board_api_keys_user_id_user_id_fk": { + "name": "board_api_keys_user_id_user_id_fk", + "tableFrom": "board_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_incidents": { + "name": "budget_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "threshold_type": { + "name": "threshold_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_limit": { + "name": "amount_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_observed": { + "name": "amount_observed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_incidents_company_status_idx": { + "name": "budget_incidents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_incidents_company_scope_idx": { + "name": "budget_incidents_company_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_incidents_policy_window_threshold_idx": { + "name": "budget_incidents_policy_window_threshold_idx", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "threshold_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"budget_incidents\".\"status\" <> 'dismissed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_incidents_company_id_companies_id_fk": { + "name": "budget_incidents_company_id_companies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_policy_id_budget_policies_id_fk": { + "name": "budget_incidents_policy_id_budget_policies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "budget_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_approval_id_approvals_id_fk": { + "name": "budget_incidents_approval_id_approvals_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_policies": { + "name": "budget_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'billed_cents'" + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warn_percent": { + "name": "warn_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 80 + }, + "hard_stop_enabled": { + "name": "hard_stop_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_enabled": { + "name": "notify_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_policies_company_scope_active_idx": { + "name": "budget_policies_company_scope_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_window_idx": { + "name": "budget_policies_company_window_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_scope_metric_unique_idx": { + "name": "budget_policies_company_scope_metric_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_policies_company_id_companies_id_fk": { + "name": "budget_policies_company_id_companies_id_fk", + "tableFrom": "budget_policies", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_auth_challenges": { + "name": "cli_auth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_access": { + "name": "requested_access", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'board'" + }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pending_key_hash": { + "name": "pending_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pending_key_name": { + "name": "pending_key_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "board_api_key_id": { + "name": "board_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cli_auth_challenges_secret_hash_idx": { + "name": "cli_auth_challenges_secret_hash_idx", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_auth_challenges_approved_by_idx": { + "name": "cli_auth_challenges_approved_by_idx", + "columns": [ + { + "expression": "approved_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_auth_challenges_requested_company_idx": { + "name": "cli_auth_challenges_requested_company_idx", + "columns": [ + { + "expression": "requested_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_auth_challenges_requested_company_id_companies_id_fk": { + "name": "cli_auth_challenges_requested_company_id_companies_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "companies", + "columnsFrom": [ + "requested_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_auth_challenges_approved_by_user_id_user_id_fk": { + "name": "cli_auth_challenges_approved_by_user_id_user_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "user", + "columnsFrom": [ + "approved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk": { + "name": "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "board_api_keys", + "columnsFrom": [ + "board_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "feedback_data_sharing_enabled": { + "name": "feedback_data_sharing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feedback_data_sharing_consent_at": { + "name": "feedback_data_sharing_consent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_consent_by_user_id": { + "name": "feedback_data_sharing_consent_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_terms_version": { + "name": "feedback_data_sharing_terms_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_logos": { + "name": "company_logos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_logos_company_uq": { + "name": "company_logos_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_logos_asset_uq": { + "name": "company_logos_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_logos_company_id_companies_id_fk": { + "name": "company_logos_company_id_companies_id_fk", + "tableFrom": "company_logos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_logos_asset_id_assets_id_fk": { + "name": "company_logos_asset_id_assets_id_fk", + "tableFrom": "company_logos", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skills": { + "name": "company_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "source_locator": { + "name": "source_locator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trust_level": { + "name": "trust_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown_only'" + }, + "compatibility": { + "name": "compatibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'compatible'" + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skills_company_key_idx": { + "name": "company_skills_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_name_idx": { + "name": "company_skills_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skills_company_id_companies_id_fk": { + "name": "company_skills_company_id_companies_id_fk", + "tableFrom": "company_skills", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_user_sidebar_preferences": { + "name": "company_user_sidebar_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_order": { + "name": "project_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_user_sidebar_preferences_company_idx": { + "name": "company_user_sidebar_preferences_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_user_idx": { + "name": "company_user_sidebar_preferences_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_company_user_uq": { + "name": "company_user_sidebar_preferences_company_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_user_sidebar_preferences_company_id_companies_id_fk": { + "name": "company_user_sidebar_preferences_company_id_companies_id_fk", + "tableFrom": "company_user_sidebar_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_provider_occurred_idx": { + "name": "cost_events_company_provider_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_biller_occurred_idx": { + "name": "cost_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_heartbeat_run_idx": { + "name": "cost_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "cost_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_revisions_document_revision_uq": { + "name": "document_revisions_document_revision_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_revisions_company_document_created_idx": { + "name": "document_revisions_company_document_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_revisions_company_id_companies_id_fk": { + "name": "document_revisions_company_id_companies_id_fk", + "tableFrom": "document_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_revisions_document_id_documents_id_fk": { + "name": "document_revisions_document_id_documents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_created_by_agent_id_agents_id_fk": { + "name": "document_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_revisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "document_revisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "document_revisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "latest_body": { + "name": "latest_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_updated_idx": { + "name": "documents_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_company_created_idx": { + "name": "documents_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_companies_id_fk": { + "name": "documents_company_id_companies_id_fk", + "tableFrom": "documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_created_by_agent_id_agents_id_fk": { + "name": "documents_created_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "documents_updated_by_agent_id_agents_id_fk": { + "name": "documents_updated_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_workspaces": { + "name": "execution_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy_type": { + "name": "strategy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_fs'" + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "derived_from_execution_workspace_id": { + "name": "derived_from_execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_eligible_at": { + "name": "cleanup_eligible_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_reason": { + "name": "cleanup_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_workspaces_company_project_status_idx": { + "name": "execution_workspaces_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_project_workspace_status_idx": { + "name": "execution_workspaces_company_project_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_source_issue_idx": { + "name": "execution_workspaces_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_last_used_idx": { + "name": "execution_workspaces_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_branch_idx": { + "name": "execution_workspaces_company_branch_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspaces_company_id_companies_id_fk": { + "name": "execution_workspaces_company_id_companies_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "execution_workspaces_project_id_projects_id_fk": { + "name": "execution_workspaces_project_id_projects_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { + "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_source_issue_id_issues_id_fk": { + "name": "execution_workspaces_source_issue_id_issues_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "derived_from_execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_exports": { + "name": "feedback_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feedback_vote_id": { + "name": "feedback_vote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_only'" + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "export_id": { + "name": "export_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-envelope-v2'" + }, + "bundle_version": { + "name": "bundle_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-bundle-v2'" + }, + "payload_version": { + "name": "payload_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-v1'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_snapshot": { + "name": "payload_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_summary": { + "name": "target_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_exports_feedback_vote_idx": { + "name": "feedback_exports_feedback_vote_idx", + "columns": [ + { + "expression": "feedback_vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_created_idx": { + "name": "feedback_exports_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_status_idx": { + "name": "feedback_exports_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_issue_idx": { + "name": "feedback_exports_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_project_idx": { + "name": "feedback_exports_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_author_idx": { + "name": "feedback_exports_company_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_exports_company_id_companies_id_fk": { + "name": "feedback_exports_company_id_companies_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_exports_feedback_vote_id_feedback_votes_id_fk": { + "name": "feedback_exports_feedback_vote_id_feedback_votes_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "feedback_votes", + "columnsFrom": [ + "feedback_vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_issue_id_issues_id_fk": { + "name": "feedback_exports_issue_id_issues_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_project_id_projects_id_fk": { + "name": "feedback_exports_project_id_projects_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_votes": { + "name": "feedback_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_with_labs": { + "name": "shared_with_labs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_votes_company_issue_idx": { + "name": "feedback_votes_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_issue_target_idx": { + "name": "feedback_votes_issue_target_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_author_idx": { + "name": "feedback_votes_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_company_target_author_idx": { + "name": "feedback_votes_company_target_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_votes_company_id_companies_id_fk": { + "name": "feedback_votes_company_id_companies_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_votes_issue_id_issues_id_fk": { + "name": "feedback_votes_issue_id_issues_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finance_events": { + "name": "finance_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_kind": { + "name": "event_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'debit'" + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_adapter_type": { + "name": "execution_adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing_tier": { + "name": "pricing_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_invoice_id": { + "name": "external_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "finance_events_company_occurred_idx": { + "name": "finance_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_biller_occurred_idx": { + "name": "finance_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_kind_occurred_idx": { + "name": "finance_events_company_kind_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_direction_occurred_idx": { + "name": "finance_events_company_direction_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_heartbeat_run_idx": { + "name": "finance_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_cost_event_idx": { + "name": "finance_events_company_cost_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finance_events_company_id_companies_id_fk": { + "name": "finance_events_company_id_companies_id_fk", + "tableFrom": "finance_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_agent_id_agents_id_fk": { + "name": "finance_events_agent_id_agents_id_fk", + "tableFrom": "finance_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_issue_id_issues_id_fk": { + "name": "finance_events_issue_id_issues_id_fk", + "tableFrom": "finance_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_project_id_projects_id_fk": { + "name": "finance_events_project_id_projects_id_fk", + "tableFrom": "finance_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_goal_id_goals_id_fk": { + "name": "finance_events_goal_id_goals_id_fk", + "tableFrom": "finance_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "finance_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_cost_event_id_cost_events_id_fk": { + "name": "finance_events_cost_event_id_cost_events_id_fk", + "tableFrom": "finance_events", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "process_pid": { + "name": "process_pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_group_id": { + "name": "process_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_started_at": { + "name": "process_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retry_of_run_id": { + "name": "retry_of_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "process_loss_retry_count": { + "name": "process_loss_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "issue_comment_status": { + "name": "issue_comment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_applicable'" + }, + "issue_comment_satisfied_by_comment_id": { + "name": "issue_comment_satisfied_by_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_comment_retry_queued_at": { + "name": "issue_comment_retry_queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "retry_of_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inbox_dismissals": { + "name": "inbox_dismissals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_dismissals_company_user_idx": { + "name": "inbox_dismissals_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_dismissals_company_item_idx": { + "name": "inbox_dismissals_company_item_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_dismissals_company_user_item_idx": { + "name": "inbox_dismissals_company_user_item_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inbox_dismissals_company_id_companies_id_fk": { + "name": "inbox_dismissals_company_id_companies_id_fk", + "tableFrom": "inbox_dismissals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_settings": { + "name": "instance_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "general": { + "name": "general", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "experimental": { + "name": "experimental", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_settings_singleton_key_idx": { + "name": "instance_settings_singleton_key_idx", + "columns": [ + { + "expression": "singleton_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_body_search_idx": { + "name": "issue_comments_body_search_idx", + "columns": [ + { + "expression": "body", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_comments_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_comments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_documents": { + "name": "issue_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_documents_company_issue_key_uq": { + "name": "issue_documents_company_issue_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_document_uq": { + "name": "issue_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_company_issue_updated_idx": { + "name": "issue_documents_company_issue_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_documents_company_id_companies_id_fk": { + "name": "issue_documents_company_id_companies_id_fk", + "tableFrom": "issue_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_documents_issue_id_issues_id_fk": { + "name": "issue_documents_issue_id_issues_id_fk", + "tableFrom": "issue_documents", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_documents_document_id_documents_id_fk": { + "name": "issue_documents_document_id_documents_id_fk", + "tableFrom": "issue_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_execution_decisions": { + "name": "issue_execution_decisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_type": { + "name": "stage_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_agent_id": { + "name": "actor_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_execution_decisions_company_issue_idx": { + "name": "issue_execution_decisions_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_execution_decisions_stage_idx": { + "name": "issue_execution_decisions_stage_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_execution_decisions_company_id_companies_id_fk": { + "name": "issue_execution_decisions_company_id_companies_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_execution_decisions_issue_id_issues_id_fk": { + "name": "issue_execution_decisions_issue_id_issues_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_execution_decisions_actor_agent_id_agents_id_fk": { + "name": "issue_execution_decisions_actor_agent_id_agents_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "agents", + "columnsFrom": [ + "actor_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_inbox_archives": { + "name": "issue_inbox_archives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_inbox_archives_company_issue_idx": { + "name": "issue_inbox_archives_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_user_idx": { + "name": "issue_inbox_archives_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_issue_user_idx": { + "name": "issue_inbox_archives_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_inbox_archives_company_id_companies_id_fk": { + "name": "issue_inbox_archives_company_id_companies_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_inbox_archives_issue_id_issues_id_fk": { + "name": "issue_inbox_archives_issue_id_issues_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_relations": { + "name": "issue_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "related_issue_id": { + "name": "related_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_relations_company_issue_idx": { + "name": "issue_relations_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_related_issue_idx": { + "name": "issue_relations_company_related_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_type_idx": { + "name": "issue_relations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_edge_uq": { + "name": "issue_relations_company_edge_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_relations_company_id_companies_id_fk": { + "name": "issue_relations_company_id_companies_id_fk", + "tableFrom": "issue_relations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_relations_issue_id_issues_id_fk": { + "name": "issue_relations_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_related_issue_id_issues_id_fk": { + "name": "issue_relations_related_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "related_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_created_by_agent_id_agents_id_fk": { + "name": "issue_relations_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_relations", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_work_products": { + "name": "issue_work_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "runtime_service_id": { + "name": "runtime_service_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_work_products_company_issue_type_idx": { + "name": "issue_work_products_company_issue_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_execution_workspace_type_idx": { + "name": "issue_work_products_company_execution_workspace_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_provider_external_id_idx": { + "name": "issue_work_products_company_provider_external_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_updated_idx": { + "name": "issue_work_products_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_work_products_company_id_companies_id_fk": { + "name": "issue_work_products_company_id_companies_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_work_products_project_id_projects_id_fk": { + "name": "issue_work_products_project_id_projects_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_issue_id_issues_id_fk": { + "name": "issue_work_products_issue_id_issues_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_work_products_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issue_work_products_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk": { + "name": "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "workspace_runtime_services", + "columnsFrom": [ + "runtime_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_work_products_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_policy": { + "name": "execution_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_state": { + "name": "execution_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_preference": { + "name": "execution_workspace_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_settings": { + "name": "execution_workspace_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_origin_idx": { + "name": "issues_company_origin_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_workspace_idx": { + "name": "issues_company_project_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_execution_workspace_idx": { + "name": "issues_company_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_title_search_idx": { + "name": "issues_title_search_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_identifier_search_idx": { + "name": "issues_identifier_search_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_description_search_idx": { + "name": "issues_description_search_idx", + "columns": [ + { + "expression": "description", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_open_routine_execution_uq": { + "name": "issues_open_routine_execution_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'routine_execution'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"execution_run_id\" is not null\n and \"issues\".\"status\" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_workspace_id_project_workspaces_id_fk": { + "name": "issues_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issues_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_company_settings": { + "name": "plugin_company_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_company_settings_company_idx": { + "name": "plugin_company_settings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_plugin_idx": { + "name": "plugin_company_settings_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_company_plugin_uq": { + "name": "plugin_company_settings_company_plugin_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_company_settings_company_id_companies_id_fk": { + "name": "plugin_company_settings_company_id_companies_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_company_settings_plugin_id_plugins_id_fk": { + "name": "plugin_company_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_config": { + "name": "plugin_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_config_plugin_id_idx": { + "name": "plugin_config_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_config_plugin_id_plugins_id_fk": { + "name": "plugin_config_plugin_id_plugins_id_fk", + "tableFrom": "plugin_config", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_entities": { + "name": "plugin_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_entities_plugin_idx": { + "name": "plugin_entities_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_type_idx": { + "name": "plugin_entities_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_scope_idx": { + "name": "plugin_entities_scope_idx", + "columns": [ + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_external_idx": { + "name": "plugin_entities_external_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_entities_plugin_id_plugins_id_fk": { + "name": "plugin_entities_plugin_id_plugins_id_fk", + "tableFrom": "plugin_entities", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_job_runs": { + "name": "plugin_job_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_job_runs_job_idx": { + "name": "plugin_job_runs_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_plugin_idx": { + "name": "plugin_job_runs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_status_idx": { + "name": "plugin_job_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_job_runs_job_id_plugin_jobs_id_fk": { + "name": "plugin_job_runs_job_id_plugin_jobs_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugin_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_job_runs_plugin_id_plugins_id_fk": { + "name": "plugin_job_runs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_jobs": { + "name": "plugin_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_key": { + "name": "job_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_jobs_plugin_idx": { + "name": "plugin_jobs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_next_run_idx": { + "name": "plugin_jobs_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_unique_idx": { + "name": "plugin_jobs_unique_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_jobs_plugin_id_plugins_id_fk": { + "name": "plugin_jobs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_jobs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_logs": { + "name": "plugin_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_logs_plugin_time_idx": { + "name": "plugin_logs_plugin_time_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_logs_level_idx": { + "name": "plugin_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_logs_plugin_id_plugins_id_fk": { + "name": "plugin_logs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_logs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_state": { + "name": "plugin_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_state_plugin_scope_idx": { + "name": "plugin_state_plugin_scope_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_state_plugin_id_plugins_id_fk": { + "name": "plugin_state_plugin_id_plugins_id_fk", + "tableFrom": "plugin_state", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_state_unique_entry_idx": { + "name": "plugin_state_unique_entry_idx", + "nullsNotDistinct": true, + "columns": [ + "plugin_id", + "scope_kind", + "scope_id", + "namespace", + "state_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_webhook_deliveries": { + "name": "plugin_webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "webhook_key": { + "name": "webhook_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_webhook_deliveries_plugin_idx": { + "name": "plugin_webhook_deliveries_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_status_idx": { + "name": "plugin_webhook_deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_key_idx": { + "name": "plugin_webhook_deliveries_key_idx", + "columns": [ + { + "expression": "webhook_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_webhook_deliveries_plugin_id_plugins_id_fk": { + "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk", + "tableFrom": "plugin_webhook_deliveries", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_version": { + "name": "api_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'installed'" + }, + "install_order": { + "name": "install_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "package_path": { + "name": "package_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugins_plugin_key_idx": { + "name": "plugins_plugin_key_idx", + "columns": [ + { + "expression": "plugin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugins_status_idx": { + "name": "plugins_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_ref": { + "name": "default_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "setup_command": { + "name": "setup_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_command": { + "name": "cleanup_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_provider": { + "name": "remote_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_workspace_ref": { + "name": "remote_workspace_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_workspace_key": { + "name": "shared_workspace_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_source_type_idx": { + "name": "project_workspaces_project_source_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_company_shared_key_idx": { + "name": "project_workspaces_company_shared_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_workspace_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_remote_ref_idx": { + "name": "project_workspaces_project_remote_ref_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_workspace_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_policy": { + "name": "execution_workspace_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "triggered_at": { + "name": "triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_payload": { + "name": "trigger_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "linked_issue_id": { + "name": "linked_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "coalesced_into_run_id": { + "name": "coalesced_into_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_runs_company_routine_idx": { + "name": "routine_runs_company_routine_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_trigger_idx": { + "name": "routine_runs_trigger_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_linked_issue_idx": { + "name": "routine_runs_linked_issue_idx", + "columns": [ + { + "expression": "linked_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_trigger_idempotency_idx": { + "name": "routine_runs_trigger_idempotency_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_company_id_companies_id_fk": { + "name": "routine_runs_company_id_companies_id_fk", + "tableFrom": "routine_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_runs_trigger_id_routine_triggers_id_fk": { + "name": "routine_runs_trigger_id_routine_triggers_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routine_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_runs_linked_issue_id_issues_id_fk": { + "name": "routine_runs_linked_issue_id_issues_id_fk", + "tableFrom": "routine_runs", + "tableTo": "issues", + "columnsFrom": [ + "linked_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_triggers": { + "name": "routine_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "signing_mode": { + "name": "signing_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replay_window_sec": { + "name": "replay_window_sec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_rotated_at": { + "name": "last_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_triggers_company_routine_idx": { + "name": "routine_triggers_company_routine_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_company_kind_idx": { + "name": "routine_triggers_company_kind_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_next_run_idx": { + "name": "routine_triggers_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_public_id_idx": { + "name": "routine_triggers_public_id_idx", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_public_id_uq": { + "name": "routine_triggers_public_id_uq", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_triggers_company_id_companies_id_fk": { + "name": "routine_triggers_company_id_companies_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_triggers_routine_id_routines_id_fk": { + "name": "routine_triggers_routine_id_routines_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_triggers_secret_id_company_secrets_id_fk": { + "name": "routine_triggers_secret_id_company_secrets_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_triggers_created_by_agent_id_agents_id_fk": { + "name": "routine_triggers_created_by_agent_id_agents_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_triggers_updated_by_agent_id_agents_id_fk": { + "name": "routine_triggers_updated_by_agent_id_agents_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_issue_id": { + "name": "parent_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'coalesce_if_active'" + }, + "catch_up_policy": { + "name": "catch_up_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip_missed'" + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_company_status_idx": { + "name": "routines_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_assignee_idx": { + "name": "routines_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_project_idx": { + "name": "routines_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_company_id_companies_id_fk": { + "name": "routines_company_id_companies_id_fk", + "tableFrom": "routines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_project_id_projects_id_fk": { + "name": "routines_project_id_projects_id_fk", + "tableFrom": "routines", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_goal_id_goals_id_fk": { + "name": "routines_goal_id_goals_id_fk", + "tableFrom": "routines", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_parent_issue_id_issues_id_fk": { + "name": "routines_parent_issue_id_issues_id_fk", + "tableFrom": "routines", + "tableTo": "issues", + "columnsFrom": [ + "parent_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_assignee_agent_id_agents_id_fk": { + "name": "routines_assignee_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routines_created_by_agent_id_agents_id_fk": { + "name": "routines_created_by_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_updated_by_agent_id_agents_id_fk": { + "name": "routines_updated_by_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_sidebar_preferences": { + "name": "user_sidebar_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_order": { + "name": "company_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_sidebar_preferences_user_uq": { + "name": "user_sidebar_preferences_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operations": { + "name": "workspace_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_operations_company_run_started_idx": { + "name": "workspace_operations_company_run_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operations_company_workspace_started_idx": { + "name": "workspace_operations_company_workspace_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operations_company_id_companies_id_fk": { + "name": "workspace_operations_company_id_companies_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_operations_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_operations_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_execution_workspace_status_idx": { + "name": "workspace_runtime_services_company_execution_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index bce93f11a8..757b7c9a14 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1776084034244, "tag": "0056_spooky_ultragirl", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1776309613598, + "tag": "0057_tidy_join_requests", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/join_requests.ts b/packages/db/src/schema/join_requests.ts index 458e45d080..3813c27db7 100644 --- a/packages/db/src/schema/join_requests.ts +++ b/packages/db/src/schema/join_requests.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { invites } from "./invites.js"; @@ -37,5 +38,11 @@ export const joinRequests = pgTable( table.requestType, table.createdAt, ), + pendingHumanUserUniqueIdx: uniqueIndex("join_requests_pending_human_user_uq") + .on(table.companyId, table.requestingUserId) + .where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestingUserId} IS NOT NULL`), + pendingHumanEmailUniqueIdx: uniqueIndex("join_requests_pending_human_email_uq") + .on(table.companyId, sql`lower(${table.requestEmailSnapshot})`) + .where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestEmailSnapshot} IS NOT NULL`), }), ); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f62d2777a0..a9f5de3d43 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -362,6 +362,30 @@ export type PrincipalType = (typeof PRINCIPAL_TYPES)[number]; export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const; export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number]; +export const COMPANY_MEMBERSHIP_ROLES = [ + "owner", + "admin", + "operator", + "viewer", + "member", +] as const; +export type CompanyMembershipRole = (typeof COMPANY_MEMBERSHIP_ROLES)[number]; + +export const HUMAN_COMPANY_MEMBERSHIP_ROLES = [ + "owner", + "admin", + "operator", + "viewer", +] as const; +export type HumanCompanyMembershipRole = (typeof HUMAN_COMPANY_MEMBERSHIP_ROLES)[number]; + +export const HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS: Record = { + owner: "Owner", + admin: "Admin", + operator: "Operator", + viewer: "Viewer", +}; + export const INSTANCE_USER_ROLES = ["instance_admin"] as const; export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fab8c1ed7a..d1569b67ab 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -54,6 +54,9 @@ export { LIVE_EVENT_TYPES, PRINCIPAL_TYPES, MEMBERSHIP_STATUSES, + COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, INSTANCE_USER_ROLES, INVITE_TYPES, INVITE_JOIN_TYPES, @@ -127,6 +130,8 @@ export { type LiveEventType, type PrincipalType, type MembershipStatus, + type CompanyMembershipRole, + type HumanCompanyMembershipRole, type InstanceUserRole, type InviteType, type InviteJoinType, @@ -308,11 +313,21 @@ export type { SidebarBadges, SidebarOrderPreference, InboxDismissal, + AccessUserProfile, + CompanyMemberRecord, + CompanyMembersResponse, CompanyMembership, + CompanyInviteListResponse, + CompanyInviteRecord, PrincipalPermissionGrant, Invite, JoinRequest, + JoinRequestInviteSummary, + JoinRequestRecord, InstanceUserRoleGrant, + AdminUserDirectoryEntry, + UserCompanyAccessEntry, + UserCompanyAccessResponse, CompanyPortabilityInclude, CompanyPortabilityEnvInput, CompanyPortabilityFileEntry, @@ -566,12 +581,19 @@ export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, acceptInviteSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + currentUserProfileSchema, + authSessionSchema, + updateCurrentUserProfileSchema, + updateCompanyMemberSchema, + updateCompanyMemberWithPermissionsSchema, updateMemberPermissionsSchema, + searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCostEvent, type CreateFinanceEvent, @@ -580,12 +602,19 @@ export { type CreateCompanyInvite, type CreateOpenClawInvitePrompt, type AcceptInvite, + type ListCompanyInvitesQuery, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CurrentUserProfile, + type AuthSession, + type UpdateCurrentUserProfile, + type UpdateCompanyMember, + type UpdateCompanyMemberWithPermissions, type UpdateMemberPermissions, + type SearchAdminUsersQuery, type UpdateUserCompanyAccess, companySkillSourceTypeSchema, companySkillTrustLevelSchema, @@ -663,18 +692,23 @@ export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, SKILL_MENTION_SCHEME, + USER_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, + buildUserMentionHref, extractAgentMentionIds, + extractProjectMentionIds, extractSkillMentionIds, + extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, - extractProjectMentionIds, + parseUserMentionHref, type ParsedAgentMention, type ParsedProjectMention, type ParsedSkillMention, + type ParsedUserMention, } from "./project-mentions.js"; export { diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 5a156959a9..709fd27d6e 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -3,12 +3,15 @@ import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, + buildUserMentionHref, extractAgentMentionIds, extractProjectMentionIds, extractSkillMentionIds, + extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, + parseUserMentionHref, } from "./project-mentions.js"; describe("project-mentions", () => { @@ -30,6 +33,14 @@ describe("project-mentions", () => { expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); }); + it("round-trips user mentions", () => { + const href = buildUserMentionHref("user-123"); + expect(parseUserMentionHref(href)).toEqual({ + userId: "user-123", + }); + expect(extractUserMentionIds(`[@Taylor](${href})`)).toEqual(["user-123"]); + }); + it("round-trips skill mentions with slug metadata", () => { const href = buildSkillMentionHref("skill-123", "release-changelog"); expect(parseSkillMentionHref(href)).toEqual({ diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 117fad3947..b2d3ee9316 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,5 +1,6 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; +export const USER_MENTION_SCHEME = "user://"; export const SKILL_MENTION_SCHEME = "skill://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; @@ -8,6 +9,7 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; +const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi; const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; @@ -22,6 +24,10 @@ export interface ParsedAgentMention { icon: string | null; } +export interface ParsedUserMention { + userId: string; +} + export interface ParsedSkillMention { skillId: string; slug: string | null; @@ -111,6 +117,28 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null { }; } +export function buildUserMentionHref(userId: string): string { + return `${USER_MENTION_SCHEME}${userId.trim()}`; +} + +export function parseUserMentionHref(href: string): ParsedUserMention | null { + if (!href.startsWith(USER_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "user:") return null; + + const userId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!userId) return null; + + return { userId }; +} + export function buildSkillMentionHref(skillId: string, slug?: string | null): string { const trimmedSkillId = skillId.trim(); const normalizedSlug = normalizeSkillSlug(slug ?? null); @@ -165,6 +193,18 @@ export function extractAgentMentionIds(markdown: string): string[] { return [...ids]; } +export function extractUserMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(USER_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseUserMentionHref(match[1]); + if (parsed) ids.add(parsed.userId); + } + return [...ids]; +} + export function extractSkillMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); diff --git a/packages/shared/src/types/access.ts b/packages/shared/src/types/access.ts index db6554eea2..25f605ba51 100644 --- a/packages/shared/src/types/access.ts +++ b/packages/shared/src/types/access.ts @@ -1,5 +1,7 @@ import type { AgentAdapterType, + CompanyStatus, + HumanCompanyMembershipRole, InstanceUserRole, InviteJoinType, InviteType, @@ -33,6 +35,30 @@ export interface PrincipalPermissionGrant { updatedAt: Date; } +export interface AccessUserProfile { + id: string; + email: string | null; + name: string | null; + image: string | null; +} + +export interface CompanyMemberRecord extends CompanyMembership { + principalType: "user"; + membershipRole: HumanCompanyMembershipRole | null; + user: AccessUserProfile | null; + grants: PrincipalPermissionGrant[]; +} + +export interface CompanyMembersResponse { + members: CompanyMemberRecord[]; + access: { + currentUserRole: HumanCompanyMembershipRole | null; + canManageMembers: boolean; + canInviteUsers: boolean; + canApproveJoinRequests: boolean; + }; +} + export interface Invite { id: string; companyId: string | null; @@ -48,6 +74,22 @@ export interface Invite { updatedAt: Date; } +export type InviteState = "active" | "revoked" | "accepted" | "expired"; + +export interface CompanyInviteRecord extends Invite { + companyName: string | null; + humanRole: HumanCompanyMembershipRole | null; + inviteMessage: string | null; + state: InviteState; + invitedByUser: AccessUserProfile | null; + relatedJoinRequestId: string | null; +} + +export interface CompanyInviteListResponse { + invites: CompanyInviteRecord[]; + nextOffset: number | null; +} + export interface JoinRequest { id: string; inviteId: string; @@ -72,6 +114,26 @@ export interface JoinRequest { updatedAt: Date; } +export interface JoinRequestInviteSummary { + id: string; + inviteType: InviteType; + allowedJoinTypes: InviteJoinType; + humanRole: HumanCompanyMembershipRole | null; + inviteMessage: string | null; + createdAt: Date; + expiresAt: Date; + revokedAt: Date | null; + acceptedAt: Date | null; + invitedByUser: AccessUserProfile | null; +} + +export interface JoinRequestRecord extends JoinRequest { + requesterUser: AccessUserProfile | null; + approvedByUser: AccessUserProfile | null; + rejectedByUser: AccessUserProfile | null; + invite: JoinRequestInviteSummary | null; +} + export interface InstanceUserRoleGrant { id: string; userId: string; @@ -79,3 +141,21 @@ export interface InstanceUserRoleGrant { createdAt: Date; updatedAt: Date; } + +export interface AdminUserDirectoryEntry extends AccessUserProfile { + isInstanceAdmin: boolean; + activeCompanyMembershipCount: number; +} + +export interface UserCompanyAccessEntry extends CompanyMembership { + principalType: "user"; + companyName: string | null; + companyStatus: CompanyStatus | null; +} + +export interface UserCompanyAccessResponse { + user: (AccessUserProfile & { + isInstanceAdmin: boolean; + }) | null; + companyAccess: UserCompanyAccessEntry[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 62ca4dde0a..0089c92d01 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -173,11 +173,21 @@ export type { SidebarBadges } from "./sidebar-badges.js"; export type { SidebarOrderPreference } from "./sidebar-preferences.js"; export type { InboxDismissal } from "./inbox-dismissal.js"; export type { + AccessUserProfile, + CompanyMemberRecord, + CompanyMembersResponse, CompanyMembership, + CompanyInviteListResponse, + CompanyInviteRecord, PrincipalPermissionGrant, Invite, JoinRequest, + JoinRequestInviteSummary, + JoinRequestRecord, InstanceUserRoleGrant, + AdminUserDirectoryEntry, + UserCompanyAccessEntry, + UserCompanyAccessResponse, } from "./access.js"; export type { QuotaWindow, ProviderQuotaResult } from "./quota.js"; export type { diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 6bd7da1b0e..2aa5036155 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -1,10 +1,10 @@ import type { PauseReason, ProjectStatus } from "../constants.js"; -import type { AgentEnvConfig } from "./secrets.js"; import type { ProjectExecutionWorkspacePolicy, ProjectWorkspaceRuntimeConfig, WorkspaceRuntimeService, } from "./workspace-runtime.js"; +import type { AgentEnvConfig } from "./secrets.js"; export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path"; export type ProjectWorkspaceVisibility = "default" | "advanced"; diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 6da95c126e..8cd8f5c3a0 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -1,14 +1,18 @@ import { z } from "zod"; import { + AGENT_ADAPTER_TYPES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, INVITE_JOIN_TYPES, JOIN_REQUEST_STATUSES, JOIN_REQUEST_TYPES, + MEMBERSHIP_STATUSES, PERMISSION_KEYS, } from "../constants.js"; import { optionalAgentAdapterTypeSchema } from "../adapter-type.js"; export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), + humanRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), agentMessage: z.string().max(4000).optional().nullable(), }); @@ -46,6 +50,14 @@ export const listJoinRequestsQuerySchema = z.object({ export type ListJoinRequestsQuery = z.infer; +export const listCompanyInvitesQuerySchema = z.object({ + state: z.enum(["active", "revoked", "accepted", "expired"]).optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +export type ListCompanyInvitesQuery = z.infer; + export const claimJoinRequestApiKeySchema = z.object({ claimSecret: z.string().min(16).max(256), }); @@ -85,8 +97,82 @@ export const updateMemberPermissionsSchema = z.object({ export type UpdateMemberPermissions = z.infer; +export const updateCompanyMemberSchema = z.object({ + membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), + status: z.enum(MEMBERSHIP_STATUSES).optional(), +}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { + message: "membershipRole or status is required", +}); + +export type UpdateCompanyMember = z.infer; + +export const updateCompanyMemberWithPermissionsSchema = z.object({ + membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), + status: z.enum(MEMBERSHIP_STATUSES).optional(), + grants: updateMemberPermissionsSchema.shape.grants.default([]), +}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { + message: "membershipRole or status is required", +}); + +export type UpdateCompanyMemberWithPermissions = z.infer; + export const updateUserCompanyAccessSchema = z.object({ companyIds: z.array(z.string().uuid()).default([]), }); export type UpdateUserCompanyAccess = z.infer; + +export const searchAdminUsersQuerySchema = z.object({ + query: z.string().trim().max(120).optional().default(""), +}); + +export type SearchAdminUsersQuery = z.infer; + +const profileImageAssetPathPattern = /^\/api\/assets\/[^/?#]+\/content(?:\?[^#]*)?(?:#.*)?$/; + +function isValidProfileImage(value: string): boolean { + if (profileImageAssetPathPattern.test(value)) return true; + + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +const profileImageSchema = z + .string() + .trim() + .min(1) + .max(4000) + .refine(isValidProfileImage, { message: "Invalid profile image URL" }); + +export const currentUserProfileSchema = z.object({ + id: z.string().min(1), + email: z.string().email().nullable(), + name: z.string().min(1).max(120).nullable(), + image: profileImageSchema.nullable(), +}); + +export type CurrentUserProfile = z.infer; + +export const authSessionSchema = z.object({ + session: z.object({ + id: z.string().min(1), + userId: z.string().min(1), + }), + user: currentUserProfileSchema, +}); + +export type AuthSession = z.infer; + +export const updateCurrentUserProfileSchema = z.object({ + name: z.string().trim().min(1).max(120), + image: z + .union([profileImageSchema, z.literal(""), z.null()]) + .optional() + .transform((value) => value === "" ? null : value), +}); + +export type UpdateCurrentUserProfile = z.infer; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index fad6382380..db5507e099 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -99,7 +99,6 @@ export const workspaceRuntimeServiceSchema = z.object({ createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }).strict(); - export const executionWorkspaceCloseReadinessSchema = z.object({ workspaceId: z.string().uuid(), state: executionWorkspaceCloseReadinessStateSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 4f8e659bbc..8e1662f428 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -253,22 +253,36 @@ export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, acceptInviteSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + currentUserProfileSchema, + authSessionSchema, + updateCurrentUserProfileSchema, + updateCompanyMemberSchema, + updateCompanyMemberWithPermissionsSchema, updateMemberPermissionsSchema, + searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, type CreateOpenClawInvitePrompt, type AcceptInvite, + type ListCompanyInvitesQuery, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CurrentUserProfile, + type AuthSession, + type UpdateCurrentUserProfile, + type UpdateCompanyMember, + type UpdateCompanyMemberWithPermissions, type UpdateMemberPermissions, + type SearchAdminUsersQuery, type UpdateUserCompanyAccess, } from "./access.js"; diff --git a/releases/v2026.414.0.md b/releases/v2026.414.0.md new file mode 100644 index 0000000000..d1c2863520 --- /dev/null +++ b/releases/v2026.414.0.md @@ -0,0 +1,107 @@ +# v2026.414.0 + +> Released: 2026-04-14 + +## Security + +- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. Previously, certain administrative endpoints were accessible without adequate permission verification. All users are strongly encouraged to upgrade. ([#3315](https://github.com/paperclipai/paperclip/pull/3315), [#3009](https://github.com/paperclipai/paperclip/pull/3009), @KhairulA) +- **Removed hardcoded JWT secret fallback** — The `createBetterAuthInstance` function no longer falls back to a hardcoded JWT secret, closing a credential-hygiene gap. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), @cleanunicorn) +- **Redact Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure. +- **Dependency bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog) + +## Highlights + +- **Multi-user access and invites** — Full multi-user authentication, company roles, and invite management. Board users can create invite links, approve join requests, and manage member roles. Invite flows support auto-accept for signed-in users, paginated history, and human-readable requester identities in approval views. +- **Human user identities everywhere** — Human users now appear with real names and avatars across activity feeds, issue tables, assignee pickers, and @-mention menus. A lightweight user directory endpoint powers consistent identity resolution across the UI. +- **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079)) +- **External adapter plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm) +- **Execution policies** — Issues can carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222)) +- **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797)) +- **Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435)) + +## Improvements + +- **Invite UX polish** — Auto-submit for signed-in invites, inline auth flow, paginated invite history, requester identity in join approvals, and prevention of duplicate join requests and member re-invites. +- **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220)) +- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, j/k keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) +- **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356)) +- **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999)) +- **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) +- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/paperclipai/paperclip/pull/3542)) +- **Auto-checkout for scoped wakes** — Agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/paperclipai/paperclip/pull/3538)) +- **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792)) +- **Keyboard shortcuts cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772)) +- **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), [#2793](https://github.com/paperclipai/paperclip/pull/2793), @kimnamu) +- **Codex fast mode** — Added fast mode support for the Codex local adapter with env probe safeguards. ([#3383](https://github.com/paperclipai/paperclip/pull/3383)) +- **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins) +- **GitHub webhook signing modes** — Added `github_hmac` and `none` webhook signing modes with timing-safe HMAC comparison. ([#1961](https://github.com/paperclipai/paperclip/pull/1961), @antonio-mello-ai) +- **Sidebar order persistence** — Sidebar project and company ordering preferences now persist per-user. +- **Workspace runtime controls** — Start/stop controls, runtime state reconciliation, runtime service improvements, and workspace branch/folder display in the issue properties sidebar. ([#3354](https://github.com/paperclipai/paperclip/pull/3354)) +- **Attachment improvements** — Arbitrary file attachments (not just images), drag-and-drop non-image files onto markdown editor, and square-cropped image gallery grid. ([#2749](https://github.com/paperclipai/paperclip/pull/2749)) +- **Image gallery in chat** — Clicking images in chat messages now opens a full gallery viewer. +- **Mobile UX** — Gmail-inspired mobile top bar for inbox issue views, responsive execution workspace pages, mobile mention menu placement, and mobile comment copy button feedback. +- **Routine improvements** — Draft routine defaults, run-time overrides, routine title variables, and relaxed project/agent requirements for routines. ([#3220](https://github.com/paperclipai/paperclip/pull/3220)) +- **Project environment variables** — Projects can now define environment variables that are inherited by workspace runs. +- **Skill auto-enable** — Mentioned skills are automatically enabled for heartbeat runs. +- **Comment wake batching** — Multiple comment wakes are batched into a single inline payload for more efficient agent heartbeats. +- **Server-side adapter pause/resume** — Builtin adapter types can now be paused/resumed from the server with `overridePaused`. ([#2542](https://github.com/paperclipai/paperclip/pull/2542), @plind-dm) +- **Skill slash-command autocomplete** — Skill names now autocomplete in the editor. +- **Worktree reseed command** — New CLI command to reseed worktrees from latest repo state. ([#3353](https://github.com/paperclipai/paperclip/pull/3353)) + +## Fixes + +- **Assignee name overflow** — Fixed long assignee names overflowing in the issues list grid with proper truncation. +- **Company alerts isolation** — Company-level alerts no longer appear in personal inbox. +- **Invite state management** — Fixed reused invite refresh pending state, paginated invite history cache isolation, and invite flow state mapping across reloads. +- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, and main-pane focus on navigation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) +- **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang) +- **Inbox keyboard navigation** — Fixed j/k traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) +- **Execution workspaces** — Fixed linked worktree reuse, dev runner isolation, workspace import regressions, and workspace preflight through server toolchain. +- **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer) +- **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar) +- **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros) +- **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa) +- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions and preserved instructions on resume fallback. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey) +- **Agent auth JWT** — Fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#2866](https://github.com/paperclipai/paperclip/pull/2866), @ergonaworks) +- **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163)) +- **Shimmer animation** — Fixed shimmer text using invalid `hsl()` wrapper on `oklch` colors, loop jitter, and added pause between repeats. +- **Mention selection** — Restored touch mention selection and fixed spaced mention queries. +- **Inbox archive** — Fixed archive flashing back after fade-out. +- **Goal description** — Made goal description area scrollable in create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326) +- **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354)) +- **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors. +- **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing. +- **Codex resume error** — Recognize missing-rollout Codex resume error as stale session. +- **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305)) +- **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation. +- **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins) +- **Windows adapter** — Uses `cmd.exe` for `.cmd`/`.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt) +- **Markdown autoformat** — Fixed autoformat of pasted markdown in inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison) +- **Paused agent dimming** — Correctly dim paused agents in list and org chart views; skip dimming on Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore) +- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to "agent". ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm) +- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup log. + +## Upgrade Guide + +Nine new database migrations (`0049`–`0056`) will run automatically on startup. These add: + +- Issue blocker relations table (`0049`) +- Project environment variables (`0050`) +- Trigram search indexes on issues and comments (`0051` — requires `pg_trgm` extension) +- Execution policy decision tracking (`0052`) +- Non-issue inbox dismissals (`0053`) +- Relaxed routine constraints (`0054`) +- Heartbeat run process group tracking (`0055`) +- User sidebar preferences (`0056`) + +All migrations are additive — no existing data is modified or removed. + +**`pg_trgm` extension**: Migration `0051` creates the `pg_trgm` PostgreSQL extension for full-text search. If your database user does not have `CREATE EXTENSION` privileges, ask your DBA to run `CREATE EXTENSION IF NOT EXISTS pg_trgm;` before upgrading. + +If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening. + +## Contributors + +Thank you to everyone who contributed to this release! + +@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @cryppadotta, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index a26ef6380e..a30096882a 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; -import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs"; +import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts"; import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts"; @@ -557,7 +557,7 @@ async function getDevHealthPayload() { if (!response.ok) { throw new Error(`Health request failed (${response.status})`); } - return await parseJsonResponseWithLimit<{ devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } }>(response); + return await parseJsonResponseWithLimit(response); } async function waitForChildExit() { diff --git a/scripts/paperclip-commit-metrics.ts b/scripts/paperclip-commit-metrics.ts index 8a648afac1..5f989ca1db 100644 --- a/scripts/paperclip-commit-metrics.ts +++ b/scripts/paperclip-commit-metrics.ts @@ -436,9 +436,6 @@ async function searchWindow( for (let page = 2; page <= pageCount; page += 1) { const response = await searchPage(client, options, start, end, page, 100); - if (response.incomplete_results) { - throw new Error(`GitHub returned incomplete search results for window ${windowKey} on page ${page}`); - } ingestSearchItems(cache, response.items, shas); } @@ -512,7 +509,7 @@ function sortFilteredShas(cache: CacheFile, shas: string[]): string[] { } function formatQueryDate(value: Date): string { - return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z"); + return value.toISOString().replace(".000Z", "Z"); } function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set) { @@ -850,14 +847,6 @@ class GitHubClient { return (await response.json()) as T; } - const retryAfter = response.headers.get("retry-after"); - if ((response.status === 403 || response.status === 429) && retryAfter) { - const waitMs = Math.max(Number.parseInt(retryAfter, 10) * 1000, 1_000); - console.error(`GitHub secondary rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`); - await sleep(waitMs); - continue; - } - const remaining = response.headers.get("x-ratelimit-remaining"); const resetAt = response.headers.get("x-ratelimit-reset"); if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) { diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index ba90e9ff9f..725f9c6780 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -31,26 +31,35 @@ source_env_path="$(dirname "$source_config_path")/.env" mkdir -p "$paperclip_dir" -run_paperclipai_command() { - local command_args=("$@") - if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then - pnpm paperclipai "${command_args[@]}" +run_isolated_worktree_init() { + local base_cli_runner="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" + local base_cli_entry="$base_cwd/cli/src/index.ts" + + if [[ -f "$base_cli_runner" && -f "$base_cli_entry" ]]; then + ( + cd "$worktree_cwd" + node "$base_cli_runner" "$base_cli_entry" worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) return 0 fi - local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" - local base_cli_entry_path="$base_cwd/cli/src/index.ts" - if command -v node >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then - node "$base_cli_tsx_path" "$base_cli_entry_path" "${command_args[@]}" + if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then + ( + cd "$worktree_cwd" + pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) return 0 fi if command -v paperclipai >/dev/null 2>&1; then - paperclipai "${command_args[@]}" + ( + cd "$worktree_cwd" + paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) return 0 fi - return 1 + return 127 } paperclipai_command_available() { @@ -71,19 +80,6 @@ paperclipai_command_available() { return 1 } -run_isolated_worktree_init() { - run_paperclipai_command \ - worktree \ - init \ - --force \ - --seed-mode \ - minimal \ - --name \ - "$worktree_name" \ - --from-config \ - "$source_config_path" -} - write_fallback_worktree_config() { WORKTREE_NAME="$worktree_name" \ BASE_CWD="$base_cwd" \ diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts new file mode 100644 index 0000000000..de839370cb --- /dev/null +++ b/server/src/__tests__/access-service.test.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + companyMemberships, + createDb, + principalPermissionGrants, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { accessService } from "../services/access.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createCompanyWithOwner(db: ReturnType) { + const company = await db + .insert(companies) + .values({ + name: `Access Service ${randomUUID()}`, + issuePrefix: `AS${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + + const owner = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `owner-${randomUUID()}`, + status: "active", + membershipRole: "owner", + }) + .returning() + .then((rows) => rows[0]!); + + return { company, owner }; +} + +describeEmbeddedPostgres("access service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("rejects combined access updates that would demote the last active owner", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.updateMemberAndPermissions( + company.id, + owner.id, + { membershipRole: "admin", grants: [] }, + "admin-user", + ), + ).rejects.toThrow("Cannot remove the last active owner"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.membershipRole).toBe("owner"); + }); + + it("rejects role-only updates that would suspend the last active owner", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.updateMember(company.id, owner.id, { status: "suspended" }), + ).rejects.toThrow("Cannot remove the last active owner"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.status).toBe("active"); + }); +}); diff --git a/server/src/__tests__/access-validators.test.ts b/server/src/__tests__/access-validators.test.ts new file mode 100644 index 0000000000..6fa63b9e8c --- /dev/null +++ b/server/src/__tests__/access-validators.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + updateCompanyMemberWithPermissionsSchema, + updateCurrentUserProfileSchema, +} from "@paperclipai/shared"; + +describe("access validators", () => { + it("accepts HTTP(S) and Paperclip asset image URLs", () => { + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "https://example.com/avatar.png", + }).success).toBe(true); + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "/api/assets/avatar/content", + }).success).toBe(true); + }); + + it("rejects data URI profile images", () => { + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "data:image/png;base64,AAAA", + }).success).toBe(false); + }); + + it("defaults omitted combined member grants to an empty list", () => { + const result = updateCompanyMemberWithPermissionsSchema.parse({ + membershipRole: "operator", + }); + + expect(result.grants).toEqual([]); + }); +}); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 31168aecd3..cab559afab 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -31,7 +31,7 @@ let setOverridePaused: typeof import("../adapters/registry.js").setOverridePause let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes; let errorHandler: typeof import("../middleware/index.js").errorHandler; -function createApp() { +function createApp(actorOverrides: Partial = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -41,6 +41,7 @@ function createApp() { companyIds: [], source: "local_implicit", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -166,4 +167,18 @@ describe("adapter routes", () => { fields: [{ key: "mode" }], }); }); + + it("rejects signed-in users without org access", async () => { + const app = createApp({ + userId: "outsider-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/adapters/claude_local/config-schema"); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + }); }); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 9b0fa6182d..310237b73b 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -395,14 +395,6 @@ describe("agent skill routes", () => { }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( - expect.objectContaining({ - id: "11111111-1111-4111-8111-111111111111", - adapterType: "claude_local", - }), - { "AGENTS.md": "You are QA." }, - { entryFile: "AGENTS.md", replaceExisting: false }, - ); expect(mockAgentService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ diff --git a/server/src/__tests__/auth-routes.test.ts b/server/src/__tests__/auth-routes.test.ts new file mode 100644 index 0000000000..88033d661d --- /dev/null +++ b/server/src/__tests__/auth-routes.test.ts @@ -0,0 +1,170 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createUpdateChain(row: unknown) { + return { + set(values: unknown) { + return { + where() { + return { + returning() { + return Promise.resolve([{ ...(row as Record), ...(values as Record) }]); + }, + }; + }, + }; + }, + }; +} + +function createDb(row: Record) { + return { + select: vi.fn(() => createSelectChain([row])), + update: vi.fn(() => createUpdateChain(row)), + } as any; +} + +async function createApp(actor: Express.Request["actor"], row: Record) { + const [{ authRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/auth.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api/auth", authRoutes(createDb(row))); + app.use(errorHandler); + return app; +} + +describe("auth routes", () => { + const baseUser = { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: "https://example.com/jane.png", + }; + + beforeEach(() => { + vi.resetModules(); + }); + + it("returns the persisted user profile in the session payload", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app).get("/api/auth/get-session"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + session: { + id: "paperclip:session:user-1", + userId: "user-1", + }, + user: baseUser, + }); + }); + + it("updates the signed-in profile", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "local_implicit", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Board Operator", image: "" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "user-1", + name: "Board Operator", + email: "jane@example.com", + image: null, + }); + }); + + it("preserves the existing avatar when updating only the profile name", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "local_implicit", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Board Operator" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "user-1", + name: "Board Operator", + email: "jane@example.com", + image: "https://example.com/jane.png", + }); + }); + + it("accepts Paperclip asset paths for avatars", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Jane Example", image: "/api/assets/asset-1/content" }); + + expect(res.status).toBe(200); + expect(res.body.image).toBe("/api/assets/asset-1/content"); + }); + + it("rejects invalid avatar image references", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Jane Example", image: "not-a-url" }); + + expect(res.status).toBe(400); + }); +}); diff --git a/server/src/__tests__/auth-session-route.test.ts b/server/src/__tests__/auth-session-route.test.ts new file mode 100644 index 0000000000..91eeb8a07f --- /dev/null +++ b/server/src/__tests__/auth-session-route.test.ts @@ -0,0 +1,61 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { actorMiddleware } from "../middleware/auth.js"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createDb() { + return { + select: vi + .fn() + .mockImplementationOnce(() => createSelectChain([])) + .mockImplementationOnce(() => createSelectChain([])), + } as any; +} + +describe("actorMiddleware authenticated session profile", () => { + it("preserves the signed-in user name and email on the board actor", async () => { + const app = express(); + app.use( + actorMiddleware(createDb(), { + deploymentMode: "authenticated", + resolveSession: async () => ({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "User One", + email: "user@example.com", + }, + }), + }), + ); + app.get("/actor", (req, res) => { + res.json(req.actor); + }); + + const res = await request(app).get("/actor"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + type: "board", + userId: "user-1", + userName: "User One", + userEmail: "user@example.com", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }); + }); +}); diff --git a/server/src/__tests__/authz-company-access.test.ts b/server/src/__tests__/authz-company-access.test.ts new file mode 100644 index 0000000000..b4b713592a --- /dev/null +++ b/server/src/__tests__/authz-company-access.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { assertBoardOrgAccess, assertCompanyAccess, hasBoardOrgAccess } from "../routes/authz.js"; + +function makeReq(input: { + method?: string; + actor: Express.Request["actor"]; +}) { + return { + method: input.method ?? "GET", + actor: input.actor, + } as Express.Request; +} + +describe("assertCompanyAccess", () => { + it("allows viewer memberships to read", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [ + { companyId: "company-1", membershipRole: "viewer", status: "active" }, + ], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); + + it("rejects viewer memberships for writes", () => { + const req = makeReq({ + method: "PATCH", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [ + { companyId: "company-1", membershipRole: "viewer", status: "active" }, + ], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("Viewer access is read-only"); + }); + + it("rejects writes when membership details are present but omit the target company", () => { + const req = makeReq({ + method: "POST", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have active company access"); + }); + + it("allows legacy board actors that only provide company ids", () => { + const req = makeReq({ + method: "POST", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); + + it("rejects signed-in instance admins without explicit company access", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + memberships: [], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have access to this company"); + }); + + it("allows local trusted board access without explicit membership", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); +}); + +describe("assertBoardOrgAccess", () => { + it("allows signed-in board users with active company access", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + isInstanceAdmin: false, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(true); + expect(() => assertBoardOrgAccess(req)).not.toThrow(); + }); + + it("allows instance admins without company memberships", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "admin-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: true, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(true); + expect(() => assertBoardOrgAccess(req)).not.toThrow(); + }); + + it("rejects signed-in users without company access or instance admin rights", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "outsider-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(false); + expect(() => assertBoardOrgAccess(req)).toThrow("Company membership or instance admin access required"); + }); +}); diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 4076639f4c..233615cfe3 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -15,8 +15,6 @@ const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null; const instructionsIndex = argv.indexOf("--append-system-prompt-file"); const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null; const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; -const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file"); -const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null; const payload = { argv, prompt: fs.readFileSync(0, "utf8"), @@ -25,8 +23,6 @@ const payload = { instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [], claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, - appendedSystemPromptFilePath, - appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null, }; if (capturePath) { fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index a4e77f9b7c..ddfa75b263 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -729,7 +729,6 @@ describe("codex execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); - it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/company-user-directory-route.test.ts b/server/src/__tests__/company-user-directory-route.test.ts new file mode 100644 index 0000000000..c56ea4e0d4 --- /dev/null +++ b/server/src/__tests__/company-user-directory-route.test.ts @@ -0,0 +1,132 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const activeMemberships = [ + { principalId: "user-2", status: "active" as const }, + { principalId: "user-1", status: "active" as const }, + ]; + const users = [ + { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" }, + { id: "user-2", name: null, email: "alex@example.com", image: null }, + ]; + + const isCompanyMembershipsTable = (table: unknown) => + !!table && + typeof table === "object" && + "membershipRole" in table && + "principalType" in table && + "principalId" in table; + const isAuthUsersTable = (table: unknown) => + !!table && + typeof table === "object" && + "emailVerified" in table && + "createdAt" in table && + "updatedAt" in table; + + return { + select() { + return { + from(table: unknown) { + if (isCompanyMembershipsTable(table)) { + const query = { + where() { + return query; + }, + orderBy() { + return Promise.resolve(activeMemberships); + }, + }; + return query; + } + if (isAuthUsersTable(table)) { + return { + where() { + return Promise.resolve(users); + }, + }; + } + throw new Error("Unexpected table"); + }, + }; + }, + }; +} + +function createApp(actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(createDbStub() as never, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /companies/:companyId/user-directory", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns active human users for operators without manage-permissions access", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + }); + + const res = await request(app).get("/api/companies/company-1/user-directory"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + users: [ + { + principalId: "user-2", + status: "active", + user: { id: "user-2", name: null, email: "alex@example.com", image: null }, + }, + { + principalId: "user-1", + status: "active", + user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" }, + }, + ], + }); + }); +}); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index dbeef4720a..1ca6d02fab 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -12,7 +12,6 @@ import { issues, projectWorkspaces, projects, - workspaceRuntimeServices, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -136,7 +135,6 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { afterEach(async () => { await db.delete(issues); - await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); @@ -326,136 +324,4 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { "git_branch_delete", ])); }, 20_000); - - it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => { - const companyId = randomUUID(); - const projectId = randomUUID(); - const projectWorkspaceId = randomUUID(); - const executionWorkspaceId = randomUUID(); - const olderServiceId = randomUUID(); - const currentServiceId = randomUUID(); - const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`; - const startedAt = new Date("2026-04-04T17:00:00.000Z"); - const stoppedAt = new Date("2026-04-04T17:05:00.000Z"); - const runningAt = new Date("2026-04-04T17:10:00.000Z"); - - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: "PAP", - requireBoardApprovalForNewAgents: false, - }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspaces", - status: "in_progress", - executionWorkspacePolicy: { - enabled: true, - }, - }); - await db.insert(projectWorkspaces).values({ - id: projectWorkspaceId, - companyId, - projectId, - name: "Primary", - sourceType: "local_path", - isPrimary: true, - cwd: "/tmp/paperclip-primary", - metadata: { - runtimeConfig: { - desiredState: "running", - workspaceRuntime: { - services: [{ name: "paperclip-dev", command: "pnpm dev" }], - }, - }, - }, - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Shared workspace", - status: "active", - providerType: "local_fs", - cwd: "/tmp/paperclip-primary", - }); - await db.insert(workspaceRuntimeServices).values([ - { - id: olderServiceId, - companyId, - projectId, - projectWorkspaceId, - executionWorkspaceId: null, - issueId: null, - scopeType: "project_workspace", - scopeId: projectWorkspaceId, - serviceName: "paperclip-dev", - status: "stopped", - lifecycle: "shared", - reuseKey, - command: "pnpm dev", - cwd: "/tmp/paperclip-primary", - port: 49195, - url: "http://127.0.0.1:49195", - provider: "local_process", - providerRef: "11111", - ownerAgentId: null, - startedByRunId: null, - lastUsedAt: stoppedAt, - startedAt, - stoppedAt, - stopPolicy: { type: "manual" }, - healthStatus: "unknown", - createdAt: startedAt, - updatedAt: stoppedAt, - }, - { - id: currentServiceId, - companyId, - projectId, - projectWorkspaceId, - executionWorkspaceId: null, - issueId: null, - scopeType: "project_workspace", - scopeId: projectWorkspaceId, - serviceName: "paperclip-dev", - status: "running", - lifecycle: "shared", - reuseKey, - command: "pnpm dev", - cwd: "/tmp/paperclip-primary", - port: 49222, - url: "http://127.0.0.1:49222", - provider: "local_process", - providerRef: "22222", - ownerAgentId: null, - startedByRunId: null, - lastUsedAt: runningAt, - startedAt: runningAt, - stoppedAt: null, - stopPolicy: { type: "manual" }, - healthStatus: "healthy", - createdAt: runningAt, - updatedAt: runningAt, - }, - ]); - - const workspace = await svc.getById(executionWorkspaceId); - const listed = await svc.list(companyId, { projectId }); - - expect(workspace?.runtimeServices).toHaveLength(1); - expect(workspace?.runtimeServices?.[0]).toMatchObject({ - id: currentServiceId, - status: "running", - projectWorkspaceId, - executionWorkspaceId: null, - url: "http://127.0.0.1:49222", - }); - expect(listed[0]?.runtimeServices).toHaveLength(1); - expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId); - }); }); diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index e941776ee8..2d294c0e0d 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; import type { Db } from "@paperclipai/db"; -import { serverVersion } from "../version.js"; import { healthRoutes } from "../routes/health.js"; +import * as devServerStatus from "../dev-server-status.js"; +import { serverVersion } from "../version.js"; const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn()); @@ -27,10 +28,8 @@ describe("GET /health", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("returns 200 with status ok", async () => { const app = createApp(); - const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "ok", version: serverVersion }); @@ -45,6 +44,7 @@ describe("GET /health", () => { const res = await request(app).get("/health"); expect(res.status).toBe(200); + expect(db.execute).toHaveBeenCalledTimes(1); expect(res.body).toMatchObject({ status: "ok", version: serverVersion }); }); @@ -60,7 +60,7 @@ describe("GET /health", () => { expect(res.body).toEqual({ status: "unhealthy", version: serverVersion, - error: "database_unreachable", + error: "database_unreachable" }); }); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 62b82e55f8..5d907470fa 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -536,7 +536,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); it("tracks the first heartbeat with the agent role instead of adapter type", async () => { - const { runId } = await seedRunFixture({ + const { agentId, runId } = await seedRunFixture({ agentStatus: "running", includeIssue: false, }); @@ -548,6 +548,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { mockTelemetryClient, expect.objectContaining({ agentRole: "engineer", + agentId, }), ); }); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index d97f63c70f..23a5808885 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -201,44 +201,6 @@ describe("buildRealizedExecutionWorkspaceFromPersisted", () => { expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature"); expect(result.source).toBe("task_session"); }); - - it("falls back to realization when the persisted workspace has no local path yet", () => { - const result = buildRealizedExecutionWorkspaceFromPersisted({ - base: buildResolvedWorkspace({ - cwd: "/tmp/project-primary", - repoRef: "main", - }), - workspace: { - id: "execution-workspace-2", - companyId: "company-1", - projectId: "project-1", - projectWorkspaceId: "workspace-1", - sourceIssueId: "issue-2", - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "PAP-999-missing-provider-ref", - status: "active", - cwd: null, - repoUrl: "https://example.com/paperclip.git", - baseRef: "main", - branchName: "feature/PAP-999-missing-provider-ref", - providerType: "git_worktree", - providerRef: null, - derivedFromExecutionWorkspaceId: null, - lastUsedAt: new Date(), - openedAt: new Date(), - closedAt: null, - cleanupEligibleAt: null, - cleanupReason: null, - config: null, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - expect(result).toBeNull(); - }); }); describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => { diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index ff265001e3..65dfe7ac35 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -176,6 +176,22 @@ describe("instance settings routes", () => { }); }); + it("rejects signed-in users without company access from reading general settings", async () => { + const app = await createApp({ + type: "board", + userId: "user-2", + source: "session", + isInstanceAdmin: false, + companyIds: [], + memberships: [], + }); + + const res = await request(app).get("/api/instance/settings/general"); + + expect(res.status).toBe(403); + expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled(); + }); + it("rejects non-admin board users from updating general settings", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/invite-accept-existing-member.test.ts b/server/src/__tests__/invite-accept-existing-member.test.ts new file mode 100644 index 0000000000..913fcdbf32 --- /dev/null +++ b/server/src/__tests__/invite-accept-existing-member.test.ts @@ -0,0 +1,126 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const updateMock = vi.fn(); + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "user-1", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + + const db = { + select() { + return { + from() { + return { + where() { + return Promise.resolve([invite]); + }, + }; + }, + }; + }, + update(...args: unknown[]) { + updateMock(...args); + return { + set() { + return { + where() { + return { + returning() { + return Promise.resolve([]); + }, + }; + }, + }; + }, + }; + }, + }; + + return { db, updateMock }; +} + +function createApp(db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "session", + userId: "user-1", + companyIds: ["company-1"], + memberships: [ + { + companyId: "company-1", + membershipRole: "owner", + status: "active", + }, + ], + }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /invites/:token/accept", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not consume a human invite when the signed-in user is already a company member", async () => { + const { db, updateMock } = createDbStub(); + const app = createApp(db); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("You already belong to this company"); + expect(updateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/invite-create-route.test.ts b/server/src/__tests__/invite-create-route.test.ts new file mode 100644 index 0000000000..5512d8dce2 --- /dev/null +++ b/server/src/__tests__/invite-create-route.test.ts @@ -0,0 +1,126 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +const logActivityMock = vi.fn(); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: (...args: unknown[]) => logActivityMock(...args), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + + return { + insert() { + return { + values() { + return { + returning() { + return Promise.resolve([createdInvite]); + }, + }; + }, + }; + }, + select(_shape?: unknown) { + return { + from() { + const query = { + leftJoin() { + return query; + }, + where() { + return Promise.resolve([{ + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }]); + }, + }; + return query; + }, + }; + }, + }; +} + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: null, + companyIds: ["company-1"], + }; + next(); + }); + app.use( + "/api", + accessRoutes(createDbStub() as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/invites", () => { + beforeEach(() => { + logActivityMock.mockReset(); + }); + + it("returns an absolute invite URL using the request base URL", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/companies/company-1/invites") + .set("host", "paperclip.example") + .set("x-forwarded-proto", "https") + .send({ + allowedJoinTypes: "human", + humanRole: "viewer", + }); + + expect(res.status).toBe(201); + expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.invitePath).toMatch(/^\/invite\/pcp_invite_/); + expect(res.body.inviteUrl).toMatch(/^https:\/\/paperclip\.example\/invite\/pcp_invite_/); + }); +}); diff --git a/server/src/__tests__/invite-expiry.test.ts b/server/src/__tests__/invite-expiry.test.ts index c84a2a9557..66a7e7d280 100644 --- a/server/src/__tests__/invite-expiry.test.ts +++ b/server/src/__tests__/invite-expiry.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { companyInviteExpiresAt } from "../routes/access.js"; describe("companyInviteExpiresAt", () => { - it("sets invite expiration to 10 minutes after invite creation time", () => { + it("sets invite expiration to 72 hours after invite creation time", () => { const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z"); const expiresAt = companyInviteExpiresAt(createdAtMs); - expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z"); + expect(expiresAt.toISOString()).toBe("2026-03-09T00:00:00.000Z"); }); }); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 7dd342677f..fae007e059 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { agentJoinGrantsFromDefaults } from "../routes/access.js"; +import { + agentJoinGrantsFromDefaults, + humanJoinGrantsFromDefaults, +} from "../services/invite-grants.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; describe("agentJoinGrantsFromDefaults", () => { it("adds tasks:assign when invite defaults do not specify agent grants", () => { @@ -55,3 +63,59 @@ describe("agentJoinGrantsFromDefaults", () => { ]); }); }); + +describe("human invite roles", () => { + it("maps owner to the full management grant set", () => { + expect(grantsForHumanRole("owner")).toEqual([ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "users:manage_permissions", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]); + }); + + it("defaults legacy or missing roles to operator", () => { + expect(normalizeHumanRole("member")).toBe("operator"); + expect(resolveHumanInviteRole(null)).toBe("operator"); + }); + + it("reads the configured human invite role from defaults", () => { + expect( + resolveHumanInviteRole({ + human: { + role: "viewer", + }, + }), + ).toBe("viewer"); + }); + + it("falls back to role grants when human invite defaults omit explicit grants", () => { + expect(humanJoinGrantsFromDefaults(null, "operator")).toEqual([ + { permissionKey: "tasks:assign", scope: null }, + ]); + }); + + it("preserves explicit human invite grants", () => { + expect( + humanJoinGrantsFromDefaults( + { + human: { + grants: [ + { + permissionKey: "users:invite", + scope: { companyId: "company-1" }, + }, + ], + }, + }, + "operator", + ), + ).toEqual([ + { + permissionKey: "users:invite", + scope: { companyId: "company-1" }, + }, + ]); + }); +}); diff --git a/server/src/__tests__/invite-list-route.test.ts b/server/src/__tests__/invite-list-route.test.ts new file mode 100644 index 0000000000..30b52b7965 --- /dev/null +++ b/server/src/__tests__/invite-list-route.test.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { companies, createDb, invites, joinRequests } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres invite list route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("GET /companies/:companyId/invites", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let companyId!: string; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-invite-list-route-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + }); + + afterEach(async () => { + await db.delete(joinRequests); + await db.delete(invites); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp(currentCompanyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: null, + companyIds: [currentCompanyId], + }; + next(); + }); + app.use( + "/api", + accessRoutes(db, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; + } + + it("returns invite history in descending pages with nextOffset", async () => { + const inviteOneId = randomUUID(); + const inviteTwoId = randomUUID(); + const inviteThreeId = randomUUID(); + + await db.insert(invites).values([ + { + id: inviteOneId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-1", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2026-04-20T00:00:00.000Z"), + createdAt: new Date("2026-04-10T00:00:00.000Z"), + updatedAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + id: inviteTwoId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-2", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "operator" }, + expiresAt: new Date("2026-04-21T00:00:00.000Z"), + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + }, + { + id: inviteThreeId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-3", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "admin" }, + expiresAt: new Date("2026-04-22T00:00:00.000Z"), + createdAt: new Date("2026-04-12T00:00:00.000Z"), + updatedAt: new Date("2026-04-12T00:00:00.000Z"), + }, + ]); + + await db.insert(joinRequests).values({ + id: randomUUID(), + inviteId: inviteThreeId, + companyId, + requestType: "human", + status: "pending_approval", + requestIp: "127.0.0.1", + requestEmailSnapshot: "person@example.com", + createdAt: new Date("2026-04-12T00:05:00.000Z"), + updatedAt: new Date("2026-04-12T00:05:00.000Z"), + }); + + const app = createApp(companyId); + + const firstPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2`); + + expect(firstPage.status).toBe(200); + expect(firstPage.body.invites).toHaveLength(2); + expect(firstPage.body.invites.map((invite: { id: string }) => invite.id)).toEqual([inviteThreeId, inviteTwoId]); + expect(firstPage.body.invites[0].relatedJoinRequestId).toBeTruthy(); + expect(firstPage.body.nextOffset).toBe(2); + + const secondPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2&offset=2`); + + expect(secondPage.status).toBe(200); + expect(secondPage.body.invites).toHaveLength(1); + expect(secondPage.body.invites[0].id).toBe(inviteOneId); + expect(secondPage.body.nextOffset).toBeNull(); + }); +}); diff --git a/server/src/__tests__/invite-logo-route.test.ts b/server/src/__tests__/invite-logo-route.test.ts new file mode 100644 index 0000000000..a18137c3ab --- /dev/null +++ b/server/src/__tests__/invite-logo-route.test.ts @@ -0,0 +1,146 @@ +import { Readable } from "node:stream"; +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockStorage = vi.hoisted(() => ({ + getObject: vi.fn(), + headObject: vi.fn(), +})); + +vi.mock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, +})); + +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +function createSelectChain(rows: unknown[]) { + const query = { + leftJoin() { + return query; + }, + where() { + return Promise.resolve(rows); + }, + }; + return { + from() { + return query; + }, + }; +} + +function createDbStub(inviteRows: unknown[], companyRows: unknown[]) { + let selectCall = 0; + return { + select() { + selectCall += 1; + return selectCall === 1 + ? createSelectChain(inviteRows) + : createSelectChain(companyRows); + }, + }; +} + +function createApp(db: Record) { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "anon" }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /invites/:token/logo", () => { + beforeEach(() => { + mockStorage.getObject.mockReset(); + mockStorage.headObject.mockReset(); + }); + + it("serves the company logo for an active invite without company auth", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + mockStorage.headObject.mockResolvedValue({ + exists: true, + contentType: "image/png", + contentLength: 3, + }); + mockStorage.getObject.mockResolvedValue({ + contentType: "image/png", + contentLength: 3, + stream: Readable.from([Buffer.from("png")]), + }); + const app = createApp( + createDbStub([invite], [{ + companyId: "company-1", + objectKey: "assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }]), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test/logo"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("image/png"); + expect(mockStorage.headObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1"); + expect(mockStorage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1"); + }); + + it("returns 404 when the logo asset record exists but storage does not", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + mockStorage.headObject.mockResolvedValue({ exists: false }); + const app = createApp( + createDbStub([invite], [{ + companyId: "company-1", + objectKey: "assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }]), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test/logo"); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Invite logo not found"); + expect(mockStorage.getObject).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts new file mode 100644 index 0000000000..6fd7172af6 --- /dev/null +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -0,0 +1,270 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockStorage = vi.hoisted(() => ({ + headObject: vi.fn(), +})); + +vi.mock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, +})); + +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +function createSelectChain(rows: unknown[]) { + const query = { + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows).then(resolve); + }, + leftJoin() { + return query; + }, + orderBy() { + return query; + }, + where() { + return query; + }, + }; + return { + from() { + return query; + }, + }; +} + +function createDbStub(...selectResponses: unknown[][]) { + let selectCall = 0; + return { + select() { + const rows = selectResponses[selectCall] ?? []; + selectCall += 1; + return createSelectChain(rows); + }, + }; +} + +function createApp( + db: Record, + actor: Record = { type: "anon" }, +) { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /invites/:token", () => { + beforeEach(() => { + mockStorage.headObject.mockReset(); + mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" }); + }); + + it("returns company branding in the invite summary response", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyId).toBe("company-1"); + expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.companyBrandColor).toBe("#114488"); + expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo"); + expect(res.body.inviteType).toBe("company_join"); + }); + + it("omits companyLogoUrl when the stored logo object is missing", async () => { + mockStorage.headObject.mockResolvedValue({ exists: false }); + + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyLogoUrl).toBeNull(); + }); + + it("returns pending join-request status for an already-accepted invite", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [{ requestType: "human", status: "pending_approval" }], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.joinRequestStatus).toBe("pending_approval"); + expect(res.body.joinRequestType).toBe("human"); + expect(res.body.companyName).toBe("Acme Robotics"); + }); + + it("falls back to a reusable human join request when the accepted invite reused an existing queue entry", async () => { + const invite = { + id: "invite-2", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [], + [{ email: "jane@example.com" }], + [ + { + id: "join-1", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "jane@example.com", + }, + ], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + { type: "board", userId: "user-1", source: "session" }, + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.joinRequestStatus).toBe("pending_approval"); + expect(res.body.joinRequestType).toBe("human"); + }); +}); diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 1e36c3528f..6a2df50d94 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -180,7 +180,6 @@ describe("issue feedback trace routes", () => { }); const res = await request(app).get("/api/feedback-traces/trace-1"); - expect(res.status).toBe(403); expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 75aefd6a36..3e233634df 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -379,7 +379,6 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { it("returns null instead of throwing for malformed non-uuid issue refs", async () => { await expect(svc.getById("not-a-uuid")).resolves.toBeNull(); }); - it("filters issues by execution workspace id", async () => { const companyId = randomUUID(); const projectId = randomUUID(); @@ -1217,6 +1216,283 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", }); }); +describeEmbeddedPostgres("issueService.create workspace inheritance", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + sharedWorkspaceKey: "workspace-key", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBe(executionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }); + }); + + it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const parentProjectWorkspaceId = randomUUID(); + const parentExecutionWorkspaceId = randomUUID(); + const explicitProjectWorkspaceId = randomUUID(); + const explicitExecutionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values([ + { + id: parentProjectWorkspaceId, + companyId, + projectId, + name: "Parent workspace", + }, + { + id: explicitProjectWorkspaceId, + companyId, + projectId, + name: "Explicit workspace", + }, + ]); + + await db.insert(executionWorkspaces).values([ + { + id: parentExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Parent worktree", + status: "active", + providerType: "git_worktree", + }, + { + id: explicitExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: explicitProjectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Explicit shared workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId: parentExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + projectWorkspaceId: explicitProjectWorkspaceId, + executionWorkspaceId: explicitExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "shared_workspace", + }, + }); + + expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId); + expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", + }); + }); + + it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const sourceIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "operator_branch", + strategyType: "git_worktree", + name: "Operator branch", + status: "active", + providerType: "git_worktree", + }); + + await db.insert(issues).values({ + id: sourceIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Source issue", + status: "todo", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "operator_branch", + }, + }); + + const followUp = await svc.create(companyId, { + projectId, + title: "Follow-up issue", + inheritExecutionWorkspaceFromIssueId: sourceIssueId, + }); + + expect(followUp.parentId).toBeNull(); + expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId); + expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId); + expect(followUp.executionWorkspacePreference).toBe("reuse_existing"); + expect(followUp.executionWorkspaceSettings).toEqual({ + mode: "operator_branch", + }); + }); +}); + describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { let db!: ReturnType; let svc!: ReturnType; diff --git a/server/src/__tests__/join-request-dedupe.test.ts b/server/src/__tests__/join-request-dedupe.test.ts new file mode 100644 index 0000000000..133d6ae9a6 --- /dev/null +++ b/server/src/__tests__/join-request-dedupe.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + collapseDuplicatePendingHumanJoinRequests, + findReusableHumanJoinRequest, +} from "../lib/join-request-dedupe.js"; + +describe("findReusableHumanJoinRequest", () => { + it("reuses the newest pending request for the same user", () => { + const rows = [ + { + id: "pending-new", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "pending-old", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "other-user", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-2", + requestEmailSnapshot: "other@example.com", + }, + ] as const; + + expect( + findReusableHumanJoinRequest(rows, { + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + })?.id + ).toBe("pending-new"); + }); + + it("falls back to email matching when the user id is unavailable", () => { + const rows = [ + { + id: "approved-existing", + requestType: "human", + status: "approved", + requestingUserId: null, + requestEmailSnapshot: "Person@Example.com", + }, + { + id: "agent-request", + requestType: "agent", + status: "pending_approval", + requestingUserId: null, + requestEmailSnapshot: null, + }, + ] as const; + + expect( + findReusableHumanJoinRequest(rows, { + requestingUserId: null, + requestEmailSnapshot: "person@example.com", + })?.id + ).toBe("approved-existing"); + }); +}); + +describe("collapseDuplicatePendingHumanJoinRequests", () => { + it("keeps only the newest pending human row per requester", () => { + const rows = [ + { + id: "human-new", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "human-old", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "approved-history", + requestType: "human", + status: "approved", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "agent-pending", + requestType: "agent", + status: "pending_approval", + requestingUserId: null, + requestEmailSnapshot: null, + }, + ] as const; + + expect(collapseDuplicatePendingHumanJoinRequests(rows).map((row) => row.id)) + .toEqual(["human-new", "approved-history", "agent-pending"]); + }); +}); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index d6537c415c..d813310794 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -32,6 +32,9 @@ const mockBoardAuthService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockStorage = vi.hoisted(() => ({ + headObject: vi.fn(), +})); function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ @@ -44,6 +47,10 @@ function registerModuleMocks() { })); } +vi.mock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, +})); + function createDbStub() { const createdInvite = { id: "invite-1", @@ -76,20 +83,35 @@ function createDbStub() { "feedbackDataSharingEnabled" in table; const select = vi.fn((selection?: unknown) => ({ from(table: unknown) { - return { + const query = { + leftJoin: vi.fn().mockReturnThis(), where: vi.fn().mockImplementation(() => { if (isInvitesTable(table)) { return Promise.resolve([createdInvite]); } + if (selection && typeof selection === "object" && "objectKey" in selection) { + return Promise.resolve([{ + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }]); + } if ( (selection && typeof selection === "object" && "name" in selection) || isCompaniesTable(table) ) { - return Promise.resolve([{ name: "Acme AI" }]); + return Promise.resolve([{ + name: "Acme AI", + brandColor: "#225577", + logoAssetId: "logo-1", + }]); } return Promise.resolve([]); }), }; + return query; }, })); return { @@ -135,6 +157,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { mockAccessService.canUser.mockResolvedValue(false); mockAgentService.getById.mockReset(); mockLogActivity.mockResolvedValue(undefined); + mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" }); }); it("rejects non-CEO agent callers", async () => { @@ -212,6 +235,8 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { expect(res.status).toBe(200); expect(res.body.companyName).toBe("Acme AI"); + expect(res.body.companyBrandColor).toBe("#225577"); + expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo"); expect(res.body.inviteType).toBe("company_join"); expect(res.body.allowedJoinTypes).toBe("agent"); }); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 3150a18225..4c965b0d8b 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -5,11 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockRegistry = vi.hoisted(() => ({ getById: vi.fn(), getByKey: vi.fn(), + upsertConfig: vi.fn(), })); const mockLifecycle = vi.hoisted(() => ({ load: vi.fn(), upgrade: vi.fn(), + unload: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), })); vi.mock("../services/plugin-registry.js", () => ({ @@ -138,6 +142,31 @@ describe("plugin install and upgrade authz", () => { expect(mockLifecycle.upgrade).not.toHaveBeenCalled(); }, 20_000); + it.each([ + ["delete", "delete", "/api/plugins/11111111-1111-4111-8111-111111111111", undefined], + ["enable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/enable", {}], + ["disable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/disable", {}], + ["config", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/config", { configJson: {} }], + ] as const)("rejects plugin %s for non-admin board users", async (_name, method, path, body) => { + const { app } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const req = method === "delete" ? request(app).delete(path) : request(app).post(path).send(body); + const res = await req; + + expect(res.status).toBe(403); + expect(mockRegistry.getById).not.toHaveBeenCalled(); + expect(mockRegistry.upsertConfig).not.toHaveBeenCalled(); + expect(mockLifecycle.unload).not.toHaveBeenCalled(); + expect(mockLifecycle.enable).not.toHaveBeenCalled(); + expect(mockLifecycle.disable).not.toHaveBeenCalled(); + }, 20_000); + it("allows instance admins to upgrade plugins", async () => { const pluginId = "11111111-1111-4111-8111-111111111111"; mockRegistry.getById.mockResolvedValue({ diff --git a/server/src/__tests__/shared-telemetry-events.test.ts b/server/src/__tests__/shared-telemetry-events.test.ts new file mode 100644 index 0000000000..1d6483d2e8 --- /dev/null +++ b/server/src/__tests__/shared-telemetry-events.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { + trackAgentCreated, + trackAgentFirstHeartbeat, + trackAgentTaskCompleted, + trackInstallCompleted, +} from "@paperclipai/shared/telemetry"; +import type { TelemetryClient } from "@paperclipai/shared/telemetry"; + +function createClient(): TelemetryClient { + return { + track: vi.fn(), + hashPrivateRef: vi.fn((value: string) => `hashed:${value}`), + } as unknown as TelemetryClient; +} + +describe("shared telemetry agent events", () => { + it("includes agent_id for agent.created", () => { + const client = createClient(); + + trackAgentCreated(client, { + agentRole: "engineer", + agentId: "11111111-1111-4111-8111-111111111111", + }); + + expect(client.track).toHaveBeenCalledWith("agent.created", { + agent_role: "engineer", + agent_id: "11111111-1111-4111-8111-111111111111", + }); + }); + + it("includes agent_id for agent.first_heartbeat", () => { + const client = createClient(); + + trackAgentFirstHeartbeat(client, { + agentRole: "coder", + agentId: "22222222-2222-4222-8222-222222222222", + }); + + expect(client.track).toHaveBeenCalledWith("agent.first_heartbeat", { + agent_role: "coder", + agent_id: "22222222-2222-4222-8222-222222222222", + }); + }); + + it("includes agent_id for agent.task_completed", () => { + const client = createClient(); + + trackAgentTaskCompleted(client, { + agentRole: "qa", + agentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(client.track).toHaveBeenCalledWith("agent.task_completed", { + agent_role: "qa", + agent_id: "33333333-3333-4333-8333-333333333333", + }); + }); + + it("keeps non-agent event dimensions unchanged", () => { + const client = createClient(); + + trackInstallCompleted(client, { adapterType: "codex_local" }); + + expect(client.track).toHaveBeenCalledWith("install.completed", { + adapter_type: "codex_local", + }); + expect(client.track).not.toHaveBeenCalledWith( + "install.completed", + expect.objectContaining({ agent_id: expect.any(String) }), + ); + }); +}); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 3317a25413..d3eb6a9f0e 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -206,6 +206,113 @@ describe("worktree config repair", () => { expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); }); + it("does not persist transient runtime home overrides over repo-local worktree env", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-")); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const transientHome = path.join(tempRoot, "tests", "e2e", ".tmp", "multiuser-authenticated"); + const worktreeRoot = path.join(tempRoot, "PAP-989-multi-user-implementation-using-plan-from-pap-958"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const instanceId = "pap-989-multi-user-implementation-using-plan-from-pap-958"; + const stableInstanceRoot = path.join(isolatedHome, "instances", instanceId); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(transientHome), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(transientHome, "instances", instanceId, "db"), + embeddedPostgresPort: 54334, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(transientHome, "instances", instanceId, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(transientHome, "instances", instanceId, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3104, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(transientHome, "instances", instanceId, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(transientHome, "instances", instanceId, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + `PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`, + `PAPERCLIP_INSTANCE_ID=${JSON.stringify(instanceId)}`, + `PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`, + `PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`, + 'PAPERCLIP_IN_WORKTREE="true"', + 'PAPERCLIP_WORKTREE_NAME="PAP-989-multi-user-implementation-using-plan-from-pap-958"', + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-989-multi-user-implementation-using-plan-from-pap-958"; + process.env.PAPERCLIP_HOME = transientHome; + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + process.env.PAPERCLIP_CONFIG = configPath; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: false, + }); + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db")); + expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups")); + expect(repairedConfig.logging.logDir).toBe(path.join(stableInstanceRoot, "logs")); + expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(stableInstanceRoot, "data", "storage")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe( + path.join(stableInstanceRoot, "secrets", "master.key"), + ); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).not.toContain(`PAPERCLIP_HOME=${JSON.stringify(transientHome)}`); + expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); + }); + it("rebalances duplicate ports for already isolated worktree configs", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-")); const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index 7c833865b1..96931b0f16 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -38,7 +38,6 @@ export function buildInvocationEnvForLogs( env: Record, options: BuildInvocationEnvForLogsOptions = {}, ): Record { - // TODO: Remove this fallback once @paperclipai/adapter-utils exports buildInvocationEnvForLogs everywhere we consume it. const maybeBuildInvocationEnvForLogs = ( serverUtils as typeof serverUtils & { buildInvocationEnvForLogs?: ( diff --git a/server/src/app.ts b/server/src/app.ts index c60de55526..971645d5e9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -28,6 +28,7 @@ import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { llmRoutes } from "./routes/llms.js"; +import { authRoutes } from "./routes/auth.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; @@ -155,23 +156,7 @@ export async function createApp( resolveSession: opts.resolveSession, }), ); - app.get("/api/auth/get-session", (req, res) => { - if (req.actor.type !== "board" || !req.actor.userId) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - res.json({ - session: { - id: `paperclip:${req.actor.source}:${req.actor.userId}`, - userId: req.actor.userId, - }, - user: { - id: req.actor.userId, - email: null, - name: req.actor.source === "local_implicit" ? "Local Board" : null, - }, - }); - }); + app.use("/api/auth", authRoutes(db)); if (opts.betterAuthHandler) { app.all("/api/auth/{*authPath}", opts.betterAuthHandler); } diff --git a/server/src/lib/join-request-dedupe.ts b/server/src/lib/join-request-dedupe.ts new file mode 100644 index 0000000000..249ef11f65 --- /dev/null +++ b/server/src/lib/join-request-dedupe.ts @@ -0,0 +1,88 @@ +import { joinRequests } from "@paperclipai/db"; + +type JoinRequestLike = Pick< + typeof joinRequests.$inferSelect, + | "id" + | "requestType" + | "status" + | "requestingUserId" + | "requestEmailSnapshot" + | "createdAt" + | "updatedAt" +>; + +function nonEmptyTrimmed(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +export function normalizeJoinRequestEmail( + email: string | null | undefined +): string | null { + const trimmed = nonEmptyTrimmed(email); + return trimmed ? trimmed.toLowerCase() : null; +} + +export function humanJoinRequestIdentity( + row: Pick< + JoinRequestLike, + "requestType" | "requestingUserId" | "requestEmailSnapshot" + > +): string | null { + if (row.requestType !== "human") return null; + const requestingUserId = nonEmptyTrimmed(row.requestingUserId); + if (requestingUserId) return `user:${requestingUserId}`; + const email = normalizeJoinRequestEmail(row.requestEmailSnapshot); + return email ? `email:${email}` : null; +} + +export function findReusableHumanJoinRequest< + T extends Pick< + JoinRequestLike, + "id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot" + >, +>( + rows: T[], + actor: { requestingUserId?: string | null; requestEmailSnapshot?: string | null } +): T | null { + const actorUserId = nonEmptyTrimmed(actor.requestingUserId); + if (actorUserId) { + const sameUser = rows.find( + (row) => + row.requestType === "human" && + (row.status === "pending_approval" || row.status === "approved") && + row.requestingUserId === actorUserId + ); + if (sameUser) return sameUser; + } + + const actorEmail = normalizeJoinRequestEmail(actor.requestEmailSnapshot); + if (!actorEmail) return null; + return ( + rows.find( + (row) => + row.requestType === "human" && + (row.status === "pending_approval" || row.status === "approved") && + normalizeJoinRequestEmail(row.requestEmailSnapshot) === actorEmail + ) ?? null + ); +} + +export function collapseDuplicatePendingHumanJoinRequests< + T extends Pick< + JoinRequestLike, + "id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot" + >, +>(rows: T[]): T[] { + const seen = new Set(); + return rows.filter((row) => { + if (row.requestType !== "human" || row.status !== "pending_approval") { + return true; + } + const identity = humanJoinRequestIdentity(row); + if (!identity) return true; + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 6140c87d5b..2438aee306 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -23,7 +23,14 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa return async (req, _res, next) => { req.actor = opts.deploymentMode === "local_trusted" - ? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" } + ? { + type: "board", + userId: "local-board", + userName: "Local Board", + userEmail: null, + isInstanceAdmin: true, + source: "local_implicit", + } : { type: "none", source: "none" }; const runIdHeader = req.header("x-paperclip-run-id"); @@ -49,7 +56,11 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) .then((rows) => rows[0] ?? null), db - .select({ companyId: companyMemberships.companyId }) + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + status: companyMemberships.status, + }) .from(companyMemberships) .where( and( @@ -62,7 +73,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa req.actor = { type: "board", userId, + userName: session.user.name ?? null, + userEmail: session.user.email ?? null, companyIds: memberships.map((row) => row.companyId), + memberships, isInstanceAdmin: Boolean(roleRow), runId: runIdHeader ?? undefined, source: "session", @@ -90,7 +104,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa req.actor = { type: "board", userId: boardKey.userId, + userName: access.user?.name ?? null, + userEmail: access.user?.email ?? null, companyIds: access.companyIds, + memberships: access.memberships, isInstanceAdmin: access.isInstanceAdmin, keyId: boardKey.id, runId: runIdHeader || undefined, diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 0be3adab1f..c86637caf0 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -9,14 +9,18 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, eq, isNull, desc } from "drizzle-orm"; +import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + assets, agentApiKeys, authUsers, companies, + companyLogos, + companyMemberships, invites, - joinRequests + joinRequests, + principalPermissionGrants, } from "@paperclipai/db"; import { acceptInviteSchema, @@ -24,13 +28,17 @@ import { claimJoinRequestApiKeySchema, createCompanyInviteSchema, createOpenClawInvitePromptSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, resolveCliAuthChallengeSchema, + searchAdminUsersQuerySchema, + updateCompanyMemberWithPermissionsSchema, + updateCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS } from "@paperclipai/shared"; -import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; +import type { DeploymentExposure, DeploymentMode, PermissionKey } from "@paperclipai/shared"; import { forbidden, conflict, @@ -48,11 +56,22 @@ import { logActivity, notifyHireApproved } from "../services/index.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; +import { humanJoinGrantsFromDefaults } from "../services/invite-grants.js"; +import { + collapseDuplicatePendingHumanJoinRequests, + findReusableHumanJoinRequest, +} from "../lib/join-request-dedupe.js"; import { assertAuthenticated, assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js"; +import { getStorageService } from "../storage/index.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -62,7 +81,12 @@ const INVITE_TOKEN_PREFIX = "pcp_invite_"; const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const INVITE_TOKEN_SUFFIX_LENGTH = 8; const INVITE_TOKEN_MAX_RETRIES = 5; -const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000; +const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000; + +type MemberGrantPayload = { + permissionKey: PermissionKey; + scope?: Record | null; +}; function createInviteToken() { const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH); @@ -858,9 +882,20 @@ function toInviteSummaryResponse( req: Request, token: string, invite: typeof invites.$inferSelect, - companyName: string | null = null + company: + | string + | { + name: string | null; + brandColor: string | null; + logoUrl: string | null; + } + | null = null ) { + const companyInfo = typeof company === "string" + ? { name: company, brandColor: null, logoUrl: null } + : company; const baseUrl = requestBaseUrl(req); + const invitePath = `/invite/${token}`; const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; const skillIndexPath = `/api/invites/${token}/skills/index`; @@ -868,10 +903,15 @@ function toInviteSummaryResponse( return { id: invite.id, companyId: invite.companyId, - companyName, + companyName: companyInfo?.name ?? null, + companyLogoUrl: companyInfo?.logoUrl ?? null, + companyBrandColor: companyInfo?.brandColor ?? null, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite), expiresAt: invite.expiresAt, + invitePath, + inviteUrl: baseUrl ? `${baseUrl}${invitePath}` : invitePath, onboardingPath, onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath, onboardingTextPath, @@ -886,6 +926,353 @@ function toInviteSummaryResponse( }; } +function actorHasActiveUserMembership(req: Request, companyId: string) { + return ( + req.actor.type === "board" && + typeof req.actor.userId === "string" && + Array.isArray(req.actor.memberships) && + req.actor.memberships.some( + (membership) => + membership.companyId === companyId && membership.status === "active", + ) + ); +} + +async function loadUsersById(db: Db, userIds: string[]) { + if (userIds.length === 0) return new Map>(); + const rows = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(inArray(authUsers.id, userIds)); + return new Map(rows.map((row) => [row.id, toUserProfile(row)])); +} + +async function loadCompanyAccessSummary( + req: Request, + access: ReturnType, + companyId: string, +) { + if (req.actor.type !== "board") { + return { + currentUserRole: null, + canManageMembers: false, + canInviteUsers: false, + canApproveJoinRequests: false, + }; + } + if (isLocalImplicit(req)) { + return { + currentUserRole: "owner" as const, + canManageMembers: true, + canInviteUsers: true, + canApproveJoinRequests: true, + }; + } + const userId = req.actor.userId ?? null; + const membership = + userId ? await access.getMembership(companyId, "user", userId) : null; + const [canManageMembers, canInviteUsers, canApproveJoinRequests] = + await Promise.all([ + access.canUser(companyId, userId, "users:manage_permissions"), + access.canUser(companyId, userId, "users:invite"), + access.canUser(companyId, userId, "joins:approve"), + ]); + + return { + currentUserRole: + membership?.status === "active" && membership.membershipRole + ? normalizeHumanRole(membership.membershipRole, "operator") + : null, + canManageMembers, + canInviteUsers, + canApproveJoinRequests, + }; +} + +async function loadCompanyMemberRecords(db: Db, companyId: string) { + const members = await db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)); + + const userIds = [...new Set(members.map((member) => member.principalId))]; + const [userMap, grants] = await Promise.all([ + loadUsersById(db, userIds), + userIds.length > 0 + ? db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "user"), + inArray(principalPermissionGrants.principalId, userIds), + ), + ) + : Promise.resolve([]), + ]); + + const grantsByPrincipalId = new Map(); + for (const grant of grants) { + const existing = grantsByPrincipalId.get(grant.principalId) ?? []; + existing.push(grant); + grantsByPrincipalId.set(grant.principalId, existing); + } + + return members.map((member) => ({ + ...member, + principalType: "user" as const, + membershipRole: member.membershipRole + ? normalizeHumanRole(member.membershipRole, "operator") + : null, + user: userMap.get(member.principalId) ?? null, + grants: grantsByPrincipalId.get(member.principalId) ?? [], + })); +} + +async function loadCompanyUserDirectory(db: Db, companyId: string) { + const members = await db + .select({ + principalId: companyMemberships.principalId, + status: companyMemberships.status, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)); + + const userIds = [...new Set(members.map((member) => member.principalId))]; + const userMap = await loadUsersById(db, userIds); + + return members.map((member) => ({ + principalId: member.principalId, + status: "active" as const, + user: userMap.get(member.principalId) ?? null, + })); +} + +function inviteStateWhereClause( + state: "active" | "accepted" | "expired" | "revoked" | undefined, +) { + const now = new Date(); + switch (state) { + case "active": + return and( + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now), + ); + case "accepted": + return isNotNull(invites.acceptedAt); + case "expired": + return and( + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + lte(invites.expiresAt, now), + ); + case "revoked": + return isNotNull(invites.revokedAt); + default: + return undefined; + } +} + +async function loadCompanyInviteRecords( + db: Db, + companyId: string, + options: { + state?: "active" | "accepted" | "expired" | "revoked"; + limit: number; + offset: number; + }, +) { + const whereClause = inviteStateWhereClause(options.state); + const rows = await db + .select() + .from(invites) + .where(whereClause ? and(eq(invites.companyId, companyId), whereClause) : eq(invites.companyId, companyId)) + .orderBy(desc(invites.createdAt)) + .limit(options.limit + 1) + .offset(options.offset); + const hasMore = rows.length > options.limit; + const visibleRows = hasMore ? rows.slice(0, options.limit) : rows; + const userIds = [ + ...new Set( + visibleRows + .map((invite) => invite.invitedByUserId) + .filter((value): value is string => Boolean(value)), + ), + ]; + const [userMap, joinRows, companyName] = await Promise.all([ + loadUsersById(db, userIds), + visibleRows.length + ? db + .select({ id: joinRequests.id, inviteId: joinRequests.inviteId }) + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + inArray( + joinRequests.inviteId, + visibleRows.map((invite) => invite.id), + ), + ), + ) + : Promise.resolve([]), + db + .select({ name: companies.name }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((companyRows) => companyRows[0]?.name ?? null), + ]); + const joinRequestIdByInviteId = new Map( + joinRows.map((row: { inviteId: string; id: string }) => [row.inviteId, row.id]), + ); + + return { + invites: visibleRows.map((invite) => ({ + ...invite, + companyName, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + state: inviteState(invite), + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + relatedJoinRequestId: joinRequestIdByInviteId.get(invite.id) ?? null, + })), + nextOffset: hasMore ? options.offset + options.limit : null, + }; +} + +async function loadJoinRequestRecords(db: Db, companyId: string) { + const rows = collapseDuplicatePendingHumanJoinRequests( + await db + .select() + .from(joinRequests) + .where(eq(joinRequests.companyId, companyId)) + .orderBy(desc(joinRequests.createdAt)) + ); + const inviteIds = [...new Set(rows.map((row) => row.inviteId))]; + const inviteRows = inviteIds.length + ? await db + .select() + .from(invites) + .where(inArray(invites.id, inviteIds)) + : []; + const userIds = [ + ...new Set( + [ + ...rows.map((row) => row.requestingUserId), + ...rows.map((row) => row.approvedByUserId), + ...rows.map((row) => row.rejectedByUserId), + ...inviteRows.map((invite) => invite.invitedByUserId), + ].filter((value): value is string => Boolean(value)), + ), + ]; + const userMap = await loadUsersById(db, userIds); + const inviteMap = new Map(inviteRows.map((invite) => [invite.id, invite])); + + return rows.map((row) => { + const invite = inviteMap.get(row.inviteId) ?? null; + return { + ...toJoinRequestResponse(row), + requesterUser: row.requestingUserId + ? userMap.get(row.requestingUserId) ?? null + : null, + approvedByUser: row.approvedByUserId + ? userMap.get(row.approvedByUserId) ?? null + : null, + rejectedByUser: row.rejectedByUserId + ? userMap.get(row.rejectedByUserId) ?? null + : null, + invite: invite + ? { + id: invite.id, + inviteType: invite.inviteType, + allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + revokedAt: invite.revokedAt, + acceptedAt: invite.acceptedAt, + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + } + : null, + }; + }); +} + +async function loadUserCompanyAccessResponse( + db: Db, + access: ReturnType, + userId: string, +) { + const [memberships, user, isInstanceAdmin] = await Promise.all([ + access.listUserCompanyAccess(userId), + db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null), + access.isInstanceAdmin(userId), + ]); + const companyIds = [...new Set(memberships.map((membership) => membership.companyId))]; + const companyRows = companyIds.length + ? await db + .select({ + id: companies.id, + name: companies.name, + status: companies.status, + }) + .from(companies) + .where(inArray(companies.id, companyIds)) + : []; + const companyMap = new Map(companyRows.map((company) => [company.id, company])); + + return { + user: user + ? { + ...toUserProfile(user), + isInstanceAdmin, + } + : null, + companyAccess: memberships.map((membership) => { + const company = companyMap.get(membership.companyId) ?? null; + return { + ...membership, + principalType: "user" as const, + companyName: company?.name ?? null, + companyStatus: company?.status ?? null, + }; + }), + }; +} + function buildOnboardingDiscoveryDiagnostics(input: { apiBaseUrl: string; deploymentMode: DeploymentMode; @@ -1350,12 +1737,22 @@ function extractInviteMessage( function mergeInviteDefaults( defaultsPayload: Record | null | undefined, - agentMessage: string | null + agentMessage: string | null, + humanRole: "owner" | "admin" | "operator" | "viewer" | null = null, ): Record | null { const merged = defaultsPayload && typeof defaultsPayload === "object" ? { ...defaultsPayload } : {}; + if (humanRole) { + const existingHuman = + isPlainObject(merged.human) ? { ...(merged.human as Record) } : {}; + merged.human = { + ...existingHuman, + role: humanRole, + grants: grantsForHumanRole(humanRole), + }; + } if (agentMessage) { merged.agentMessage = agentMessage; } @@ -1375,10 +1772,44 @@ function inviteExpired(invite: typeof invites.$inferSelect) { return invite.expiresAt.getTime() <= Date.now(); } +function inviteState(invite: typeof invites.$inferSelect) { + if (invite.revokedAt) return "revoked" as const; + if (invite.acceptedAt) return "accepted" as const; + if (inviteExpired(invite)) return "expired" as const; + return "active" as const; +} + +function extractInviteHumanRole(invite: typeof invites.$inferSelect) { + if (invite.allowedJoinTypes === "agent") return null; + return resolveHumanInviteRole( + invite.defaultsPayload as Record | null | undefined, + ); +} + function isLocalImplicit(req: Request) { return req.actor.type === "board" && req.actor.source === "local_implicit"; } +function toUserProfile( + user: + | { + id: string; + email: string | null; + name: string | null; + image?: string | null; + } + | null + | undefined, +) { + if (!user) return null; + return { + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + }; +} + async function resolveActorEmail(db: Db, req: Request): Promise { if (isLocalImplicit(req)) return "local@paperclip.local"; const userId = req.actor.userId; @@ -1391,6 +1822,57 @@ async function resolveActorEmail(db: Db, req: Request): Promise { return user?.email ?? null; } +async function resolveAcceptedInviteJoinRequest( + db: Db, + req: Request, + invite: typeof invites.$inferSelect | null, +) { + if (!invite?.acceptedAt) return null; + + const directJoinRequest = await db + .select({ + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + }) + .from(joinRequests) + .where(eq(joinRequests.inviteId, invite.id)) + .then((rows) => rows[0] ?? null); + if (directJoinRequest) return directJoinRequest; + + if (!invite.companyId) return null; + + const actorRequestingUserId = isLocalImplicit(req) + ? "local-board" + : req.actor.userId ?? null; + const actorEmail = await resolveActorEmail(db, req); + if (!actorRequestingUserId && !actorEmail) return null; + + return findReusableHumanJoinRequest( + await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + }) + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, invite.companyId), + eq(joinRequests.requestType, "human"), + ), + ) + .orderBy(desc(joinRequests.createdAt)), + { + requestingUserId: actorRequestingUserId, + requestEmailSnapshot: actorEmail, + }, + ); +} + function grantsFromDefaults( defaultsPayload: Record | null | undefined, key: "human" | "agent" @@ -1849,6 +2331,7 @@ export function accessRoutes( req: Request; companyId: string; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: "owner" | "admin" | "operator" | "viewer" | null; defaultsPayload?: Record | null; agentMessage?: string | null; }) { @@ -1856,13 +2339,18 @@ export function accessRoutes( typeof input.agentMessage === "string" ? input.agentMessage.trim() || null : null; + const effectiveHumanRole = + input.allowedJoinTypes === "agent" + ? null + : input.humanRole ?? "operator"; const insertValues = { companyId: input.companyId, inviteType: "company_join" as const, allowedJoinTypes: input.allowedJoinTypes, defaultsPayload: mergeInviteDefaults( input.defaultsPayload ?? null, - normalizedAgentMessage + normalizedAgentMessage, + effectiveHumanRole, ), expiresAt: companyInviteExpiresAt(), invitedByUserId: input.req.actor.userId ?? null @@ -1897,14 +2385,89 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } - async function getInviteCompanyName(companyId: string | null) { - if (!companyId) return null; + async function getInviteCompanyBranding( + companyId: string | null, + inviteToken: string | null = null, + ): Promise<{ + name: string | null; + brandColor: string | null; + logoAssetId: string | null; + logoUrl: string | null; + }> { + if (!companyId) { + return { name: null, brandColor: null, logoAssetId: null, logoUrl: null }; + } const company = await db - .select({ name: companies.name }) + .select({ + name: companies.name, + brandColor: companies.brandColor, + logoAssetId: companyLogos.assetId, + }) .from(companies) + .leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)) .where(eq(companies.id, companyId)) .then((rows) => rows[0] ?? null); - return company?.name ?? null; + let logoUrl: string | null = null; + if (inviteToken && company?.logoAssetId) { + const logoAsset = await getInviteLogoAsset(companyId); + if (logoAsset?.companyId) { + try { + const storage = getStorageService(); + const logoObject = await storage.headObject(logoAsset.companyId, logoAsset.objectKey); + if (logoObject.exists) { + logoUrl = `/api/invites/${inviteToken}/logo`; + } + } catch (err) { + logger.warn( + { + err, + companyId, + logoAssetId: company.logoAssetId, + }, + "invite logo storage check failed", + ); + } + } + } + + return { + name: company?.name ?? null, + brandColor: company?.brandColor ?? null, + logoAssetId: company?.logoAssetId ?? null, + logoUrl, + }; + } + + async function getInviteLogoAsset(companyId: string | null): Promise<{ + companyId: string | null; + objectKey: string; + contentType: string | null; + byteSize: number | null; + originalFilename: string | null; + } | null> { + if (!companyId) return null; + const logoAsset = await db + .select({ + companyId: companies.id, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + originalFilename: assets.originalFilename, + }) + .from(companies) + .leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)) + .leftJoin(assets, eq(assets.id, companyLogos.assetId)) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + + if (!logoAsset?.objectKey) return null; + return { + companyId: logoAsset.companyId, + objectKey: logoAsset.objectKey, + contentType: logoAsset.contentType, + byteSize: logoAsset.byteSize, + originalFilename: logoAsset.originalFilename, + }; } router.get("/skills/available", (req, res) => { @@ -1948,6 +2511,7 @@ export function accessRoutes( req, companyId, allowedJoinTypes: req.body.allowedJoinTypes, + humanRole: req.body.humanRole ?? null, defaultsPayload: req.body.defaultsPayload ?? null, agentMessage: req.body.agentMessage ?? null }); @@ -1966,22 +2530,24 @@ export function accessRoutes( inviteType: created.inviteType, allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), + humanRole: extractInviteHumanRole(created), hasAgentMessage: Boolean(normalizedAgentMessage) } }); - const companyName = await getInviteCompanyName(created.companyId); + const companyBranding = await getInviteCompanyBranding(created.companyId, token); const inviteSummary = toInviteSummaryResponse( req, token, created, - companyName + companyBranding ); res.status(201).json({ ...created, token, - inviteUrl: `/invite/${token}`, - companyName, + invitePath: inviteSummary.invitePath, + inviteUrl: inviteSummary.inviteUrl, + companyName: companyBranding.name, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2000,6 +2566,7 @@ export function accessRoutes( req, companyId, allowedJoinTypes: "agent", + humanRole: null, defaultsPayload: null, agentMessage: req.body.agentMessage ?? null }); @@ -2022,18 +2589,19 @@ export function accessRoutes( } }); - const companyName = await getInviteCompanyName(created.companyId); + const companyBranding = await getInviteCompanyBranding(created.companyId, token); const inviteSummary = toInviteSummaryResponse( req, token, created, - companyName + companyBranding ); res.status(201).json({ ...created, token, - inviteUrl: `/invite/${token}`, - companyName, + invitePath: inviteSummary.invitePath, + inviteUrl: inviteSummary.inviteUrl, + companyName: companyBranding.name, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2049,17 +2617,82 @@ export function accessRoutes( .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); + const inviteJoinRequest = await resolveAcceptedInviteJoinRequest(db, req, invite); if ( !invite || invite.revokedAt || - invite.acceptedAt || - inviteExpired(invite) + inviteExpired(invite) || + (invite.acceptedAt && !inviteJoinRequest) ) { throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); - res.json(toInviteSummaryResponse(req, token, invite, companyName)); + const companyBranding = await getInviteCompanyBranding(invite.companyId, token); + const inviterName = invite.invitedByUserId + ? await loadUsersById(db, [invite.invitedByUserId]).then( + (m) => m.get(invite.invitedByUserId!)?.name ?? null + ) + : null; + res.json({ + ...toInviteSummaryResponse(req, token, invite, companyBranding), + invitedByUserName: inviterName, + joinRequestStatus: inviteJoinRequest?.status ?? null, + joinRequestType: inviteJoinRequest?.requestType ?? null, + }); + }); + + router.get("/invites/:token/logo", async (req, res, next) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + const inviteJoinRequest = await resolveAcceptedInviteJoinRequest(db, req, invite); + if ( + !invite || + invite.revokedAt || + inviteExpired(invite) || + (invite.acceptedAt && !inviteJoinRequest) + ) { + throw notFound("Invite not found"); + } + + const logoAsset = await getInviteLogoAsset(invite.companyId); + if (!logoAsset || !logoAsset.companyId) { + throw notFound("Invite logo not found"); + } + const companyId = logoAsset.companyId; + + const storage = getStorageService(); + const logoHead = await storage.headObject(companyId, logoAsset.objectKey); + if (!logoHead.exists) { + throw notFound("Invite logo not found"); + } + const object = await storage.getObject(companyId, logoAsset.objectKey); + const responseContentType = + logoAsset.contentType || + logoHead.contentType || + object.contentType || + "application/octet-stream"; + res.setHeader("Content-Type", responseContentType); + res.setHeader( + "Content-Length", + String(logoAsset.byteSize || logoHead.contentLength || object.contentLength || 0), + ); + res.setHeader("Cache-Control", "private, max-age=60"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (responseContentType === "image/svg+xml") { + res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + } + const filename = logoAsset.originalFilename ?? "company-logo"; + res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`); + + object.stream.on("error", (err) => { + next(err); + }); + object.stream.pipe(res); }); router.get("/invites/:token/onboarding", async (req, res) => { @@ -2074,10 +2707,10 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); res.json(buildInviteOnboardingManifest(req, token, invite, { ...opts, - companyName + companyName: companyBranding.name })); }); @@ -2093,13 +2726,13 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); res .type("text/plain; charset=utf-8") .send( buildInviteOnboardingTextDocument(req, token, invite, { ...opts, - companyName + companyName: companyBranding.name }) ); }); @@ -2266,6 +2899,12 @@ export function accessRoutes( ) { throw unauthorized("Authenticated user is required"); } + if ( + requestType === "human" && + actorHasActiveUserMembership(req, companyId) + ) { + throw conflict("You already belong to this company"); + } if (requestType === "agent" && !req.body.agentName) { if ( !inviteAlreadyAccepted || @@ -2358,48 +2997,82 @@ export function accessRoutes( const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; - const created = !inviteAlreadyAccepted - ? await db.transaction(async (tx) => { - await tx - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where( - and( - eq(invites.id, invite.id), - isNull(invites.acceptedAt), - isNull(invites.revokedAt) + const existingHumanJoinRequest = + requestType === "human" + ? findReusableHumanJoinRequest( + await db + .select() + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + eq(joinRequests.requestType, "human") + ) ) - ); + .orderBy(desc(joinRequests.createdAt)), + { + requestingUserId: req.actor.userId ?? "local-board", + requestEmailSnapshot: actorEmail + } + ) + : null; + const created = !inviteAlreadyAccepted + ? existingHumanJoinRequest + ? await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); + return existingHumanJoinRequest; + }) + : await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); - const row = await tx - .insert(joinRequests) - .values({ - inviteId: invite.id, - companyId, - requestType, - status: "pending_approval", - requestIp: requestIp(req), - requestingUserId: - requestType === "human" - ? req.actor.userId ?? "local-board" - : null, - requestEmailSnapshot: - requestType === "human" ? actorEmail : null, - agentName: requestType === "agent" ? req.body.agentName : null, - adapterType: requestType === "agent" ? adapterType : null, - capabilities: - requestType === "agent" - ? req.body.capabilities ?? null - : null, - agentDefaultsPayload: - requestType === "agent" ? joinDefaults.normalized : null, - claimSecretHash, - claimSecretExpiresAt - }) - .returning() - .then((rows) => rows[0]); - return row; - }) + const row = await tx + .insert(joinRequests) + .values({ + inviteId: invite.id, + companyId, + requestType, + status: "pending_approval", + requestIp: requestIp(req), + requestingUserId: + requestType === "human" + ? req.actor.userId ?? "local-board" + : null, + requestEmailSnapshot: + requestType === "human" ? actorEmail : null, + agentName: + requestType === "agent" ? req.body.agentName : null, + adapterType: requestType === "agent" ? adapterType : null, + capabilities: + requestType === "agent" + ? req.body.capabilities ?? null + : null, + agentDefaultsPayload: + requestType === "agent" ? joinDefaults.normalized : null, + claimSecretHash, + claimSecretExpiresAt + }) + .returning() + .then((rows) => rows[0]); + return row; + }) : await db .update(joinRequests) .set({ @@ -2545,21 +3218,23 @@ export function accessRoutes( entityId: created.id, details: { requestType, - requestIp: created.requestIp, - inviteReplay: inviteAlreadyAccepted + requestIp: requestIp(req), + inviteReplay: inviteAlreadyAccepted, + reusedExistingJoinRequest: + Boolean(existingHumanJoinRequest) && !inviteAlreadyAccepted } }); const response = toJoinRequestResponse(created); if (claimSecret) { - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); const onboardingManifest = buildInviteOnboardingManifest( req, token, invite, { ...opts, - companyName + companyName: companyBranding.name } ); res.status(202).json({ @@ -2621,22 +3296,26 @@ export function accessRoutes( res.json(revoked); }); + router.get("/companies/:companyId/invites", async (req, res) => { + const companyId = req.params.companyId as string; + await assertCompanyPermission(req, companyId, "users:invite"); + const query = listCompanyInvitesQuerySchema.parse(req.query); + const invitesForCompany = await loadCompanyInviteRecords(db, companyId, query); + res.json(invitesForCompany); + }); + router.get("/companies/:companyId/join-requests", async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "joins:approve"); const query = listJoinRequestsQuerySchema.parse(req.query); - const all = await db - .select() - .from(joinRequests) - .where(eq(joinRequests.companyId, companyId)) - .orderBy(desc(joinRequests.createdAt)); + const all = await loadJoinRequestRecords(db, companyId); const filtered = all.filter((row) => { if (query.status && row.status !== query.status) return false; if (query.requestType && row.requestType !== query.requestType) return false; return true; }); - res.json(filtered.map(toJoinRequestResponse)); + res.json(filtered); }); router.post( @@ -2671,16 +3350,19 @@ export function accessRoutes( if (existing.requestType === "human") { if (!existing.requestingUserId) throw conflict("Join request missing user identity"); + const membershipRole = resolveHumanInviteRole( + invite.defaultsPayload as Record | null, + ); await access.ensureMembership( companyId, "user", existing.requestingUserId, - "member", + membershipRole, "active" ); - const grants = grantsFromDefaults( + const grants = humanJoinGrantsFromDefaults( invite.defaultsPayload as Record | null, - "human" + membershipRole ); await access.setPrincipalGrants( companyId, @@ -2917,10 +3599,241 @@ export function accessRoutes( router.get("/companies/:companyId/members", async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); - const members = await access.listMembers(companyId); - res.json(members); + const [members, currentAccess] = await Promise.all([ + loadCompanyMemberRecords(db, companyId), + loadCompanyAccessSummary(req, access, companyId), + ]); + res.json({ + members, + access: currentAccess, + }); }); + router.get("/companies/:companyId/user-directory", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const users = await loadCompanyUserDirectory(db, companyId); + res.json({ users }); + }); + + router.patch( + "/companies/:companyId/members/:memberId", + validate(updateCompanyMemberSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + + const updated = await db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.id, memberId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + req.body.membershipRole !== undefined + ? req.body.membershipRole + : existing.membershipRole; + const nextStatus = req.body.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + return tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + }); + if (!updated) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.updated", + entityType: "company_membership", + entityId: memberId, + details: { + membershipRole: updated.membershipRole, + status: updated.status, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); + } + ); + + router.patch( + "/companies/:companyId/members/:memberId/role-and-grants", + validate(updateCompanyMemberWithPermissionsSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + + const updated = await db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.id, memberId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + req.body.membershipRole !== undefined + ? req.body.membershipRole + : existing.membershipRole; + const nextStatus = req.body.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + const now = new Date(); + const updatedMember = await tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + + const grants = (req.body.grants ?? []) as MemberGrantPayload[]; + if (grants.length > 0) { + await tx.insert(principalPermissionGrants).values( + grants.map((grant) => ({ + companyId, + principalType: existing.principalType, + principalId: existing.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId: req.actor.userId ?? null, + createdAt: now, + updatedAt: now, + })), + ); + } + + return updatedMember; + }); + if (!updated) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.access_updated", + entityType: "company_membership", + entityId: memberId, + details: { + membershipRole: updated.membershipRole, + status: updated.status, + grantCount: req.body.grants?.length ?? 0, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); + } + ); + router.patch( "/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), @@ -2935,7 +3848,22 @@ export function accessRoutes( req.actor.userId ?? null ); if (!updated) throw notFound("Member not found"); - res.json(updated); + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.permissions_updated", + entityType: "company_membership", + entityId: memberId, + details: { + grantCount: req.body.grants?.length ?? 0, + }, + }); + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); } ); @@ -2949,6 +3877,66 @@ export function accessRoutes( } ); + router.get("/admin/users", async (req, res) => { + await assertInstanceAdmin(req); + const query = searchAdminUsersQuerySchema.parse(req.query); + const needle = query.query.trim().toLowerCase(); + const users = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .orderBy(desc(authUsers.updatedAt)); + const filteredUsers = needle + ? users.filter((user) => + [user.name, user.email] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(needle)), + ) + : users; + const userIds = filteredUsers.slice(0, 50).map((user) => user.id); + const memberships = userIds.length + ? await db + .select({ + principalId: companyMemberships.principalId, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + inArray(companyMemberships.principalId, userIds), + ), + ) + : []; + const membershipCountByUserId = new Map(); + for (const membership of memberships) { + membershipCountByUserId.set( + membership.principalId, + (membershipCountByUserId.get(membership.principalId) ?? 0) + 1, + ); + } + const adminIds = new Set( + await Promise.all( + userIds.map(async (userId) => + (await access.isInstanceAdmin(userId)) ? userId : null, + ), + ).then((values) => values.filter((value): value is string => Boolean(value))), + ); + + res.json( + filteredUsers.slice(0, 50).map((user) => ({ + ...toUserProfile(user), + isInstanceAdmin: adminIds.has(user.id), + activeCompanyMembershipCount: + membershipCountByUserId.get(user.id) ?? 0, + })), + ); + }); + router.post( "/admin/users/:userId/demote-instance-admin", async (req, res) => { @@ -2963,8 +3951,7 @@ export function accessRoutes( router.get("/admin/users/:userId/company-access", async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.listUserCompanyAccess(userId); - res.json(memberships); + res.json(await loadUserCompanyAccessResponse(db, access, userId)); }); router.put( @@ -2973,11 +3960,11 @@ export function accessRoutes( async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.setUserCompanyAccess( + await access.setUserCompanyAccess( userId, req.body.companyIds ?? [] ); - res.json(memberships); + res.json(await loadUserCompanyAccessResponse(db, access, userId)); } ); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 811060adab..a01f3132ba 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -41,7 +41,7 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; -import { assertBoard } from "./authz.js"; +import { assertBoardOrgAccess } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; const execFileAsync = promisify(execFile); @@ -192,7 +192,7 @@ export function adapterRoutes() { * its model count, and load status. */ router.get("/adapters", async (_req, res) => { - assertBoard(_req); + assertBoardOrgAccess(_req); const registeredAdapters = listServerAdapters(); const externalRecords = new Map( @@ -218,7 +218,7 @@ export function adapterRoutes() { * - version?: string — target version for npm packages */ router.post("/adapters/install", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest; @@ -350,7 +350,7 @@ export function adapterRoutes() { * Request body: { "disabled": boolean } */ router.patch("/adapters/:type", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; const { disabled } = req.body as { disabled?: boolean }; @@ -385,7 +385,7 @@ export function adapterRoutes() { * keep the adapter they started with. */ router.patch("/adapters/:type/override", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; const { paused } = req.body as { paused?: boolean }; @@ -413,7 +413,7 @@ export function adapterRoutes() { * Unregister an external adapter. Built-in adapters cannot be removed. */ router.delete("/adapters/:type", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; @@ -488,7 +488,7 @@ export function adapterRoutes() { * Cannot be used on built-in adapter types. */ router.post("/adapters/:type/reload", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const type = req.params.type; @@ -540,7 +540,7 @@ export function adapterRoutes() { // This is a convenience shortcut for remove + install with the same // package name, but without the risk of losing the store record. router.post("/adapters/:type/reinstall", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const type = req.params.type; @@ -613,7 +613,7 @@ export function adapterRoutes() { const CONFIG_SCHEMA_TTL_MS = 30_000; router.get("/adapters/:type/config-schema", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { type } = req.params; const adapter = findActiveServerAdapter(type); @@ -651,7 +651,7 @@ export function adapterRoutes() { // The adapter package must export a "./ui-parser" entry in package.json // pointing to a self-contained ESM module with zero runtime dependencies. router.get("/adapters/:type/ui-parser.js", (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { type } = req.params; const source = getOrExtractUiParserSource(type); if (!source) { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000000..ee94341c8f --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,100 @@ +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { authUsers } from "@paperclipai/db"; +import { + authSessionSchema, + currentUserProfileSchema, + updateCurrentUserProfileSchema, +} from "@paperclipai/shared"; +import { unauthorized } from "../errors.js"; +import { validate } from "../middleware/validate.js"; + +async function loadCurrentUserProfile(db: Db, userId: string) { + const user = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null); + + if (!user) { + throw unauthorized("Signed-in user not found"); + } + + return currentUserProfileSchema.parse({ + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + }); +} + +export function authRoutes(db: Db) { + const router = Router(); + + router.get("/get-session", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + const user = await loadCurrentUserProfile(db, req.actor.userId); + res.json(authSessionSchema.parse({ + session: { + id: `paperclip:${req.actor.source ?? "none"}:${req.actor.userId}`, + userId: req.actor.userId, + }, + user, + })); + }); + + router.get("/profile", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + res.json(await loadCurrentUserProfile(db, req.actor.userId)); + }); + + router.patch("/profile", validate(updateCurrentUserProfileSchema), async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + const patch = updateCurrentUserProfileSchema.parse(req.body); + const now = new Date(); + + const updated = await db + .update(authUsers) + .set({ + name: patch.name, + ...(patch.image !== undefined ? { image: patch.image } : {}), + updatedAt: now, + }) + .where(eq(authUsers.id, req.actor.userId)) + .returning({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .then((rows) => rows[0] ?? null); + + if (!updated) { + throw unauthorized("Signed-in user not found"); + } + + res.json(currentUserProfileSchema.parse({ + id: updated.id, + email: updated.email ?? null, + name: updated.name ?? null, + image: updated.image ?? null, + })); + }); + + return router; +} diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index e9017c93a3..ec6c49621d 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -13,6 +13,24 @@ export function assertBoard(req: Request) { } } +export function hasBoardOrgAccess(req: Request) { + if (req.actor.type !== "board") { + return false; + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { + return true; + } + return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0; +} + +export function assertBoardOrgAccess(req: Request) { + assertBoard(req); + if (hasBoardOrgAccess(req)) { + return; + } + throw forbidden("Company membership or instance admin access required"); +} + export function assertInstanceAdmin(req: Request) { assertBoard(req); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { @@ -26,11 +44,22 @@ export function assertCompanyAccess(req: Request, companyId: string) { if (req.actor.type === "agent" && req.actor.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } - if (req.actor.type === "board" && req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { + if (req.actor.type === "board" && req.actor.source !== "local_implicit") { const allowedCompanies = req.actor.companyIds ?? []; if (!allowedCompanies.includes(companyId)) { throw forbidden("User does not have access to this company"); } + const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET"; + const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method); + if (!isSafeMethod && !req.actor.isInstanceAdmin && Array.isArray(req.actor.memberships)) { + const membership = req.actor.memberships.find((item) => item.companyId === companyId); + if (!membership || membership.status !== "active") { + throw forbidden("User does not have active company access"); + } + if (membership.membershipRole === "viewer") { + throw forbidden("Viewer access is read-only"); + } + } } } diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index d5216e72d8..a1992dd41c 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -4,6 +4,7 @@ import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { logger } from "../middleware/logger.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; @@ -49,11 +50,12 @@ export function healthRoutes( try { await db.execute(sql`SELECT 1`); - } catch { + } catch (error) { + logger.warn({ err: error }, "Health check database probe failed"); res.status(503).json({ status: "unhealthy", version: serverVersion, - error: "database_unreachable", + error: "database_unreachable" }); return; } diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 7bd24e36a3..946278ef4e 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -4,7 +4,7 @@ import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSc import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { instanceSettingsService, logActivity } from "../services/index.js"; -import { getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; function assertCanManageInstanceSettings(req: Request) { if (req.actor.type !== "board") { @@ -22,10 +22,8 @@ export function instanceSettingsRoutes(db: Db) { router.get("/instance/settings/general", async (req, res) => { // General settings (e.g. keyboardShortcuts) are readable by any - // authenticated board user. Only PATCH requires instance-admin. - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + // authenticated org member or instance admin. Only PATCH requires instance-admin. + assertBoardOrgAccess(req); res.json(await svc.getGeneral()); }); @@ -60,11 +58,9 @@ export function instanceSettingsRoutes(db: Db) { ); router.get("/instance/settings/experimental", async (req, res) => { - // Experimental settings are readable by any authenticated board user. - // Only PATCH requires instance-admin. - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + // Experimental settings are readable by any authenticated org member + // or instance admin. Only PATCH requires instance-admin. + assertBoardOrgAccess(req); res.json(await svc.getExperimental()); }); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index a6368be9c6..d75912d07c 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -48,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; import type { ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; -import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; /** UI slot declaration extracted from plugin manifest */ @@ -372,7 +372,7 @@ export function pluginRoutes( * Response: `PluginRecord[]` */ router.get("/plugins", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const rawStatus = req.query.status; if (rawStatus !== undefined) { if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) { @@ -396,7 +396,7 @@ export function pluginRoutes( * These can be installed through the normal local-path install flow. */ router.get("/plugins/examples", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); res.json(listBundledPluginExamples()); }); @@ -441,7 +441,7 @@ export function pluginRoutes( * Response: PluginUiContribution[] */ router.get("/plugins/ui-contributions", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const plugins = await registry.listByStatus("ready"); const contributions: PluginUiContribution[] = plugins @@ -484,7 +484,7 @@ export function pluginRoutes( * Errors: 501 if tool dispatcher is not configured */ router.get("/plugins/tools", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -518,7 +518,7 @@ export function pluginRoutes( * - 502 if the plugin worker is unavailable or the RPC call fails */ router.post("/plugins/tools/execute", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -797,7 +797,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/data", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -880,7 +880,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -964,7 +964,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/data/:key", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1043,7 +1043,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1124,7 +1124,7 @@ export function pluginRoutes( * - 501 if bridge deps or stream bus are not configured */ router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps?.streamBus) { res.status(501).json({ error: "Plugin stream bridge is not enabled" }); @@ -1202,7 +1202,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { @@ -1232,7 +1232,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.delete("/plugins/:pluginId", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const purge = req.query.purge === "true"; @@ -1268,7 +1268,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/enable", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1306,7 +1306,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/disable", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const body = req.body as { reason?: string } | undefined; const reason = body?.reason; @@ -1347,7 +1347,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/health", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1415,7 +1415,7 @@ export function pluginRoutes( * Response: Array of log entries, newest first. */ router.get("/plugins/:pluginId/logs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1517,7 +1517,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/config", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1547,7 +1547,7 @@ export function pluginRoutes( * - 404 if plugin not found */ router.post("/plugins/:pluginId/config", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1652,7 +1652,7 @@ export function pluginRoutes( * - 502 if the worker is unavailable */ router.post("/plugins/:pluginId/config/test", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1749,7 +1749,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -1795,7 +1795,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -1843,7 +1843,7 @@ export function pluginRoutes( * - 400 if job not found, not active, already running, or worker unavailable */ router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -2049,7 +2049,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/dashboard", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 505b770475..8b4d1f2285 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -5,6 +5,7 @@ import { inboxDismissals, joinRequests } from "@paperclipai/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { accessService } from "../services/access.js"; import { dashboardService } from "../services/dashboard.js"; +import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js"; import { assertCompanyAccess } from "./authz.js"; function buildDismissedAtByKey( @@ -35,14 +36,24 @@ export function sidebarBadgeRoutes(db: Db) { } const visibleJoinRequests = canApproveJoins - ? await db - .select({ - id: joinRequests.id, - updatedAt: joinRequests.updatedAt, - createdAt: joinRequests.createdAt, - }) - .from(joinRequests) - .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + ? collapseDuplicatePendingHumanJoinRequests( + await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + updatedAt: joinRequests.updatedAt, + createdAt: joinRequests.createdAt, + }) + .from(joinRequests) + .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + ).map(({ id, updatedAt, createdAt }) => ({ + id, + updatedAt, + createdAt, + })) : []; const dismissedAtByKey = diff --git a/server/src/services/access.ts b/server/src/services/access.ts index 3e30e1ab3b..792c28f460 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -6,6 +6,7 @@ import { principalPermissionGrants, } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; +import { conflict } from "../errors.js"; type MembershipRow = typeof companyMemberships.$inferSelect; type GrantInput = { @@ -83,6 +84,14 @@ export function accessService(db: Db) { .orderBy(sql`${companyMemberships.createdAt} desc`); } + async function getMemberById(companyId: string, memberId: string) { + return db + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + } + async function listActiveUserMemberships(companyId: string) { return db .select() @@ -103,11 +112,7 @@ export function accessService(db: Db) { grants: GrantInput[], grantedByUserId: string | null, ) { - const member = await db - .select() - .from(companyMemberships) - .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) - .then((rows) => rows[0] ?? null); + const member = await getMemberById(companyId, memberId); if (!member) return null; await db.transaction(async (tx) => { @@ -139,6 +144,101 @@ export function accessService(db: Db) { return member; } + async function updateMemberAndPermissions( + companyId: string, + memberId: string, + data: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + grants: GrantInput[]; + }, + grantedByUserId: string | null, + ) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; + const nextStatus = data.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + const now = new Date(); + const updated = await tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + if (data.grants.length > 0) { + await tx.insert(principalPermissionGrants).values( + data.grants.map((grant) => ({ + companyId, + principalType: existing.principalType, + principalId: existing.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId, + createdAt: now, + updatedAt: now, + })), + ); + } + + return updated; + }); + } + async function promoteInstanceAdmin(userId: string) { const existing = await db .select() @@ -190,7 +290,7 @@ export function accessService(db: Db) { principalType: "user", principalId: userId, status: "active", - membershipRole: "member", + membershipRole: "operator", }); } }); @@ -359,16 +459,84 @@ export function accessService(db: Db) { }); } + async function updateMember( + companyId: string, + memberId: string, + data: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + }, + ) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; + const nextStatus = data.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + return tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + }); + } + return { isInstanceAdmin, canUser, hasPermission, getMembership, + getMemberById, ensureMembership, listMembers, listActiveUserMemberships, copyActiveUserMemberships, setMemberPermissions, + updateMemberAndPermissions, promoteInstanceAdmin, demoteInstanceAdmin, listUserCompanyAccess, @@ -376,5 +544,6 @@ export function accessService(db: Db) { setPrincipalGrants, listPrincipalGrants, setPrincipalPermission, + updateMember, }; } diff --git a/server/src/services/board-auth.ts b/server/src/services/board-auth.ts index 19e533c116..0637457c54 100644 --- a/server/src/services/board-auth.ts +++ b/server/src/services/board-auth.ts @@ -62,7 +62,11 @@ export function boardAuthService(db: Db) { .where(eq(authUsers.id, userId)) .then((rows) => rows[0] ?? null), db - .select({ companyId: companyMemberships.companyId }) + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + status: companyMemberships.status, + }) .from(companyMemberships) .where( and( @@ -71,7 +75,7 @@ export function boardAuthService(db: Db) { eq(companyMemberships.status, "active"), ), ) - .then((rows) => rows.map((row) => row.companyId)), + .then((rows) => rows), db .select({ id: instanceUserRoles.id }) .from(instanceUserRoles) @@ -81,7 +85,8 @@ export function boardAuthService(db: Db) { return { user, - companyIds: memberships, + companyIds: memberships.map((row) => row.companyId), + memberships, isInstanceAdmin: Boolean(adminRole), }; } diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts new file mode 100644 index 0000000000..6e8513ee45 --- /dev/null +++ b/server/src/services/company-member-roles.ts @@ -0,0 +1,59 @@ +import { PERMISSION_KEYS } from "@paperclipai/shared"; +import type { HumanCompanyMembershipRole } from "@paperclipai/shared"; + +const HUMAN_COMPANY_MEMBERSHIP_ROLES: HumanCompanyMembershipRole[] = [ + "owner", + "admin", + "operator", + "viewer", +]; + +export function normalizeHumanRole( + value: unknown, + fallback: HumanCompanyMembershipRole = "operator" +): HumanCompanyMembershipRole { + if (value === "member") return "operator"; + return HUMAN_COMPANY_MEMBERSHIP_ROLES.includes(value as HumanCompanyMembershipRole) + ? (value as HumanCompanyMembershipRole) + : fallback; +} + +export function grantsForHumanRole( + role: HumanCompanyMembershipRole +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + switch (role) { + case "owner": + return [ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "users:manage_permissions", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]; + case "admin": + return [ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]; + case "operator": + return [{ permissionKey: "tasks:assign", scope: null }]; + case "viewer": + return []; + } +} + +export function resolveHumanInviteRole( + defaultsPayload: Record | null | undefined +): HumanCompanyMembershipRole { + if (!defaultsPayload || typeof defaultsPayload !== "object") return "operator"; + const scoped = defaultsPayload.human; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return "operator"; + } + return normalizeHumanRole((scoped as Record).role, "operator"); +} diff --git a/server/src/services/invite-grants.ts b/server/src/services/invite-grants.ts new file mode 100644 index 0000000000..32c9aa42fe --- /dev/null +++ b/server/src/services/invite-grants.ts @@ -0,0 +1,68 @@ +import { PERMISSION_KEYS } from "@paperclipai/shared"; +import type { HumanCompanyMembershipRole } from "@paperclipai/shared"; +import { grantsForHumanRole } from "./company-member-roles.js"; + +export function grantsFromDefaults( + defaultsPayload: Record | null | undefined, + key: "human" | "agent" +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + if (!defaultsPayload || typeof defaultsPayload !== "object") return []; + const scoped = defaultsPayload[key]; + if (!scoped || typeof scoped !== "object") return []; + const grants = (scoped as Record).grants; + if (!Array.isArray(grants)) return []; + const validPermissionKeys = new Set(PERMISSION_KEYS); + const result: Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; + }> = []; + for (const item of grants) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.permissionKey !== "string") continue; + if (!validPermissionKeys.has(record.permissionKey)) continue; + result.push({ + permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], + scope: + record.scope && + typeof record.scope === "object" && + !Array.isArray(record.scope) + ? (record.scope as Record) + : null, + }); + } + return result; +} + +export function agentJoinGrantsFromDefaults( + defaultsPayload: Record | null | undefined +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + const grants = grantsFromDefaults(defaultsPayload, "agent"); + if (grants.some((grant) => grant.permissionKey === "tasks:assign")) { + return grants; + } + return [ + ...grants, + { + permissionKey: "tasks:assign", + scope: null, + }, + ]; +} + +export function humanJoinGrantsFromDefaults( + defaultsPayload: Record | null | undefined, + membershipRole: HumanCompanyMembershipRole +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + const grants = grantsFromDefaults(defaultsPayload, "human"); + return grants.length > 0 ? grants : grantsForHumanRole(membershipRole); +} diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 78b301eef3..1915e2ffdb 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -6,9 +6,16 @@ declare global { actor: { type: "board" | "agent" | "none"; userId?: string; + userName?: string | null; + userEmail?: string | null; agentId?: string; companyId?: string; companyIds?: string[]; + memberships?: Array<{ + companyId: string; + membershipRole?: string | null; + status?: string; + }>; isInstanceAdmin?: boolean; keyId?: string; runId?: string; diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index 3656b7dc6a..2c6170bca5 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -118,11 +118,19 @@ function resolveWorktreeRuntimeContext( const configPath = resolvePaperclipConfigPath(overrideConfigPath); const envPath = resolvePaperclipEnvPath(configPath); + const persistedEnv = readEnvEntries(envPath); const worktreeRoot = path.resolve(path.dirname(configPath), ".."); - const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); - const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); + const worktreeName = + nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ?? + nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? + path.basename(worktreeRoot); + const instanceId = + nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ?? + nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? + sanitizeWorktreeInstanceId(worktreeName); const homeDir = resolveHomeAwarePath( - nonEmpty(env.PAPERCLIP_HOME) ?? + nonEmpty(persistedEnv.PAPERCLIP_HOME) ?? + nonEmpty(env.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees", ); diff --git a/tests/e2e/multi-user-authenticated.spec.ts b/tests/e2e/multi-user-authenticated.spec.ts new file mode 100644 index 0000000000..2652da389f --- /dev/null +++ b/tests/e2e/multi-user-authenticated.spec.ts @@ -0,0 +1,326 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { test, expect, type Browser, type Page } from "@playwright/test"; + +const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3105"; +const DATA_DIR = process.env.PAPERCLIP_E2E_DATA_DIR ?? process.env.PAPERCLIP_HOME; +const CONFIG_PATH = process.env.PAPERCLIP_E2E_CONFIG_PATH ?? path.resolve(process.cwd(), ".paperclip/config.json"); +const BOOTSTRAP_SCRIPT_PATH = path.resolve(process.cwd(), "packages/db/scripts/create-auth-bootstrap-invite.ts"); +const OWNER_PASSWORD = "paperclip-owner-password"; +const INVITED_PASSWORD = "paperclip-invited-password"; + +type HumanUser = { + name: string; + email: string; + password: string; +}; + +type CompanySummary = { + id: string; + name: string; + issuePrefix?: string | null; +}; + +type CompanyMember = { + id: string; + membershipRole: "owner" | "admin" | "operator" | "viewer"; + status: "pending" | "active" | "suspended"; + user: { id: string; email: string | null; name: string | null } | null; +}; + +type SessionJsonResponse = { + ok: boolean; + status: number; + text: string; + json: T | null; +}; + +const runId = Date.now(); +const companyName = `MU-Auth-${runId}`; +const ownerUser: HumanUser = { + name: "Owner User", + email: `owner-${runId}@paperclip.local`, + password: OWNER_PASSWORD, +}; +const invitedUser: HumanUser = { + name: "Invited User", + email: `invitee-${runId}@paperclip.local`, + password: INVITED_PASSWORD, +}; + +function createBootstrapInvite() { + if (!DATA_DIR) { + throw new Error("PAPERCLIP_E2E_DATA_DIR or PAPERCLIP_HOME is required for authenticated bootstrap tests"); + } + if (!existsSync(CONFIG_PATH)) { + throw new Error(`Authenticated bootstrap config not found at ${CONFIG_PATH}`); + } + if (!existsSync(BOOTSTRAP_SCRIPT_PATH)) { + throw new Error(`Authenticated bootstrap helper not found at ${BOOTSTRAP_SCRIPT_PATH}`); + } + + const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + return execFileSync( + pnpmCommand, + [ + "--filter", + "@paperclipai/db", + "exec", + "tsx", + BOOTSTRAP_SCRIPT_PATH, + "--config", + CONFIG_PATH, + "--base-url", + BASE, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + FORCE_COLOR: "0", + NO_COLOR: "1", + PAPERCLIP_HOME: DATA_DIR, + }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + } + ).trim(); +} + +async function signUp(page: Page, user: HumanUser) { + await page.goto(`${BASE}/auth`); + await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible(); + await page.getByRole("button", { name: "Create one" }).click(); + await expect(page.getByRole("heading", { name: "Create your Paperclip account" })).toBeVisible(); + await page.getByLabel("Name").fill(user.name); + await page.getByLabel("Email").fill(user.email); + await page.getByLabel("Password").fill(user.password); + await page.getByRole("button", { name: "Create Account" }).click(); + await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 }); +} + +async function acceptBootstrapInvite(page: Page, inviteUrl: string) { + await page.goto(inviteUrl); + await expect(page.getByRole("heading", { name: "Bootstrap your Paperclip instance" })).toBeVisible(); + await page.getByRole("button", { name: "Accept bootstrap invite" }).click(); + await expect(page.getByRole("heading", { name: "Bootstrap complete" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("link", { name: "Open board" }).click(); +} + +async function createCompanyForSession(page: Page, nextCompanyName: string) { + const createRes = await sessionJsonRequest(page, `${BASE}/api/companies`, { + method: "POST", + data: { name: nextCompanyName }, + }); + expect(createRes.ok).toBe(true); + expect(createRes.json).toBeTruthy(); + return createRes.json!; +} + +async function createAuthenticatedInvite(page: Page, companyPrefix: string) { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({ + timeout: 20_000, + }); + await page.getByTestId("company-settings-human-invite-role").selectOption("operator"); + await page.getByTestId("company-settings-create-human-invite").click(); + const inviteField = page.getByTestId("company-settings-human-invite-url"); + await expect(inviteField).toBeVisible({ timeout: 20_000 }); + return (await inviteField.inputValue()).trim(); +} + +async function signUpFromInvite(page: Page, inviteUrl: string, user: HumanUser) { + await page.goto(inviteUrl); + await expect(page.getByText("Sign in or create an account before submitting a human join request.")).toBeVisible(); + await page.getByRole("link", { name: "Sign in / Create account" }).click(); + await expect(page).toHaveURL(/\/auth\?next=/); + await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible(); + await page.getByRole("button", { name: "Create one" }).click(); + await page.getByLabel("Name").fill(user.name); + await page.getByLabel("Email").fill(user.email); + await page.getByLabel("Password").fill(user.password); + await page.getByRole("button", { name: "Create Account" }).click(); + await expect(page).toHaveURL(new RegExp(`/invite/[^/]+$`), { timeout: 20_000 }); +} + +async function acceptHumanInvite(page: Page) { + await expect(page.getByRole("button", { name: "Join company" })).toBeEnabled(); + await page.getByRole("button", { name: "Join company" }).click(); + await expect(page.getByRole("heading", { name: "You joined the company" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("link", { name: "Open board" }).click(); +} + +async function sessionJsonRequest( + page: Page, + url: string, + options: { + method?: string; + data?: unknown; + } = {} +) { + return page.evaluate( + async ({ url: targetUrl, method, data }) => { + const response = await fetch(targetUrl, { + method, + credentials: "include", + headers: data === undefined ? undefined : { "Content-Type": "application/json" }, + body: data === undefined ? undefined : JSON.stringify(data), + }); + const text = await response.text(); + let json: unknown = null; + if (text.length > 0) { + try { + json = JSON.parse(text); + } catch { + json = null; + } + } + return { + ok: response.ok, + status: response.status, + text, + json, + }; + }, + { + url, + method: options.method ?? "GET", + data: options.data, + } + ) as Promise>; +} + +async function waitForMember(page: Page, companyId: string, email: string) { + let member: CompanyMember | null = null; + await expect + .poll( + async () => { + const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>( + page, + `${BASE}/api/companies/${companyId}/members` + ); + expect(membersRes.ok).toBe(true); + const body = membersRes.json; + if (!body) return null; + member = body.members.find((entry) => entry.user?.email === email) ?? null; + return member; + }, + { + timeout: 20_000, + intervals: [500, 1_000, 2_000], + } + ) + .toMatchObject({ + status: "active", + membershipRole: "operator", + user: { email }, + }); + return member!; +} + +async function waitForMemberRole( + page: Page, + companyId: string, + memberId: string, + membershipRole: CompanyMember["membershipRole"] +) { + await expect + .poll( + async () => { + const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>( + page, + `${BASE}/api/companies/${companyId}/members` + ); + expect(membersRes.ok).toBe(true); + const body = membersRes.json; + if (!body) return null; + return body.members.find((member) => member.id === memberId) ?? null; + }, + { + timeout: 20_000, + intervals: [500, 1_000, 2_000], + } + ) + .toMatchObject({ + id: memberId, + membershipRole, + }); +} + +async function newPage(browser: Browser) { + const context = await browser.newContext(); + const page = await context.newPage(); + return { context, page }; +} + +test.describe("Multi-user: authenticated mode", () => { + test("authenticated humans can bootstrap, invite, join, and respect viewer restrictions", async ({ + browser, + page, + }) => { + test.setTimeout(180_000); + + const healthRes = await page.request.get(`${BASE}/api/health`); + expect(healthRes.ok()).toBe(true); + const health = (await healthRes.json()) as { + deploymentMode?: string; + bootstrapStatus?: string; + }; + expect(health.deploymentMode).toBe("authenticated"); + + await signUp(page, ownerUser); + await acceptBootstrapInvite(page, createBootstrapInvite()); + + const company = await createCompanyForSession(page, companyName); + const companyPrefix = company.issuePrefix ?? company.id; + await page.goto(`${BASE}/${companyPrefix}/dashboard`); + await expect(page.getByTestId("layout-account-menu-trigger")).toContainText(ownerUser.name); + await page.getByTestId("layout-account-menu-trigger").click(); + await expect(page.getByText(ownerUser.email)).toBeVisible(); + const inviteUrl = await createAuthenticatedInvite(page, companyPrefix); + + const invited = await newPage(browser); + try { + await signUpFromInvite(invited.page, inviteUrl, invitedUser); + await acceptHumanInvite(invited.page); + await expect(invited.page).not.toHaveURL(/\/auth/, { timeout: 10_000 }); + + const joinedMember = await waitForMember(page, company.id, invitedUser.email); + + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + const roleSelect = page.getByTestId(`company-settings-member-role-${joinedMember.id}`); + await expect(roleSelect).toBeVisible({ timeout: 20_000 }); + await roleSelect.selectOption("viewer"); + await expect(roleSelect).toHaveValue("viewer"); + await waitForMemberRole(page, company.id, joinedMember.id, "viewer"); + + await invited.page.goto(`${BASE}/${companyPrefix}/company/settings`); + await expect( + invited.page.getByText("Your current company role cannot create human invites.") + ).toBeVisible({ timeout: 20_000 }); + await expect( + invited.page.getByTestId("company-settings-create-human-invite") + ).toHaveCount(0); + + const forbiddenInviteRes = await sessionJsonRequest( + invited.page, + `${BASE}/api/companies/${company.id}/invites`, + { + method: "POST", + data: { + allowedJoinTypes: "human", + humanRole: "viewer", + }, + } + ); + expect(forbiddenInviteRes.status).toBe(403); + } finally { + await invited.context.close(); + } + }); +}); diff --git a/tests/e2e/multi-user.spec.ts b/tests/e2e/multi-user.spec.ts new file mode 100644 index 0000000000..d03eac3553 --- /dev/null +++ b/tests/e2e/multi-user.spec.ts @@ -0,0 +1,518 @@ +import { test, expect, type Page, type APIRequestContext } from "@playwright/test"; + +/** + * E2E: Multi-user implementation tests (local_trusted mode). + * + * Covers: + * 1. Company member management API (list, update role, suspend) + * 2. Human invite creation and acceptance API + * 3. Company Settings UI — member list, role editing, invite creation + * 4. Invite landing page UI + * 5. Role-based access control (viewer read-only) + * 6. Last-owner protection + */ + +const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3104"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Ensure the server is bootstrapped (claimed) before running tests. */ +async function ensureBootstrapped(request: APIRequestContext): Promise { + const healthRes = await request.get(`${BASE}/api/health`); + const health = await healthRes.json(); + if (health.bootstrapStatus === "ready") return; + + // If bootstrap_pending, we need to use the claim token from the bootstrap invite. + // In local_trusted mode, just try hitting companies — that should auto-bootstrap. + if (health.deploymentMode === "local_trusted") { + // local_trusted should work without explicit bootstrap + return; + } +} + +/** Create a company via the onboarding wizard API shortcut. */ +async function createCompanyViaWizard( + request: APIRequestContext, + name: string +): Promise<{ companyId: string; agentId: string; prefix: string }> { + await ensureBootstrapped(request); + + const createRes = await request.post(`${BASE}/api/companies`, { + data: { name }, + }); + if (!createRes.ok()) { + const errText = await createRes.text(); + throw new Error( + `Failed to create company (${createRes.status()}): ${errText}` + ); + } + const company = await createRes.json(); + + // Create a CEO agent + const agentRes = await request.post( + `${BASE}/api/companies/${company.id}/agents`, + { + data: { + name: "CEO", + role: "ceo", + title: "CEO", + adapterType: "claude_local", + }, + } + ); + expect(agentRes.ok()).toBe(true); + const agent = await agentRes.json(); + + return { + companyId: company.id, + agentId: agent.id, + prefix: company.issuePrefix ?? company.id, + }; +} + +/** Create a human invite and return token + invite URL. */ +async function createHumanInvite( + request: APIRequestContext, + companyId: string, + role: string = "operator" +): Promise<{ token: string; inviteUrl: string; inviteId: string }> { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { + allowedJoinTypes: "human", + humanRole: role, + }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + return { + token: body.token, + inviteUrl: body.inviteUrl, + inviteId: body.id, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("Multi-user: API", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-API-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("GET /companies/:id/members returns member list with access info", async ({ + request, + }) => { + const res = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("members"); + expect(body).toHaveProperty("access"); + expect(Array.isArray(body.members)).toBe(true); + expect(body.access).toHaveProperty("currentUserRole"); + expect(body.access).toHaveProperty("canManageMembers"); + expect(body.access).toHaveProperty("canInviteUsers"); + }); + + test("POST /companies/:id/invites creates a human invite with role", async ({ + request, + }) => { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { + allowedJoinTypes: "human", + humanRole: "operator", + }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("token"); + expect(body).toHaveProperty("inviteUrl"); + expect(body.allowedJoinTypes).toBe("human"); + expect(body.inviteUrl).toContain("/invite/"); + }); + + test("GET /invites/:token returns invite summary", async ({ request }) => { + const invite = await createHumanInvite(request, companyId, "viewer"); + const res = await request.get(`${BASE}/api/invites/${invite.token}`); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("companyId"); + expect(body).toHaveProperty("allowedJoinTypes"); + expect(body.allowedJoinTypes).toBe("human"); + expect(body).toHaveProperty("inviteType"); + expect(body.inviteType).toBe("company_join"); + }); + + test("POST /invites/:token/accept (human) creates membership", async ({ + request, + }) => { + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { + data: { requestType: "human" }, + } + ); + expect(acceptRes.ok()).toBe(true); + const body = await acceptRes.json(); + + // In local_trusted, human accept should succeed + expect(body).toHaveProperty("id"); + }); + + test("POST /invites/:token/accept rejects agent on human-only invite", async ({ + request, + }) => { + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { + data: { requestType: "agent", agentName: "Rogue" }, + } + ); + expect(acceptRes.ok()).toBe(false); + expect(acceptRes.status()).toBe(400); + }); + + test("POST /companies/:id/invites supports all four roles", async ({ + request, + }) => { + for (const role of ["owner", "admin", "operator", "viewer"]) { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { allowedJoinTypes: "human", humanRole: role }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.token).toBeTruthy(); + } + }); + + test("PATCH /companies/:id/members/:memberId cannot remove last owner", async ({ + request, + }) => { + // Create a fresh company for this test + const fresh = await createCompanyViaWizard( + request, + `MU-LastOwner-${Date.now()}` + ); + + // First promote the local-board member to owner + const membersRes = await request.get( + `${BASE}/api/companies/${fresh.companyId}/members` + ); + const { members } = await membersRes.json(); + + // Find the board member (should be the only one) + const boardMember = members.find( + (m: { principalId: string }) => m.principalId === "local-board" + ); + if (!boardMember) { + test.skip(); + return; + } + + // Promote to owner first + const promoteRes = await request.patch( + `${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`, + { data: { membershipRole: "owner" } } + ); + expect(promoteRes.ok()).toBe(true); + + // Now try to demote the last (and only) owner to operator — should fail + const demoteRes = await request.patch( + `${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`, + { data: { membershipRole: "operator" } } + ); + expect(demoteRes.status()).toBe(409); + const errBody = await demoteRes.json(); + expect(JSON.stringify(errBody)).toContain("last active owner"); + }); + + test("POST /companies/:id/openclaw/invite-prompt creates agent invite", async ({ + request, + }) => { + const res = await request.post( + `${BASE}/api/companies/${companyId}/openclaw/invite-prompt`, + { + data: { agentMessage: "E2E test agent invite" }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("token"); + expect(body).toHaveProperty("inviteUrl"); + expect(body.allowedJoinTypes).toBe("agent"); + }); +}); + +test.describe("Multi-user: Company Settings UI", () => { + let companyId: string; + let companyPrefix: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-UI-${Date.now()}` + ); + companyId = result.companyId; + companyPrefix = result.prefix; + }); + + test("shows Team and Invites sections on settings page", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByTestId("company-settings-team-section")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("shows human invite creation controls", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + const inviteButton = page.getByTestId("company-settings-create-human-invite"); + await expect(inviteButton).toBeVisible({ timeout: 10_000 }); + + const roleSelect = page.getByTestId("company-settings-human-invite-role"); + await expect(roleSelect).toBeVisible(); + }); + + test("can create human invite and shows URL", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + const inviteButton = page.getByTestId("company-settings-create-human-invite"); + await expect(inviteButton).toBeVisible({ timeout: 10_000 }); + await inviteButton.click(); + + await expect(page.getByTestId("company-settings-human-invite-url")).toBeVisible({ + timeout: 10_000, + }); + }); +}); + +test.describe("Multi-user: Invite Landing UI", () => { + let companyId: string; + let inviteToken: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Invite-${Date.now()}` + ); + companyId = result.companyId; + + const invite = await createHumanInvite(request, companyId, "operator"); + inviteToken = invite.token; + }); + + test("invite landing page loads with join options", async ({ page }) => { + await page.goto(`${BASE}/invite/${inviteToken}`); + await page.waitForLoadState("networkidle"); + + // Should show the invite landing page heading + await expect( + page.getByRole("heading", { name: /join/i }) + ).toBeVisible({ timeout: 10_000 }); + }); + + test("invite landing shows human join type", async ({ page }) => { + await page.goto(`${BASE}/invite/${inviteToken}`); + await page.waitForLoadState("networkidle"); + + // For a human-only invite, should show human join option + const humanOption = page.locator("text=/human/i"); + await expect(humanOption).toBeVisible({ timeout: 10_000 }); + }); + + test("expired/invalid invite token returns error", async ({ page }) => { + await page.goto(`${BASE}/invite/invalid-token-e2e-test`); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("invite-error")).toBeVisible({ timeout: 10_000 }); + }); +}); + +test.describe("Multi-user: Member role management API", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Roles-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("invite + accept creates member with correct role", async ({ + request, + }) => { + // Create invite for 'viewer' role + const invite = await createHumanInvite(request, companyId, "viewer"); + + // Accept the invite + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { data: { requestType: "human" } } + ); + expect(acceptRes.ok()).toBe(true); + + // Check members list + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + // Should have at least one member (the creator/local-board) + expect(members.length).toBeGreaterThanOrEqual(1); + }); + + test("PATCH member role updates correctly", async ({ request }) => { + // First create an invite and accept it to get a second member + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { data: { requestType: "human" } } + ); + expect(acceptRes.ok()).toBe(true); + + // List members + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + // Find a non-owner member to modify + const nonOwner = members.find( + (m: { membershipRole: string }) => m.membershipRole !== "owner" + ); + if (!nonOwner) { + test.skip(); + return; + } + + // Update role to admin + const patchRes = await request.patch( + `${BASE}/api/companies/${companyId}/members/${nonOwner.id}`, + { data: { membershipRole: "admin" } } + ); + expect(patchRes.ok()).toBe(true); + const updated = await patchRes.json(); + expect(updated.membershipRole).toBe("admin"); + }); + + test("PATCH member status to suspended works", async ({ request }) => { + // Create another member + const invite = await createHumanInvite(request, companyId, "operator"); + await request.post(`${BASE}/api/invites/${invite.token}/accept`, { + data: { requestType: "human" }, + }); + + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + const nonOwner = members.find( + (m: { membershipRole: string; status: string }) => + m.membershipRole !== "owner" && m.status === "active" + ); + if (!nonOwner) { + test.skip(); + return; + } + + const patchRes = await request.patch( + `${BASE}/api/companies/${companyId}/members/${nonOwner.id}`, + { data: { status: "suspended" } } + ); + expect(patchRes.ok()).toBe(true); + const updated = await patchRes.json(); + expect(updated.status).toBe("suspended"); + }); +}); + +test.describe("Multi-user: Agent invite flow", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Agent-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("agent invite accept creates pending join request", async ({ + request, + }) => { + // Create agent invite + const res = await request.post( + `${BASE}/api/companies/${companyId}/openclaw/invite-prompt`, + { data: {} } + ); + expect(res.ok()).toBe(true); + const { token } = await res.json(); + + // Accept as agent + const acceptRes = await request.post( + `${BASE}/api/invites/${token}/accept`, + { + data: { + requestType: "agent", + agentName: "TestAgent", + adapterType: "claude_local", + }, + } + ); + expect(acceptRes.ok()).toBe(true); + const body = await acceptRes.json(); + expect(body).toHaveProperty("id"); + expect(body.status).toBe("pending_approval"); + }); + + test("join requests list shows pending agent request", async ({ + request, + }) => { + const res = await request.get( + `${BASE}/api/companies/${companyId}/join-requests?status=pending_approval` + ); + expect(res.ok()).toBe(true); + const requests = await res.json(); + expect(Array.isArray(requests)).toBe(true); + }); +}); + +test.describe("Multi-user: Health check integration", () => { + test("health endpoint reports deployment mode", async ({ request }) => { + const res = await request.get(`${BASE}/api/health`); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body).toHaveProperty("deploymentMode"); + expect(body).toHaveProperty("authReady"); + expect(body.authReady).toBe(true); + }); +}); diff --git a/tests/e2e/playwright-multiuser-authenticated.config.ts b/tests/e2e/playwright-multiuser-authenticated.config.ts new file mode 100644 index 0000000000..f80f13372a --- /dev/null +++ b/tests/e2e/playwright-multiuser-authenticated.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3105); +const BASE_URL = process.env.PAPERCLIP_E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "multi-user-authenticated.spec.ts", + timeout: 180_000, + expect: { + timeout: 20_000, + }, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +}); diff --git a/tests/e2e/playwright-multiuser.config.ts b/tests/e2e/playwright-multiuser.config.ts new file mode 100644 index 0000000000..0c2d79be1a --- /dev/null +++ b/tests/e2e/playwright-multiuser.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3104); +const BASE_URL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "multi-user.spec.ts", + timeout: 60_000, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + // No webServer — expects an already-running server at BASE_URL. + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 46579bc9c0..4f1c720668 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -12,6 +12,9 @@ const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-e2e-home export default defineConfig({ testDir: ".", testMatch: "**/*.spec.ts", + // These suites target dedicated multi-user configurations/ports and are + // intentionally not part of the default local_trusted e2e run. + testIgnore: ["multi-user.spec.ts", "multi-user-authenticated.spec.ts"], timeout: 60_000, retries: 0, use: { diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx new file mode 100644 index 0000000000..bec9c199ae --- /dev/null +++ b/ui/src/App.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudAccessGate } from "./components/CloudAccessGate"; + +const mockHealthApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockAccessApi = vi.hoisted(() => ({ + getCurrentBoardAccess: vi.fn(), +})); + +vi.mock("./api/health", () => ({ + healthApi: mockHealthApi, +})); + +vi.mock("./api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("./api/access", () => ({ + accessApi: mockAccessApi, +})); + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to }: { to: string }) =>
Navigate:{to}
, + Outlet: () =>
Outlet content
, + Route: ({ children }: { children?: ReactNode }) => <>{children}, + Routes: ({ children }: { children?: ReactNode }) => <>{children}, + useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }), + useParams: () => ({}), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CloudAccessGate", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("shows a no-access message for signed-in users without org access", async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: [], + source: "session", + keyId: null, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("No company access"); + expect(container.textContent).not.toContain("Outlet content"); + + await act(async () => { + root.unmount(); + }); + }); + + it("allows authenticated users with company access through to the board", async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: ["company-1"], + source: "session", + keyId: null, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Outlet content"); + expect(container.textContent).not.toContain("No company access"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index cfe0f487e2..9fe1f9d9df 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,10 +1,8 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; -import { authApi } from "./api/auth"; -import { healthApi } from "./api/health"; +import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; import { Companies } from "./pages/Companies"; import { Agents } from "./pages/Agents"; @@ -25,18 +23,23 @@ import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; +import { CompanyAccess } from "./pages/CompanyAccess"; +import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { CompanyExport } from "./pages/CompanyExport"; import { CompanyImport } from "./pages/CompanyImport"; import { DesignGuide } from "./pages/DesignGuide"; import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings"; +import { InstanceAccess } from "./pages/InstanceAccess"; import { InstanceSettings } from "./pages/InstanceSettings"; import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; +import { ProfileSettings } from "./pages/ProfileSettings"; import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; import { IssueChatUxLab } from "./pages/IssueChatUxLab"; +import { InviteUxLab } from "./pages/InviteUxLab"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; @@ -44,80 +47,13 @@ import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { CliAuthPage } from "./pages/CliAuth"; import { InviteLandingPage } from "./pages/InviteLanding"; +import { JoinRequestQueue } from "./pages/JoinRequestQueue"; import { NotFoundPage } from "./pages/NotFound"; -import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; -function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { - return ( -
-
-

Instance setup required

-

- {hasActiveInvite - ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" - : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"} -

-
-{`pnpm paperclipai auth bootstrap-ceo`}
-        
-
-
- ); -} - -function CloudAccessGate() { - const location = useLocation(); - const healthQuery = useQuery({ - queryKey: queryKeys.health, - queryFn: () => healthApi.get(), - retry: false, - refetchInterval: (query) => { - const data = query.state.data as - | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } - | undefined; - return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" - ? 2000 - : false; - }, - refetchIntervalInBackground: true, - }); - - const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; - const sessionQuery = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - enabled: isAuthenticatedMode, - retry: false, - }); - - if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) { - return
Loading...
; - } - - if (healthQuery.error) { - return ( -
- {healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"} -
- ); - } - - if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { - return ; - } - - if (isAuthenticatedMode && !sessionQuery.data) { - const next = encodeURIComponent(`${location.pathname}${location.search}`); - return ; - } - - return ; -} - function boardRoutes() { return ( <> @@ -126,6 +62,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -177,9 +115,11 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> @@ -323,7 +263,9 @@ export function App() { } /> }> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 0a1111502e..917a049fa0 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -1,12 +1,17 @@ -import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared"; +import type { AgentAdapterType, JoinRequest, PermissionKey } from "@paperclipai/shared"; import { api } from "./client"; +export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer"; + type InviteSummary = { id: string; companyId: string | null; companyName?: string | null; + companyLogoUrl?: string | null; + companyBrandColor?: string | null; inviteType: "company_join" | "bootstrap_ceo"; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; expiresAt: string; onboardingPath?: string; onboardingUrl?: string; @@ -15,6 +20,9 @@ type InviteSummary = { skillIndexPath?: string; skillIndexUrl?: string; inviteMessage?: string | null; + invitedByUserName?: string | null; + joinRequestStatus?: JoinRequest["status"] | null; + joinRequestType?: JoinRequest["requestType"] | null; }; type AcceptInviteInput = @@ -88,17 +96,162 @@ type CompanyInviteCreated = { inviteUrl: string; expiresAt: string; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; companyName?: string | null; onboardingTextPath?: string; onboardingTextUrl?: string; inviteMessage?: string | null; }; +export type CompanyMemberGrant = { + id: string; + companyId: string; + principalType: "user"; + principalId: string; + permissionKey: PermissionKey; + scope: Record | null; + grantedByUserId: string | null; + createdAt: string; + updatedAt: string; +}; + +export type CompanyMember = { + id: string; + companyId: string; + principalType: "user"; + principalId: string; + status: "pending" | "active" | "suspended"; + membershipRole: HumanCompanyRole | null; + createdAt: string; + updatedAt: string; + user: { id: string; email: string | null; name: string | null; image: string | null } | null; + grants: CompanyMemberGrant[]; +}; + +export type CompanyMembersResponse = { + members: CompanyMember[]; + access: { + currentUserRole: HumanCompanyRole | null; + canManageMembers: boolean; + canInviteUsers: boolean; + canApproveJoinRequests: boolean; + }; +}; + +export type CompanyUserDirectoryEntry = { + principalId: string; + status: "active"; + user: { id: string; email: string | null; name: string | null; image: string | null } | null; +}; + +export type CompanyUserDirectoryResponse = { + users: CompanyUserDirectoryEntry[]; +}; + +export type CompanyInviteRecord = { + id: string; + companyId: string | null; + companyName: string | null; + inviteType: "company_join" | "bootstrap_ceo"; + allowedJoinTypes: "human" | "agent" | "both"; + humanRole: HumanCompanyRole | null; + defaultsPayload: Record | null; + expiresAt: string; + invitedByUserId: string | null; + revokedAt: string | null; + acceptedAt: string | null; + createdAt: string; + updatedAt: string; + inviteMessage: string | null; + state: "active" | "revoked" | "accepted" | "expired"; + invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + relatedJoinRequestId: string | null; +}; + +export type CompanyInviteListResponse = { + invites: CompanyInviteRecord[]; + nextOffset: number | null; +}; + +export type CompanyJoinRequest = JoinRequest & { + requesterUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + approvedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + rejectedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + invite: { + id: string; + inviteType: "company_join" | "bootstrap_ceo"; + allowedJoinTypes: "human" | "agent" | "both"; + humanRole: HumanCompanyRole | null; + inviteMessage: string | null; + createdAt: string; + expiresAt: string; + revokedAt: string | null; + acceptedAt: string | null; + invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + } | null; +}; + +export type AdminUserDirectoryEntry = { + id: string; + email: string | null; + name: string | null; + image: string | null; + isInstanceAdmin: boolean; + activeCompanyMembershipCount: number; +}; + +export type UserCompanyAccessEntry = { + id: string; + companyId: string; + principalType: "user"; + principalId: string; + status: "pending" | "active" | "suspended"; + membershipRole: HumanCompanyRole | "member" | null; + createdAt: string; + updatedAt: string; + companyName: string | null; + companyStatus: "active" | "paused" | "archived" | null; +}; + +export type UserCompanyAccessResponse = { + user: { + id: string; + email: string | null; + name: string | null; + image: string | null; + isInstanceAdmin: boolean; + } | null; + companyAccess: UserCompanyAccessEntry[]; +}; + +export type CurrentBoardAccess = { + user: { id: string; email: string | null; name: string | null; image: string | null } | null; + userId: string; + isInstanceAdmin: boolean; + companyIds: string[]; + source: string; + keyId: string | null; +}; + +function buildInviteListQuery(options: { + state?: "active" | "revoked" | "accepted" | "expired"; + limit?: number; + offset?: number; +}) { + const params = new URLSearchParams(); + if (options.state) params.set("state", options.state); + if (options.limit) params.set("limit", String(options.limit)); + if (options.offset) params.set("offset", String(options.offset)); + const query = params.toString(); + return query ? `?${query}` : ""; +} + export const accessApi = { createCompanyInvite: ( companyId: string, input: { allowedJoinTypes?: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; defaultsPayload?: Record | null; agentMessage?: string | null; } = {}, @@ -126,8 +279,67 @@ export const accessApi = { input, ), - listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") => - api.get(`/companies/${companyId}/join-requests?status=${status}`), + listInvites: ( + companyId: string, + options: { + state?: "active" | "revoked" | "accepted" | "expired"; + limit?: number; + offset?: number; + } = {}, + ) => + api.get( + `/companies/${companyId}/invites${buildInviteListQuery(options)}`, + ), + + revokeInvite: (inviteId: string) => api.post(`/invites/${inviteId}/revoke`, {}), + + listJoinRequests: ( + companyId: string, + status: "pending_approval" | "approved" | "rejected" = "pending_approval", + requestType?: "human" | "agent", + ) => + api.get( + `/companies/${companyId}/join-requests?status=${status}${requestType ? `&requestType=${requestType}` : ""}`, + ), + + listMembers: (companyId: string) => + api.get(`/companies/${companyId}/members`), + + listUserDirectory: (companyId: string) => + api.get(`/companies/${companyId}/user-directory`), + + updateMember: ( + companyId: string, + memberId: string, + input: { + membershipRole?: HumanCompanyRole | null; + status?: "pending" | "active" | "suspended"; + }, + ) => api.patch(`/companies/${companyId}/members/${memberId}`, input), + + updateMemberPermissions: ( + companyId: string, + memberId: string, + input: { + grants: Array<{ + permissionKey: PermissionKey; + scope?: Record | null; + }>; + }, + ) => api.patch(`/companies/${companyId}/members/${memberId}/permissions`, input), + + updateMemberAccess: ( + companyId: string, + memberId: string, + input: { + membershipRole?: HumanCompanyRole | null; + status?: "pending" | "active" | "suspended"; + grants: Array<{ + permissionKey: PermissionKey; + scope?: Record | null; + }>; + }, + ) => api.patch(`/companies/${companyId}/members/${memberId}/role-and-grants`, input), approveJoinRequest: (companyId: string, requestId: string) => api.post(`/companies/${companyId}/join-requests/${requestId}/approve`, {}), @@ -158,4 +370,22 @@ export const accessApi = { cancelCliAuthChallenge: (id: string, token: string) => api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }), + + searchAdminUsers: (query: string) => + api.get(`/admin/users?query=${encodeURIComponent(query)}`), + + promoteInstanceAdmin: (userId: string) => + api.post(`/admin/users/${userId}/promote-instance-admin`, {}), + + demoteInstanceAdmin: (userId: string) => + api.post(`/admin/users/${userId}/demote-instance-admin`, {}), + + getUserCompanyAccess: (userId: string) => + api.get(`/admin/users/${userId}/company-access`), + + setUserCompanyAccess: (userId: string, companyIds: string[]) => + api.put(`/admin/users/${userId}/company-access`, { companyIds }), + + getCurrentBoardAccess: () => + api.get("/cli-auth/me"), }; diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 5aa5b69ad2..b0da51651a 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -1,27 +1,63 @@ -export type AuthSession = { - session: { id: string; userId: string }; - user: { id: string; email: string | null; name: string | null }; -}; +import { + authSessionSchema, + currentUserProfileSchema, + type AuthSession, + type CurrentUserProfile, + type UpdateCurrentUserProfile, +} from "@paperclipai/shared"; + +type AuthErrorBody = + | { + code?: string; + message?: string; + error?: string | { code?: string; message?: string }; + } + | null; + +export class AuthApiError extends Error { + status: number; + code: string | null; + body: unknown; + + constructor(message: string, status: number, body: unknown, code: string | null = null) { + super(message); + this.name = "AuthApiError"; + this.status = status; + this.code = code; + this.body = body; + } +} function toSession(value: unknown): AuthSession | null { + const direct = authSessionSchema.safeParse(value); + if (direct.success) return direct.data; + if (!value || typeof value !== "object") return null; - const record = value as Record; - const sessionValue = record.session; - const userValue = record.user; - if (!sessionValue || typeof sessionValue !== "object") return null; - if (!userValue || typeof userValue !== "object") return null; - const session = sessionValue as Record; - const user = userValue as Record; - if (typeof session.id !== "string" || typeof session.userId !== "string") return null; - if (typeof user.id !== "string") return null; - return { - session: { id: session.id, userId: session.userId }, - user: { - id: user.id, - email: typeof user.email === "string" ? user.email : null, - name: typeof user.name === "string" ? user.name : null, - }, - }; + const nested = authSessionSchema.safeParse((value as Record).data); + return nested.success ? nested.data : null; +} + +function extractAuthError(payload: AuthErrorBody, status: number) { + const nested = + payload?.error && typeof payload.error === "object" + ? payload.error + : null; + const code = + typeof nested?.code === "string" + ? nested.code + : typeof payload?.code === "string" + ? payload.code + : null; + const message = + typeof nested?.message === "string" && nested.message.trim().length > 0 + ? nested.message + : typeof payload?.message === "string" && payload.message.trim().length > 0 + ? payload.message + : typeof payload?.error === "string" && payload.error.trim().length > 0 + ? payload.error + : `Request failed: ${status}`; + + return new AuthApiError(message, status, payload, code); } async function authPost(path: string, body: Record) { @@ -33,16 +69,25 @@ async function authPost(path: string, body: Record) { }); const payload = await res.json().catch(() => null); if (!res.ok) { - const message = - (payload as { error?: { message?: string } | string } | null)?.error && - typeof (payload as { error?: { message?: string } | string }).error === "object" - ? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`) - : (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`; - throw new Error(message); + throw extractAuthError(payload as AuthErrorBody, res.status); } return payload; } +async function authPatch(path: string, body: Record, parse: (value: unknown) => T): Promise { + const res = await fetch(`/api/auth${path}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + const payload = await res.json().catch(() => null); + if (!res.ok) { + throw extractAuthError(payload as AuthErrorBody, res.status); + } + return parse(payload); +} + export const authApi = { getSession: async (): Promise => { const res = await fetch("/api/auth/get-session", { @@ -68,6 +113,21 @@ export const authApi = { await authPost("/sign-up/email", input); }, + getProfile: async (): Promise => { + const res = await fetch("/api/auth/profile", { + credentials: "include", + headers: { Accept: "application/json" }, + }); + const payload = await res.json().catch(() => null); + if (!res.ok) { + throw new Error((payload as { error?: string } | null)?.error ?? `Failed to load profile (${res.status})`); + } + return currentUserProfileSchema.parse(payload); + }, + + updateProfile: async (input: UpdateCurrentUserProfile): Promise => + authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)), + signOut: async () => { await authPost("/sign-out", {}); }, diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index dbe8878527..c969a9599a 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -4,6 +4,7 @@ import { timeAgo } from "../lib/timeAgo"; import { cn } from "../lib/utils"; import { formatActivityVerb } from "../lib/activity-format"; import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared"; +import type { CompanyUserProfile } from "../lib/company-members"; function entityLink(entityType: string, entityId: string, name?: string | null): string | null { switch (entityType) { @@ -19,13 +20,14 @@ function entityLink(entityType: string, entityId: string, name?: string | null): interface ActivityRowProps { event: ActivityEvent; agentMap: Map; + userProfileMap?: Map; entityNameMap: Map; entityTitleMap?: Map; className?: string; } -export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) { - const verb = formatActivityVerb(event.action, event.details, { agentMap }); +export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) { + const verb = formatActivityVerb(event.action, event.details, { agentMap, userProfileMap }); const isHeartbeatEvent = event.entityType === "heartbeat_run"; const heartbeatAgentId = isHeartbeatEvent @@ -43,13 +45,16 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl : entityLink(event.entityType, event.entityId, name); const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null; - const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown"); + const userProfile = event.actorType === "user" ? userProfileMap?.get(event.actorId) : null; + const actorName = actor?.name ?? (event.actorType === "system" ? "System" : userProfile?.label ?? (event.actorType === "user" ? "Board" : event.actorId || "Unknown")); + const actorAvatarUrl = userProfile?.image ?? null; const inner = (

diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx new file mode 100644 index 0000000000..eaea736bcd --- /dev/null +++ b/ui/src/components/CloudAccessGate.tsx @@ -0,0 +1,114 @@ +import { Navigate, Outlet, useLocation } from "@/lib/router"; +import { useQuery } from "@tanstack/react-query"; +import { accessApi } from "@/api/access"; +import { authApi } from "@/api/auth"; +import { healthApi } from "@/api/health"; +import { queryKeys } from "@/lib/queryKeys"; + +function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { + return ( +

+
+

Instance setup required

+

+ {hasActiveInvite + ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" + : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"} +

+
+{`pnpm paperclipai auth bootstrap-ceo`}
+        
+
+
+ ); +} + +function NoBoardAccessPage() { + return ( +
+
+

No company access

+

+ This account is signed in, but it does not have an active company membership or instance-admin access on + this Paperclip instance. +

+

+ Use a company invite or sign in with an account that already belongs to this org. +

+
+
+ ); +} + +export function CloudAccessGate() { + const location = useLocation(); + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + retry: false, + refetchInterval: (query) => { + const data = query.state.data as + | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } + | undefined; + return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" + ? 2000 + : false; + }, + refetchIntervalInBackground: true, + }); + + const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; + const sessionQuery = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + enabled: isAuthenticatedMode, + retry: false, + }); + + const boardAccessQuery = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + enabled: isAuthenticatedMode && !!sessionQuery.data, + retry: false, + }); + + if ( + healthQuery.isLoading || + (isAuthenticatedMode && sessionQuery.isLoading) || + (isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading) + ) { + return
Loading...
; + } + + if (healthQuery.error || boardAccessQuery.error) { + return ( +
+ {healthQuery.error instanceof Error + ? healthQuery.error.message + : boardAccessQuery.error instanceof Error + ? boardAccessQuery.error.message + : "Failed to load app state"} +
+ ); + } + + if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { + return ; + } + + if (isAuthenticatedMode && !sessionQuery.data) { + const next = encodeURIComponent(`${location.pathname}${location.search}`); + return ; + } + + if ( + isAuthenticatedMode && + sessionQuery.data && + !boardAccessQuery.data?.isInstanceAdmin && + (boardAccessQuery.data?.companyIds.length ?? 0) === 0 + ) { + return ; + } + + return ; +} diff --git a/ui/src/components/CompanyPatternIcon.tsx b/ui/src/components/CompanyPatternIcon.tsx index 6ea4078859..6591f7d13a 100644 --- a/ui/src/components/CompanyPatternIcon.tsx +++ b/ui/src/components/CompanyPatternIcon.tsx @@ -13,6 +13,7 @@ interface CompanyPatternIconProps { logoUrl?: string | null; brandColor?: string | null; className?: string; + logoFit?: "cover" | "contain"; } function hashString(value: string): number { @@ -165,6 +166,7 @@ export function CompanyPatternIcon({ logoUrl, brandColor, className, + logoFit = "cover", }: CompanyPatternIconProps) { const initial = companyName.trim().charAt(0).toUpperCase() || "?"; const [imageError, setImageError] = useState(false); @@ -189,7 +191,10 @@ export function CompanyPatternIcon({ src={logo} alt={`${companyName} logo`} onError={() => setImageError(true)} - className="absolute inset-0 h-full w-full object-cover" + className={cn( + "absolute inset-0 h-full w-full", + logoFit === "contain" ? "object-contain" : "object-cover", + )} /> ) : patternDataUrl ? ( vi.fn()); +const mockSidebarBadgesApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + onClick, + }: { + children: React.ReactNode; + to: string; + onClick?: () => void; + }) => ( + + ), +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip" }, + }), +})); + +vi.mock("@/context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + setSidebarOpen: vi.fn(), + }), +})); + +vi.mock("./SidebarNavItem", () => ({ + SidebarNavItem: (props: { + to: string; + label: string; + end?: boolean; + badge?: number; + }) => { + sidebarNavItemMock(props); + return
{props.label}
; + }, +})); + +vi.mock("@/api/sidebarBadges", () => ({ + sidebarBadgesApi: mockSidebarBadgesApi, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CompanySettingsSidebar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockSidebarBadgesApi.get.mockResolvedValue({ + inbox: 0, + approvals: 0, + failedRuns: 0, + joinRequests: 2, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders the company back link and the settings sections in the sidebar", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Paperclip"); + expect(container.textContent).toContain("Company Settings"); + expect(container.textContent).toContain("General"); + expect(container.textContent).toContain("Access"); + expect(container.textContent).toContain("Invites"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings", + label: "General", + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/access", + label: "Access", + badge: 2, + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/invites", + label: "Invites", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx new file mode 100644 index 0000000000..6339cf1fdd --- /dev/null +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -0,0 +1,69 @@ +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { sidebarBadgesApi } from "@/api/sidebarBadges"; +import { ApiError } from "@/api/client"; +import { Link } from "@/lib/router"; +import { queryKeys } from "@/lib/queryKeys"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { SidebarNavItem } from "./SidebarNavItem"; + +export function CompanySettingsSidebar() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + const { data: badges } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.sidebarBadges(selectedCompanyId) + : ["sidebar-badges", "__disabled__"] as const, + queryFn: async () => { + try { + return await sidebarBadgesApi.get(selectedCompanyId!); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + return null; + } + throw error; + } + }, + enabled: !!selectedCompanyId, + retry: false, + refetchInterval: 15_000, + }); + + return ( + + ); +} diff --git a/ui/src/components/ExecutionParticipantPicker.tsx b/ui/src/components/ExecutionParticipantPicker.tsx index 7e8942b58d..cf21a5c35f 100644 --- a/ui/src/components/ExecutionParticipantPicker.tsx +++ b/ui/src/components/ExecutionParticipantPicker.tsx @@ -1,6 +1,10 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { Agent, Issue } from "@paperclipai/shared"; +import { useQuery } from "@tanstack/react-query"; +import { accessApi } from "../api/access"; import { formatAssigneeUserLabel } from "../lib/assignees"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; +import { queryKeys } from "../lib/queryKeys"; import { sortAgentsByRecency, getRecentAssigneeIds } from "../lib/recent-assignees"; import { buildExecutionPolicy, @@ -34,14 +38,27 @@ export function ExecutionParticipantPicker({ const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); const values = stageType === "review" ? reviewerValues : approverValues; + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(issue.companyId), + queryFn: () => accessApi.listUserDirectory(issue.companyId), + enabled: !!issue.companyId, + }); const sortedAgents = sortAgentsByRecency( agents.filter((a) => a.status !== "terminated"), getRecentAssigneeIds(), ); + const userLabelMap = useMemo( + () => buildCompanyUserLabelMap(companyMembers?.users), + [companyMembers?.users], + ); + const otherUserOptions = useMemo( + () => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }), + [companyMembers?.users, currentUserId, issue.createdByUserId], + ); const userLabel = (userId: string | null | undefined) => - formatAssigneeUserLabel(userId, currentUserId); + formatAssigneeUserLabel(userId, currentUserId, userLabelMap); const creatorUserLabel = userLabel(issue.createdByUserId); const agentName = (id: string) => { @@ -138,6 +155,24 @@ export function ExecutionParticipantPicker({ {creatorUserLabel ?? "Requester"} )} + {otherUserOptions + .filter((option) => { + if (!search.trim()) return true; + return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase()); + }) + .map((option) => ( + + ))} {sortedAgents .filter((agent) => { if (!search.trim()) return true; diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index e37bc16659..fe4477966a 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} - + Archive {workspaceName} and clean up any owned workspace artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. {readinessQuery.isLoading ? ( -
- +
+ Checking whether this workspace is safe to close...
) : readinessQuery.error ? ( -
+
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
) : readiness ? ( -
-
+
+
{readiness.state === "blocked" ? "Close is blocked" @@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({ {blockingIssues.length > 0 ? (
-

Blocking issues

-
+

Blocking issues

+
{blockingIssues.map((issue) => ( -
+
{issue.identifier ?? issue.id} · {issue.title} @@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.blockingReasons.length > 0 ? (
-

Blocking reasons

-