2026-03-28 15:42:44 -05:00
# ! / u s r / b i n / e n v - S n o d e - - i m p o r t t s x
import { spawn } from "node:child_process" ;
import { existsSync , mkdirSync , readdirSync , rmSync , statSync , writeFileSync } from "node:fs" ;
import path from "node:path" ;
import { createInterface } from "node:readline/promises" ;
import { stdin , stdout } from "node:process" ;
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 <dotta@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 09:44:19 -05:00
import { createCapturedOutputBuffer , parseJsonResponseWithLimit } from "./dev-runner-output.ts" ;
2026-03-28 15:42:44 -05:00
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs" ;
import { createDevServiceIdentity , repoRoot } from "./dev-service-profile.ts" ;
2026-04-11 08:17:16 -05:00
import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts" ;
2026-03-28 15:42:44 -05:00
import {
findAdoptableLocalService ,
removeLocalServiceRegistryRecord ,
touchLocalServiceRegistryRecord ,
writeLocalServiceRegistryRecord ,
} from "../server/src/services/local-service-supervisor.ts" ;
2026-04-10 07:48:42 -05:00
// Keep these values local so the dev runner can boot from the server package's
// tsx context without requiring workspace package resolution first.
const BIND_MODES = [ "loopback" , "lan" , "tailnet" , "custom" ] as const ;
type BindMode = ( typeof BIND_MODES ) [ number ] ;
2026-04-11 08:17:16 -05:00
const worktreeEnvBootstrap = bootstrapDevRunnerWorktreeEnv ( repoRoot , process . env ) ;
if ( worktreeEnvBootstrap . missingEnv ) {
console . error (
2026-04-11 08:35:53 -05:00
` [paperclip] linked git worktree at ${ repoRoot } is missing ${ path . relative ( repoRoot , worktreeEnvBootstrap . envPath ) } . Run \` paperclipai worktree init \` in this worktree before \` pnpm dev \` . ` ,
2026-04-11 08:17:16 -05:00
) ;
process . exit ( 1 ) ;
}
2026-03-28 15:42:44 -05:00
const mode = process . argv [ 2 ] === "watch" ? "watch" : "dev" ;
const cliArgs = process . argv . slice ( 3 ) ;
const scanIntervalMs = 1500 ;
const autoRestartPollIntervalMs = 2500 ;
const gracefulShutdownTimeoutMs = 10 _000 ;
const changedPathSampleLimit = 5 ;
const devServerStatusFilePath = path . join ( repoRoot , ".paperclip" , "dev-server-status.json" ) ;
const watchedDirectories = [
"cli" ,
"scripts" ,
"server" ,
"packages/adapter-utils" ,
"packages/adapters" ,
"packages/db" ,
"packages/plugins/sdk" ,
"packages/shared" ,
] . map ( ( relativePath ) = > path . join ( repoRoot , relativePath ) ) ;
const watchedFiles = [
".env" ,
"package.json" ,
"pnpm-workspace.yaml" ,
"tsconfig.base.json" ,
"tsconfig.json" ,
"vitest.config.ts" ,
] . map ( ( relativePath ) = > path . join ( repoRoot , relativePath ) ) ;
const ignoredDirectoryNames = new Set ( [
".git" ,
".turbo" ,
".vite" ,
"coverage" ,
"dist" ,
"node_modules" ,
"ui-dist" ,
] ) ;
const ignoredRelativePaths = new Set ( [
".paperclip/dev-server-status.json" ,
] ) ;
const tailscaleAuthFlagNames = new Set ( [
"--tailscale-auth" ,
"--authenticated-private" ,
] ) ;
let tailscaleAuth = false ;
2026-04-10 07:32:16 -05:00
let bindMode : BindMode | null = null ;
let bindHost : string | null = null ;
2026-03-28 15:42:44 -05:00
const forwardedArgs : string [ ] = [ ] ;
2026-04-10 07:32:16 -05:00
for ( let index = 0 ; index < cliArgs . length ; index += 1 ) {
const arg = cliArgs [ index ] ;
2026-03-28 15:42:44 -05:00
if ( tailscaleAuthFlagNames . has ( arg ) ) {
tailscaleAuth = true ;
continue ;
}
2026-04-10 07:32:16 -05:00
if ( arg === "--bind" ) {
const value = cliArgs [ index + 1 ] ;
if ( ! value || value . startsWith ( "--" ) || ! BIND_MODES . includes ( value as BindMode ) ) {
console . error ( ` [paperclip] invalid --bind value. Use one of: ${ BIND_MODES . join ( ", " ) } ` ) ;
process . exit ( 1 ) ;
}
bindMode = value as BindMode ;
index += 1 ;
continue ;
}
if ( arg === "--bind-host" ) {
const value = cliArgs [ index + 1 ] ;
if ( ! value || value . startsWith ( "--" ) ) {
console . error ( "[paperclip] --bind-host requires a value" ) ;
process . exit ( 1 ) ;
}
bindHost = value ;
index += 1 ;
continue ;
}
2026-03-28 15:42:44 -05:00
forwardedArgs . push ( arg ) ;
}
if ( process . env . npm_config_tailscale_auth === "true" ) {
tailscaleAuth = true ;
}
if ( process . env . npm_config_authenticated_private === "true" ) {
tailscaleAuth = true ;
}
2026-04-10 07:32:16 -05:00
if ( ! bindMode && process . env . npm_config_bind && BIND_MODES . includes ( process . env . npm_config_bind as BindMode ) ) {
bindMode = process . env . npm_config_bind as BindMode ;
}
if ( ! bindHost && process . env . npm_config_bind_host ) {
bindHost = process . env . npm_config_bind_host ;
}
if ( bindMode === "custom" && ! bindHost ) {
console . error ( "[paperclip] --bind custom requires --bind-host <host>" ) ;
process . exit ( 1 ) ;
}
2026-03-28 15:42:44 -05:00
const env : NodeJS.ProcessEnv = {
. . . process . env ,
PAPERCLIP_UI_DEV_MIDDLEWARE : "true" ,
} ;
if ( mode === "dev" ) {
env . PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath ;
2026-03-28 15:52:55 -05:00
env . PAPERCLIP_MIGRATION_AUTO_APPLY ? ? = "true" ;
2026-03-28 15:42:44 -05:00
}
if ( mode === "watch" ) {
env . PAPERCLIP_MIGRATION_PROMPT ? ? = "never" ;
env . PAPERCLIP_MIGRATION_AUTO_APPLY ? ? = "true" ;
}
2026-04-10 07:32:16 -05:00
if ( tailscaleAuth || bindMode ) {
const effectiveBind = bindMode ? ? "lan" ;
if ( tailscaleAuth ) {
console . log ( "[paperclip] note: --tailscale-auth/--authenticated-private are legacy aliases for --bind lan" ) ;
}
env . PAPERCLIP_BIND = effectiveBind ;
if ( bindHost ) {
env . PAPERCLIP_BIND_HOST = bindHost ;
} else {
delete env . PAPERCLIP_BIND_HOST ;
}
if ( effectiveBind === "loopback" && ! tailscaleAuth ) {
delete env . PAPERCLIP_DEPLOYMENT_MODE ;
delete env . PAPERCLIP_DEPLOYMENT_EXPOSURE ;
delete env . PAPERCLIP_AUTH_BASE_URL_MODE ;
console . log ( "[paperclip] dev mode: local_trusted (bind=loopback)" ) ;
} else {
env . PAPERCLIP_DEPLOYMENT_MODE = "authenticated" ;
env . PAPERCLIP_DEPLOYMENT_EXPOSURE = "private" ;
env . PAPERCLIP_AUTH_BASE_URL_MODE = "auto" ;
console . log (
` [paperclip] dev mode: authenticated/private (bind= ${ effectiveBind } ${ bindHost ? ` : ${ bindHost } ` : "" } ) ` ,
) ;
}
2026-03-28 15:42:44 -05:00
} else {
2026-04-10 07:32:16 -05:00
delete env . PAPERCLIP_BIND ;
delete env . PAPERCLIP_BIND_HOST ;
delete env . PAPERCLIP_DEPLOYMENT_MODE ;
delete env . PAPERCLIP_DEPLOYMENT_EXPOSURE ;
delete env . PAPERCLIP_AUTH_BASE_URL_MODE ;
2026-03-28 15:42:44 -05:00
console . log ( "[paperclip] dev mode: local_trusted (default)" ) ;
}
const serverPort = Number . parseInt ( env . PORT ? ? process . env . PORT ? ? "3100" , 10 ) || 3100 ;
const devService = createDevServiceIdentity ( {
mode ,
forwardedArgs ,
2026-04-10 07:32:16 -05:00
networkProfile : tailscaleAuth ? ` legacy: ${ bindMode ? ? "lan" } ` : ( bindMode ? ? "default" ) ,
2026-03-28 15:42:44 -05:00
port : serverPort ,
} ) ;
const existingRunner = await findAdoptableLocalService ( {
serviceKey : devService.serviceKey ,
cwd : repoRoot ,
envFingerprint : devService.envFingerprint ,
port : serverPort ,
} ) ;
if ( existingRunner ) {
console . log (
` [paperclip] ${ devService . serviceName } already running (pid ${ existingRunner . pid } ${ typeof existingRunner . metadata ? . childPid === "number" ? ` , child ${ existingRunner . metadata . childPid } ` : "" } ) ` ,
) ;
process . exit ( 0 ) ;
}
const pnpmBin = process . platform === "win32" ? "pnpm.cmd" : "pnpm" ;
let previousSnapshot = collectWatchedSnapshot ( ) ;
let dirtyPaths = new Set < string > ( ) ;
let pendingMigrations : string [ ] = [ ] ;
let lastChangedAt : string | null = null ;
let lastRestartAt : string | null = null ;
let scanInFlight = false ;
let restartInFlight = false ;
let shuttingDown = false ;
let childExitWasExpected = false ;
let child : ReturnType < typeof spawn > | null = null ;
let childExitPromise : Promise < { code : number ; signal : NodeJS.Signals | null } > | null = null ;
let scanTimer : ReturnType < typeof setInterval > | null = null ;
let autoRestartTimer : ReturnType < typeof setInterval > | null = null ;
function toError ( error : unknown , context = "Dev runner command failed" ) {
if ( error instanceof Error ) return error ;
if ( error === undefined ) return new Error ( context ) ;
if ( typeof error === "string" ) return new Error ( ` ${ context } : ${ error } ` ) ;
try {
return new Error ( ` ${ context } : ${ JSON . stringify ( error ) } ` ) ;
} catch {
return new Error ( ` ${ context } : ${ String ( error ) } ` ) ;
}
}
process . on ( "uncaughtException" , async ( error ) = > {
await removeLocalServiceRegistryRecord ( devService . serviceKey ) ;
const err = toError ( error , "Uncaught exception in dev runner" ) ;
process . stderr . write ( ` ${ err . stack ? ? err . message } \ n ` ) ;
process . exit ( 1 ) ;
} ) ;
process . on ( "unhandledRejection" , async ( reason ) = > {
await removeLocalServiceRegistryRecord ( devService . serviceKey ) ;
const err = toError ( reason , "Unhandled promise rejection in dev runner" ) ;
process . stderr . write ( ` ${ err . stack ? ? err . message } \ n ` ) ;
process . exit ( 1 ) ;
} ) ;
function formatPendingMigrationSummary ( migrations : string [ ] ) {
if ( migrations . length === 0 ) return "none" ;
return migrations . length > 3
? ` ${ migrations . slice ( 0 , 3 ) . join ( ", " ) } (+ ${ migrations . length - 3 } more) `
: migrations . join ( ", " ) ;
}
function exitForSignal ( signal : NodeJS.Signals ) {
if ( signal === "SIGINT" ) {
process . exit ( 130 ) ;
}
if ( signal === "SIGTERM" ) {
process . exit ( 143 ) ;
}
process . exit ( 1 ) ;
}
function toRelativePath ( absolutePath : string ) {
return path . relative ( repoRoot , absolutePath ) . split ( path . sep ) . join ( "/" ) ;
}
function readSignature ( absolutePath : string ) {
const stats = statSync ( absolutePath ) ;
return ` ${ Math . trunc ( stats . mtimeMs ) } : ${ stats . size } ` ;
}
function addFileToSnapshot ( snapshot : Map < string , string > , absolutePath : string ) {
const relativePath = toRelativePath ( absolutePath ) ;
if ( ignoredRelativePaths . has ( relativePath ) ) return ;
if ( ! shouldTrackDevServerPath ( relativePath ) ) return ;
snapshot . set ( relativePath , readSignature ( absolutePath ) ) ;
}
function walkDirectory ( snapshot : Map < string , string > , absoluteDirectory : string ) {
if ( ! existsSync ( absoluteDirectory ) ) return ;
for ( const entry of readdirSync ( absoluteDirectory , { withFileTypes : true } ) ) {
if ( ignoredDirectoryNames . has ( entry . name ) ) continue ;
const absolutePath = path . join ( absoluteDirectory , entry . name ) ;
if ( entry . isDirectory ( ) ) {
walkDirectory ( snapshot , absolutePath ) ;
continue ;
}
if ( entry . isFile ( ) || entry . isSymbolicLink ( ) ) {
addFileToSnapshot ( snapshot , absolutePath ) ;
}
}
}
function collectWatchedSnapshot() {
const snapshot = new Map < string , string > ( ) ;
for ( const absoluteDirectory of watchedDirectories ) {
walkDirectory ( snapshot , absoluteDirectory ) ;
}
for ( const absoluteFile of watchedFiles ) {
if ( ! existsSync ( absoluteFile ) ) continue ;
addFileToSnapshot ( snapshot , absoluteFile ) ;
}
return snapshot ;
}
function diffSnapshots ( previous : Map < string , string > , next : Map < string , string > ) {
const changed = new Set < string > ( ) ;
for ( const [ relativePath , signature ] of next ) {
if ( previous . get ( relativePath ) !== signature ) {
changed . add ( relativePath ) ;
}
}
for ( const relativePath of previous . keys ( ) ) {
if ( ! next . has ( relativePath ) ) {
changed . add ( relativePath ) ;
}
}
return [ . . . changed ] . sort ( ) ;
}
function ensureDevStatusDirectory() {
mkdirSync ( path . dirname ( devServerStatusFilePath ) , { recursive : true } ) ;
}
function writeDevServerStatus() {
if ( mode !== "dev" ) return ;
ensureDevStatusDirectory ( ) ;
const changedPaths = [ . . . dirtyPaths ] . sort ( ) ;
writeFileSync (
devServerStatusFilePath ,
` ${ JSON . stringify ( {
dirty : changedPaths.length > 0 || pendingMigrations . length > 0 ,
lastChangedAt ,
changedPathCount : changedPaths.length ,
changedPathsSample : changedPaths.slice ( 0 , changedPathSampleLimit ) ,
pendingMigrations ,
lastRestartAt ,
} , null , 2 ) } \ n ` ,
"utf8" ,
) ;
}
function clearDevServerStatus() {
if ( mode !== "dev" ) return ;
rmSync ( devServerStatusFilePath , { force : true } ) ;
}
async function updateDevServiceRecord ( extra? : Record < string , unknown > ) {
await writeLocalServiceRegistryRecord ( {
version : 1 ,
serviceKey : devService.serviceKey ,
profileKind : "paperclip-dev" ,
serviceName : devService.serviceName ,
command : "dev-runner.ts" ,
cwd : repoRoot ,
envFingerprint : devService.envFingerprint ,
port : serverPort ,
url : ` http://127.0.0.1: ${ serverPort } ` ,
pid : process.pid ,
processGroupId : null ,
provider : "local_process" ,
runtimeServiceId : null ,
reuseKey : null ,
startedAt : lastRestartAt ? ? new Date ( ) . toISOString ( ) ,
lastSeenAt : new Date ( ) . toISOString ( ) ,
metadata : {
repoRoot ,
mode ,
childPid : child?.pid ? ? null ,
url : ` http://127.0.0.1: ${ serverPort } ` ,
. . . extra ,
} ,
} ) ;
}
async function runPnpm ( args : string [ ] , options : {
stdio ? : "inherit" | [ "ignore" , "pipe" , "pipe" ] ;
env? : NodeJS.ProcessEnv ;
2026-03-28 15:52:55 -05:00
cwd? : string ;
2026-03-28 15:42:44 -05:00
} = { } ) {
return await new Promise < { code : number ; signal : NodeJS.Signals | null ; stdout : string ; stderr : string } > ( ( resolve , reject ) = > {
const spawned = spawn ( pnpmBin , args , {
stdio : options.stdio ? ? [ "ignore" , "pipe" , "pipe" ] ,
env : options.env ? ? process . env ,
2026-03-28 15:52:55 -05:00
cwd : options.cwd ,
2026-03-28 15:42:44 -05:00
shell : process.platform === "win32" ,
} ) ;
2026-04-06 19:24:51 -05:00
const stdoutBuffer = createCapturedOutputBuffer ( ) ;
const stderrBuffer = createCapturedOutputBuffer ( ) ;
2026-03-28 15:42:44 -05:00
if ( spawned . stdout ) {
spawned . stdout . on ( "data" , ( chunk ) = > {
2026-04-06 19:24:51 -05:00
stdoutBuffer . append ( chunk ) ;
2026-03-28 15:42:44 -05:00
} ) ;
}
if ( spawned . stderr ) {
spawned . stderr . on ( "data" , ( chunk ) = > {
2026-04-06 19:24:51 -05:00
stderrBuffer . append ( chunk ) ;
2026-03-28 15:42:44 -05:00
} ) ;
}
spawned . on ( "error" , reject ) ;
spawned . on ( "exit" , ( code , signal ) = > {
2026-04-06 19:24:51 -05:00
const stdout = stdoutBuffer . finish ( ) ;
const stderr = stderrBuffer . finish ( ) ;
2026-03-28 15:42:44 -05:00
resolve ( {
code : code ? ? 0 ,
signal ,
2026-04-06 19:24:51 -05:00
stdout : stdout.text ,
stderr : stderr.text ,
2026-03-28 15:42:44 -05:00
} ) ;
} ) ;
} ) ;
}
async function getMigrationStatusPayload() {
const status = await runPnpm (
[ "--filter" , "@paperclipai/db" , "exec" , "tsx" , "src/migration-status.ts" , "--json" ] ,
{ env } ,
) ;
if ( status . code !== 0 ) {
process . stderr . write (
status . stderr ||
status . stdout ||
` [paperclip] Command failed with code ${ status . code } : pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json \ n ` ,
) ;
process . exit ( status . code ) ;
}
try {
return JSON . parse ( status . stdout . trim ( ) ) as { status? : string ; pendingMigrations? : string [ ] } ;
} catch ( error ) {
process . stderr . write (
status . stderr ||
status . stdout ||
"[paperclip] migration-status returned invalid JSON payload\n" ,
) ;
throw toError ( error , "Unable to parse migration-status JSON output" ) ;
}
}
async function refreshPendingMigrations() {
const payload = await getMigrationStatusPayload ( ) ;
pendingMigrations =
payload . status === "needsMigrations" && Array . isArray ( payload . pendingMigrations )
? payload . pendingMigrations . filter ( ( entry ) = > typeof entry === "string" && entry . trim ( ) . length > 0 )
: [ ] ;
writeDevServerStatus ( ) ;
return payload ;
}
async function maybePreflightMigrations ( options : { interactive? : boolean ; autoApply? : boolean ; exitOnDecline? : boolean } = { } ) {
const interactive = options . interactive ? ? mode === "watch" ;
const autoApply = options . autoApply ? ? env . PAPERCLIP_MIGRATION_AUTO_APPLY === "true" ;
const exitOnDecline = options . exitOnDecline ? ? mode === "watch" ;
const payload = await refreshPendingMigrations ( ) ;
if ( payload . status !== "needsMigrations" || pendingMigrations . length === 0 ) {
return ;
}
let shouldApply = autoApply ;
if ( ! autoApply && interactive ) {
if ( ! stdin . isTTY || ! stdout . isTTY ) {
shouldApply = true ;
} else {
const prompt = createInterface ( { input : stdin , output : stdout } ) ;
try {
const answer = (
await prompt . question (
` Apply pending migrations ( ${ formatPendingMigrationSummary ( pendingMigrations ) } ) now? (y/N): ` ,
)
)
. trim ( )
. toLowerCase ( ) ;
shouldApply = answer === "y" || answer === "yes" ;
} finally {
prompt . close ( ) ;
}
}
}
if ( ! shouldApply ) {
if ( exitOnDecline ) {
process . stderr . write (
` [paperclip] Pending migrations detected ( ${ formatPendingMigrationSummary ( pendingMigrations ) } ). Refusing to start watch mode against a stale schema. \ n ` ,
) ;
process . exit ( 1 ) ;
}
return ;
}
2026-03-28 15:52:55 -05:00
const exit = await runPnpm ( [ "db:migrate" ] , {
2026-03-28 15:42:44 -05:00
stdio : "inherit" ,
env ,
2026-03-28 15:52:55 -05:00
cwd : repoRoot ,
2026-03-28 15:42:44 -05:00
} ) ;
if ( exit . signal ) {
exitForSignal ( exit . signal ) ;
return ;
}
if ( exit . code !== 0 ) {
process . exit ( exit . code ) ;
}
await refreshPendingMigrations ( ) ;
}
async function buildPluginSdk() {
console . log ( "[paperclip] building plugin sdk..." ) ;
const result = await runPnpm (
[ "--filter" , "@paperclipai/plugin-sdk" , "build" ] ,
{ stdio : "inherit" } ,
) ;
if ( result . signal ) {
exitForSignal ( result . signal ) ;
return ;
}
if ( result . code !== 0 ) {
console . error ( "[paperclip] plugin sdk build failed" ) ;
process . exit ( result . code ) ;
}
}
async function markChildAsCurrent() {
previousSnapshot = collectWatchedSnapshot ( ) ;
dirtyPaths = new Set ( ) ;
lastChangedAt = null ;
lastRestartAt = new Date ( ) . toISOString ( ) ;
await refreshPendingMigrations ( ) ;
await updateDevServiceRecord ( ) ;
}
async function scanForBackendChanges() {
if ( mode !== "dev" || scanInFlight || restartInFlight ) return ;
scanInFlight = true ;
try {
const nextSnapshot = collectWatchedSnapshot ( ) ;
const changed = diffSnapshots ( previousSnapshot , nextSnapshot ) ;
previousSnapshot = nextSnapshot ;
if ( changed . length === 0 ) return ;
for ( const relativePath of changed ) {
dirtyPaths . add ( relativePath ) ;
}
lastChangedAt = new Date ( ) . toISOString ( ) ;
await refreshPendingMigrations ( ) ;
} finally {
scanInFlight = false ;
}
}
async function getDevHealthPayload() {
const response = await fetch ( ` http://127.0.0.1: ${ serverPort } /api/health ` ) ;
if ( ! response . ok ) {
throw new Error ( ` Health request failed ( ${ response . status } ) ` ) ;
}
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 <dotta@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 09:44:19 -05:00
return await parseJsonResponseWithLimit ( response ) ;
2026-03-28 15:42:44 -05:00
}
async function waitForChildExit() {
if ( ! childExitPromise ) {
return { code : 0 , signal : null } ;
}
return await childExitPromise ;
}
async function stopChildForRestart() {
if ( ! child ) return { code : 0 , signal : null } ;
childExitWasExpected = true ;
child . kill ( "SIGTERM" ) ;
const killTimer = setTimeout ( ( ) = > {
if ( child ) {
child . kill ( "SIGKILL" ) ;
}
} , gracefulShutdownTimeoutMs ) ;
try {
return await waitForChildExit ( ) ;
} finally {
clearTimeout ( killTimer ) ;
}
}
async function startServerChild() {
await buildPluginSdk ( ) ;
const serverScript = mode === "watch" ? "dev:watch" : "dev" ;
child = spawn (
pnpmBin ,
[ "--filter" , "@paperclipai/server" , serverScript , . . . forwardedArgs ] ,
{ stdio : "inherit" , env , shell : process.platform === "win32" } ,
) ;
childExitPromise = new Promise ( ( resolve , reject ) = > {
child ? . on ( "error" , reject ) ;
child ? . on ( "exit" , ( code , signal ) = > {
const expected = childExitWasExpected ;
childExitWasExpected = false ;
child = null ;
childExitPromise = null ;
void touchLocalServiceRegistryRecord ( devService . serviceKey , {
metadata : {
repoRoot ,
mode ,
childPid : null ,
url : ` http://127.0.0.1: ${ serverPort } ` ,
} ,
} ) ;
resolve ( { code : code ? ? 0 , signal } ) ;
if ( restartInFlight || expected || shuttingDown ) {
return ;
}
if ( signal ) {
exitForSignal ( signal ) ;
return ;
}
process . exit ( code ? ? 0 ) ;
} ) ;
} ) ;
await markChildAsCurrent ( ) ;
}
async function maybeAutoRestartChild() {
if ( mode !== "dev" || restartInFlight || ! child ) return ;
if ( dirtyPaths . size === 0 && pendingMigrations . length === 0 ) return ;
restartInFlight = true ;
let health : { devServer ? : { enabled? : boolean ; autoRestartEnabled? : boolean ; activeRunCount? : number } } | null = null ;
try {
health = await getDevHealthPayload ( ) ;
} catch {
restartInFlight = false ;
return ;
}
const devServer = health ? . devServer ;
if ( ! devServer ? . enabled || devServer . autoRestartEnabled !== true ) {
restartInFlight = false ;
return ;
}
if ( ( devServer . activeRunCount ? ? 0 ) > 0 ) {
restartInFlight = false ;
return ;
}
try {
await maybePreflightMigrations ( {
autoApply : true ,
interactive : false ,
exitOnDecline : false ,
} ) ;
await stopChildForRestart ( ) ;
await startServerChild ( ) ;
} catch ( error ) {
const err = toError ( error , "Auto-restart failed" ) ;
process . stderr . write ( ` ${ err . stack ? ? err . message } \ n ` ) ;
process . exit ( 1 ) ;
} finally {
restartInFlight = false ;
}
}
function installDevIntervals() {
if ( mode !== "dev" ) return ;
scanTimer = setInterval ( ( ) = > {
void scanForBackendChanges ( ) ;
} , scanIntervalMs ) ;
autoRestartTimer = setInterval ( ( ) = > {
void maybeAutoRestartChild ( ) ;
} , autoRestartPollIntervalMs ) ;
}
function clearDevIntervals() {
if ( scanTimer ) {
clearInterval ( scanTimer ) ;
scanTimer = null ;
}
if ( autoRestartTimer ) {
clearInterval ( autoRestartTimer ) ;
autoRestartTimer = null ;
}
}
async function shutdown ( signal : NodeJS.Signals ) {
if ( shuttingDown ) return ;
shuttingDown = true ;
clearDevIntervals ( ) ;
clearDevServerStatus ( ) ;
await removeLocalServiceRegistryRecord ( devService . serviceKey ) ;
if ( ! child ) {
exitForSignal ( signal ) ;
return ;
}
childExitWasExpected = true ;
child . kill ( signal ) ;
const exit = await waitForChildExit ( ) ;
if ( exit . signal ) {
exitForSignal ( exit . signal ) ;
return ;
}
process . exit ( exit . code ? ? 0 ) ;
}
process . on ( "SIGINT" , ( ) = > {
void shutdown ( "SIGINT" ) ;
} ) ;
process . on ( "SIGTERM" , ( ) = > {
void shutdown ( "SIGTERM" ) ;
} ) ;
await maybePreflightMigrations ( ) ;
await startServerChild ( ) ;
installDevIntervals ( ) ;
if ( mode === "watch" ) {
const exit = await waitForChildExit ( ) ;
await removeLocalServiceRegistryRecord ( devService . serviceKey ) ;
if ( exit . signal ) {
exitForSignal ( exit . signal ) ;
}
process . exit ( exit . code ? ? 0 ) ;
}