Move plans from doc/plan/ into doc/plans/ and add YYYY-MM-DD date prefixes to all undated plan files based on document headers or earliest git commit dates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
16 KiB
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
- Two deployment modes remain:
local_trustedcloud_hosted
local_trusted:
- no login UX
- implicit local instance admin actor
- loopback-only server binding
- full admin/settings/invite/approval capabilities available locally
cloud_hosted:
- Better Auth for humans
- email/password only
- no email verification requirement in V1
- Permissions:
- one shared authorization system for humans and agents
- normalized grants table (
principal_permission_grants) - no separate “agent permissions engine”
- Invites:
- copy-link only (no outbound email sending in V1)
- unified
company_joinlink that supports human or agent path - acceptance creates
pending_approvaljoin request - no access until admin approval
- Join review metadata:
- source IP required
- no GeoIP/country lookup in V1
- Agent API keys:
- indefinite by default
- hash at rest
- display once on claim
- revoke/regenerate supported
- Local ingress:
- public/untrusted ingress is out of scope for V1
- no
--dangerous-agent-ingressin V1
3. Current baseline and delta
Current baseline (repo as of this doc):
- server actor model defaults to
boardinserver/src/middleware/auth.ts - authorization is mostly
assertBoard+ company check inserver/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 loopbackcloud_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.actorshape 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:
useragent
Role layers:
instance_admin(instance-wide)- company-scoped grants via
principal_permission_grants
Evaluation order:
- resolve principal from actor
- resolve instance role (
instance_adminshort-circuit for admin-only actions) - resolve company membership (
activerequired for company access) - resolve grant + scope for requested action
5. Data model
5.1 Better Auth tables
Managed by Better Auth adapter/migrations (expected minimum):
usersessionaccountverification
Note:
- use Better Auth canonical table names/types to avoid custom forks
5.2 New Paperclip tables
instance_user_roles
iduuid pkuser_idtext not nullroletext not null (instance_admin)created_at,updated_at- unique index:
(user_id, role)
company_memberships
iduuid pkcompany_iduuid fkcompanies.idnot nullprincipal_typetext not null (user | agent)principal_idtext not nullstatustext not null (pending | active | suspended)membership_roletext nullcreated_at,updated_at- unique index:
(company_id, principal_type, principal_id) - index:
(principal_type, principal_id, status)
principal_permission_grants
iduuid pkcompany_iduuid fkcompanies.idnot nullprincipal_typetext not null (user | agent)principal_idtext not nullpermission_keytext not nullscopejsonb nullgranted_by_user_idtext nullcreated_at,updated_at- unique index:
(company_id, principal_type, principal_id, permission_key) - index:
(company_id, permission_key)
invites
iduuid pkcompany_iduuid fkcompanies.idnot nullinvite_typetext not null (company_join | bootstrap_ceo)token_hashtext not nullallowed_join_typestext not null (human | agent | both) forcompany_joindefaults_payloadjsonb nullexpires_attimestamptz not nullinvited_by_user_idtext nullrevoked_attimestamptz nullaccepted_attimestamptz nullcreated_attimestamptz not null default now()- unique index:
(token_hash) - index:
(company_id, invite_type, revoked_at, expires_at)
join_requests
iduuid pkinvite_iduuid fkinvites.idnot nullcompany_iduuid fkcompanies.idnot nullrequest_typetext not null (human | agent)statustext not null (pending_approval | approved | rejected)request_iptext not nullrequesting_user_idtext nullrequest_email_snapshottext nullagent_nametext nulladapter_typetext nullcapabilitiestext nullagent_defaults_payloadjsonb nullcreated_agent_iduuid fkagents.idnullapproved_by_user_idtext nullapproved_attimestamptz nullrejected_by_user_idtext nullrejected_attimestamptz nullcreated_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
issues
- add
assignee_user_idtext null - enforce single-assignee invariant:
- at most one of
assignee_agent_idandassignee_user_idis non-null
- at most one of
agents
- keep existing
permissionsJSON for transition only - mark as deprecated in code path once principal grants are live
5.4 Migration strategy
Migration ordering:
- add new tables/columns/indexes
- 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
- switch authz reads to new tables
- remove legacy board-only checks
6. API contract (new/changed)
All under /api.
6.1 Health
GET /api/health response additions:
deploymentModeauthReadybootstrapStatus(ready | bootstrap_pending)
6.2 Invites
POST /api/companies/:companyId/invites
- create
company_joininvite - copy-link value returned once
GET /api/invites/:token
- validate token
- return invite landing payload
- includes
allowedJoinTypes
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)
POST /api/invites/:inviteId/revoke
- revokes non-consumed invite
6.3 Join requests
-
GET /api/companies/:companyId/join-requests?status=pending_approval&requestType=... -
POST /api/companies/:companyId/join-requests/:requestId/approve
- human:
- create/activate
company_memberships - apply default grants
- create/activate
- agent:
- create
agentsrow - create pending claim context for API key
- create/activate agent membership
- apply default grants
- create
-
POST /api/companies/:companyId/join-requests/:requestId/reject -
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
GET /api/companies/:companyId/members
- returns both principal types
PATCH /api/companies/:companyId/members/:memberId/permissions
- upsert/remove grants
PUT /api/admin/users/:userId/company-access
- instance admin only
-
GET /api/admin/users/:userId/company-access -
POST /api/admin/users/:userId/promote-instance-admin -
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.tsserver/src/config.tsserver/src/index.tsserver/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.tsserver/src/routes/authz.tsserver/src/middleware/board-mutation-guard.ts
Changes:
- stop defaulting every request to board in cloud mode
- map local requests to
local_implicit_adminactor in local mode - map Better Auth session to
useractor in cloud mode - preserve agent bearer path
- replace
assertBoardwith permission-oriented helpers:requireInstanceAdmin(req)requireCompanyAccess(req, companyId)requireCompanyPermission(req, companyId, permissionKey, scope?)
7.4 Authorization services
Files:
server/src/services(new modules)memberships.tspermissions.tsinvites.tsjoin-requests.tsinstance-admin.ts
Changes:
- centralized permission evaluation
- centralized membership resolution
- one place for principal-type branching
7.5 Routes
Files:
server/src/routes/index.tsand new route modules:auth.ts(if needed)invites.tsjoin-requests.tsmembers.tsinstance-admin.tsinbox.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.createdinvite.revokedjoin.requestedjoin.approvedjoin.rejectedmembership.activatedpermission.grantedpermission.revokedinstance_admin.promotedinstance_admin.demotedagent_api_key.claimedagent_api_key.revoked
7.7 Real-time and inbox propagation
Files:
server/src/services/live-events.tsserver/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.tscli/src/commands/onboard.tscli/src/commands/configure.tscli/src/prompts/server.ts
Commands:
paperclipai auth bootstrap-ceo
- create bootstrap invite
- print one-time URL
paperclipai 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)BootstrapPendingpageInviteLandingpageInstanceSettingspage- join approval components in
Inbox - member/grant management in company settings
Required UX:
- Cloud unauthenticated user:
- redirect to login/signup
- Cloud bootstrap pending:
- block app with setup command guidance
- Invite landing:
- choose human vs agent path (respect
allowedJoinTypes) - submit join request
- show pending approval confirmation
- Inbox:
- show join approval cards with approve/reject actions
- include source IP and human email snapshot when applicable
- Local mode:
- no login prompts
- full settings/invite/approval UI available
10. Security controls
- Token handling
- invite tokens hashed at rest
- API keys hashed at rest
- one-time plaintext key reveal only
- Local mode isolation
- loopback bind enforcement
- startup hard-fail on non-loopback host
- Cloud auth
- no implicit board fallback
- session auth mandatory for human mutations
- Join workflow hardening
- one request per invite token
- pending request has no data access
- approval required before membership activation
- 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
assertBoardcalls only after permission helpers cover all routes
11.2 Data compatibility
- do not delete
agents.permissionsin V1 - stop reading it once grants are wired
- remove in post-V1 cleanup migration
11.3 Better Auth user ID handling
- treat
user.idas text end-to-end - existing
created_by_user_idand 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:
pnpm -r typecheck
pnpm test:run
pnpm build
If any command is skipped, record exactly what was skipped and why.
15. Done criteria
- Behavior matches locked V1 decisions in this doc and
doc/plan/humans-and-permissions.md. - Cloud mode requires auth; local mode has no login UX.
- Unified invite + pending approval flow works for both humans and agents.
- Shared principal membership + permission system is live for users and agents.
- Local mode remains loopback-only and fails otherwise.
- Inbox shows actionable join approvals.
- All new mutating paths are activity-logged.