doc/plans/2026-02-21-humans-and-permissions-implementation.md
Status: Draft
Date: 2026-02-21
Owners: Server + UI + CLI + DB + Shared
Companion plan: doc/plan/humans-and-permissions.md
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.
local_trustedcloud_hostedlocal_trusted:cloud_hosted:principal_permission_grants)company_join link that supports human or agent pathpending_approval join request--dangerous-agent-ingress in V1Current baseline (repo as of this doc):
board in server/src/middleware/auth.tsassertBoard + company check in server/src/routes/authz.tsRequired delta:
Add explicit runtime mode:
deployment.mode = local_trusted | cloud_hostedConfig behavior:
packages/shared/src/config-schema.ts)server/src/config.ts)/api/healthStartup guardrails:
local_trusted: fail startup if bind host is not loopbackcloud_hosted: fail startup if Better Auth is not configuredReplace implicit “board” semantics with explicit actors:
user (session-authenticated human)agent (bearer API key)local_implicit_admin (local_trusted only)Implementation note:
req.actor shape backward-compatible during migration by introducing a normalizer helper"board" checks route-by-route after new authz helpers are in placeAuthorization input tuple:
(company_id, principal_type, principal_id, permission_key, scope_payload)Principal types:
useragentRole layers:
instance_admin (instance-wide)principal_permission_grantsEvaluation order:
instance_admin short-circuit for admin-only actions)active required for company access)Managed by Better Auth adapter/migrations (expected minimum):
usersessionaccountverificationNote:
instance_user_rolesid uuid pkuser_id text not nullrole text not null (instance_admin)created_at, updated_at(user_id, role)company_membershipsid uuid pkcompany_id uuid fk companies.id not nullprincipal_type text not null (user | agent)principal_id text not nullstatus text not null (pending | active | suspended)membership_role text nullcreated_at, updated_at(company_id, principal_type, principal_id)(principal_type, principal_id, status)principal_permission_grantsid uuid pkcompany_id uuid fk companies.id not nullprincipal_type text not null (user | agent)principal_id text not nullpermission_key text not nullscope jsonb nullgranted_by_user_id text nullcreated_at, updated_at(company_id, principal_type, principal_id, permission_key)(company_id, permission_key)invitesid uuid pkcompany_id uuid fk companies.id not nullinvite_type text not null (company_join | bootstrap_ceo)token_hash text not nullallowed_join_types text not null (human | agent | both) for company_joindefaults_payload jsonb nullexpires_at timestamptz not nullinvited_by_user_id text nullrevoked_at timestamptz nullaccepted_at timestamptz nullcreated_at timestamptz not null default now()(token_hash)(company_id, invite_type, revoked_at, expires_at)join_requestsid uuid pkinvite_id uuid fk invites.id not nullcompany_id uuid fk companies.id not nullrequest_type text not null (human | agent)status text not null (pending_approval | approved | rejected)request_ip text not nullrequesting_user_id text nullrequest_email_snapshot text nullagent_name text nulladapter_type text nullcapabilities text nullagent_defaults_payload jsonb nullcreated_agent_id uuid fk agents.id nullapproved_by_user_id text nullapproved_at timestamptz nullrejected_by_user_id text nullrejected_at timestamptz nullcreated_at, updated_at(company_id, status, request_type, created_at desc)(invite_id) to enforce one request per consumed inviteissuesassignee_user_id text nullassignee_agent_id and assignee_user_id is non-nullagentspermissions JSON for transition onlyMigration ordering:
All under /api.
GET /api/health response additions:
deploymentModeauthReadybootstrapStatus (ready | bootstrap_pending)POST /api/companies/:companyId/invitescompany_join inviteGET /api/invites/:tokenallowedJoinTypesPOST /api/invites/:token/acceptrequestType: human | agentagentName, adapterType, capabilities, optional adapter defaultsjoin_requests(status=pending_approval)POST /api/invites/:inviteId/revokeGET /api/companies/:companyId/join-requests?status=pending_approval&requestType=...
POST /api/companies/:companyId/join-requests/:requestId/approve
company_membershipsagents rowPOST /api/companies/:companyId/join-requests/:requestId/reject
POST /api/join-requests/:requestId/claim-api-key
agent_api_keysGET /api/companies/:companyId/membersPATCH /api/companies/:companyId/members/:memberId/permissionsPUT /api/admin/users/:userId/company-accessGET /api/admin/users/:userId/company-access
POST /api/admin/users/:userId/promote-instance-admin
POST /api/admin/users/:userId/demote-instance-admin
GET /api/companies/:companyId/inbox additions:
joins:approveFiles:
packages/shared/src/config-schema.tsserver/src/config.tsserver/src/index.tsserver/src/startup-banner.tsChanges:
local_trustedcloud_hostedFiles:
server/package.json (dependency)server/src/auth/* (new)server/src/app.ts (mount auth handler endpoints + session middleware)Changes:
Files:
server/src/middleware/auth.tsserver/src/routes/authz.tsserver/src/middleware/board-mutation-guard.tsChanges:
local_implicit_admin actor in local modeuser actor in cloud modeassertBoard with permission-oriented helpers:
requireInstanceAdmin(req)requireCompanyAccess(req, companyId)requireCompanyPermission(req, companyId, permissionKey, scope?)Files:
server/src/services (new modules)
memberships.tspermissions.tsinvites.tsjoin-requests.tsinstance-admin.tsChanges:
Files:
server/src/routes/index.ts and new route modules:
auth.ts (if needed)invites.tsjoin-requests.tsmembers.tsinstance-admin.tsinbox.ts (or extension of existing inbox source)Changes:
Files:
server/src/services/activity-log.tsRequired actions:
invite.createdinvite.revokedjoin.requestedjoin.approvedjoin.rejectedmembership.activatedpermission.grantedpermission.revokedinstance_admin.promotedinstance_admin.demotedagent_api_key.claimedagent_api_key.revokedFiles:
server/src/services/live-events.tsserver/src/realtime/live-events-ws.tsChanges:
Files:
cli/src/index.tscli/src/commands/onboard.tscli/src/commands/configure.tscli/src/prompts/server.tsCommands:
paperclipai auth bootstrap-ceopaperclipai onboardbootstrap_pending, print bootstrap URL and next stepsConfig additions:
Files:
ui/src/App.tsxui/src/api/*AuthLogin / AuthSignup (cloud mode)BootstrapPending pageInviteLanding pageInstanceSettings pageInboxRequired UX:
allowedJoinTypes)assertBoard calls only after permission helpers cover all routesagents.permissions in V1user.id as text end-to-endcreated_by_user_id and similar text fields remain valid401SPEC-implementation, DEVELOPING, CLI)Before handoff:
pnpm -r typecheck
pnpm test:run
pnpm build
If any command is skipped, record exactly what was skipped and why.
doc/plan/humans-and-permissions.md.