2026-02-26 16:33:20 -06:00
import { createHash , randomBytes , timingSafeEqual } from "node:crypto" ;
import fs from "node:fs" ;
import path from "node:path" ;
import { fileURLToPath } from "node:url" ;
2026-02-23 14:40:32 -06:00
import { Router } from "express" ;
import type { Request } from "express" ;
import { and , eq , isNull , desc } from "drizzle-orm" ;
2026-03-03 08:45:26 -06:00
import type { Db } from "@paperclipai/db" ;
2026-02-23 14:40:32 -06:00
import {
agentApiKeys ,
authUsers ,
invites ,
joinRequests ,
2026-03-03 08:45:26 -06:00
} from "@paperclipai/db" ;
2026-02-23 14:40:32 -06:00
import {
acceptInviteSchema ,
2026-02-26 16:33:20 -06:00
claimJoinRequestApiKeySchema ,
2026-02-23 14:40:32 -06:00
createCompanyInviteSchema ,
listJoinRequestsQuerySchema ,
updateMemberPermissionsSchema ,
updateUserCompanyAccessSchema ,
PERMISSION_KEYS ,
2026-03-03 08:45:26 -06:00
} from "@paperclipai/shared" ;
import type { DeploymentExposure , DeploymentMode } from "@paperclipai/shared" ;
2026-02-23 14:40:32 -06:00
import { forbidden , conflict , notFound , unauthorized , badRequest } from "../errors.js" ;
2026-03-06 09:36:20 -06:00
import { logger } from "../middleware/logger.js" ;
2026-02-23 14:40:32 -06:00
import { validate } from "../middleware/validate.js" ;
2026-03-06 08:17:42 -06:00
import { accessService , agentService , logActivity , notifyHireApproved } from "../services/index.js" ;
2026-02-23 14:40:32 -06:00
import { assertCompanyAccess } from "./authz.js" ;
2026-02-23 16:25:31 -06:00
import { claimBoardOwnership , inspectBoardClaimChallenge } from "../board-claim.js" ;
2026-02-23 14:40:32 -06:00
function hashToken ( token : string ) {
return createHash ( "sha256" ) . update ( token ) . digest ( "hex" ) ;
}
2026-03-05 12:52:39 -06:00
const INVITE_TOKEN_PREFIX = "pcp_invite_" ;
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" ;
const INVITE_TOKEN_SUFFIX_LENGTH = 8 ;
const INVITE_TOKEN_MAX_RETRIES = 5 ;
2026-03-06 10:10:23 -06:00
const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000 ;
2026-03-05 12:52:39 -06:00
2026-02-23 14:40:32 -06:00
function createInviteToken() {
2026-03-05 12:52:39 -06:00
const bytes = randomBytes ( INVITE_TOKEN_SUFFIX_LENGTH ) ;
let suffix = "" ;
for ( let idx = 0 ; idx < INVITE_TOKEN_SUFFIX_LENGTH ; idx += 1 ) {
suffix += INVITE_TOKEN_ALPHABET [ bytes [ idx ] ! % INVITE_TOKEN_ALPHABET . length ] ;
}
return ` ${ INVITE_TOKEN_PREFIX } ${ suffix } ` ;
2026-02-23 14:40:32 -06:00
}
2026-02-26 16:33:20 -06:00
function createClaimSecret() {
return ` pcp_claim_ ${ randomBytes ( 24 ) . toString ( "hex" ) } ` ;
}
2026-03-06 10:10:23 -06:00
export function companyInviteExpiresAt ( nowMs : number = Date . now ( ) ) {
return new Date ( nowMs + COMPANY_INVITE_TTL_MS ) ;
}
2026-02-26 16:33:20 -06:00
function tokenHashesMatch ( left : string , right : string ) {
const leftBytes = Buffer . from ( left , "utf8" ) ;
const rightBytes = Buffer . from ( right , "utf8" ) ;
return leftBytes . length === rightBytes . length && timingSafeEqual ( leftBytes , rightBytes ) ;
}
function requestBaseUrl ( req : Request ) {
const forwardedProto = req . header ( "x-forwarded-proto" ) ;
const proto = forwardedProto ? . split ( "," ) [ 0 ] ? . trim ( ) || req . protocol || "http" ;
const host = req . header ( "x-forwarded-host" ) ? . split ( "," ) [ 0 ] ? . trim ( ) || req . header ( "host" ) ;
if ( ! host ) return "" ;
return ` ${ proto } :// ${ host } ` ;
}
function readSkillMarkdown ( skillName : string ) : string | null {
const normalized = skillName . trim ( ) . toLowerCase ( ) ;
if ( normalized !== "paperclip" && normalized !== "paperclip-create-agent" ) return null ;
const moduleDir = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const candidates = [
2026-03-03 16:06:12 -06:00
path . resolve ( moduleDir , "../../skills" , normalized , "SKILL.md" ) , // published: dist/routes/ -> <pkg>/skills/
path . resolve ( process . cwd ( ) , "skills" , normalized , "SKILL.md" ) , // cwd (e.g. monorepo root)
path . resolve ( moduleDir , "../../../skills" , normalized , "SKILL.md" ) , // dev: src/routes/ -> repo root/skills/
2026-02-26 16:33:20 -06:00
] ;
for ( const skillPath of candidates ) {
try {
return fs . readFileSync ( skillPath , "utf8" ) ;
} catch {
// Continue to next candidate.
}
}
return null ;
}
function toJoinRequestResponse ( row : typeof joinRequests . $inferSelect ) {
const { claimSecretHash : _claimSecretHash , . . . safe } = row ;
return safe ;
}
2026-03-02 16:43:59 -06:00
type JoinDiagnostic = {
code : string ;
level : "info" | "warn" ;
message : string ;
hint? : string ;
} ;
function isPlainObject ( value : unknown ) : value is Record < string , unknown > {
return typeof value === "object" && value !== null && ! Array . isArray ( value ) ;
}
function isLoopbackHost ( hostname : string ) : boolean {
const value = hostname . trim ( ) . toLowerCase ( ) ;
return value === "localhost" || value === "127.0.0.1" || value === "::1" ;
}
2026-03-05 15:54:55 -06:00
function isWakePath ( pathname : string ) : boolean {
const value = pathname . trim ( ) . toLowerCase ( ) ;
return value === "/hooks/wake" || value . endsWith ( "/hooks/wake" ) ;
}
2026-03-02 16:43:59 -06:00
function normalizeHostname ( value : string | null | undefined ) : string | null {
if ( ! value ) return null ;
const trimmed = value . trim ( ) ;
if ( ! trimmed ) return null ;
if ( trimmed . startsWith ( "[" ) ) {
const end = trimmed . indexOf ( "]" ) ;
return end > 1 ? trimmed . slice ( 1 , end ) . toLowerCase ( ) : trimmed . toLowerCase ( ) ;
}
const firstColon = trimmed . indexOf ( ":" ) ;
if ( firstColon > - 1 ) return trimmed . slice ( 0 , firstColon ) . toLowerCase ( ) ;
return trimmed . toLowerCase ( ) ;
}
function normalizeHeaderMap ( input : unknown ) : Record < string , string > | undefined {
if ( ! isPlainObject ( input ) ) return undefined ;
const out : Record < string , string > = { } ;
for ( const [ key , value ] of Object . entries ( input ) ) {
if ( typeof value !== "string" ) continue ;
const trimmedKey = key . trim ( ) ;
const trimmedValue = value . trim ( ) ;
if ( ! trimmedKey || ! trimmedValue ) continue ;
out [ trimmedKey ] = trimmedValue ;
}
return Object . keys ( out ) . length > 0 ? out : undefined ;
}
2026-03-06 09:36:20 -06:00
function nonEmptyTrimmedString ( value : unknown ) : string | null {
if ( typeof value !== "string" ) return null ;
const trimmed = value . trim ( ) ;
return trimmed . length > 0 ? trimmed : null ;
}
function headerMapHasKeyIgnoreCase ( headers : Record < string , string > , targetKey : string ) : boolean {
const normalizedTarget = targetKey . trim ( ) . toLowerCase ( ) ;
return Object . keys ( headers ) . some ( ( key ) = > key . trim ( ) . toLowerCase ( ) === normalizedTarget ) ;
}
2026-03-06 10:14:57 -06:00
function headerMapGetIgnoreCase ( headers : Record < string , string > , targetKey : string ) : string | null {
const normalizedTarget = targetKey . trim ( ) . toLowerCase ( ) ;
const key = Object . keys ( headers ) . find ( ( candidate ) = > candidate . trim ( ) . toLowerCase ( ) === normalizedTarget ) ;
if ( ! key ) return null ;
const value = headers [ key ] ;
return typeof value === "string" ? value : null ;
}
function toAuthorizationHeaderValue ( rawToken : string ) : string {
const trimmed = rawToken . trim ( ) ;
if ( ! trimmed ) return trimmed ;
return /^bearer\s+/i . test ( trimmed ) ? trimmed : ` Bearer ${ trimmed } ` ;
}
2026-03-06 09:36:20 -06:00
export function buildJoinDefaultsPayloadForAccept ( input : {
adapterType : string | null ;
defaultsPayload : unknown ;
responsesWebhookUrl? : unknown ;
responsesWebhookMethod? : unknown ;
responsesWebhookHeaders? : unknown ;
paperclipApiUrl? : unknown ;
webhookAuthHeader? : unknown ;
inboundOpenClawAuthHeader? : string | null ;
} ) : unknown {
if ( input . adapterType !== "openclaw" ) {
return input . defaultsPayload ;
}
const merged = isPlainObject ( input . defaultsPayload )
? { . . . ( input . defaultsPayload as Record < string , unknown > ) }
: { } as Record < string , unknown > ;
if ( ! nonEmptyTrimmedString ( merged . url ) ) {
const legacyUrl = nonEmptyTrimmedString ( input . responsesWebhookUrl ) ;
if ( legacyUrl ) merged . url = legacyUrl ;
}
if ( ! nonEmptyTrimmedString ( merged . method ) ) {
const legacyMethod = nonEmptyTrimmedString ( input . responsesWebhookMethod ) ;
if ( legacyMethod ) merged . method = legacyMethod . toUpperCase ( ) ;
}
if ( ! nonEmptyTrimmedString ( merged . paperclipApiUrl ) ) {
const legacyPaperclipApiUrl = nonEmptyTrimmedString ( input . paperclipApiUrl ) ;
if ( legacyPaperclipApiUrl ) merged . paperclipApiUrl = legacyPaperclipApiUrl ;
}
if ( ! nonEmptyTrimmedString ( merged . webhookAuthHeader ) ) {
const providedWebhookAuthHeader = nonEmptyTrimmedString ( input . webhookAuthHeader ) ;
if ( providedWebhookAuthHeader ) merged . webhookAuthHeader = providedWebhookAuthHeader ;
}
const mergedHeaders = normalizeHeaderMap ( merged . headers ) ? ? { } ;
const compatibilityHeaders = normalizeHeaderMap ( input . responsesWebhookHeaders ) ;
if ( compatibilityHeaders ) {
for ( const [ key , value ] of Object . entries ( compatibilityHeaders ) ) {
if ( ! headerMapHasKeyIgnoreCase ( mergedHeaders , key ) ) {
mergedHeaders [ key ] = value ;
}
}
}
const inboundOpenClawAuthHeader = nonEmptyTrimmedString ( input . inboundOpenClawAuthHeader ) ;
if ( inboundOpenClawAuthHeader && ! headerMapHasKeyIgnoreCase ( mergedHeaders , "x-openclaw-auth" ) ) {
mergedHeaders [ "x-openclaw-auth" ] = inboundOpenClawAuthHeader ;
}
if ( Object . keys ( mergedHeaders ) . length > 0 ) {
merged . headers = mergedHeaders ;
} else {
delete merged . headers ;
}
2026-03-06 10:14:57 -06:00
const hasAuthorizationHeader = headerMapHasKeyIgnoreCase ( mergedHeaders , "authorization" ) ;
const hasWebhookAuthHeader = Boolean ( nonEmptyTrimmedString ( merged . webhookAuthHeader ) ) ;
if ( ! hasAuthorizationHeader && ! hasWebhookAuthHeader ) {
const openClawAuthToken = headerMapGetIgnoreCase ( mergedHeaders , "x-openclaw-auth" ) ;
if ( openClawAuthToken ) {
merged . webhookAuthHeader = toAuthorizationHeaderValue ( openClawAuthToken ) ;
}
}
2026-03-06 09:36:20 -06:00
return Object . keys ( merged ) . length > 0 ? merged : null ;
}
function summarizeSecretForLog ( value : unknown ) : { present : true ; length : number ; sha256Prefix : string } | null {
const trimmed = nonEmptyTrimmedString ( value ) ;
if ( ! trimmed ) return null ;
return {
present : true ,
length : trimmed.length ,
sha256Prefix : hashToken ( trimmed ) . slice ( 0 , 12 ) ,
} ;
}
function summarizeOpenClawDefaultsForLog ( defaultsPayload : unknown ) {
const defaults = isPlainObject ( defaultsPayload ) ? ( defaultsPayload as Record < string , unknown > ) : null ;
const headers = defaults ? normalizeHeaderMap ( defaults . headers ) : undefined ;
const openClawAuthHeaderValue = headers
? Object . entries ( headers ) . find ( ( [ key ] ) = > key . trim ( ) . toLowerCase ( ) === "x-openclaw-auth" ) ? . [ 1 ] ? ? null
: null ;
return {
present : Boolean ( defaults ) ,
keys : defaults ? Object . keys ( defaults ) . sort ( ) : [ ] ,
url : defaults ? nonEmptyTrimmedString ( defaults . url ) : null ,
method : defaults ? nonEmptyTrimmedString ( defaults . method ) : null ,
paperclipApiUrl : defaults ? nonEmptyTrimmedString ( defaults . paperclipApiUrl ) : null ,
headerKeys : headers ? Object . keys ( headers ) . sort ( ) : [ ] ,
webhookAuthHeader : defaults ? summarizeSecretForLog ( defaults . webhookAuthHeader ) : null ,
openClawAuthHeader : summarizeSecretForLog ( openClawAuthHeaderValue ) ,
} ;
}
2026-03-02 16:43:59 -06:00
function buildJoinConnectivityDiagnostics ( input : {
deploymentMode : DeploymentMode ;
deploymentExposure : DeploymentExposure ;
bindHost : string ;
allowedHostnames : string [ ] ;
callbackUrl : URL | null ;
} ) : JoinDiagnostic [ ] {
const diagnostics : JoinDiagnostic [ ] = [ ] ;
const bindHost = normalizeHostname ( input . bindHost ) ;
const callbackHost = input . callbackUrl ? normalizeHostname ( input . callbackUrl . hostname ) : null ;
const allowSet = new Set (
input . allowedHostnames
. map ( ( entry ) = > normalizeHostname ( entry ) )
. filter ( ( entry ) : entry is string = > Boolean ( entry ) ) ,
) ;
diagnostics . push ( {
code : "openclaw_deployment_context" ,
level : "info" ,
message : ` Deployment context: mode= ${ input . deploymentMode } , exposure= ${ input . deploymentExposure } . ` ,
} ) ;
if ( input . deploymentMode === "authenticated" && input . deploymentExposure === "private" ) {
if ( ! bindHost || isLoopbackHost ( bindHost ) ) {
diagnostics . push ( {
code : "openclaw_private_bind_loopback" ,
level : "warn" ,
message : "Paperclip is bound to loopback in authenticated/private mode." ,
hint : "Bind to a reachable private hostname/IP for remote OpenClaw callbacks." ,
} ) ;
}
if ( bindHost && ! isLoopbackHost ( bindHost ) && ! allowSet . has ( bindHost ) ) {
diagnostics . push ( {
code : "openclaw_private_bind_not_allowed" ,
level : "warn" ,
message : ` Paperclip bind host \ " ${ bindHost } \ " is not in allowed hostnames. ` ,
2026-03-03 08:45:26 -06:00
hint : ` Run pnpm paperclipai allowed-hostname ${ bindHost } ` ,
2026-03-02 16:43:59 -06:00
} ) ;
}
if ( callbackHost && ! isLoopbackHost ( callbackHost ) && allowSet . size === 0 ) {
diagnostics . push ( {
code : "openclaw_private_allowed_hostnames_empty" ,
level : "warn" ,
message : "No explicit allowed hostnames are configured for authenticated/private mode." ,
2026-03-03 08:45:26 -06:00
hint : "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs off-host." ,
2026-03-02 16:43:59 -06:00
} ) ;
}
}
if (
input . deploymentMode === "authenticated" &&
input . deploymentExposure === "public" &&
input . callbackUrl &&
input . callbackUrl . protocol !== "https:"
) {
diagnostics . push ( {
code : "openclaw_public_http_callback" ,
level : "warn" ,
message : "OpenClaw callback URL uses HTTP in authenticated/public mode." ,
hint : "Prefer HTTPS for public deployments." ,
} ) ;
}
return diagnostics ;
}
function normalizeAgentDefaultsForJoin ( input : {
adapterType : string | null ;
defaultsPayload : unknown ;
deploymentMode : DeploymentMode ;
deploymentExposure : DeploymentExposure ;
bindHost : string ;
allowedHostnames : string [ ] ;
} ) {
const diagnostics : JoinDiagnostic [ ] = [ ] ;
if ( input . adapterType !== "openclaw" ) {
const normalized = isPlainObject ( input . defaultsPayload )
? ( input . defaultsPayload as Record < string , unknown > )
: null ;
return { normalized , diagnostics } ;
}
if ( ! isPlainObject ( input . defaultsPayload ) ) {
diagnostics . push ( {
code : "openclaw_callback_config_missing" ,
level : "warn" ,
message : "No OpenClaw callback config was provided in agentDefaultsPayload." ,
2026-03-05 15:54:55 -06:00
hint : "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw SSE endpoint immediately after approval." ,
2026-03-02 16:43:59 -06:00
} ) ;
return { normalized : null as Record < string , unknown > | null , diagnostics } ;
}
const defaults = input . defaultsPayload as Record < string , unknown > ;
2026-03-05 15:54:55 -06:00
const normalized : Record < string , unknown > = { streamTransport : "sse" } ;
2026-03-02 16:43:59 -06:00
let callbackUrl : URL | null = null ;
const rawUrl = typeof defaults . url === "string" ? defaults . url . trim ( ) : "" ;
if ( ! rawUrl ) {
diagnostics . push ( {
code : "openclaw_callback_url_missing" ,
level : "warn" ,
message : "OpenClaw callback URL is missing." ,
2026-03-05 15:54:55 -06:00
hint : "Set agentDefaultsPayload.url to your OpenClaw SSE endpoint." ,
2026-03-02 16:43:59 -06:00
} ) ;
} else {
try {
callbackUrl = new URL ( rawUrl ) ;
if ( callbackUrl . protocol !== "http:" && callbackUrl . protocol !== "https:" ) {
diagnostics . push ( {
code : "openclaw_callback_url_protocol" ,
level : "warn" ,
message : ` Unsupported callback protocol: ${ callbackUrl . protocol } ` ,
hint : "Use http:// or https://." ,
} ) ;
} else {
normalized . url = callbackUrl . toString ( ) ;
diagnostics . push ( {
code : "openclaw_callback_url_configured" ,
level : "info" ,
message : ` Callback endpoint set to ${ callbackUrl . toString ( ) } ` ,
} ) ;
}
2026-03-05 15:54:55 -06:00
if ( isWakePath ( callbackUrl . pathname ) ) {
diagnostics . push ( {
code : "openclaw_callback_wake_path_incompatible" ,
level : "warn" ,
message : "Configured callback path targets /hooks/wake, which is not stream-capable for strict SSE mode." ,
hint : "Use an endpoint that returns text/event-stream for the full run duration." ,
} ) ;
}
2026-03-02 16:43:59 -06:00
if ( isLoopbackHost ( callbackUrl . hostname ) ) {
diagnostics . push ( {
code : "openclaw_callback_loopback" ,
level : "warn" ,
message : "OpenClaw callback endpoint uses loopback hostname." ,
hint : "Use a reachable hostname/IP when OpenClaw runs on another machine." ,
} ) ;
}
} catch {
diagnostics . push ( {
code : "openclaw_callback_url_invalid" ,
level : "warn" ,
message : ` Invalid callback URL: ${ rawUrl } ` ,
} ) ;
}
}
const rawMethod = typeof defaults . method === "string" ? defaults . method . trim ( ) . toUpperCase ( ) : "" ;
normalized . method = rawMethod || "POST" ;
if ( typeof defaults . timeoutSec === "number" && Number . isFinite ( defaults . timeoutSec ) ) {
2026-03-05 15:54:55 -06:00
normalized . timeoutSec = Math . max ( 0 , Math . min ( 7200 , Math . floor ( defaults . timeoutSec ) ) ) ;
2026-03-02 16:43:59 -06:00
}
const headers = normalizeHeaderMap ( defaults . headers ) ;
if ( headers ) normalized . headers = headers ;
if ( typeof defaults . webhookAuthHeader === "string" && defaults . webhookAuthHeader . trim ( ) ) {
normalized . webhookAuthHeader = defaults . webhookAuthHeader . trim ( ) ;
}
if ( isPlainObject ( defaults . payloadTemplate ) ) {
normalized . payloadTemplate = defaults . payloadTemplate ;
}
2026-03-06 08:39:29 -06:00
const rawPaperclipApiUrl = typeof defaults . paperclipApiUrl === "string"
? defaults . paperclipApiUrl . trim ( )
: "" ;
if ( rawPaperclipApiUrl ) {
try {
const parsedPaperclipApiUrl = new URL ( rawPaperclipApiUrl ) ;
if ( parsedPaperclipApiUrl . protocol !== "http:" && parsedPaperclipApiUrl . protocol !== "https:" ) {
diagnostics . push ( {
code : "openclaw_paperclip_api_url_protocol" ,
level : "warn" ,
message : ` paperclipApiUrl must use http:// or https:// (got ${ parsedPaperclipApiUrl . protocol } ). ` ,
} ) ;
} else {
normalized . paperclipApiUrl = parsedPaperclipApiUrl . toString ( ) ;
diagnostics . push ( {
code : "openclaw_paperclip_api_url_configured" ,
level : "info" ,
message : ` paperclipApiUrl set to ${ parsedPaperclipApiUrl . toString ( ) } ` ,
} ) ;
if ( isLoopbackHost ( parsedPaperclipApiUrl . hostname ) ) {
diagnostics . push ( {
code : "openclaw_paperclip_api_url_loopback" ,
level : "warn" ,
message :
"paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host." ,
hint : "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments." ,
} ) ;
}
}
} catch {
diagnostics . push ( {
code : "openclaw_paperclip_api_url_invalid" ,
level : "warn" ,
message : ` Invalid paperclipApiUrl: ${ rawPaperclipApiUrl } ` ,
} ) ;
}
}
2026-03-02 16:43:59 -06:00
diagnostics . push (
. . . buildJoinConnectivityDiagnostics ( {
deploymentMode : input.deploymentMode ,
deploymentExposure : input.deploymentExposure ,
bindHost : input.bindHost ,
allowedHostnames : input.allowedHostnames ,
callbackUrl ,
} ) ,
) ;
return { normalized , diagnostics } ;
}
2026-02-26 16:33:20 -06:00
function toInviteSummaryResponse ( req : Request , token : string , invite : typeof invites . $inferSelect ) {
const baseUrl = requestBaseUrl ( req ) ;
const onboardingPath = ` /api/invites/ ${ token } /onboarding ` ;
2026-03-04 16:29:14 -06:00
const onboardingTextPath = ` /api/invites/ ${ token } /onboarding.txt ` ;
2026-03-05 12:10:01 -06:00
const inviteMessage = extractInviteMessage ( invite ) ;
2026-02-26 16:33:20 -06:00
return {
id : invite.id ,
companyId : invite.companyId ,
inviteType : invite.inviteType ,
allowedJoinTypes : invite.allowedJoinTypes ,
expiresAt : invite.expiresAt ,
onboardingPath ,
onboardingUrl : baseUrl ? ` ${ baseUrl } ${ onboardingPath } ` : onboardingPath ,
2026-03-04 16:29:14 -06:00
onboardingTextPath ,
onboardingTextUrl : baseUrl ? ` ${ baseUrl } ${ onboardingTextPath } ` : onboardingTextPath ,
2026-02-26 16:33:20 -06:00
skillIndexPath : "/api/skills/index" ,
skillIndexUrl : baseUrl ? ` ${ baseUrl } /api/skills/index ` : "/api/skills/index" ,
2026-03-05 12:10:01 -06:00
inviteMessage ,
2026-02-26 16:33:20 -06:00
} ;
}
2026-03-04 16:29:14 -06:00
function buildOnboardingDiscoveryDiagnostics ( input : {
apiBaseUrl : string ;
deploymentMode : DeploymentMode ;
deploymentExposure : DeploymentExposure ;
bindHost : string ;
allowedHostnames : string [ ] ;
} ) : JoinDiagnostic [ ] {
const diagnostics : JoinDiagnostic [ ] = [ ] ;
let apiHost : string | null = null ;
if ( input . apiBaseUrl ) {
try {
apiHost = normalizeHostname ( new URL ( input . apiBaseUrl ) . hostname ) ;
} catch {
apiHost = null ;
}
}
const bindHost = normalizeHostname ( input . bindHost ) ;
const allowSet = new Set (
input . allowedHostnames
. map ( ( entry ) = > normalizeHostname ( entry ) )
. filter ( ( entry ) : entry is string = > Boolean ( entry ) ) ,
) ;
if ( apiHost && isLoopbackHost ( apiHost ) ) {
diagnostics . push ( {
code : "openclaw_onboarding_api_loopback" ,
level : "warn" ,
message :
"Onboarding URL resolves to loopback hostname. Remote OpenClaw agents cannot reach localhost on your Paperclip host." ,
hint : "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain)." ,
} ) ;
}
if (
input . deploymentMode === "authenticated" &&
input . deploymentExposure === "private" &&
( ! bindHost || isLoopbackHost ( bindHost ) )
) {
diagnostics . push ( {
code : "openclaw_onboarding_private_loopback_bind" ,
level : "warn" ,
message : "Paperclip is bound to loopback in authenticated/private mode." ,
hint : "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding." ,
} ) ;
}
if (
input . deploymentMode === "authenticated" &&
input . deploymentExposure === "private" &&
apiHost &&
! isLoopbackHost ( apiHost ) &&
allowSet . size > 0 &&
! allowSet . has ( apiHost )
) {
diagnostics . push ( {
code : "openclaw_onboarding_private_host_not_allowed" ,
level : "warn" ,
message : ` Onboarding host " ${ apiHost } " is not in allowed hostnames for authenticated/private mode. ` ,
hint : ` Run pnpm paperclipai allowed-hostname ${ apiHost } ` ,
} ) ;
}
return diagnostics ;
}
2026-03-05 12:28:27 -06:00
function buildOnboardingConnectionCandidates ( input : {
apiBaseUrl : string ;
bindHost : string ;
allowedHostnames : string [ ] ;
} ) : string [ ] {
let base : URL | null = null ;
try {
if ( input . apiBaseUrl ) {
base = new URL ( input . apiBaseUrl ) ;
}
} catch {
base = null ;
}
const protocol = base ? . protocol ? ? "http:" ;
const port = base ? . port ? ` : ${ base . port } ` : "" ;
const candidates = new Set < string > ( ) ;
if ( base ) {
candidates . add ( base . origin ) ;
}
const bindHost = normalizeHostname ( input . bindHost ) ;
if ( bindHost && ! isLoopbackHost ( bindHost ) ) {
candidates . add ( ` ${ protocol } // ${ bindHost } ${ port } ` ) ;
}
for ( const rawHost of input . allowedHostnames ) {
const host = normalizeHostname ( rawHost ) ;
if ( ! host ) continue ;
candidates . add ( ` ${ protocol } // ${ host } ${ port } ` ) ;
}
if ( base && isLoopbackHost ( base . hostname ) ) {
candidates . add ( ` ${ protocol } //host.docker.internal ${ port } ` ) ;
}
return Array . from ( candidates ) ;
}
2026-03-02 16:43:59 -06:00
function buildInviteOnboardingManifest (
req : Request ,
token : string ,
invite : typeof invites . $inferSelect ,
opts : {
deploymentMode : DeploymentMode ;
deploymentExposure : DeploymentExposure ;
bindHost : string ;
allowedHostnames : string [ ] ;
} ,
) {
2026-02-26 16:33:20 -06:00
const baseUrl = requestBaseUrl ( req ) ;
const skillPath = "/api/skills/paperclip" ;
const skillUrl = baseUrl ? ` ${ baseUrl } ${ skillPath } ` : skillPath ;
const registrationEndpointPath = ` /api/invites/ ${ token } /accept ` ;
const registrationEndpointUrl = baseUrl ? ` ${ baseUrl } ${ registrationEndpointPath } ` : registrationEndpointPath ;
2026-03-04 16:29:14 -06:00
const onboardingTextPath = ` /api/invites/ ${ token } /onboarding.txt ` ;
const onboardingTextUrl = baseUrl ? ` ${ baseUrl } ${ onboardingTextPath } ` : onboardingTextPath ;
2026-03-05 13:05:04 -06:00
const testResolutionPath = ` /api/invites/ ${ token } /test-resolution ` ;
const testResolutionUrl = baseUrl ? ` ${ baseUrl } ${ testResolutionPath } ` : testResolutionPath ;
2026-03-04 16:29:14 -06:00
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics ( {
apiBaseUrl : baseUrl ,
deploymentMode : opts.deploymentMode ,
deploymentExposure : opts.deploymentExposure ,
bindHost : opts.bindHost ,
allowedHostnames : opts.allowedHostnames ,
} ) ;
2026-03-05 12:28:27 -06:00
const connectionCandidates = buildOnboardingConnectionCandidates ( {
apiBaseUrl : baseUrl ,
bindHost : opts.bindHost ,
allowedHostnames : opts.allowedHostnames ,
} ) ;
2026-02-26 16:33:20 -06:00
return {
invite : toInviteSummaryResponse ( req , token , invite ) ,
onboarding : {
instructions :
"Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops." ,
2026-03-05 12:10:01 -06:00
inviteMessage : extractInviteMessage ( invite ) ,
2026-02-26 16:33:20 -06:00
recommendedAdapterType : "openclaw" ,
requiredFields : {
requestType : "agent" ,
agentName : "Display name for this agent" ,
2026-03-05 15:54:55 -06:00
adapterType : "Use 'openclaw' for OpenClaw streaming agents" ,
2026-02-26 16:33:20 -06:00
capabilities : "Optional capability summary" ,
agentDefaultsPayload :
2026-03-06 08:39:29 -06:00
"Optional adapter config such as url/method/headers/webhookAuthHeader and paperclipApiUrl for OpenClaw SSE endpoint" ,
2026-02-26 16:33:20 -06:00
} ,
registrationEndpoint : {
method : "POST" ,
path : registrationEndpointPath ,
url : registrationEndpointUrl ,
} ,
claimEndpointTemplate : {
method : "POST" ,
path : "/api/join-requests/{requestId}/claim-api-key" ,
body : {
claimSecret : "one-time claim secret returned when the join request is created" ,
} ,
} ,
2026-03-02 16:43:59 -06:00
connectivity : {
deploymentMode : opts.deploymentMode ,
deploymentExposure : opts.deploymentExposure ,
bindHost : opts.bindHost ,
allowedHostnames : opts.allowedHostnames ,
2026-03-05 12:28:27 -06:00
connectionCandidates ,
2026-03-05 13:05:04 -06:00
testResolutionEndpoint : {
method : "GET" ,
path : testResolutionPath ,
url : testResolutionUrl ,
query : {
2026-03-05 15:54:55 -06:00
url : "https://your-openclaw-agent.example/v1/responses" ,
2026-03-05 13:05:04 -06:00
timeoutMs : 5000 ,
} ,
} ,
2026-03-04 16:29:14 -06:00
diagnostics : discoveryDiagnostics ,
2026-03-02 16:43:59 -06:00
guidance :
opts . deploymentMode === "authenticated" && opts . deploymentExposure === "private"
2026-03-03 08:45:26 -06:00
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
2026-03-02 16:43:59 -06:00
: "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims." ,
} ,
2026-03-04 16:29:14 -06:00
textInstructions : {
path : onboardingTextPath ,
url : onboardingTextUrl ,
contentType : "text/plain" ,
} ,
2026-02-26 16:33:20 -06:00
skill : {
name : "paperclip" ,
path : skillPath ,
url : skillUrl ,
installPath : "~/.openclaw/skills/paperclip/SKILL.md" ,
} ,
} ,
} ;
}
2026-03-04 16:29:14 -06:00
export function buildInviteOnboardingTextDocument (
req : Request ,
token : string ,
invite : typeof invites . $inferSelect ,
opts : {
deploymentMode : DeploymentMode ;
deploymentExposure : DeploymentExposure ;
bindHost : string ;
allowedHostnames : string [ ] ;
} ,
) {
const manifest = buildInviteOnboardingManifest ( req , token , invite , opts ) ;
const onboarding = manifest . onboarding as {
2026-03-05 12:10:01 -06:00
inviteMessage? : string | null ;
2026-03-04 16:29:14 -06:00
registrationEndpoint : { method : string ; path : string ; url : string } ;
claimEndpointTemplate : { method : string ; path : string } ;
textInstructions : { path : string ; url : string } ;
skill : { path : string ; url : string ; installPath : string } ;
2026-03-05 13:05:04 -06:00
connectivity : {
diagnostics? : JoinDiagnostic [ ] ;
guidance? : string ;
connectionCandidates? : string [ ] ;
testResolutionEndpoint ? : { method? : string ; path? : string ; url? : string } ;
} ;
2026-03-04 16:29:14 -06:00
} ;
const diagnostics = Array . isArray ( onboarding . connectivity ? . diagnostics )
? onboarding . connectivity . diagnostics
: [ ] ;
const lines = [
"# Paperclip OpenClaw Onboarding" ,
"" ,
"This document is meant to be readable by both humans and agents." ,
"" ,
"## Invite" ,
` - inviteType: ${ invite . inviteType } ` ,
` - allowedJoinTypes: ${ invite . allowedJoinTypes } ` ,
` - expiresAt: ${ invite . expiresAt . toISOString ( ) } ` ,
"" ,
2026-03-05 12:10:01 -06:00
] ;
if ( onboarding . inviteMessage ) {
lines . push ( "## Message from inviter" , onboarding . inviteMessage , "" ) ;
}
lines . push (
2026-03-04 16:29:14 -06:00
"## Step 1: Submit agent join request" ,
` ${ onboarding . registrationEndpoint . method } ${ onboarding . registrationEndpoint . url } ` ,
"" ,
"Body (JSON):" ,
"{" ,
' "requestType": "agent",' ,
' "agentName": "My OpenClaw Agent",' ,
' "adapterType": "openclaw",' ,
' "capabilities": "Optional summary",' ,
' "agentDefaultsPayload": {' ,
2026-03-05 15:54:55 -06:00
' "url": "https://your-openclaw-agent.example/v1/responses",' ,
2026-03-06 08:39:29 -06:00
' "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",' ,
2026-03-05 15:54:55 -06:00
' "streamTransport": "sse",' ,
2026-03-04 16:29:14 -06:00
' "method": "POST",' ,
' "headers": { "x-openclaw-auth": "replace-me" },' ,
2026-03-05 15:54:55 -06:00
' "timeoutSec": 0' ,
2026-03-04 16:29:14 -06:00
" }" ,
"}" ,
"" ,
"Expected response includes:" ,
"- request id" ,
"- one-time claimSecret" ,
"- claimApiKeyPath" ,
"" ,
"## Step 2: Wait for board approval" ,
"The board approves the join request in Paperclip before key claim is allowed." ,
"" ,
"## Step 3: Claim API key (one-time)" ,
` ${ onboarding . claimEndpointTemplate . method } /api/join-requests/{requestId}/claim-api-key ` ,
"" ,
"Body (JSON):" ,
"{" ,
' "claimSecret": "<one-time-claim-secret>"' ,
"}" ,
"" ,
"Important:" ,
"- claim secrets expire" ,
"- claim secrets are single-use" ,
"- claim fails before board approval" ,
"" ,
"## Step 4: Install Paperclip skill in OpenClaw" ,
` GET ${ onboarding . skill . url } ` ,
` Install path: ${ onboarding . skill . installPath } ` ,
"" ,
"## Text onboarding URL" ,
` ${ onboarding . textInstructions . url } ` ,
"" ,
"## Connectivity guidance" ,
onboarding . connectivity ? . guidance ? ? "Ensure Paperclip is reachable from your OpenClaw runtime." ,
2026-03-05 12:10:01 -06:00
) ;
2026-03-04 16:29:14 -06:00
2026-03-05 13:05:04 -06:00
if ( onboarding . connectivity ? . testResolutionEndpoint ? . url ) {
lines . push (
"" ,
"## Optional: test callback resolution from Paperclip" ,
2026-03-05 15:54:55 -06:00
` ${ onboarding . connectivity . testResolutionEndpoint . method ? ? "GET" } ${ onboarding . connectivity . testResolutionEndpoint . url } ?url=https%3A%2F%2Fyour-openclaw-agent.example%2Fv1%2Fresponses ` ,
2026-03-05 13:05:04 -06:00
"" ,
2026-03-05 15:54:55 -06:00
"This endpoint checks whether Paperclip can reach your OpenClaw endpoint and reports reachable, timeout, or unreachable." ,
2026-03-05 13:05:04 -06:00
) ;
}
2026-03-05 12:28:27 -06:00
const connectionCandidates = Array . isArray ( onboarding . connectivity ? . connectionCandidates )
? onboarding . connectivity . connectionCandidates . filter ( ( entry ) : entry is string = > Boolean ( entry ) )
: [ ] ;
if ( connectionCandidates . length > 0 ) {
lines . push ( "" , "## Suggested Paperclip base URLs to try" ) ;
for ( const candidate of connectionCandidates ) {
lines . push ( ` - ${ candidate } ` ) ;
}
lines . push (
"" ,
"Test each candidate with:" ,
"- GET <candidate>/api/health" ,
2026-03-06 08:39:29 -06:00
"- set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request" ,
2026-03-05 12:28:27 -06:00
"" ,
"If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration." ,
"For authenticated/private mode, they may need:" ,
"- pnpm paperclipai allowed-hostname <host>" ,
"- then restart Paperclip and retry onboarding." ,
) ;
}
2026-03-04 16:29:14 -06:00
if ( diagnostics . length > 0 ) {
lines . push ( "" , "## Connectivity diagnostics" ) ;
for ( const diag of diagnostics ) {
lines . push ( ` - [ ${ diag . level } ] ${ diag . message } ` ) ;
if ( diag . hint ) lines . push ( ` hint: ${ diag . hint } ` ) ;
}
}
lines . push (
"" ,
"## Helpful endpoints" ,
` ${ onboarding . registrationEndpoint . path } ` ,
` ${ onboarding . claimEndpointTemplate . path } ` ,
` ${ onboarding . skill . path } ` ,
manifest . invite . onboardingPath ,
) ;
2026-03-05 13:05:04 -06:00
if ( onboarding . connectivity ? . testResolutionEndpoint ? . path ) {
lines . push ( ` ${ onboarding . connectivity . testResolutionEndpoint . path } ` ) ;
}
2026-03-04 16:29:14 -06:00
return ` ${ lines . join ( "\n" ) } \ n ` ;
}
2026-03-05 12:10:01 -06:00
function extractInviteMessage ( invite : typeof invites . $inferSelect ) : string | null {
const rawDefaults = invite . defaultsPayload ;
if ( ! rawDefaults || typeof rawDefaults !== "object" || Array . isArray ( rawDefaults ) ) {
return null ;
}
const rawMessage = ( rawDefaults as Record < string , unknown > ) . agentMessage ;
if ( typeof rawMessage !== "string" ) {
return null ;
}
const trimmed = rawMessage . trim ( ) ;
return trimmed . length ? trimmed : null ;
}
function mergeInviteDefaults (
defaultsPayload : Record < string , unknown > | null | undefined ,
agentMessage : string | null ,
) : Record < string , unknown > | null {
const merged = defaultsPayload && typeof defaultsPayload === "object"
? { . . . defaultsPayload }
: { } ;
if ( agentMessage ) {
merged . agentMessage = agentMessage ;
}
return Object . keys ( merged ) . length ? merged : null ;
}
2026-02-23 14:40:32 -06:00
function requestIp ( req : Request ) {
const forwarded = req . header ( "x-forwarded-for" ) ;
if ( forwarded ) {
const first = forwarded . split ( "," ) [ 0 ] ? . trim ( ) ;
if ( first ) return first ;
}
return req . ip || "unknown" ;
}
function inviteExpired ( invite : typeof invites . $inferSelect ) {
return invite . expiresAt . getTime ( ) <= Date . now ( ) ;
}
function isLocalImplicit ( req : Request ) {
return req . actor . type === "board" && req . actor . source === "local_implicit" ;
}
async function resolveActorEmail ( db : Db , req : Request ) : Promise < string | null > {
if ( isLocalImplicit ( req ) ) return "local@paperclip.local" ;
const userId = req . actor . userId ;
if ( ! userId ) return null ;
const user = await db
. select ( { email : authUsers.email } )
. from ( authUsers )
. where ( eq ( authUsers . id , userId ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
return user ? . email ? ? null ;
}
function grantsFromDefaults (
defaultsPayload : Record < string , unknown > | null | undefined ,
key : "human" | "agent" ,
) : Array < { permissionKey : ( typeof PERMISSION_KEYS ) [ number ] ; scope : Record < string , unknown > | null } > {
if ( ! defaultsPayload || typeof defaultsPayload !== "object" ) return [ ] ;
const scoped = defaultsPayload [ key ] ;
if ( ! scoped || typeof scoped !== "object" ) return [ ] ;
const grants = ( scoped as Record < string , unknown > ) . grants ;
if ( ! Array . isArray ( grants ) ) return [ ] ;
const validPermissionKeys = new Set < string > ( PERMISSION_KEYS ) ;
const result : Array < {
permissionKey : ( typeof PERMISSION_KEYS ) [ number ] ;
scope : Record < string , unknown > | null ;
} > = [ ] ;
for ( const item of grants ) {
if ( ! item || typeof item !== "object" ) continue ;
const record = item as Record < string , unknown > ;
if ( typeof record . permissionKey !== "string" ) continue ;
if ( ! validPermissionKeys . has ( record . permissionKey ) ) continue ;
result . push ( {
permissionKey : record.permissionKey as ( typeof PERMISSION_KEYS ) [ number ] ,
scope :
record . scope && typeof record . scope === "object" && ! Array . isArray ( record . scope )
? ( record . scope as Record < string , unknown > )
: null ,
} ) ;
}
return result ;
}
2026-03-05 12:52:39 -06:00
function isInviteTokenHashCollisionError ( error : unknown ) {
const candidates = [
error ,
( error as { cause? : unknown } | null ) ? . cause ? ? null ,
] ;
for ( const candidate of candidates ) {
if ( ! candidate || typeof candidate !== "object" ) continue ;
const code = "code" in candidate && typeof candidate . code === "string" ? candidate.code : null ;
const message = "message" in candidate && typeof candidate . message === "string" ? candidate . message : "" ;
const constraint = "constraint" in candidate && typeof candidate . constraint === "string"
? candidate . constraint
: null ;
if ( code !== "23505" ) continue ;
if ( constraint === "invites_token_hash_unique_idx" ) return true ;
if ( message . includes ( "invites_token_hash_unique_idx" ) ) return true ;
}
return false ;
}
2026-03-05 13:05:04 -06:00
function isAbortError ( error : unknown ) {
return error instanceof Error && error . name === "AbortError" ;
}
type InviteResolutionProbe = {
status : "reachable" | "timeout" | "unreachable" ;
method : "HEAD" ;
durationMs : number ;
httpStatus : number | null ;
message : string ;
} ;
async function probeInviteResolutionTarget ( url : URL , timeoutMs : number ) : Promise < InviteResolutionProbe > {
const startedAt = Date . now ( ) ;
const controller = new AbortController ( ) ;
const timeout = setTimeout ( ( ) = > controller . abort ( ) , timeoutMs ) ;
try {
const response = await fetch ( url , {
method : "HEAD" ,
redirect : "manual" ,
signal : controller.signal ,
} ) ;
const durationMs = Date . now ( ) - startedAt ;
if (
response . ok ||
response . status === 401 ||
response . status === 403 ||
response . status === 404 ||
response . status === 405 ||
response . status === 422 ||
response . status === 500 ||
response . status === 501
) {
return {
status : "reachable" ,
method : "HEAD" ,
durationMs ,
httpStatus : response.status ,
message : ` Webhook endpoint responded to HEAD with HTTP ${ response . status } . ` ,
} ;
}
return {
status : "unreachable" ,
method : "HEAD" ,
durationMs ,
httpStatus : response.status ,
message : ` Webhook endpoint probe returned HTTP ${ response . status } . ` ,
} ;
} catch ( error ) {
const durationMs = Date . now ( ) - startedAt ;
if ( isAbortError ( error ) ) {
return {
status : "timeout" ,
method : "HEAD" ,
durationMs ,
httpStatus : null ,
message : ` Webhook endpoint probe timed out after ${ timeoutMs } ms. ` ,
} ;
}
return {
status : "unreachable" ,
method : "HEAD" ,
durationMs ,
httpStatus : null ,
message : error instanceof Error ? error . message : "Webhook endpoint probe failed." ,
} ;
} finally {
clearTimeout ( timeout ) ;
}
}
2026-03-02 16:43:59 -06:00
export function accessRoutes (
db : Db ,
opts : {
deploymentMode : DeploymentMode ;
deploymentExposure : DeploymentExposure ;
bindHost : string ;
allowedHostnames : string [ ] ;
} ,
) {
2026-02-23 14:40:32 -06:00
const router = Router ( ) ;
const access = accessService ( db ) ;
const agents = agentService ( db ) ;
async function assertInstanceAdmin ( req : Request ) {
if ( req . actor . type !== "board" ) throw unauthorized ( ) ;
if ( isLocalImplicit ( req ) ) return ;
const allowed = await access . isInstanceAdmin ( req . actor . userId ) ;
if ( ! allowed ) throw forbidden ( "Instance admin required" ) ;
}
2026-02-23 16:25:31 -06:00
router . get ( "/board-claim/:token" , async ( req , res ) = > {
const token = ( req . params . token as string ) . trim ( ) ;
const code = typeof req . query . code === "string" ? req . query . code . trim ( ) : undefined ;
if ( ! token ) throw notFound ( "Board claim challenge not found" ) ;
const challenge = inspectBoardClaimChallenge ( token , code ) ;
if ( challenge . status === "invalid" ) throw notFound ( "Board claim challenge not found" ) ;
res . json ( challenge ) ;
} ) ;
router . post ( "/board-claim/:token/claim" , async ( req , res ) = > {
const token = ( req . params . token as string ) . trim ( ) ;
const code = typeof req . body ? . code === "string" ? req . body . code . trim ( ) : undefined ;
if ( ! token ) throw notFound ( "Board claim challenge not found" ) ;
if ( ! code ) throw badRequest ( "Claim code is required" ) ;
if ( req . actor . type !== "board" || req . actor . source !== "session" || ! req . actor . userId ) {
throw unauthorized ( "Sign in before claiming board ownership" ) ;
}
const claimed = await claimBoardOwnership ( db , {
token ,
code ,
userId : req.actor.userId ,
} ) ;
if ( claimed . status === "invalid" ) throw notFound ( "Board claim challenge not found" ) ;
if ( claimed . status === "expired" ) throw conflict ( "Board claim challenge expired. Restart server to generate a new one." ) ;
if ( claimed . status === "claimed" ) {
res . json ( { claimed : true , userId : claimed.claimedByUserId ? ? req . actor . userId } ) ;
return ;
}
throw conflict ( "Board claim challenge is no longer available" ) ;
} ) ;
2026-02-23 14:40:32 -06:00
async function assertCompanyPermission ( req : Request , companyId : string , permissionKey : any ) {
assertCompanyAccess ( req , companyId ) ;
if ( req . actor . type === "agent" ) {
if ( ! req . actor . agentId ) throw forbidden ( ) ;
const allowed = await access . hasPermission ( companyId , "agent" , req . actor . agentId , permissionKey ) ;
if ( ! allowed ) throw forbidden ( "Permission denied" ) ;
return ;
}
if ( req . actor . type !== "board" ) throw unauthorized ( ) ;
if ( isLocalImplicit ( req ) ) return ;
const allowed = await access . canUser ( companyId , req . actor . userId , permissionKey ) ;
if ( ! allowed ) throw forbidden ( "Permission denied" ) ;
}
2026-02-26 16:33:20 -06:00
router . get ( "/skills/index" , ( _req , res ) = > {
res . json ( {
skills : [
{ name : "paperclip" , path : "/api/skills/paperclip" } ,
{ name : "paperclip-create-agent" , path : "/api/skills/paperclip-create-agent" } ,
] ,
} ) ;
} ) ;
router . get ( "/skills/:skillName" , ( req , res ) = > {
const skillName = ( req . params . skillName as string ) . trim ( ) . toLowerCase ( ) ;
const markdown = readSkillMarkdown ( skillName ) ;
if ( ! markdown ) throw notFound ( "Skill not found" ) ;
res . type ( "text/markdown" ) . send ( markdown ) ;
} ) ;
2026-02-23 14:40:32 -06:00
router . post (
"/companies/:companyId/invites" ,
validate ( createCompanyInviteSchema ) ,
async ( req , res ) = > {
const companyId = req . params . companyId as string ;
await assertCompanyPermission ( req , companyId , "users:invite" ) ;
2026-03-05 12:10:01 -06:00
const normalizedAgentMessage = typeof req . body . agentMessage === "string"
? req . body . agentMessage . trim ( ) || null
: null ;
2026-03-05 12:52:39 -06:00
const insertValues = {
companyId ,
inviteType : "company_join" as const ,
allowedJoinTypes : req.body.allowedJoinTypes ,
defaultsPayload : mergeInviteDefaults ( req . body . defaultsPayload ? ? null , normalizedAgentMessage ) ,
2026-03-06 10:10:23 -06:00
expiresAt : companyInviteExpiresAt ( ) ,
2026-03-05 12:52:39 -06:00
invitedByUserId : req.actor.userId ? ? null ,
} ;
let token : string | null = null ;
let created : typeof invites . $inferSelect | null = null ;
for ( let attempt = 0 ; attempt < INVITE_TOKEN_MAX_RETRIES ; attempt += 1 ) {
const candidateToken = createInviteToken ( ) ;
try {
const row = await db
. insert ( invites )
. values ( {
. . . insertValues ,
tokenHash : hashToken ( candidateToken ) ,
} )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ) ;
token = candidateToken ;
created = row ;
break ;
} catch ( error ) {
if ( ! isInviteTokenHashCollisionError ( error ) ) {
throw error ;
}
}
}
if ( ! token || ! created ) {
throw conflict ( "Failed to generate a unique invite token. Please retry." ) ;
}
2026-02-23 14:40:32 -06:00
await logActivity ( db , {
companyId ,
actorType : req.actor.type === "agent" ? "agent" : "user" ,
actorId : req.actor.type === "agent" ? req . actor . agentId ? ? "unknown-agent" : req . actor . userId ? ? "board" ,
action : "invite.created" ,
entityType : "invite" ,
entityId : created.id ,
details : {
inviteType : created.inviteType ,
allowedJoinTypes : created.allowedJoinTypes ,
expiresAt : created.expiresAt.toISOString ( ) ,
2026-03-05 12:10:01 -06:00
hasAgentMessage : Boolean ( normalizedAgentMessage ) ,
2026-02-23 14:40:32 -06:00
} ,
} ) ;
2026-03-05 12:10:01 -06:00
const inviteSummary = toInviteSummaryResponse ( req , token , created ) ;
2026-02-23 14:40:32 -06:00
res . status ( 201 ) . json ( {
. . . created ,
token ,
inviteUrl : ` /invite/ ${ token } ` ,
2026-03-05 12:10:01 -06:00
onboardingTextPath : inviteSummary.onboardingTextPath ,
onboardingTextUrl : inviteSummary.onboardingTextUrl ,
inviteMessage : inviteSummary.inviteMessage ,
2026-02-23 14:40:32 -06:00
} ) ;
} ,
) ;
router . get ( "/invites/:token" , async ( req , res ) = > {
const token = ( req . params . token as string ) . trim ( ) ;
if ( ! token ) throw notFound ( "Invite not found" ) ;
const invite = await db
. select ( )
. from ( invites )
. where ( eq ( invites . tokenHash , hashToken ( token ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! invite || invite . revokedAt || invite . acceptedAt || inviteExpired ( invite ) ) {
throw notFound ( "Invite not found" ) ;
}
2026-02-26 16:33:20 -06:00
res . json ( toInviteSummaryResponse ( req , token , invite ) ) ;
} ) ;
router . get ( "/invites/:token/onboarding" , async ( req , res ) = > {
const token = ( req . params . token as string ) . trim ( ) ;
if ( ! token ) throw notFound ( "Invite not found" ) ;
const invite = await db
. select ( )
. from ( invites )
. where ( eq ( invites . tokenHash , hashToken ( token ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! invite || invite . revokedAt || inviteExpired ( invite ) ) {
throw notFound ( "Invite not found" ) ;
}
2026-03-02 16:43:59 -06:00
res . json ( buildInviteOnboardingManifest ( req , token , invite , opts ) ) ;
2026-02-23 14:40:32 -06:00
} ) ;
2026-03-04 16:29:14 -06:00
router . get ( "/invites/:token/onboarding.txt" , async ( req , res ) = > {
const token = ( req . params . token as string ) . trim ( ) ;
if ( ! token ) throw notFound ( "Invite not found" ) ;
const invite = await db
. select ( )
. from ( invites )
. where ( eq ( invites . tokenHash , hashToken ( token ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! invite || invite . revokedAt || inviteExpired ( invite ) ) {
throw notFound ( "Invite not found" ) ;
}
res . type ( "text/plain; charset=utf-8" ) . send ( buildInviteOnboardingTextDocument ( req , token , invite , opts ) ) ;
} ) ;
2026-03-05 13:05:04 -06:00
router . get ( "/invites/:token/test-resolution" , async ( req , res ) = > {
const token = ( req . params . token as string ) . trim ( ) ;
if ( ! token ) throw notFound ( "Invite not found" ) ;
const invite = await db
. select ( )
. from ( invites )
. where ( eq ( invites . tokenHash , hashToken ( token ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! invite || invite . revokedAt || inviteExpired ( invite ) ) {
throw notFound ( "Invite not found" ) ;
}
const rawUrl = typeof req . query . url === "string" ? req . query . url . trim ( ) : "" ;
if ( ! rawUrl ) throw badRequest ( "url query parameter is required" ) ;
let target : URL ;
try {
target = new URL ( rawUrl ) ;
} catch {
throw badRequest ( "url must be an absolute http(s) URL" ) ;
}
if ( target . protocol !== "http:" && target . protocol !== "https:" ) {
throw badRequest ( "url must use http or https" ) ;
}
const parsedTimeoutMs = typeof req . query . timeoutMs === "string" ? Number ( req . query . timeoutMs ) : NaN ;
const timeoutMs = Number . isFinite ( parsedTimeoutMs )
? Math . max ( 1000 , Math . min ( 15000 , Math . floor ( parsedTimeoutMs ) ) )
: 5000 ;
const probe = await probeInviteResolutionTarget ( target , timeoutMs ) ;
res . json ( {
inviteId : invite.id ,
testResolutionPath : ` /api/invites/ ${ token } /test-resolution ` ,
requestedUrl : target.toString ( ) ,
timeoutMs ,
. . . probe ,
} ) ;
} ) ;
2026-02-23 14:40:32 -06:00
router . post ( "/invites/:token/accept" , validate ( acceptInviteSchema ) , async ( req , res ) = > {
const token = ( req . params . token as string ) . trim ( ) ;
if ( ! token ) throw notFound ( "Invite not found" ) ;
const invite = await db
. select ( )
. from ( invites )
. where ( eq ( invites . tokenHash , hashToken ( token ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! invite || invite . revokedAt || invite . acceptedAt || inviteExpired ( invite ) ) {
throw notFound ( "Invite not found" ) ;
}
if ( invite . inviteType === "bootstrap_ceo" ) {
if ( req . body . requestType !== "human" ) {
throw badRequest ( "Bootstrap invite requires human request type" ) ;
}
if ( req . actor . type !== "board" || ( ! req . actor . userId && ! isLocalImplicit ( req ) ) ) {
throw unauthorized ( "Authenticated user required for bootstrap acceptance" ) ;
}
const userId = req . actor . userId ? ? "local-board" ;
const existingAdmin = await access . isInstanceAdmin ( userId ) ;
if ( ! existingAdmin ) {
await access . promoteInstanceAdmin ( userId ) ;
}
const updatedInvite = await db
. update ( invites )
. set ( { acceptedAt : new Date ( ) , updatedAt : new Date ( ) } )
. where ( eq ( invites . id , invite . id ) )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ? ? invite ) ;
res . status ( 202 ) . json ( {
inviteId : updatedInvite.id ,
inviteType : updatedInvite.inviteType ,
bootstrapAccepted : true ,
userId ,
} ) ;
return ;
}
const requestType = req . body . requestType as "human" | "agent" ;
const companyId = invite . companyId ;
if ( ! companyId ) throw conflict ( "Invite is missing company scope" ) ;
if ( invite . allowedJoinTypes !== "both" && invite . allowedJoinTypes !== requestType ) {
throw badRequest ( ` Invite does not allow ${ requestType } joins ` ) ;
}
if ( requestType === "human" && req . actor . type !== "board" ) {
throw unauthorized ( "Human invite acceptance requires authenticated user" ) ;
}
if ( requestType === "human" && ! req . actor . userId && ! isLocalImplicit ( req ) ) {
throw unauthorized ( "Authenticated user is required" ) ;
}
if ( requestType === "agent" && ! req . body . agentName ) {
throw badRequest ( "agentName is required for agent join requests" ) ;
}
2026-03-06 09:36:20 -06:00
const openClawDefaultsPayload = requestType === "agent"
? buildJoinDefaultsPayloadForAccept ( {
adapterType : req.body.adapterType ? ? null ,
defaultsPayload : req.body.agentDefaultsPayload ? ? null ,
responsesWebhookUrl : req.body.responsesWebhookUrl ? ? null ,
responsesWebhookMethod : req.body.responsesWebhookMethod ? ? null ,
responsesWebhookHeaders : req.body.responsesWebhookHeaders ? ? null ,
paperclipApiUrl : req.body.paperclipApiUrl ? ? null ,
webhookAuthHeader : req.body.webhookAuthHeader ? ? null ,
inboundOpenClawAuthHeader : req.header ( "x-openclaw-auth" ) ? ? null ,
} )
: null ;
if ( requestType === "agent" && ( req . body . adapterType ? ? null ) === "openclaw" ) {
logger . info (
{
inviteId : invite.id ,
requestType ,
adapterType : req.body.adapterType ? ? null ,
bodyKeys : isPlainObject ( req . body ) ? Object . keys ( req . body ) . sort ( ) : [ ] ,
responsesWebhookUrl : nonEmptyTrimmedString ( req . body . responsesWebhookUrl ) ,
paperclipApiUrl : nonEmptyTrimmedString ( req . body . paperclipApiUrl ) ,
webhookAuthHeader : summarizeSecretForLog ( req . body . webhookAuthHeader ) ,
inboundOpenClawAuthHeader : summarizeSecretForLog ( req . header ( "x-openclaw-auth" ) ? ? null ) ,
rawAgentDefaults : summarizeOpenClawDefaultsForLog ( req . body . agentDefaultsPayload ? ? null ) ,
mergedAgentDefaults : summarizeOpenClawDefaultsForLog ( openClawDefaultsPayload ) ,
} ,
"invite accept received OpenClaw join payload" ,
) ;
}
2026-03-02 16:43:59 -06:00
const joinDefaults = requestType === "agent"
? normalizeAgentDefaultsForJoin ( {
adapterType : req.body.adapterType ? ? null ,
2026-03-06 09:36:20 -06:00
defaultsPayload : openClawDefaultsPayload ,
2026-03-02 16:43:59 -06:00
deploymentMode : opts.deploymentMode ,
deploymentExposure : opts.deploymentExposure ,
bindHost : opts.bindHost ,
allowedHostnames : opts.allowedHostnames ,
} )
: { normalized : null as Record < string , unknown > | null , diagnostics : [ ] as JoinDiagnostic [ ] } ;
2026-03-06 09:36:20 -06:00
if ( requestType === "agent" && ( req . body . adapterType ? ? null ) === "openclaw" ) {
logger . info (
{
inviteId : invite.id ,
joinRequestDiagnostics : joinDefaults.diagnostics.map ( ( diag ) = > ( {
code : diag.code ,
level : diag.level ,
} ) ) ,
normalizedAgentDefaults : summarizeOpenClawDefaultsForLog ( joinDefaults . normalized ) ,
} ,
"invite accept normalized OpenClaw defaults" ,
) ;
}
2026-02-26 16:33:20 -06:00
const claimSecret = requestType === "agent" ? createClaimSecret ( ) : null ;
const claimSecretHash = claimSecret ? hashToken ( claimSecret ) : null ;
const claimSecretExpiresAt = claimSecret
? new Date ( Date . now ( ) + 7 * 24 * 60 * 60 * 1000 )
: null ;
2026-02-23 14:40:32 -06:00
const actorEmail = requestType === "human" ? await resolveActorEmail ( db , req ) : null ;
const created = await db . transaction ( async ( tx ) = > {
await tx
. update ( invites )
. set ( { acceptedAt : new Date ( ) , updatedAt : new Date ( ) } )
. where ( and ( eq ( invites . id , invite . id ) , isNull ( invites . acceptedAt ) , isNull ( invites . revokedAt ) ) ) ;
const row = await tx
. insert ( joinRequests )
. values ( {
inviteId : invite.id ,
companyId ,
requestType ,
status : "pending_approval" ,
requestIp : requestIp ( req ) ,
requestingUserId : requestType === "human" ? req . actor . userId ? ? "local-board" : null ,
requestEmailSnapshot : requestType === "human" ? actorEmail : null ,
agentName : requestType === "agent" ? req.body.agentName : null ,
adapterType : requestType === "agent" ? req . body . adapterType ? ? null : null ,
capabilities : requestType === "agent" ? req . body . capabilities ? ? null : null ,
2026-03-02 16:43:59 -06:00
agentDefaultsPayload : requestType === "agent" ? joinDefaults.normalized : null ,
2026-02-26 16:33:20 -06:00
claimSecretHash ,
claimSecretExpiresAt ,
2026-02-23 14:40:32 -06:00
} )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ) ;
return row ;
} ) ;
2026-03-06 10:14:57 -06:00
if ( requestType === "agent" && ( req . body . adapterType ? ? null ) === "openclaw" ) {
const expectedDefaults = summarizeOpenClawDefaultsForLog ( joinDefaults . normalized ) ;
const persistedDefaults = summarizeOpenClawDefaultsForLog ( created . agentDefaultsPayload ) ;
const missingPersistedFields : string [ ] = [ ] ;
if ( expectedDefaults . url && ! persistedDefaults . url ) missingPersistedFields . push ( "url" ) ;
if ( expectedDefaults . paperclipApiUrl && ! persistedDefaults . paperclipApiUrl ) {
missingPersistedFields . push ( "paperclipApiUrl" ) ;
}
if ( expectedDefaults . webhookAuthHeader && ! persistedDefaults . webhookAuthHeader ) {
missingPersistedFields . push ( "webhookAuthHeader" ) ;
}
if ( expectedDefaults . openClawAuthHeader && ! persistedDefaults . openClawAuthHeader ) {
missingPersistedFields . push ( "headers.x-openclaw-auth" ) ;
}
if ( expectedDefaults . headerKeys . length > 0 && persistedDefaults . headerKeys . length === 0 ) {
missingPersistedFields . push ( "headers" ) ;
}
logger . info (
{
inviteId : invite.id ,
joinRequestId : created.id ,
joinRequestStatus : created.status ,
expectedDefaults ,
persistedDefaults ,
diagnostics : joinDefaults.diagnostics.map ( ( diag ) = > ( {
code : diag.code ,
level : diag.level ,
message : diag.message ,
hint : diag.hint ? ? null ,
} ) ) ,
} ,
"invite accept persisted OpenClaw join request" ,
) ;
if ( missingPersistedFields . length > 0 ) {
logger . warn (
{
inviteId : invite.id ,
joinRequestId : created.id ,
missingPersistedFields ,
} ,
"invite accept detected missing persisted OpenClaw defaults" ,
) ;
}
}
2026-02-23 14:40:32 -06:00
await logActivity ( db , {
companyId ,
actorType : req.actor.type === "agent" ? "agent" : "user" ,
actorId :
req . actor . type === "agent"
? req . actor . agentId ? ? "invite-agent"
: req . actor . userId ? ? ( requestType === "agent" ? "invite-anon" : "board" ) ,
action : "join.requested" ,
entityType : "join_request" ,
entityId : created.id ,
details : { requestType , requestIp : created.requestIp } ,
} ) ;
2026-02-26 16:33:20 -06:00
const response = toJoinRequestResponse ( created ) ;
if ( claimSecret ) {
2026-03-02 16:43:59 -06:00
const onboardingManifest = buildInviteOnboardingManifest ( req , token , invite , opts ) ;
2026-02-26 16:33:20 -06:00
res . status ( 202 ) . json ( {
. . . response ,
claimSecret ,
claimApiKeyPath : ` /api/join-requests/ ${ created . id } /claim-api-key ` ,
onboarding : onboardingManifest.onboarding ,
2026-03-02 16:43:59 -06:00
diagnostics : joinDefaults.diagnostics ,
2026-02-26 16:33:20 -06:00
} ) ;
return ;
}
2026-03-02 16:43:59 -06:00
res . status ( 202 ) . json ( {
. . . response ,
. . . ( joinDefaults . diagnostics . length > 0 ? { diagnostics : joinDefaults.diagnostics } : { } ) ,
} ) ;
2026-02-23 14:40:32 -06:00
} ) ;
router . post ( "/invites/:inviteId/revoke" , async ( req , res ) = > {
const id = req . params . inviteId as string ;
const invite = await db . select ( ) . from ( invites ) . where ( eq ( invites . id , id ) ) . then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! invite ) throw notFound ( "Invite not found" ) ;
if ( invite . inviteType === "bootstrap_ceo" ) {
await assertInstanceAdmin ( req ) ;
} else {
if ( ! invite . companyId ) throw conflict ( "Invite is missing company scope" ) ;
await assertCompanyPermission ( req , invite . companyId , "users:invite" ) ;
}
if ( invite . acceptedAt ) throw conflict ( "Invite already consumed" ) ;
if ( invite . revokedAt ) return res . json ( invite ) ;
const revoked = await db
. update ( invites )
. set ( { revokedAt : new Date ( ) , updatedAt : new Date ( ) } )
. where ( eq ( invites . id , id ) )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ) ;
if ( invite . companyId ) {
await logActivity ( db , {
companyId : invite.companyId ,
actorType : req.actor.type === "agent" ? "agent" : "user" ,
actorId : req.actor.type === "agent" ? req . actor . agentId ? ? "unknown-agent" : req . actor . userId ? ? "board" ,
action : "invite.revoked" ,
entityType : "invite" ,
entityId : id ,
} ) ;
}
res . json ( revoked ) ;
} ) ;
router . get ( "/companies/:companyId/join-requests" , async ( req , res ) = > {
const companyId = req . params . companyId as string ;
await assertCompanyPermission ( req , companyId , "joins:approve" ) ;
const query = listJoinRequestsQuerySchema . parse ( req . query ) ;
const all = await db
. select ( )
. from ( joinRequests )
. where ( eq ( joinRequests . companyId , companyId ) )
. orderBy ( desc ( joinRequests . createdAt ) ) ;
const filtered = all . filter ( ( row ) = > {
if ( query . status && row . status !== query . status ) return false ;
if ( query . requestType && row . requestType !== query . requestType ) return false ;
return true ;
} ) ;
2026-02-26 16:33:20 -06:00
res . json ( filtered . map ( toJoinRequestResponse ) ) ;
2026-02-23 14:40:32 -06:00
} ) ;
router . post ( "/companies/:companyId/join-requests/:requestId/approve" , async ( req , res ) = > {
const companyId = req . params . companyId as string ;
const requestId = req . params . requestId as string ;
await assertCompanyPermission ( req , companyId , "joins:approve" ) ;
const existing = await db
. select ( )
. from ( joinRequests )
. where ( and ( eq ( joinRequests . companyId , companyId ) , eq ( joinRequests . id , requestId ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! existing ) throw notFound ( "Join request not found" ) ;
if ( existing . status !== "pending_approval" ) throw conflict ( "Join request is not pending" ) ;
const invite = await db
. select ( )
. from ( invites )
. where ( eq ( invites . id , existing . inviteId ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! invite ) throw notFound ( "Invite not found" ) ;
let createdAgentId : string | null = existing . createdAgentId ? ? null ;
if ( existing . requestType === "human" ) {
if ( ! existing . requestingUserId ) throw conflict ( "Join request missing user identity" ) ;
await access . ensureMembership ( companyId , "user" , existing . requestingUserId , "member" , "active" ) ;
const grants = grantsFromDefaults ( invite . defaultsPayload as Record < string , unknown > | null , "human" ) ;
await access . setPrincipalGrants (
companyId ,
"user" ,
existing . requestingUserId ,
grants ,
req . actor . userId ? ? null ,
) ;
} else {
const created = await agents . create ( companyId , {
name : existing.agentName ? ? "New Agent" ,
role : "general" ,
title : null ,
status : "idle" ,
reportsTo : null ,
capabilities : existing.capabilities ? ? null ,
adapterType : existing.adapterType ? ? "process" ,
adapterConfig :
existing . agentDefaultsPayload && typeof existing . agentDefaultsPayload === "object"
? ( existing . agentDefaultsPayload as Record < string , unknown > )
: { } ,
runtimeConfig : { } ,
budgetMonthlyCents : 0 ,
spentMonthlyCents : 0 ,
permissions : { } ,
lastHeartbeatAt : null ,
metadata : null ,
} ) ;
createdAgentId = created . id ;
await access . ensureMembership ( companyId , "agent" , created . id , "member" , "active" ) ;
const grants = grantsFromDefaults ( invite . defaultsPayload as Record < string , unknown > | null , "agent" ) ;
await access . setPrincipalGrants ( companyId , "agent" , created . id , grants , req . actor . userId ? ? null ) ;
}
const approved = await db
. update ( joinRequests )
. set ( {
status : "approved" ,
approvedByUserId : req.actor.userId ? ? ( isLocalImplicit ( req ) ? "local-board" : null ) ,
approvedAt : new Date ( ) ,
createdAgentId ,
updatedAt : new Date ( ) ,
} )
. where ( eq ( joinRequests . id , requestId ) )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ) ;
await logActivity ( db , {
companyId ,
actorType : "user" ,
actorId : req.actor.userId ? ? "board" ,
action : "join.approved" ,
entityType : "join_request" ,
entityId : requestId ,
details : { requestType : existing.requestType , createdAgentId } ,
} ) ;
2026-03-06 08:17:42 -06:00
if ( createdAgentId ) {
void notifyHireApproved ( db , {
companyId ,
agentId : createdAgentId ,
source : "join_request" ,
sourceId : requestId ,
approvedAt : new Date ( ) ,
} ) . catch ( ( ) = > { } ) ;
}
2026-02-26 16:33:20 -06:00
res . json ( toJoinRequestResponse ( approved ) ) ;
2026-02-23 14:40:32 -06:00
} ) ;
router . post ( "/companies/:companyId/join-requests/:requestId/reject" , async ( req , res ) = > {
const companyId = req . params . companyId as string ;
const requestId = req . params . requestId as string ;
await assertCompanyPermission ( req , companyId , "joins:approve" ) ;
const existing = await db
. select ( )
. from ( joinRequests )
. where ( and ( eq ( joinRequests . companyId , companyId ) , eq ( joinRequests . id , requestId ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! existing ) throw notFound ( "Join request not found" ) ;
if ( existing . status !== "pending_approval" ) throw conflict ( "Join request is not pending" ) ;
const rejected = await db
. update ( joinRequests )
. set ( {
status : "rejected" ,
rejectedByUserId : req.actor.userId ? ? ( isLocalImplicit ( req ) ? "local-board" : null ) ,
rejectedAt : new Date ( ) ,
updatedAt : new Date ( ) ,
} )
. where ( eq ( joinRequests . id , requestId ) )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ) ;
await logActivity ( db , {
companyId ,
actorType : "user" ,
actorId : req.actor.userId ? ? "board" ,
action : "join.rejected" ,
entityType : "join_request" ,
entityId : requestId ,
details : { requestType : existing.requestType } ,
} ) ;
2026-02-26 16:33:20 -06:00
res . json ( toJoinRequestResponse ( rejected ) ) ;
2026-02-23 14:40:32 -06:00
} ) ;
2026-02-26 16:33:20 -06:00
router . post ( "/join-requests/:requestId/claim-api-key" , validate ( claimJoinRequestApiKeySchema ) , async ( req , res ) = > {
2026-02-23 14:40:32 -06:00
const requestId = req . params . requestId as string ;
2026-02-26 16:33:20 -06:00
const presentedClaimSecretHash = hashToken ( req . body . claimSecret ) ;
2026-02-23 14:40:32 -06:00
const joinRequest = await db
. select ( )
. from ( joinRequests )
. where ( eq ( joinRequests . id , requestId ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! joinRequest ) throw notFound ( "Join request not found" ) ;
if ( joinRequest . requestType !== "agent" ) throw badRequest ( "Only agent join requests can claim API keys" ) ;
if ( joinRequest . status !== "approved" ) throw conflict ( "Join request must be approved before key claim" ) ;
if ( ! joinRequest . createdAgentId ) throw conflict ( "Join request has no created agent" ) ;
2026-02-26 16:33:20 -06:00
if ( ! joinRequest . claimSecretHash ) throw conflict ( "Join request is missing claim secret metadata" ) ;
if ( ! tokenHashesMatch ( joinRequest . claimSecretHash , presentedClaimSecretHash ) ) {
throw forbidden ( "Invalid claim secret" ) ;
}
if ( joinRequest . claimSecretExpiresAt && joinRequest . claimSecretExpiresAt . getTime ( ) <= Date . now ( ) ) {
throw conflict ( "Claim secret expired" ) ;
}
if ( joinRequest . claimSecretConsumedAt ) throw conflict ( "Claim secret already used" ) ;
2026-02-23 14:40:32 -06:00
const existingKey = await db
. select ( { id : agentApiKeys.id } )
. from ( agentApiKeys )
. where ( eq ( agentApiKeys . agentId , joinRequest . createdAgentId ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( existingKey ) throw conflict ( "API key already claimed" ) ;
2026-02-26 16:33:20 -06:00
const consumed = await db
. update ( joinRequests )
. set ( { claimSecretConsumedAt : new Date ( ) , updatedAt : new Date ( ) } )
. where ( and ( eq ( joinRequests . id , requestId ) , isNull ( joinRequests . claimSecretConsumedAt ) ) )
. returning ( { id : joinRequests.id } )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! consumed ) throw conflict ( "Claim secret already used" ) ;
2026-02-23 14:40:32 -06:00
const created = await agents . createApiKey ( joinRequest . createdAgentId , "initial-join-key" ) ;
await logActivity ( db , {
companyId : joinRequest.companyId ,
actorType : "system" ,
actorId : "join-claim" ,
action : "agent_api_key.claimed" ,
entityType : "agent_api_key" ,
entityId : created.id ,
details : { agentId : joinRequest.createdAgentId , joinRequestId : requestId } ,
} ) ;
res . status ( 201 ) . json ( {
keyId : created.id ,
token : created.token ,
agentId : joinRequest.createdAgentId ,
createdAt : created.createdAt ,
} ) ;
} ) ;
router . get ( "/companies/:companyId/members" , async ( req , res ) = > {
const companyId = req . params . companyId as string ;
await assertCompanyPermission ( req , companyId , "users:manage_permissions" ) ;
const members = await access . listMembers ( companyId ) ;
res . json ( members ) ;
} ) ;
router . patch (
"/companies/:companyId/members/:memberId/permissions" ,
validate ( updateMemberPermissionsSchema ) ,
async ( req , res ) = > {
const companyId = req . params . companyId as string ;
const memberId = req . params . memberId as string ;
await assertCompanyPermission ( req , companyId , "users:manage_permissions" ) ;
const updated = await access . setMemberPermissions (
companyId ,
memberId ,
req . body . grants ? ? [ ] ,
req . actor . userId ? ? null ,
) ;
if ( ! updated ) throw notFound ( "Member not found" ) ;
res . json ( updated ) ;
} ,
) ;
router . post ( "/admin/users/:userId/promote-instance-admin" , async ( req , res ) = > {
await assertInstanceAdmin ( req ) ;
const userId = req . params . userId as string ;
const result = await access . promoteInstanceAdmin ( userId ) ;
res . status ( 201 ) . json ( result ) ;
} ) ;
router . post ( "/admin/users/:userId/demote-instance-admin" , async ( req , res ) = > {
await assertInstanceAdmin ( req ) ;
const userId = req . params . userId as string ;
const removed = await access . demoteInstanceAdmin ( userId ) ;
if ( ! removed ) throw notFound ( "Instance admin role not found" ) ;
res . json ( removed ) ;
} ) ;
router . get ( "/admin/users/:userId/company-access" , async ( req , res ) = > {
await assertInstanceAdmin ( req ) ;
const userId = req . params . userId as string ;
const memberships = await access . listUserCompanyAccess ( userId ) ;
res . json ( memberships ) ;
} ) ;
router . put (
"/admin/users/:userId/company-access" ,
validate ( updateUserCompanyAccessSchema ) ,
async ( req , res ) = > {
await assertInstanceAdmin ( req ) ;
const userId = req . params . userId as string ;
const memberships = await access . setUserCompanyAccess ( userId , req . body . companyIds ? ? [ ] ) ;
res . json ( memberships ) ;
} ,
) ;
return router ;
}