diff --git a/doc/CLI.md b/doc/CLI.md index f75038bc..ac6eb736 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -25,6 +25,18 @@ Choose local instance: pnpm paperclip run --instance dev ``` +## Deployment Modes + +Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`. + +Current CLI behavior: + +- `paperclip onboard` and `paperclip configure --section server` set deployment mode in config +- runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE` +- `paperclip run` and `paperclip doctor` do not yet expose a direct `--mode` flag + +Target behavior (planned) is documented in `doc/DEPLOYMENT-MODES.md` section 5. + All client commands support: - `--api-base ` diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md new file mode 100644 index 00000000..83484300 --- /dev/null +++ b/doc/DEPLOYMENT-MODES.md @@ -0,0 +1,107 @@ +# Deployment Modes + +Status: Canonical deployment and auth mode model +Date: 2026-02-23 + +## 1. Purpose + +Paperclip supports two runtime modes: + +1. `local_trusted` +2. `authenticated` + +`authenticated` supports two exposure policies: + +1. `private` +2. `public` + +This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements. + +## 2. Canonical Model + +| Runtime Mode | Exposure | Human auth | Primary use | +|---|---|---|---| +| `local_trusted` | n/a | No login required | Single-operator local machine workflow | +| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) | +| `authenticated` | `public` | Login required | Internet-facing/cloud deployment | + +## 3. Security Policy + +## `local_trusted` + +- loopback-only host binding +- no human login flow +- optimized for fastest local startup + +## `authenticated + private` + +- login required +- low-friction URL handling (`auto` base URL mode) +- private-host trust policy required + +## `authenticated + public` + +- login required +- explicit public URL required +- stricter deployment checks and failures in doctor + +## 4. Onboarding UX Contract + +Default onboarding remains interactive and flagless: + +```sh +pnpm paperclip onboard +``` + +Server prompt behavior: + +1. ask mode, default `local_trusted` +2. option copy: +- `local_trusted`: "Easiest for local setup (no login, localhost-only)" +- `authenticated`: "Login required; use for private network or public hosting" +3. if `authenticated`, ask exposure: +- `private`: "Private network access (for example Tailscale), lower setup friction" +- `public`: "Internet-facing deployment, stricter security requirements" +4. ask explicit public URL only for `authenticated + public` + +`configure --section server` follows the same interactive behavior. + +## 5. Doctor UX Contract + +Default doctor remains flagless: + +```sh +pnpm paperclip doctor +``` + +Doctor reads configured mode/exposure and applies mode-aware checks. Optional override flags are secondary. + +## 6. Board/User Integration Contract + +Board identity must be represented by a real DB user principal for user-based features to work consistently. + +Required integration points: + +- real user row in `authUsers` for Board identity +- `instance_user_roles` entry for Board admin authority +- `company_memberships` integration for user-level task assignment and access + +This is required because user assignment paths validate active membership for `assigneeUserId`. + +## 7. Current Code Reality (As Of 2026-02-23) + +- runtime values are `local_trusted | authenticated` +- `authenticated` uses Better Auth sessions and bootstrap invite flow +- `local_trusted` ensures a real local Board user principal in `authUsers` with `instance_user_roles` admin access +- company creation ensures creator membership in `company_memberships` so user assignment/access flows remain consistent + +## 8. Naming and Compatibility Policy + +- canonical naming is `local_trusted` and `authenticated` with `private/public` exposure +- no long-term compatibility alias layer for discarded naming variants + +## 9. Relationship to Other Docs + +- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md` +- V1 contract: `doc/SPEC-implementation.md` +- operator workflows: `doc/DEVELOPING.md` and `doc/CLI.md` diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 61b9aa47..67e4fe1c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2,6 +2,14 @@ This project can run fully in local dev without setting up PostgreSQL manually. +## Deployment Modes + +For mode definitions and intended CLI behavior, see `doc/DEPLOYMENT-MODES.md`. + +Current implementation status: + +- canonical model: `local_trusted` and `authenticated` (with `private/public` exposure) + ## Prerequisites - Node.js 20+ diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index a180efc0..741df662 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -84,10 +84,12 @@ More detailed task structure TBD. ## Guidelines -There are two deployment scenarios that need to be maintained: +There are two runtime modes Paperclip must support: -- a single user, local trusted deployment - this should be easy to install with a single `npx paperclip run` command and the environment is trusted and self-contained on a local machine (e.g. local files, agents, embedded db, easy to use) -- multi-user cloud deployment - allows for a hosted deploy (remote deployment, user logins, hosted db, scalable) +- `local_trusted` (default): single-user local trusted deployment with no login friction +- `authenticated`: login-required mode that supports both private-network and public deployment exposure policies + +Canonical mode design and command expectations live in `doc/DEPLOYMENT-MODES.md`. ## Further Detail diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e4fcbcab..9cccfd99 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -39,10 +39,10 @@ These decisions close open questions from `SPEC.md` for V1. | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | Recovery | No automatic reassignment; stale work is surfaced, not silently fixed | | Agent adapters | Built-in `process` and `http` adapters | -| Auth | Session auth for board, API keys for agents | +| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents | | Budget period | Monthly UTC calendar window | | Budget enforcement | Soft alerts + hard limit auto-pause | -| Deployment modes | Embedded PostgreSQL default; Docker/hosted Postgres supported | +| Deployment modes | Canonical model is `local_trusted` + `authenticated` with `private/public` exposure policy (see `doc/DEPLOYMENT-MODES.md`) | ## 4. Current Baseline (Repo Snapshot) diff --git a/doc/plan/humans-and-permissions-implementation.md b/doc/plan/humans-and-permissions-implementation.md new file mode 100644 index 00000000..c0279542 --- /dev/null +++ b/doc/plan/humans-and-permissions-implementation.md @@ -0,0 +1,643 @@ +# Humans and Permissions Implementation (V1) + +Status: Draft +Date: 2026-02-21 +Owners: Server + UI + CLI + DB + Shared +Companion plan: `doc/plan/humans-and-permissions.md` + +## 1. Document role + +This document is the engineering implementation contract for the humans-and-permissions plan. +It translates product decisions into concrete schema, API, middleware, UI, CLI, and test work. + +If this document conflicts with prior exploratory notes, this document wins for V1 execution. + +## 2. Locked V1 decisions + +1. Two deployment modes remain: +- `local_trusted` +- `cloud_hosted` + +2. `local_trusted`: +- no login UX +- implicit local instance admin actor +- loopback-only server binding +- full admin/settings/invite/approval capabilities available locally + +3. `cloud_hosted`: +- Better Auth for humans +- email/password only +- no email verification requirement in V1 + +4. Permissions: +- one shared authorization system for humans and agents +- normalized grants table (`principal_permission_grants`) +- no separate “agent permissions engine” + +5. Invites: +- copy-link only (no outbound email sending in V1) +- unified `company_join` link that supports human or agent path +- acceptance creates `pending_approval` join request +- no access until admin approval + +6. Join review metadata: +- source IP required +- no GeoIP/country lookup in V1 + +7. Agent API keys: +- indefinite by default +- hash at rest +- display once on claim +- revoke/regenerate supported + +8. Local ingress: +- public/untrusted ingress is out of scope for V1 +- no `--dangerous-agent-ingress` in V1 + +## 3. Current baseline and delta + +Current baseline (repo as of this doc): + +- server actor model defaults to `board` in `server/src/middleware/auth.ts` +- authorization is mostly `assertBoard` + company check in `server/src/routes/authz.ts` +- no human auth/session tables in local schema +- no principal membership or grants tables +- no invite or join-request lifecycle + +Required delta: + +- move from board-vs-agent authz to principal-based authz +- add Better Auth integration in cloud mode +- add membership/grants/invite/join-request persistence +- add approval inbox signals and actions +- preserve local no-login UX without weakening cloud security + +## 4. Architecture + +## 4.1 Deployment mode contract + +Add explicit runtime mode: + +- `deployment.mode = local_trusted | cloud_hosted` + +Config behavior: + +- mode stored in config file (`packages/shared/src/config-schema.ts`) +- loaded in server config (`server/src/config.ts`) +- surfaced in `/api/health` + +Startup guardrails: + +- `local_trusted`: fail startup if bind host is not loopback +- `cloud_hosted`: fail startup if Better Auth is not configured + +## 4.2 Actor model + +Replace implicit “board” semantics with explicit actors: + +- `user` (session-authenticated human) +- `agent` (bearer API key) +- `local_implicit_admin` (local_trusted only) + +Implementation note: + +- keep `req.actor` shape backward-compatible during migration by introducing a normalizer helper +- remove hard-coded `"board"` checks route-by-route after new authz helpers are in place + +## 4.3 Authorization model + +Authorization input tuple: + +- `(company_id, principal_type, principal_id, permission_key, scope_payload)` + +Principal types: + +- `user` +- `agent` + +Role layers: + +- `instance_admin` (instance-wide) +- company-scoped grants via `principal_permission_grants` + +Evaluation order: + +1. resolve principal from actor +2. resolve instance role (`instance_admin` short-circuit for admin-only actions) +3. resolve company membership (`active` required for company access) +4. resolve grant + scope for requested action + +## 5. Data model + +## 5.1 Better Auth tables + +Managed by Better Auth adapter/migrations (expected minimum): + +- `user` +- `session` +- `account` +- `verification` + +Note: + +- use Better Auth canonical table names/types to avoid custom forks + +## 5.2 New Paperclip tables + +1. `instance_user_roles` + +- `id` uuid pk +- `user_id` text not null +- `role` text not null (`instance_admin`) +- `created_at`, `updated_at` +- unique index: `(user_id, role)` + +2. `company_memberships` + +- `id` uuid pk +- `company_id` uuid fk `companies.id` not null +- `principal_type` text not null (`user | agent`) +- `principal_id` text not null +- `status` text not null (`pending | active | suspended`) +- `membership_role` text null +- `created_at`, `updated_at` +- unique index: `(company_id, principal_type, principal_id)` +- index: `(principal_type, principal_id, status)` + +3. `principal_permission_grants` + +- `id` uuid pk +- `company_id` uuid fk `companies.id` not null +- `principal_type` text not null (`user | agent`) +- `principal_id` text not null +- `permission_key` text not null +- `scope` jsonb null +- `granted_by_user_id` text null +- `created_at`, `updated_at` +- unique index: `(company_id, principal_type, principal_id, permission_key)` +- index: `(company_id, permission_key)` + +4. `invites` + +- `id` uuid pk +- `company_id` uuid fk `companies.id` not null +- `invite_type` text not null (`company_join | bootstrap_ceo`) +- `token_hash` text not null +- `allowed_join_types` text not null (`human | agent | both`) for `company_join` +- `defaults_payload` jsonb null +- `expires_at` timestamptz not null +- `invited_by_user_id` text null +- `revoked_at` timestamptz null +- `accepted_at` timestamptz null +- `created_at` timestamptz not null default now() +- unique index: `(token_hash)` +- index: `(company_id, invite_type, revoked_at, expires_at)` + +5. `join_requests` + +- `id` uuid pk +- `invite_id` uuid fk `invites.id` not null +- `company_id` uuid fk `companies.id` not null +- `request_type` text not null (`human | agent`) +- `status` text not null (`pending_approval | approved | rejected`) +- `request_ip` text not null +- `requesting_user_id` text null +- `request_email_snapshot` text null +- `agent_name` text null +- `adapter_type` text null +- `capabilities` text null +- `agent_defaults_payload` jsonb null +- `created_agent_id` uuid fk `agents.id` null +- `approved_by_user_id` text null +- `approved_at` timestamptz null +- `rejected_by_user_id` text null +- `rejected_at` timestamptz null +- `created_at`, `updated_at` +- index: `(company_id, status, request_type, created_at desc)` +- unique index: `(invite_id)` to enforce one request per consumed invite + +## 5.3 Existing table changes + +1. `issues` + +- add `assignee_user_id` text null +- enforce single-assignee invariant: + - at most one of `assignee_agent_id` and `assignee_user_id` is non-null + +2. `agents` + +- keep existing `permissions` JSON for transition only +- mark as deprecated in code path once principal grants are live + +## 5.4 Migration strategy + +Migration ordering: + +1. add new tables/columns/indexes +2. backfill minimum memberships/grants for existing data: +- create local implicit admin membership context in local mode at runtime (not persisted as Better Auth user) +- for cloud, bootstrap creates first admin user role on acceptance +3. switch authz reads to new tables +4. remove legacy board-only checks + +## 6. API contract (new/changed) + +All under `/api`. + +## 6.1 Health + +`GET /api/health` response additions: + +- `deploymentMode` +- `authReady` +- `bootstrapStatus` (`ready | bootstrap_pending`) + +## 6.2 Invites + +1. `POST /api/companies/:companyId/invites` +- create `company_join` invite +- copy-link value returned once + +2. `GET /api/invites/:token` +- validate token +- return invite landing payload +- includes `allowedJoinTypes` + +3. `POST /api/invites/:token/accept` +- body: + - `requestType: human | agent` + - human path: no extra payload beyond authenticated user + - agent path: `agentName`, `adapterType`, `capabilities`, optional adapter defaults +- consumes invite token +- creates `join_requests(status=pending_approval)` + +4. `POST /api/invites/:inviteId/revoke` +- revokes non-consumed invite + +## 6.3 Join requests + +1. `GET /api/companies/:companyId/join-requests?status=pending_approval&requestType=...` + +2. `POST /api/companies/:companyId/join-requests/:requestId/approve` +- human: + - create/activate `company_memberships` + - apply default grants +- agent: + - create `agents` row + - create pending claim context for API key + - create/activate agent membership + - apply default grants + +3. `POST /api/companies/:companyId/join-requests/:requestId/reject` + +4. `POST /api/join-requests/:requestId/claim-api-key` +- approved agent request only +- returns plaintext key once +- stores hash in `agent_api_keys` + +## 6.4 Membership and grants + +1. `GET /api/companies/:companyId/members` +- returns both principal types + +2. `PATCH /api/companies/:companyId/members/:memberId/permissions` +- upsert/remove grants + +3. `PUT /api/admin/users/:userId/company-access` +- instance admin only + +4. `GET /api/admin/users/:userId/company-access` + +5. `POST /api/admin/users/:userId/promote-instance-admin` + +6. `POST /api/admin/users/:userId/demote-instance-admin` + +## 6.5 Inbox + +`GET /api/companies/:companyId/inbox` additions: + +- pending join request alert items when actor can `joins:approve` +- each item includes inline action metadata: + - join request id + - request type + - source IP + - human email snapshot when applicable + +## 7. Server implementation details + +## 7.1 Config and startup + +Files: + +- `packages/shared/src/config-schema.ts` +- `server/src/config.ts` +- `server/src/index.ts` +- `server/src/startup-banner.ts` + +Changes: + +- add deployment mode + bind host settings +- enforce loopback-only for `local_trusted` +- enforce Better Auth readiness in `cloud_hosted` +- banner shows mode and bootstrap status + +## 7.2 Better Auth integration + +Files: + +- `server/package.json` (dependency) +- `server/src/auth/*` (new) +- `server/src/app.ts` (mount auth handler endpoints + session middleware) + +Changes: + +- add Better Auth server instance +- cookie/session handling for cloud mode +- no-op session auth in local mode + +## 7.3 Actor middleware + +Files: + +- `server/src/middleware/auth.ts` +- `server/src/routes/authz.ts` +- `server/src/middleware/board-mutation-guard.ts` + +Changes: + +- stop defaulting every request to board in cloud mode +- map local requests to `local_implicit_admin` actor in local mode +- map Better Auth session to `user` actor in cloud mode +- preserve agent bearer path +- replace `assertBoard` with permission-oriented helpers: + - `requireInstanceAdmin(req)` + - `requireCompanyAccess(req, companyId)` + - `requireCompanyPermission(req, companyId, permissionKey, scope?)` + +## 7.4 Authorization services + +Files: + +- `server/src/services` (new modules) + - `memberships.ts` + - `permissions.ts` + - `invites.ts` + - `join-requests.ts` + - `instance-admin.ts` + +Changes: + +- centralized permission evaluation +- centralized membership resolution +- one place for principal-type branching + +## 7.5 Routes + +Files: + +- `server/src/routes/index.ts` and new route modules: + - `auth.ts` (if needed) + - `invites.ts` + - `join-requests.ts` + - `members.ts` + - `instance-admin.ts` + - `inbox.ts` (or extension of existing inbox source) + +Changes: + +- add new endpoints listed above +- apply company and permission checks consistently +- log all mutations through activity log service + +## 7.6 Activity log and audit + +Files: + +- `server/src/services/activity-log.ts` +- call sites in invite/join/member/admin routes + +Required actions: + +- `invite.created` +- `invite.revoked` +- `join.requested` +- `join.approved` +- `join.rejected` +- `membership.activated` +- `permission.granted` +- `permission.revoked` +- `instance_admin.promoted` +- `instance_admin.demoted` +- `agent_api_key.claimed` +- `agent_api_key.revoked` + +## 7.7 Real-time and inbox propagation + +Files: + +- `server/src/services/live-events.ts` +- `server/src/realtime/live-events-ws.ts` +- inbox data source endpoint(s) + +Changes: + +- emit join-request events +- ensure inbox refresh path includes join alerts + +## 8. CLI implementation + +Files: + +- `cli/src/index.ts` +- `cli/src/commands/onboard.ts` +- `cli/src/commands/configure.ts` +- `cli/src/prompts/server.ts` + +Commands: + +1. `paperclip auth bootstrap-ceo` +- create bootstrap invite +- print one-time URL + +2. `paperclip onboard` +- in cloud mode with `bootstrap_pending`, print bootstrap URL and next steps +- in local mode, skip bootstrap requirement + +Config additions: + +- deployment mode +- bind host (validated against mode) + +## 9. UI implementation + +Files: + +- routing: `ui/src/App.tsx` +- API clients: `ui/src/api/*` +- pages/components (new): + - `AuthLogin` / `AuthSignup` (cloud mode) + - `BootstrapPending` page + - `InviteLanding` page + - `InstanceSettings` page + - join approval components in `Inbox` + - member/grant management in company settings + +Required UX: + +1. Cloud unauthenticated user: +- redirect to login/signup + +2. Cloud bootstrap pending: +- block app with setup command guidance + +3. Invite landing: +- choose human vs agent path (respect `allowedJoinTypes`) +- submit join request +- show pending approval confirmation + +4. Inbox: +- show join approval cards with approve/reject actions +- include source IP and human email snapshot when applicable + +5. Local mode: +- no login prompts +- full settings/invite/approval UI available + +## 10. Security controls + +1. Token handling + +- invite tokens hashed at rest +- API keys hashed at rest +- one-time plaintext key reveal only + +2. Local mode isolation + +- loopback bind enforcement +- startup hard-fail on non-loopback host + +3. Cloud auth + +- no implicit board fallback +- session auth mandatory for human mutations + +4. Join workflow hardening + +- one request per invite token +- pending request has no data access +- approval required before membership activation + +5. Abuse controls + +- rate limit invite accept and key claim endpoints +- structured logging for join and claim failures + +## 11. Migration and compatibility + +## 11.1 Runtime compatibility + +- keep existing board-dependent routes functional while migrating authz helper usage +- phase out `assertBoard` calls only after permission helpers cover all routes + +## 11.2 Data compatibility + +- do not delete `agents.permissions` in V1 +- stop reading it once grants are wired +- remove in post-V1 cleanup migration + +## 11.3 Better Auth user ID handling + +- treat `user.id` as text end-to-end +- existing `created_by_user_id` and similar text fields remain valid + +## 12. Testing strategy + +## 12.1 Unit tests + +- permission evaluator: + - instance admin bypass + - grant checks + - scope checks +- join approval state machine +- invite token lifecycle + +## 12.2 Integration tests + +- cloud mode unauthenticated mutation -> `401` +- local mode implicit admin mutation -> success +- invite accept -> pending join -> no access +- join approve (human) -> membership/grants active +- join approve (agent) -> key claim once +- cross-company access denied for user and agent principals +- local mode non-loopback bind -> startup failure + +## 12.3 UI tests + +- login gate in cloud mode +- bootstrap pending screen +- invite landing choose-path UX +- inbox join alert approve/reject flows + +## 12.4 Regression tests + +- existing agent API key flows still work +- task assignment and checkout invariants unchanged +- activity logging still emitted for all mutations + +## 13. Delivery plan + +## Phase A: Foundations + +- config mode/bind host support +- startup guardrails +- Better Auth integration skeleton +- actor type expansion + +## Phase B: Schema and authz core + +- add membership/grants/invite/join tables +- add permission service and helpers +- wire company/member/instance admin checks + +## Phase C: Invite + join backend + +- invite create/revoke +- invite accept -> pending request +- approve/reject + key claim +- activity log + live events + +## Phase D: UI + CLI + +- cloud login/bootstrap screens +- invite landing +- inbox join approval actions +- instance settings and member permissions +- bootstrap CLI command and onboarding updates + +## Phase E: Hardening + +- full integration/e2e coverage +- docs updates (`SPEC-implementation`, `DEVELOPING`, `CLI`) +- cleanup of legacy board-only codepaths + +## 14. Verification gate + +Before handoff: + +```sh +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +If any command is skipped, record exactly what was skipped and why. + +## 15. Done criteria + +1. Behavior matches locked V1 decisions in this doc and `doc/plan/humans-and-permissions.md`. +2. Cloud mode requires auth; local mode has no login UX. +3. Unified invite + pending approval flow works for both humans and agents. +4. Shared principal membership + permission system is live for users and agents. +5. Local mode remains loopback-only and fails otherwise. +6. Inbox shows actionable join approvals. +7. All new mutating paths are activity-logged. diff --git a/doc/plan/humans-and-permissions.md b/doc/plan/humans-and-permissions.md index e31ca02c..611881c7 100644 --- a/doc/plan/humans-and-permissions.md +++ b/doc/plan/humans-and-permissions.md @@ -1,7 +1,7 @@ # Humans and Permissions Plan Status: Draft -Date: 2026-02-20 +Date: 2026-02-21 Owner: Server + UI + Shared + DB ## Goal @@ -19,15 +19,22 @@ Current V1 assumptions are centered on one board operator. We now need: - safe cloud deployment defaults (no accidental loginless production) - local mode that still feels instant (`npx paperclip run` and go) - agent-to-human task delegation, including a human inbox +- one user account with access to multiple companies in one deployment +- instance admins who can manage company access across the instance +- join approvals surfaced as actionable inbox alerts, not buried in admin-only pages +- a symmetric invite-and-approve onboarding path for both humans and agents +- one shared membership and permission model for both humans and agents ## Product constraints 1. Keep company scoping strict for every new table, endpoint, and permission check. 2. Preserve existing control-plane invariants: + - single-assignee task model - approval gates - budget hard-stop behavior - mutation activity logging + 3. Keep local mode easy and trusted, but prevent unsafe cloud posture. ## Deployment modes @@ -40,11 +47,13 @@ Behavior: - browser opens directly into board context - embedded DB and local storage defaults remain - a local implicit human actor exists for attribution +- local implicit actor has effective `instance_admin` authority for that instance +- full invite/approval/permission settings flows remain available in local mode (including agent enrollment) Guardrails: - server binds to loopback by default -- refuse startup if mode is `local_trusted` with non-loopback bind, unless explicit `--allow-unsafe-local-network` +- fail startup if mode is `local_trusted` with non-loopback bind - UI shows a persistent "Local trusted mode" badge ## Mode B: `cloud_hosted` @@ -52,6 +61,9 @@ Guardrails: Behavior: - login required for all human endpoints +- Better Auth for human auth +- initial auth method: email + password +- email verification is not required for initial release - hosted DB and remote deployment supported - multi-user sessions and role/permission enforcement @@ -61,6 +73,13 @@ Guardrails: - fail startup if insecure auth bypass flag is set - health payload includes mode and auth readiness +## Authentication choice + +- use Better Auth for human users +- start with email/password login only +- no email confirmation requirement in V1 +- keep implementation structured so social/SSO providers can be added later without changing membership/permission semantics + ## Auth and actor model Unify request actors into a single model: @@ -73,28 +92,78 @@ Rules: - in `cloud_hosted`, only `user` and `agent` are valid actors - in `local_trusted`, unauthenticated browser/API requests resolve to `local_board_implicit` +- `local_board_implicit` is authorized as an instance admin principal for local operations - all mutating actions continue writing `activity_log` with actor type/id +## First admin bootstrap + +Problem: + +- new cloud deployments need a safe, explicit first human admin path +- app cannot assume a pre-existing admin account +- `local_trusted` does not use bootstrap flow because implicit local instance admin already exists + +Bootstrap flow: + +1. If no `instance_admin` user exists for the deployment, instance is in `bootstrap_pending` state. +2. CLI command `pnpm paperclip auth bootstrap-ceo` creates a one-time CEO onboarding invite URL for that instance. +3. `pnpm paperclip onboard` runs this bootstrap check and prints the invite URL automatically when `bootstrap_pending`. +4. Visiting the app while `bootstrap_pending` shows a blocking setup page with the exact CLI command to run (`pnpm paperclip onboard`). +5. Accepting that CEO invite creates the first admin user and exits bootstrap mode. + +Security rules: + +- bootstrap invite is single-use, short-lived, and token-hash stored at rest +- only one active bootstrap invite at a time per instance (regeneration revokes prior token) +- bootstrap actions are audited in `activity_log` + ## Data model additions ## New tables 1. `users` + - identity record for human users (email-based) +- optional instance-level role field (or companion table) for admin rights 2. `company_memberships` -- `company_id`, `user_id`, status, role metadata -- stores effective permissions and optional org scope constraints + +- `company_id`, `principal_type` (`user | agent`), `principal_id` +- status (`pending | active | suspended`), role metadata +- stores effective access state for both humans and agents +- many-to-many: one principal can belong to multiple companies 3. `invites` -- `company_id`, invite email, token hash, expires_at, invited_by, revoked_at, accepted_at -- optional default permissions payload at invite time -4. `user_permission_grants` (or JSON grant blob in membership) +- `company_id`, `invite_type` (`company_join | bootstrap_ceo`), token hash, expires_at, invited_by, revoked_at, accepted_at +- one-time share link (no pre-bound invite email) +- `allowed_join_types` (`human | agent | both`) for `company_join` links +- optional defaults payload keyed by join type: + - human defaults: initial permissions/membership role + - agent defaults: proposed role/title/adapter defaults + +4. `principal_permission_grants` + +- `company_id`, `principal_type` (`user | agent`), `principal_id`, `permission_key` - explicit grants such as `agents:create` - includes scope payload for chain-of-command limits +- normalized table (not JSON blob) for auditable grant/revoke history + +5. `join_requests` + +- `invite_id`, `company_id`, `request_type` (`human | agent`) +- `status` (`pending_approval | approved | rejected`) +- common review metadata: + - `request_ip` + - `approved_by_user_id`, `approved_at`, `rejected_by_user_id`, `rejected_at` +- human request fields: + - `requesting_user_id`, `request_email_snapshot` +- agent request fields: + - `agent_name`, `adapter_type`, `capabilities`, `created_agent_id` nullable until approved +- each consumed invite creates exactly one join request record after join type is selected + +6. `issues` extension -5. `issues` extension - add `assignee_user_id` nullable - preserve single-assignee invariant with XOR check: - exactly zero or one of `assignee_agent_id` / `assignee_user_id` @@ -102,10 +171,21 @@ Rules: ## Compatibility - existing `created_by_user_id` / `author_user_id` fields remain and become fully active -- agent API key model remains unchanged, still company-scoped +- agent API keys remain auth credentials; membership + grants remain authorization source ## Permission model (initial set) +Principle: + +- humans and agents use the same membership + grant evaluation engine +- permission checks resolve against `(company_id, principal_type, principal_id)` for both actor types +- this avoids separate authz codepaths and keeps behavior consistent + +Role layers: + +- `instance_admin`: deployment-wide admin, can access/manage all companies and user-company access mapping +- `company_member`: company-scoped permissions only + Core grants: 1. `agents:create` @@ -113,11 +193,13 @@ Core grants: 3. `users:manage_permissions` 4. `tasks:assign` 5. `tasks:assign_scope` (org-constrained delegation) +6. `joins:approve` (approve/reject human and agent join requests) Additional behavioral rules: -- board-level users can manage all grants -- non-board users can only act within explicit grants +- instance admins can promote/demote instance admins and manage user access across companies +- board-level users can manage company grants inside companies they control +- non-admin principals can only act within explicit grants - assignment checks apply to both agent and human assignees ## Chain-of-command scope design @@ -137,18 +219,44 @@ Enforcement: ## Invite and signup flow -1. Authorized user creates invite with email + grants + optional expiry. +1. Authorized user creates one `company_join` invite share link with optional defaults + expiry. 2. System sends invite URL containing one-time token. -3. Invitee signs up/logs in. -4. Email on authenticated account must match invite email. -5. Accepting invite creates active `company_membership` and permission grants. -6. Inviter/admin can revoke invite before acceptance. +3. Invite landing page presents two paths: `Join as human` or `Join as agent` (subject to `allowed_join_types`). +4. Requester selects join path and submits required data. +5. Submission consumes token and creates a `pending_approval` join request (no access yet). +6. Join request captures review metadata: + +- human: authenticated email +- both: source IP +- agent: proposed agent metadata + +7. Company admin/instance admin reviews request and approves or rejects. +8. On approval: + +- human: activate `company_membership` and apply permission grants +- agent: create agent record and enable API-key claim flow + +9. Link is one-time and cannot be reused. +10. Inviter/admin can revoke invite before acceptance. Security rules: - store invite token hashed at rest - one-time use token with short expiry - all invite lifecycle events logged in `activity_log` +- pending users cannot read or mutate any company data until approved + +## Join approval inbox + +- join requests generate inbox alerts for eligible approvers (`joins:approve` or admin role) +- alerts appear in both: + - global/company inbox feed + - dedicated pending-approvals UI +- each alert includes approve/reject actions inline (no context switch required) +- alert payload must include: + - requester email when `request_type=human` + - source IP + - request type (`human | agent`) ## Human inbox and agent-to-human delegation @@ -158,22 +266,49 @@ Behavior: - humans see assigned tasks in inbox view (including in local trusted mode) - comment and status transitions follow same issue lifecycle guards +## Agent join path (via unified invite link) + +1. Authorized user shares one `company_join` invite link (with `allowed_join_types` including `agent`). +2. Agent operator opens link, chooses `Join as agent`, and submits join payload (name/role/adapter metadata). +3. System creates `pending_approval` agent join request and captures source IP. +4. Approver sees alert in inbox and approves or rejects. +5. On approval, server creates the agent record and mints a long-lived API key. +6. API key is shown exactly once via secure claim flow with explicit "save now" instruction. + +Long-lived token policy: + +- default to long-lived revocable API keys (hash stored at rest) +- show plaintext key once only +- support immediate revoke/regenerate from admin UI +- optionally add expirations/rotation policy later without changing join flow + API additions (proposed): -- `GET /companies/:companyId/inbox` (human actor scoped to self) +- `GET /companies/:companyId/inbox` (human actor scoped to self; includes task items + pending join approval alerts when authorized) - `POST /companies/:companyId/issues/:issueId/assign-user` - `POST /companies/:companyId/invites` -- `POST /invites/:token/accept` +- `GET /invites/:token` (invite landing payload with `allowed_join_types`) +- `POST /invites/:token/accept` (body includes `requestType=human|agent` and request metadata) - `POST /invites/:inviteId/revoke` -- `GET /companies/:companyId/members` -- `PATCH /companies/:companyId/members/:userId/permissions` +- `GET /companies/:companyId/join-requests?status=pending_approval&requestType=human|agent` +- `POST /companies/:companyId/join-requests/:requestId/approve` +- `POST /companies/:companyId/join-requests/:requestId/reject` +- `POST /join-requests/:requestId/claim-api-key` (approved agent requests only) +- `GET /companies/:companyId/members` (returns both human and agent principals) +- `PATCH /companies/:companyId/members/:memberId/permissions` +- `POST /admin/users/:userId/promote-instance-admin` +- `POST /admin/users/:userId/demote-instance-admin` +- `PUT /admin/users/:userId/company-access` (set accessible companies for a user) +- `GET /admin/users/:userId/company-access` ## Local mode UX policy - no login prompt or account setup required - local implicit board user is auto-provisioned for audit attribution -- invite/multi-user screens can be hidden or marked unavailable in local mode -- if operator wants collaboration, they must switch to `cloud_hosted` +- local operator can still use instance settings and company settings as effective instance admin +- invite, join approval, and permission-management UI is available in local mode +- agent onboarding is expected in local mode, including creating invite links and approving join requests +- public/untrusted network ingress is out of scope for V1 local mode ## Cloud agents in this model @@ -181,6 +316,16 @@ API additions (proposed): - same-company boundary checks remain mandatory - agent ability to assign human tasks is permission-gated, not implicit +## Instance settings surface + +This plan introduces instance-level concerns (for example bootstrap state, instance admins, invite defaults, and token policy). There is no dedicated UI surface today. + +V1 approach: + +- add a minimal `Instance Settings` page for instance admins +- expose key instance settings in API + CLI (`paperclip configure` / `paperclip onboard`) +- show read-only instance status indicators in the main UI until full settings UX exists + ## Implementation phases ## Phase 1: Mode and guardrails @@ -188,47 +333,89 @@ API additions (proposed): - add explicit deployment mode config (`local_trusted | cloud_hosted`) - enforce startup safety checks and health visibility - implement actor resolution for local implicit board +- map local implicit board actor to instance-admin authorization context +- add bootstrap status signal in health/config surface (`ready | bootstrap_pending`) +- add minimal instance settings API/CLI surface and read-only UI indicators ## Phase 2: Human identity and memberships - add schema + migrations for users/memberships/invites - wire auth middleware for cloud mode - add membership lookup and company access checks +- implement Better Auth email/password flow (no email verification) +- implement first-admin bootstrap invite command and onboard integration +- implement one-time share-link invite acceptance flow with `pending_approval` join requests ## Phase 3: Permissions and assignment scope -- add grant model and enforcement helpers +- add shared principal grant model and enforcement helpers - add chain-of-command scope checks for assignment APIs - add tests for forbidden assignment (for example, cannot assign to CEO) +- add instance-admin promotion/demotion and global company-access management APIs +- add `joins:approve` permission checks for human and agent join approvals ## Phase 4: Invite workflow -- invite create/send/accept/revoke endpoints -- email-match enforcement and token security -- UI for invite management and membership permissions +- unified `company_join` invite create/landing/accept/revoke endpoints +- join request approve/reject endpoints with review metadata (email when applicable, IP) +- one-time token security and revocation semantics +- UI for invite management, pending join approvals, and membership permissions +- inbox alert generation for pending join requests +- ensure invite and approval UX is enabled in both `cloud_hosted` and `local_trusted` ## Phase 5: Human inbox + task assignment updates - extend issue assignee model for human users -- inbox API and UI +- inbox API and UI for: + - task assignments + - pending join approval alerts with inline approve/reject actions - agent-to-human assignment flow with policy checks +## Phase 6: Agent self-join and token claim + +- add agent join path on unified invite landing page +- capture agent join requests and admin approval flow +- create one-time API-key claim flow after approval (display once) + ## Acceptance criteria 1. `local_trusted` starts with no login and shows board UI immediately. -2. `cloud_hosted` cannot start without auth configured. -3. No request in `cloud_hosted` can mutate data without authenticated actor. -4. Humans can be invited by email, accepted with matching email, and revoked. -5. Permissions can be granted/revoked per company member. -6. Assignment scope prevents out-of-hierarchy or protected-role assignments. -7. Agents can assign tasks to humans only when allowed. -8. Humans can view assigned tasks in inbox and act on them per permissions. -9. All new mutations are company-scoped and logged in `activity_log`. +2. `local_trusted` does not expose optional human login UX in V1. +3. `local_trusted` local implicit actor can manage instance settings, invite links, join approvals, and permission grants. +4. `cloud_hosted` cannot start without auth configured. +5. No request in `cloud_hosted` can mutate data without authenticated actor. +6. If no initial admin exists, app shows bootstrap instructions with CLI command. +7. `pnpm paperclip onboard` outputs a CEO onboarding invite URL when bootstrap is pending. +8. One `company_join` link supports both human and agent onboarding via join-type selection on the invite landing page. +9. Invite delivery in V1 is copy-link only (no built-in email delivery). +10. Share-link acceptance creates a pending join request; it does not grant immediate access. +11. Pending join requests appear as inbox alerts with inline approve/reject actions. +12. Admin review view includes join metadata before decision (human email when applicable, source IP, and agent metadata for agent requests). +13. Only approved join requests unlock access: -## Open decisions +- human: active company membership + permission grants +- agent: agent creation + API-key claim eligibility -1. Auth provider choice for cloud mode (Auth.js vs hosted provider). -2. Whether local mode supports optional login in addition to implicit board. -3. Exact representation of permission grants (normalized table vs JSON schema). -4. Whether a user can belong to multiple companies in initial release. -5. Whether invite email delivery is built-in or webhook/provider integration only. +14. Agent enrollment follows the same link -> pending approval -> approve flow. +15. Approved agents can claim a long-lived API key exactly once, with plaintext display-once semantics. +16. Agent API keys are indefinite by default in V1 and revocable/regenerable by admins. +17. Public/untrusted ingress for `local_trusted` is not supported in V1 (loopback-only local server). +18. One user can hold memberships in multiple companies. +19. Instance admins can promote another user to instance admin. +20. Instance admins can manage which companies each user can access. +21. Permissions can be granted/revoked per member principal (human or agent) through one shared grant system. +22. Assignment scope prevents out-of-hierarchy or protected-role assignments. +23. Agents can assign tasks to humans only when allowed. +24. Humans can view assigned tasks in inbox and act on them per permissions. +25. All new mutations are company-scoped and logged in `activity_log`. + +## V1 decisions (locked) + +1. `local_trusted` will not support login UX in V1; implicit local board actor only. +2. Permissions use a normalized shared table: `principal_permission_grants` with scoped grants. +3. Invite delivery is copy-link only in V1 (no built-in email sending). +4. Bootstrap invite creation should require local shell access only (CLI path only, no HTTP bootstrap endpoint). +5. Approval review shows source IP only; no GeoIP/country lookup in V1. +6. Agent API-key lifetime is indefinite by default in V1, with explicit revoke/regenerate controls. +7. Local mode keeps full admin/settings/invite capabilities through the implicit local instance-admin actor. +8. Public/untrusted ingress for local mode is out of scope for V1; no `--dangerous-agent-ingress` in V1. diff --git a/doc/plans/cursor-cloud-adapter.md b/doc/plans/cursor-cloud-adapter.md new file mode 100644 index 00000000..72f6aebf --- /dev/null +++ b/doc/plans/cursor-cloud-adapter.md @@ -0,0 +1,477 @@ +# Cursor Cloud Agent Adapter — Technical Plan + +## Overview + +This document defines the V1 design for a Paperclip adapter that integrates with +Cursor Background Agents via the Cursor REST API. + +Primary references: + +- https://docs.cursor.com/background-agent/api/overview +- https://docs.cursor.com/background-agent/api +- https://docs.cursor.com/background-agent/api/webhooks + +Unlike `claude_local` and `codex_local`, this adapter is not a local subprocess. +It is a remote orchestration adapter with: + +1. launch/follow-up over HTTP +2. webhook-driven status updates when possible +3. polling fallback for reliability +4. synthesized stdout events for Paperclip UI/CLI + +## Key V1 Decisions + +1. **Auth to Cursor API** uses `Authorization: Bearer `. +2. **Callback URL** must be publicly reachable by Cursor VMs: + - local: Tailscale URL + - prod: public server URL +3. **Agent callback auth to Paperclip** uses a bootstrap exchange flow (no long-lived Paperclip key in prompt). +4. **Webhooks are V1**, polling remains fallback. +5. **Skill delivery** is fetch-on-demand from Paperclip endpoints, not full SKILL.md prompt injection. + +--- + +## Cursor API Reference (Current) + +Base URL: `https://api.cursor.com` + +Authentication header: + +- `Authorization: Bearer ` + +Core endpoints: + +| Endpoint | Method | Purpose | +|---|---|---| +| `/v0/agents` | POST | Launch agent | +| `/v0/agents/{id}` | GET | Agent status | +| `/v0/agents/{id}/conversation` | GET | Conversation history | +| `/v0/agents/{id}/followup` | POST | Follow-up prompt | +| `/v0/agents/{id}/stop` | POST | Stop/pause running agent | +| `/v0/models` | GET | Recommended model list | +| `/v0/me` | GET | API key metadata | +| `/v0/repositories` | GET | Accessible repos (strictly rate-limited) | + +Status handling policy for adapter: + +- Treat `CREATING` and `RUNNING` as non-terminal. +- Treat `FINISHED` as success terminal. +- Treat `ERROR` as failure terminal. +- Treat unknown non-active statuses as terminal failure and preserve raw status in `resultJson`. + +Webhook facts relevant to V1: + +- Cursor emits `statusChange` webhooks. +- Terminal webhook statuses include `ERROR` and `FINISHED`. +- Webhook signatures use HMAC SHA256 (`X-Webhook-Signature: sha256=...`). + +Operational limits: + +- `/v0/repositories`: 1 req/user/min, 30 req/user/hour. +- MCP not supported in Cursor background agents. + +--- + +## Package Structure + +``` +packages/adapters/cursor-cloud/ +├── package.json +├── tsconfig.json +└── src/ + ├── index.ts + ├── api.ts + ├── server/ + │ ├── index.ts + │ ├── execute.ts + │ ├── parse.ts + │ ├── test.ts + │ └── webhook.ts + ├── ui/ + │ ├── index.ts + │ ├── parse-stdout.ts + │ └── build-config.ts + └── cli/ + ├── index.ts + └── format-event.ts +``` + +`package.json` uses standard four exports (`.`, `./server`, `./ui`, `./cli`). + +--- + +## API Client (`src/api.ts`) + +`src/api.ts` is a typed wrapper over Cursor endpoints. + +```ts +interface CursorClientConfig { + apiKey: string; + baseUrl?: string; // default https://api.cursor.com +} + +interface CursorAgent { + id: string; + name: string; + status: "CREATING" | "RUNNING" | "FINISHED" | "ERROR" | string; + source: { repository: string; ref: string }; + target: { + branchName?: string; + prUrl?: string; + url?: string; + autoCreatePr?: boolean; + openAsCursorGithubApp?: boolean; + skipReviewerRequest?: boolean; + }; + summary?: string; + createdAt: string; +} +``` + +Client requirements: + +- send `Authorization: Bearer ...` on all requests +- throw typed `CursorApiError` with `status`, parsed body, and request context +- preserve raw unknown fields for debugging in error metadata + +--- + +## Adapter Config Contract (`src/index.ts`) + +```ts +export const type = "cursor_cloud"; +export const label = "Cursor Cloud Agent"; +``` + +V1 config fields: + +- `repository` (required): GitHub repo URL +- `ref` (optional, default `main`) +- `model` (optional, allow empty = auto) +- `autoCreatePr` (optional, default `false`) +- `branchName` (optional) +- `promptTemplate` / `bootstrapPromptTemplate` +- `pollIntervalSec` (optional, default `10`) +- `timeoutSec` (optional, default `0`) +- `graceSec` (optional, default `20`) +- `paperclipPublicUrl` (optional override; else `PAPERCLIP_PUBLIC_URL` env) +- `enableWebhooks` (optional, default `true`) +- `env.CURSOR_API_KEY` (required, secret_ref preferred) +- `env.CURSOR_WEBHOOK_SECRET` (required if `enableWebhooks=true`, min 32) + +Important: do not store Cursor key in plain `apiKey` top-level field. +Use `adapterConfig.env` so secret references are supported by existing secret-resolution flow. + +--- + +## Paperclip Callback + Auth Flow (V1) + +Cursor agents run remotely, so we cannot inject local env like `PAPERCLIP_API_KEY`. + +### Public URL + +The adapter must resolve a callback base URL in this order: + +1. `adapterConfig.paperclipPublicUrl` +2. `process.env.PAPERCLIP_PUBLIC_URL` + +If empty, fail `testEnvironment` and runtime execution with a clear error. + +### Bootstrap Exchange + +Goal: avoid putting long-lived Paperclip credentials in prompt text. + +Flow: + +1. Before launch/follow-up, Paperclip mints a one-time bootstrap token bound to: + - `agentId` + - `companyId` + - `runId` + - short TTL (for example 10 minutes) +2. Adapter includes only: + - `paperclipPublicUrl` + - exchange endpoint path + - bootstrap token +3. Cursor agent calls: + - `POST /api/agent-auth/exchange` +4. Paperclip validates bootstrap token and returns a run-scoped bearer JWT. +5. Cursor agent uses returned bearer token for all Paperclip API calls. + +This keeps long-lived keys out of prompt and supports clean revocation by TTL. + +--- + +## Skills Delivery Strategy (V1) + +Do not inline full SKILL.md content into the prompt. + +Instead: + +1. Prompt includes a compact instruction to fetch skills from Paperclip. +2. After auth exchange, agent fetches: + - `GET /api/skills/index` + - `GET /api/skills/paperclip` + - `GET /api/skills/paperclip-create-agent` when needed +3. Agent loads full skill content on demand. + +Benefits: + +- avoids prompt bloat +- keeps skill docs centrally updatable +- aligns with how local adapters expose skills as discoverable procedures + +--- + +## Execution Flow (`src/server/execute.ts`) + +### Step 1: Resolve Config and Secrets + +- parse adapter config via `asString/asBoolean/asNumber/parseObject` +- resolve `env.CURSOR_API_KEY` +- resolve `paperclipPublicUrl` +- validate webhook secret when webhooks enabled + +### Step 2: Session Resolution + +Session identity is Cursor `agentId` (stored in `sessionParams`). +Reuse only when repository matches. + +### Step 3: Render Prompt + +Render template as usual, then append a compact callback block: + +- public Paperclip URL +- bootstrap exchange endpoint +- bootstrap token +- skill index endpoint +- required run header behavior + +### Step 4: Launch/Follow-up + +- on resume: `POST /followup` +- else: `POST /agents` +- include webhook object when enabled: + - `url: /api/adapters/cursor-cloud/webhooks` + - `secret: CURSOR_WEBHOOK_SECRET` + +### Step 5: Progress + Completion + +Use hybrid strategy: + +- webhook events are primary status signal +- polling is fallback and transcript source (`/conversation`) + +Emit synthetic events to stdout (`init`, `status`, `assistant`, `user`, `result`). + +Completion logic: + +- success: `status === FINISHED` +- failure: `status === ERROR` or unknown terminal +- timeout: stop agent, mark timedOut + +### Step 6: Result Mapping + +`AdapterExecutionResult`: + +- `exitCode: 0` on success, `1` on terminal failure +- `errorMessage` populated on failure/timeout +- `sessionParams: { agentId, repository }` +- `provider: "cursor"` +- `usage` and `costUsd`: unavailable/null +- `resultJson`: include raw status/target/conversation snapshot + +Also ensure `result` event is emitted to stdout before return. + +--- + +## Webhook Handling (`src/server/webhook.ts` + server route) + +Add a server endpoint to receive Cursor webhook deliveries. + +Responsibilities: + +1. Verify HMAC signature from `X-Webhook-Signature`. +2. Deduplicate by `X-Webhook-ID`. +3. Validate event type (`statusChange`). +4. Route by Cursor `agentId` to active Paperclip run context. +5. Append `heartbeat_run_events` entries for audit/debug. +6. Update in-memory run signal so execute loop can short-circuit quickly. + +Security: + +- reject invalid signature (`401`) +- reject malformed payload (`400`) +- always return quickly after persistence (`2xx`) + +--- + +## Environment Test (`src/server/test.ts`) + +Checks: + +1. `CURSOR_API_KEY` present +2. key validity via `GET /v0/me` +3. repository configured and URL shape valid +4. model exists (if set) via `/v0/models` +5. `paperclipPublicUrl` present and reachable shape-valid +6. webhook secret present/length-valid when webhooks enabled + +Repository-access verification via `/v0/repositories` should be optional due strict rate limits. +Use a warning-level check only when an explicit `verifyRepositoryAccess` option is set. + +--- + +## UI + CLI + +### UI parser (`src/ui/parse-stdout.ts`) + +Handle event types: + +- `init` +- `status` +- `assistant` +- `user` +- `result` +- fallback `stdout` + +On failure results, set `isError=true` and include error text. + +### Config builder (`src/ui/build-config.ts`) + +- map `CreateConfigValues.url -> repository` +- preserve env binding shape (`plain`/`secret_ref`) +- include defaults (`pollIntervalSec`, `timeoutSec`, `graceSec`, `enableWebhooks`) + +### Adapter fields (`ui/src/adapters/cursor-cloud/config-fields.tsx`) + +Add controls for: + +- repository +- ref +- model +- autoCreatePr +- branchName +- poll interval +- timeout/grace +- paperclip public URL override +- enable webhooks +- env bindings for `CURSOR_API_KEY` and `CURSOR_WEBHOOK_SECRET` + +### CLI formatter (`src/cli/format-event.ts`) + +Format synthetic events similarly to local adapters. +Highlight terminal failures clearly. + +--- + +## Server Registration and Cross-Layer Contract Sync + +### Adapter registration + +- `server/src/adapters/registry.ts` +- `ui/src/adapters/registry.ts` +- `cli/src/adapters/registry.ts` + +### Shared contract updates (required) + +- add `cursor_cloud` to `packages/shared/src/constants.ts` (`AGENT_ADAPTER_TYPES`) +- ensure validators accept it (`packages/shared/src/validators/agent.ts`) +- update UI labels/maps where adapter names are enumerated, including: + - `ui/src/components/agent-config-primitives.tsx` + - `ui/src/components/AgentProperties.tsx` + - `ui/src/pages/Agents.tsx` +- consider onboarding wizard support for adapter selection (`ui/src/components/OnboardingWizard.tsx`) + +Without these updates, create/edit flows will reject the new adapter even if package code exists. + +--- + +## Cancellation Semantics + +Long-polling HTTP adapters must support run cancellation. + +V1 requirement: + +- register a cancellation handler per running adapter invocation +- `cancelRun` should invoke that handler (abort fetch/poll loop + optional Cursor stop call) + +Current process-only cancellation maps are insufficient by themselves for Cursor. + +--- + +## Comparison with `claude_local` + +| Aspect | `claude_local` | `cursor_cloud` | +|---|---|---| +| Execution model | local subprocess | remote API | +| Updates | stream-json stdout | webhook + polling + synthesized stdout | +| Session id | Claude session id | Cursor agent id | +| Skill delivery | local skill dir injection | authenticated fetch from Paperclip skill endpoints | +| Paperclip auth | injected local run JWT env var | bootstrap token exchange -> run JWT | +| Cancellation | OS signals | abort polling + Cursor stop endpoint | +| Usage/cost | rich | not exposed by Cursor API | + +--- + +## V1 Limitations + +1. Cursor does not expose token/cost usage in API responses. +2. Conversation stream is text-only (`user_message`/`assistant_message`). +3. MCP/tool-call granularity is unavailable. +4. Webhooks currently deliver status-change events, not full transcript deltas. + +--- + +## Future Enhancements + +1. Reduce polling frequency further when webhook reliability is high. +2. Attach image payloads from Paperclip context. +3. Add richer PR metadata surfacing in Paperclip UI. +4. Add webhook replay UI for debugging. + +--- + +## Implementation Checklist + +### Adapter package + +- [ ] `packages/adapters/cursor-cloud/package.json` exports wired +- [ ] `packages/adapters/cursor-cloud/tsconfig.json` +- [ ] `src/index.ts` metadata + configuration doc +- [ ] `src/api.ts` bearer-auth client + typed errors +- [ ] `src/server/execute.ts` hybrid webhook/poll orchestration +- [ ] `src/server/parse.ts` stream parser + not-found detection +- [ ] `src/server/test.ts` env diagnostics +- [ ] `src/server/webhook.ts` signature verification + payload helpers +- [ ] `src/server/index.ts` exports + session codec +- [ ] `src/ui/parse-stdout.ts` +- [ ] `src/ui/build-config.ts` +- [ ] `src/ui/index.ts` +- [ ] `src/cli/format-event.ts` +- [ ] `src/cli/index.ts` + +### App integration + +- [ ] register adapter in server/ui/cli registries +- [ ] add `cursor_cloud` to shared adapter constants/validators +- [ ] add adapter labels in UI surfaces +- [ ] add Cursor webhook route on server (`/api/adapters/cursor-cloud/webhooks`) +- [ ] add auth exchange route (`/api/agent-auth/exchange`) +- [ ] add skill serving routes (`/api/skills/index`, `/api/skills/:name`) +- [ ] add generic cancellation hook for non-subprocess adapters + +### Tests + +- [ ] api client auth/error mapping +- [ ] terminal status mapping (`FINISHED`, `ERROR`, unknown terminal) +- [ ] session codec round-trip +- [ ] config builder env binding handling +- [ ] webhook signature verification + dedupe +- [ ] bootstrap exchange happy path + expired/invalid token + +### Verification + +- [ ] `pnpm -r typecheck` +- [ ] `pnpm test:run` +- [ ] `pnpm build` + diff --git a/doc/plans/deployment-auth-mode-consolidation.md b/doc/plans/deployment-auth-mode-consolidation.md new file mode 100644 index 00000000..c1cd2e99 --- /dev/null +++ b/doc/plans/deployment-auth-mode-consolidation.md @@ -0,0 +1,226 @@ +# Deployment/Auth Mode Consolidation Plan + +Status: Proposal +Owner: Server + CLI + UI +Date: 2026-02-23 + +## Goal + +Keep Paperclip low-friction while making the mode model simpler and safer: + +1. `local_trusted` remains the default and easiest path. +2. one authenticated runtime mode supports both private-network local use and public cloud use. +3. onboarding/configure/doctor stay primarily interactive and flagless. +4. Board identity is represented by a real user row in the database, with explicit role/membership integration points. + +## Product Constraints (From Review) + +1. `onboard` default flow is interactive (no flags required). +2. first mode choice defaults to `local_trusted`, with clear UX copy. +3. authenticated flow gives guidance for private vs public exposure. +4. `doctor` should also be flagless by default (read config and evaluate the selected mode/profile). +5. do not add backward-compatibility alias layers for abandoned mode names. +6. plan must explicitly cover how users/Board are represented in DB and how that affects task assignment and permissions. + +## Current Implementation Audit (As Of 2026-02-23) + +## Runtime/Auth + +- Runtime deployment modes are currently `local_trusted | cloud_hosted` (`packages/shared/src/constants.ts`). +- `local_trusted` actor is currently synthetic: + - `req.actor = { type: "board", userId: "local-board", source: "local_implicit" }` (`server/src/middleware/auth.ts`). + - this is not a real auth user row by default. +- `cloud_hosted` uses Better Auth sessions and `authUsers` rows (`server/src/auth/better-auth.ts`, `packages/db/src/schema/auth.ts`). + +## Bootstrap/Admin + +- `cloud_hosted` requires `BETTER_AUTH_SECRET` and reports bootstrap status from `instance_user_roles` (`server/src/index.ts`, `server/src/routes/health.ts`). +- bootstrap invite acceptance promotes the signed-in user to `instance_admin` (`server/src/routes/access.ts`, `server/src/services/access.ts`). + +## Membership/Assignment Integration + +- User task assignment requires active `company_memberships` entry for that user (`server/src/services/issues.ts`). +- Local implicit board identity is not automatically a real membership principal; this is a gap for “board as assignable user” semantics. + +## Proposed Runtime Model + +## Modes + +1. `local_trusted` +- no login required +- localhost/loopback only +- optimized for single-operator local setup + +2. `authenticated` +- login required for human actions +- same auth stack for both private and public deployments + +## Exposure Policy (Within `authenticated`) + +1. `private` +- private-network deployments (LAN, VPN, Tailscale) +- low-friction URL handling (`auto` base URL) +- strict host allow policy for private targets + +2. `public` +- internet-facing deployments +- explicit public base URL required +- stricter deployment checks in doctor + +This is one authenticated mode with two safety policies, not two different auth systems. + +## UX Contract + +## Onboard (Primary Path: Interactive) + +Default command remains: + +```sh +pnpm paperclip onboard +``` + +Interactive server step: + +1. ask mode with default selection `local_trusted` +2. copy for options: +- `local_trusted`: "Easiest for local setup (no login, localhost-only)" +- `authenticated`: "Login required; use for private network or public hosting" +3. if `authenticated`, ask exposure: +- `private`: "Private network access (for example Tailscale), lower setup friction" +- `public`: "Internet-facing deployment, stricter security requirements" +4. only if `authenticated + public`, ask for explicit public URL + +Flags are optional power-user overrides, not required for normal setup. + +## Configure + +Default command remains interactive: + +```sh +pnpm paperclip configure --section server +``` + +Same mode/exposure questions and defaults as onboarding. + +## Doctor + +Default command remains flagless: + +```sh +pnpm paperclip doctor +``` + +Doctor reads configured mode/exposure and applies relevant checks. +Optional flags may exist for override/testing, but are not required for normal operation. + +## Board/User Data Model Integration (Required) + +## Requirement + +Board must be a real DB user principal so user-centric features (task assignment, membership, audit identity) work consistently. + +## Target Behavior + +1. `local_trusted` +- seed/ensure a deterministic local board user row in `authUsers` during setup/startup. +- actor middleware uses that real user id instead of synthetic-only identity. +- ensure: + - `instance_user_roles` includes `instance_admin` for this user. + - company membership can be created/maintained for this user where needed. + +2. `authenticated` +- Better Auth sign-up creates user row. +- bootstrap/admin flow promotes that real user to `instance_admin`. +- first company creation flow should ensure creator membership is active. + +## Why This Matters + +- `assigneeUserId` validation checks company membership. +- without a real board user + membership path, assigning tasks to board user is inconsistent. + +## Configuration Contract (Target) + +- `server.mode`: `local_trusted | authenticated` +- `server.exposure`: `private | public` (required when mode is `authenticated`) +- `auth.baseUrlMode`: `auto | explicit` +- `auth.publicBaseUrl`: required when `authenticated + public` + +No compatibility aliases for discarded naming variants. + +## No Backward-Compatibility Layer + +This change is a clean cut: + +- remove use of old split terminology in code and prompts. +- config schema uses only canonical fields/values above. +- existing dev instances can rerun onboarding or update config once. + +## Implementation Phases + +## Phase 1: Shared Schema + Config Surface + +- `packages/shared/src/constants.ts`: define canonical mode/exposure constants. +- `packages/shared/src/config-schema.ts`: add mode/exposure/auth URL fields. +- `server/src/config.ts` and CLI config types: consume canonical fields only. + +## Phase 2: CLI Interactive UX + +- `cli/src/prompts/server.ts`: implement defaulted mode prompt and authenticated exposure guidance copy. +- `cli/src/commands/onboard.ts`: keep interactive-first flow; optional overrides only. +- `cli/src/commands/configure.ts`: same behavior for server section. +- `cli/src/commands/doctor.ts`: mode-aware checks from config, flagless default flow. + +## Phase 3: Runtime/Auth Policy + +- `server/src/index.ts`: enforce mode-specific startup constraints. +- `server/src/auth/better-auth.ts`: implement `auto` vs `explicit` base URL behavior. +- host/origin trust helper for `authenticated + private`. + +## Phase 4: Board Principal Integration + +- add ensure-board-user startup/setup step: + - real local board user row + - instance admin role row +- ensure first-company creation path grants creator membership. +- remove synthetic-only assumptions where they break user assignment/membership semantics. + +## Phase 5: UI + Docs + +- update UI labels/help text around mode and exposure guidance. +- update docs: + - `doc/DEPLOYMENT-MODES.md` + - `doc/DEVELOPING.md` + - `doc/CLI.md` + - `doc/SPEC-implementation.md` + +## Test Plan + +- config schema tests for canonical mode/exposure/auth fields. +- CLI prompt tests for default interactive selections and copy. +- doctor tests by mode/exposure. +- runtime tests: + - authenticated/private works without explicit URL + - authenticated/public requires explicit URL + - private host policy rejects untrusted hosts +- Board principal tests: + - local_trusted board user exists as real DB user + - board can be assigned tasks via `assigneeUserId` after membership setup + - creator membership behavior for authenticated flows + +## Acceptance Criteria + +1. `pnpm paperclip onboard` is interactive-first and defaults to `local_trusted`. +2. authenticated mode is one runtime mode with `private/public` exposure guidance. +3. `pnpm paperclip doctor` works flagless with mode-aware checks. +4. no extra compatibility aliases for dropped naming variants. +5. Board identity is represented by real DB user/role/membership integration points, enabling consistent task assignment and permission behavior. + +## Verification Gate + +Before merge: + +```sh +pnpm -r typecheck +pnpm test:run +pnpm build +```