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 ,
2026-03-06 13:54:58 -06:00
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 ,
2026-03-06 13:54:58 -06:00
PERMISSION_KEYS
2026-03-03 08:45:26 -06:00
} from "@paperclipai/shared" ;
import type { DeploymentExposure , DeploymentMode } from "@paperclipai/shared" ;
2026-03-06 13:54:58 -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 13:54:58 -06:00
import {
accessService ,
agentService ,
deduplicateAgentName ,
logActivity ,
notifyHireApproved
} from "../services/index.js" ;
2026-02-23 14:40:32 -06:00
import { assertCompanyAccess } from "./authz.js" ;
2026-03-06 13:54:58 -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" ) ;
2026-03-06 13:54:58 -06:00
return (
leftBytes . length === rightBytes . length &&
timingSafeEqual ( leftBytes , rightBytes )
) ;
2026-02-26 16:33:20 -06:00
}
function requestBaseUrl ( req : Request ) {
const forwardedProto = req . header ( "x-forwarded-proto" ) ;
const proto = forwardedProto ? . split ( "," ) [ 0 ] ? . trim ( ) || req . protocol || "http" ;
2026-03-06 13:54:58 -06:00
const host =
req . header ( "x-forwarded-host" ) ? . split ( "," ) [ 0 ] ? . trim ( ) || req . header ( "host" ) ;
2026-02-26 16:33:20 -06:00
if ( ! host ) return "" ;
return ` ${ proto } :// ${ host } ` ;
}
function readSkillMarkdown ( skillName : string ) : string | null {
const normalized = skillName . trim ( ) . toLowerCase ( ) ;
2026-03-06 13:54:58 -06:00
if ( normalized !== "paperclip" && normalized !== "paperclip-create-agent" )
return null ;
2026-02-26 16:33:20 -06:00
const moduleDir = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const candidates = [
2026-03-06 13:54:58 -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-06 15:15:24 -06:00
function normalizeOpenClawTransport ( value : unknown ) : "sse" | "webhook" | null {
if ( typeof value !== "string" ) return "sse" ;
const normalized = value . trim ( ) . toLowerCase ( ) ;
if ( ! normalized || normalized === "sse" ) return "sse" ;
if ( normalized === "webhook" ) return "webhook" ;
return null ;
}
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 ( "]" ) ;
2026-03-06 13:54:58 -06:00
return end > 1
? trimmed . slice ( 1 , end ) . toLowerCase ( )
: trimmed . toLowerCase ( ) ;
2026-03-02 16:43:59 -06:00
}
const firstColon = trimmed . indexOf ( ":" ) ;
if ( firstColon > - 1 ) return trimmed . slice ( 0 , firstColon ) . toLowerCase ( ) ;
return trimmed . toLowerCase ( ) ;
}
2026-03-06 12:06:08 -06:00
function normalizeHeaderValue (
value : unknown ,
2026-03-06 13:54:58 -06:00
depth : number = 0
2026-03-06 12:06:08 -06:00
) : string | null {
const direct = nonEmptyTrimmedString ( value ) ;
if ( direct ) return direct ;
2026-03-06 12:31:58 -06:00
if ( ! isPlainObject ( value ) || depth >= 3 ) return null ;
2026-03-06 12:06:08 -06:00
const candidateKeys = [
"value" ,
"token" ,
"secret" ,
"apiKey" ,
"api_key" ,
"auth" ,
2026-03-06 12:31:58 -06:00
"authToken" ,
"auth_token" ,
"accessToken" ,
"access_token" ,
2026-03-06 12:06:08 -06:00
"authorization" ,
"bearer" ,
"header" ,
2026-03-06 12:31:58 -06:00
"raw" ,
"text" ,
2026-03-06 13:54:58 -06:00
"string"
2026-03-06 12:06:08 -06:00
] ;
for ( const key of candidateKeys ) {
if ( ! Object . prototype . hasOwnProperty . call ( value , key ) ) continue ;
2026-03-06 13:54:58 -06:00
const normalized = normalizeHeaderValue (
( value as Record < string , unknown > ) [ key ] ,
depth + 1
) ;
2026-03-06 12:06:08 -06:00
if ( normalized ) return normalized ;
}
2026-03-06 12:49:41 -06:00
const entries = Object . entries ( value as Record < string , unknown > ) ;
if ( entries . length === 1 ) {
const [ singleKey , singleValue ] = entries [ 0 ] ;
const normalizedKey = singleKey . trim ( ) . toLowerCase ( ) ;
if (
normalizedKey !== "type" &&
normalizedKey !== "version" &&
normalizedKey !== "secretid" &&
normalizedKey !== "secret_id"
) {
const normalized = normalizeHeaderValue ( singleValue , depth + 1 ) ;
if ( normalized ) return normalized ;
}
}
2026-03-06 12:06:08 -06:00
return null ;
}
2026-03-06 12:31:58 -06:00
function extractHeaderEntries ( input : unknown ) : Array < [ string , unknown ] > {
if ( isPlainObject ( input ) ) {
return Object . entries ( input ) ;
}
if ( ! Array . isArray ( input ) ) {
return [ ] ;
}
const entries : Array < [ string , unknown ] > = [ ] ;
for ( const item of input ) {
if ( Array . isArray ( item ) ) {
const key = nonEmptyTrimmedString ( item [ 0 ] ) ;
if ( ! key ) continue ;
entries . push ( [ key , item [ 1 ] ] ) ;
continue ;
}
if ( ! isPlainObject ( item ) ) continue ;
const mapped = item as Record < string , unknown > ;
const explicitKey =
nonEmptyTrimmedString ( mapped . key ) ? ?
nonEmptyTrimmedString ( mapped . name ) ? ?
nonEmptyTrimmedString ( mapped . header ) ;
if ( explicitKey ) {
2026-03-06 13:54:58 -06:00
const explicitValue = Object . prototype . hasOwnProperty . call (
mapped ,
"value"
)
2026-03-06 12:31:58 -06:00
? mapped . value
: Object . prototype . hasOwnProperty . call ( mapped , "token" )
2026-03-06 13:54:58 -06:00
? mapped . token
: Object . prototype . hasOwnProperty . call ( mapped , "secret" )
? mapped . secret
: mapped ;
2026-03-06 12:31:58 -06:00
entries . push ( [ explicitKey , explicitValue ] ) ;
continue ;
}
const singleEntry = Object . entries ( mapped ) ;
if ( singleEntry . length === 1 ) {
entries . push ( singleEntry [ 0 ] as [ string , unknown ] ) ;
}
}
return entries ;
}
2026-03-06 13:54:58 -06:00
function normalizeHeaderMap (
input : unknown
) : Record < string , string > | undefined {
2026-03-06 12:31:58 -06:00
const entries = extractHeaderEntries ( input ) ;
if ( entries . length === 0 ) return undefined ;
2026-03-02 16:43:59 -06:00
const out : Record < string , string > = { } ;
2026-03-06 12:31:58 -06:00
for ( const [ key , value ] of entries ) {
2026-03-06 12:06:08 -06:00
const normalizedValue = normalizeHeaderValue ( value ) ;
if ( ! normalizedValue ) continue ;
2026-03-02 16:43:59 -06:00
const trimmedKey = key . trim ( ) ;
2026-03-06 12:06:08 -06:00
const trimmedValue = normalizedValue . trim ( ) ;
2026-03-02 16:43:59 -06:00
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 ;
}
2026-03-06 13:54:58 -06:00
function headerMapHasKeyIgnoreCase (
headers : Record < string , string > ,
targetKey : string
) : boolean {
2026-03-06 09:36:20 -06:00
const normalizedTarget = targetKey . trim ( ) . toLowerCase ( ) ;
2026-03-06 13:54:58 -06:00
return Object . keys ( headers ) . some (
( key ) = > key . trim ( ) . toLowerCase ( ) === normalizedTarget
) ;
2026-03-06 09:36:20 -06:00
}
2026-03-06 13:54:58 -06:00
function headerMapGetIgnoreCase (
headers : Record < string , string > ,
targetKey : string
) : string | null {
2026-03-06 10:14:57 -06:00
const normalizedTarget = targetKey . trim ( ) . toLowerCase ( ) ;
2026-03-06 13:54:58 -06:00
const key = Object . keys ( headers ) . find (
( candidate ) = > candidate . trim ( ) . toLowerCase ( ) === normalizedTarget
) ;
2026-03-06 10:14:57 -06:00
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-07 16:01:19 -06:00
function tokenFromAuthorizationHeader ( rawHeader : string | null ) : string | null {
const trimmed = nonEmptyTrimmedString ( rawHeader ) ;
if ( ! trimmed ) return null ;
const bearerMatch = trimmed . match ( /^bearer\s+(.+)$/i ) ;
if ( bearerMatch ? . [ 1 ] ) {
return nonEmptyTrimmedString ( bearerMatch [ 1 ] ) ;
}
return trimmed ;
}
function parseBooleanLike ( value : unknown ) : boolean | null {
if ( typeof value === "boolean" ) return value ;
if ( typeof value !== "string" ) return null ;
const normalized = value . trim ( ) . toLowerCase ( ) ;
if ( normalized === "true" || normalized === "1" ) return true ;
if ( normalized === "false" || normalized === "0" ) return false ;
return null ;
}
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 ;
2026-03-06 16:50:20 -06:00
inboundOpenClawTokenHeader? : string | null ;
2026-03-06 09:36:20 -06:00
} ) : unknown {
2026-03-07 16:01:19 -06:00
if ( input . adapterType === "openclaw_gateway" ) {
const merged = isPlainObject ( input . defaultsPayload )
? { . . . ( input . defaultsPayload as Record < string , unknown > ) }
: ( { } as Record < string , unknown > ) ;
if ( ! nonEmptyTrimmedString ( merged . paperclipApiUrl ) ) {
const legacyPaperclipApiUrl = nonEmptyTrimmedString ( input . paperclipApiUrl ) ;
if ( legacyPaperclipApiUrl ) merged . paperclipApiUrl = legacyPaperclipApiUrl ;
}
const mergedHeaders = normalizeHeaderMap ( merged . headers ) ? ? { } ;
const inboundOpenClawAuthHeader = nonEmptyTrimmedString (
input . inboundOpenClawAuthHeader
) ;
const inboundOpenClawTokenHeader = nonEmptyTrimmedString (
input . inboundOpenClawTokenHeader
) ;
if (
inboundOpenClawTokenHeader &&
! headerMapHasKeyIgnoreCase ( mergedHeaders , "x-openclaw-token" )
) {
mergedHeaders [ "x-openclaw-token" ] = inboundOpenClawTokenHeader ;
}
if (
inboundOpenClawAuthHeader &&
! headerMapHasKeyIgnoreCase ( mergedHeaders , "x-openclaw-auth" )
) {
mergedHeaders [ "x-openclaw-auth" ] = inboundOpenClawAuthHeader ;
}
const discoveredToken =
headerMapGetIgnoreCase ( mergedHeaders , "x-openclaw-token" ) ? ?
headerMapGetIgnoreCase ( mergedHeaders , "x-openclaw-auth" ) ? ?
tokenFromAuthorizationHeader (
headerMapGetIgnoreCase ( mergedHeaders , "authorization" )
) ;
if (
discoveredToken &&
! headerMapHasKeyIgnoreCase ( mergedHeaders , "x-openclaw-token" )
) {
mergedHeaders [ "x-openclaw-token" ] = discoveredToken ;
}
if ( Object . keys ( mergedHeaders ) . length > 0 ) {
merged . headers = mergedHeaders ;
} else {
delete merged . headers ;
}
return Object . keys ( merged ) . length > 0 ? merged : null ;
}
2026-03-06 09:36:20 -06:00
if ( input . adapterType !== "openclaw" ) {
return input . defaultsPayload ;
}
const merged = isPlainObject ( input . defaultsPayload )
? { . . . ( input . defaultsPayload as Record < string , unknown > ) }
2026-03-06 13:54:58 -06:00
: ( { } as Record < string , unknown > ) ;
2026-03-06 09:36:20 -06:00
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 ) ) {
2026-03-06 13:54:58 -06:00
const providedWebhookAuthHeader = nonEmptyTrimmedString (
input . webhookAuthHeader
) ;
if ( providedWebhookAuthHeader )
merged . webhookAuthHeader = providedWebhookAuthHeader ;
2026-03-06 09:36:20 -06:00
}
const mergedHeaders = normalizeHeaderMap ( merged . headers ) ? ? { } ;
2026-03-06 13:54:58 -06:00
const compatibilityHeaders = normalizeHeaderMap (
input . responsesWebhookHeaders
) ;
2026-03-06 09:36:20 -06:00
if ( compatibilityHeaders ) {
for ( const [ key , value ] of Object . entries ( compatibilityHeaders ) ) {
if ( ! headerMapHasKeyIgnoreCase ( mergedHeaders , key ) ) {
mergedHeaders [ key ] = value ;
}
}
}
2026-03-06 13:54:58 -06:00
const inboundOpenClawAuthHeader = nonEmptyTrimmedString (
input . inboundOpenClawAuthHeader
) ;
2026-03-06 16:50:20 -06:00
const inboundOpenClawTokenHeader = nonEmptyTrimmedString (
input . inboundOpenClawTokenHeader
) ;
if (
inboundOpenClawTokenHeader &&
! headerMapHasKeyIgnoreCase ( mergedHeaders , "x-openclaw-token" )
) {
mergedHeaders [ "x-openclaw-token" ] = inboundOpenClawTokenHeader ;
}
2026-03-06 13:54:58 -06:00
if (
inboundOpenClawAuthHeader &&
! headerMapHasKeyIgnoreCase ( mergedHeaders , "x-openclaw-auth" )
) {
2026-03-06 09:36:20 -06:00
mergedHeaders [ "x-openclaw-auth" ] = inboundOpenClawAuthHeader ;
}
if ( Object . keys ( mergedHeaders ) . length > 0 ) {
merged . headers = mergedHeaders ;
} else {
delete merged . headers ;
}
2026-03-06 13:54:58 -06:00
const hasAuthorizationHeader = headerMapHasKeyIgnoreCase (
mergedHeaders ,
"authorization"
) ;
const hasWebhookAuthHeader = Boolean (
nonEmptyTrimmedString ( merged . webhookAuthHeader )
) ;
2026-03-06 10:14:57 -06:00
if ( ! hasAuthorizationHeader && ! hasWebhookAuthHeader ) {
2026-03-06 16:50:20 -06:00
const openClawAuthToken =
headerMapGetIgnoreCase ( mergedHeaders , "x-openclaw-token" ) ? ?
headerMapGetIgnoreCase (
2026-03-06 13:54:58 -06:00
mergedHeaders ,
"x-openclaw-auth"
) ;
2026-03-06 10:14:57 -06:00
if ( openClawAuthToken ) {
merged . webhookAuthHeader = toAuthorizationHeaderValue ( openClawAuthToken ) ;
}
}
2026-03-06 09:36:20 -06:00
return Object . keys ( merged ) . length > 0 ? merged : null ;
}
2026-03-06 13:54:58 -06:00
export function mergeJoinDefaultsPayloadForReplay (
existingDefaultsPayload : unknown ,
nextDefaultsPayload : unknown
) : unknown {
if (
! isPlainObject ( existingDefaultsPayload ) &&
! isPlainObject ( nextDefaultsPayload )
) {
2026-03-06 11:59:13 -06:00
return nextDefaultsPayload ? ? existingDefaultsPayload ;
}
if ( ! isPlainObject ( existingDefaultsPayload ) ) {
return nextDefaultsPayload ;
}
if ( ! isPlainObject ( nextDefaultsPayload ) ) {
return existingDefaultsPayload ;
}
const merged : Record < string , unknown > = {
. . . ( existingDefaultsPayload as Record < string , unknown > ) ,
2026-03-06 13:54:58 -06:00
. . . ( nextDefaultsPayload as Record < string , unknown > )
2026-03-06 11:59:13 -06:00
} ;
2026-03-06 13:54:58 -06:00
const existingHeaders = normalizeHeaderMap (
( existingDefaultsPayload as Record < string , unknown > ) . headers
) ;
const nextHeaders = normalizeHeaderMap (
( nextDefaultsPayload as Record < string , unknown > ) . headers
) ;
2026-03-06 11:59:13 -06:00
if ( existingHeaders || nextHeaders ) {
merged . headers = {
. . . ( existingHeaders ? ? { } ) ,
2026-03-06 13:54:58 -06:00
. . . ( nextHeaders ? ? { } )
2026-03-06 11:59:13 -06:00
} ;
} else if ( Object . prototype . hasOwnProperty . call ( merged , "headers" ) ) {
delete merged . headers ;
}
return merged ;
}
export function canReplayOpenClawInviteAccept ( input : {
requestType : "human" | "agent" ;
adapterType : string | null ;
2026-03-06 13:54:58 -06:00
existingJoinRequest : Pick <
typeof joinRequests . $inferSelect ,
"requestType" | "adapterType" | "status"
> | null ;
2026-03-06 11:59:13 -06:00
} ) : boolean {
if ( input . requestType !== "agent" || input . adapterType !== "openclaw" ) {
return false ;
}
if ( ! input . existingJoinRequest ) {
return false ;
}
2026-03-06 13:54:58 -06:00
if (
input . existingJoinRequest . requestType !== "agent" ||
input . existingJoinRequest . adapterType !== "openclaw"
) {
2026-03-06 11:59:13 -06:00
return false ;
}
2026-03-06 13:54:58 -06:00
return (
input . existingJoinRequest . status === "pending_approval" ||
input . existingJoinRequest . status === "approved"
) ;
2026-03-06 11:59:13 -06:00
}
2026-03-06 13:54:58 -06:00
function summarizeSecretForLog (
value : unknown
) : { present : true ; length : number ; sha256Prefix : string } | null {
2026-03-06 09:36:20 -06:00
const trimmed = nonEmptyTrimmedString ( value ) ;
if ( ! trimmed ) return null ;
return {
present : true ,
length : trimmed.length ,
2026-03-06 13:54:58 -06:00
sha256Prefix : hashToken ( trimmed ) . slice ( 0 , 12 )
2026-03-06 09:36:20 -06:00
} ;
}
function summarizeOpenClawDefaultsForLog ( defaultsPayload : unknown ) {
2026-03-06 13:54:58 -06:00
const defaults = isPlainObject ( defaultsPayload )
? ( defaultsPayload as Record < string , unknown > )
: null ;
2026-03-06 09:36:20 -06:00
const headers = defaults ? normalizeHeaderMap ( defaults . headers ) : undefined ;
const openClawAuthHeaderValue = headers
2026-03-06 16:50:20 -06:00
? headerMapGetIgnoreCase ( headers , "x-openclaw-token" ) ? ?
headerMapGetIgnoreCase ( headers , "x-openclaw-auth" )
2026-03-06 09:36:20 -06:00
: null ;
return {
present : Boolean ( defaults ) ,
keys : defaults ? Object . keys ( defaults ) . sort ( ) : [ ] ,
url : defaults ? nonEmptyTrimmedString ( defaults . url ) : null ,
method : defaults ? nonEmptyTrimmedString ( defaults . method ) : null ,
2026-03-06 13:54:58 -06:00
paperclipApiUrl : defaults
? nonEmptyTrimmedString ( defaults . paperclipApiUrl )
: null ,
2026-03-06 09:36:20 -06:00
headerKeys : headers ? Object . keys ( headers ) . sort ( ) : [ ] ,
2026-03-06 13:54:58 -06:00
webhookAuthHeader : defaults
? summarizeSecretForLog ( defaults . webhookAuthHeader )
: null ,
openClawAuthHeader : summarizeSecretForLog ( openClawAuthHeaderValue )
2026-03-06 09:36:20 -06:00
} ;
}
2026-03-07 16:01:19 -06:00
function summarizeOpenClawGatewayDefaultsForLog ( defaultsPayload : unknown ) {
const defaults = isPlainObject ( defaultsPayload )
? ( defaultsPayload as Record < string , unknown > )
: null ;
const headers = defaults ? normalizeHeaderMap ( defaults . headers ) : undefined ;
const gatewayTokenValue = headers
? headerMapGetIgnoreCase ( headers , "x-openclaw-token" ) ? ?
headerMapGetIgnoreCase ( headers , "x-openclaw-auth" ) ? ?
tokenFromAuthorizationHeader (
headerMapGetIgnoreCase ( headers , "authorization" )
)
: null ;
return {
present : Boolean ( defaults ) ,
keys : defaults ? Object . keys ( defaults ) . sort ( ) : [ ] ,
url : defaults ? nonEmptyTrimmedString ( defaults . url ) : null ,
paperclipApiUrl : defaults
? nonEmptyTrimmedString ( defaults . paperclipApiUrl )
: null ,
headerKeys : headers ? Object . keys ( headers ) . sort ( ) : [ ] ,
sessionKeyStrategy : defaults
? nonEmptyTrimmedString ( defaults . sessionKeyStrategy )
: null ,
waitTimeoutMs :
defaults && typeof defaults . waitTimeoutMs === "number"
? defaults . waitTimeoutMs
: null ,
gatewayToken : summarizeSecretForLog ( gatewayTokenValue )
} ;
}
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 ) ;
2026-03-06 13:54:58 -06:00
const callbackHost = input . callbackUrl
? normalizeHostname ( input . callbackUrl . hostname )
: null ;
2026-03-02 16:43:59 -06:00
const allowSet = new Set (
input . allowedHostnames
. map ( ( entry ) = > normalizeHostname ( entry ) )
2026-03-06 13:54:58 -06:00
. filter ( ( entry ) : entry is string = > Boolean ( entry ) )
2026-03-02 16:43:59 -06:00
) ;
diagnostics . push ( {
code : "openclaw_deployment_context" ,
level : "info" ,
2026-03-06 13:54:58 -06:00
message : ` Deployment context: mode= ${ input . deploymentMode } , exposure= ${ input . deploymentExposure } . `
2026-03-02 16:43:59 -06:00
} ) ;
2026-03-06 13:54:58 -06:00
if (
input . deploymentMode === "authenticated" &&
input . deploymentExposure === "private"
) {
2026-03-02 16:43:59 -06:00
if ( ! bindHost || isLoopbackHost ( bindHost ) ) {
diagnostics . push ( {
code : "openclaw_private_bind_loopback" ,
level : "warn" ,
2026-03-06 13:54:58 -06:00
message :
"Paperclip is bound to loopback in authenticated/private mode." ,
hint : "Bind to a reachable private hostname/IP for remote OpenClaw callbacks."
2026-03-02 16:43:59 -06:00
} ) ;
}
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-06 13:54:58 -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" ,
2026-03-06 13:54:58 -06:00
message :
"No explicit allowed hostnames are configured for authenticated/private mode." ,
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." ,
2026-03-06 13:54:58 -06:00
hint : "Prefer HTTPS for public deployments."
2026-03-02 16:43:59 -06:00
} ) ;
}
return diagnostics ;
}
function normalizeAgentDefaultsForJoin ( input : {
adapterType : string | null ;
defaultsPayload : unknown ;
deploymentMode : DeploymentMode ;
deploymentExposure : DeploymentExposure ;
bindHost : string ;
allowedHostnames : string [ ] ;
} ) {
2026-03-07 16:01:19 -06:00
const fatalErrors : string [ ] = [ ] ;
2026-03-02 16:43:59 -06:00
const diagnostics : JoinDiagnostic [ ] = [ ] ;
2026-03-07 16:01:19 -06:00
if (
input . adapterType !== "openclaw" &&
input . adapterType !== "openclaw_gateway"
) {
2026-03-02 16:43:59 -06:00
const normalized = isPlainObject ( input . defaultsPayload )
? ( input . defaultsPayload as Record < string , unknown > )
: null ;
2026-03-07 16:01:19 -06:00
return { normalized , diagnostics , fatalErrors } ;
}
if ( input . adapterType === "openclaw_gateway" ) {
if ( ! isPlainObject ( input . defaultsPayload ) ) {
diagnostics . push ( {
code : "openclaw_gateway_defaults_missing" ,
level : "warn" ,
message :
"No OpenClaw gateway config was provided in agentDefaultsPayload." ,
hint :
"Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins."
} ) ;
fatalErrors . push (
"agentDefaultsPayload is required for adapterType=openclaw_gateway"
) ;
return {
normalized : null as Record < string , unknown > | null ,
diagnostics ,
fatalErrors
} ;
}
const defaults = input . defaultsPayload as Record < string , unknown > ;
const normalized : Record < string , unknown > = { } ;
let gatewayUrl : URL | null = null ;
const rawGatewayUrl = nonEmptyTrimmedString ( defaults . url ) ;
if ( ! rawGatewayUrl ) {
diagnostics . push ( {
code : "openclaw_gateway_url_missing" ,
level : "warn" ,
message : "OpenClaw gateway URL is missing." ,
hint : "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL."
} ) ;
fatalErrors . push ( "agentDefaultsPayload.url is required" ) ;
} else {
try {
gatewayUrl = new URL ( rawGatewayUrl ) ;
if (
gatewayUrl . protocol !== "ws:" &&
gatewayUrl . protocol !== "wss:"
) {
diagnostics . push ( {
code : "openclaw_gateway_url_protocol" ,
level : "warn" ,
message : ` OpenClaw gateway URL must use ws:// or wss:// (got ${ gatewayUrl . protocol } ). `
} ) ;
fatalErrors . push (
"agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway"
) ;
} else {
normalized . url = gatewayUrl . toString ( ) ;
diagnostics . push ( {
code : "openclaw_gateway_url_configured" ,
level : "info" ,
message : ` Gateway endpoint set to ${ gatewayUrl . toString ( ) } `
} ) ;
}
} catch {
diagnostics . push ( {
code : "openclaw_gateway_url_invalid" ,
level : "warn" ,
message : ` Invalid OpenClaw gateway URL: ${ rawGatewayUrl } `
} ) ;
fatalErrors . push ( "agentDefaultsPayload.url is not a valid URL" ) ;
}
}
const headers = normalizeHeaderMap ( defaults . headers ) ? ? { } ;
const gatewayToken =
headerMapGetIgnoreCase ( headers , "x-openclaw-token" ) ? ?
headerMapGetIgnoreCase ( headers , "x-openclaw-auth" ) ? ?
tokenFromAuthorizationHeader ( headerMapGetIgnoreCase ( headers , "authorization" ) ) ;
if (
gatewayToken &&
! headerMapHasKeyIgnoreCase ( headers , "x-openclaw-token" )
) {
headers [ "x-openclaw-token" ] = gatewayToken ;
}
if ( Object . keys ( headers ) . length > 0 ) {
normalized . headers = headers ;
}
if ( ! gatewayToken ) {
diagnostics . push ( {
code : "openclaw_gateway_auth_header_missing" ,
level : "warn" ,
message : "Gateway auth token is missing from agent defaults." ,
hint :
"Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)."
} ) ;
fatalErrors . push (
"agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required"
) ;
} else if ( gatewayToken . trim ( ) . length < 16 ) {
diagnostics . push ( {
code : "openclaw_gateway_auth_header_too_short" ,
level : "warn" ,
message : ` Gateway auth token appears too short ( ${ gatewayToken . trim ( ) . length } chars). ` ,
hint :
"Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)."
} ) ;
fatalErrors . push (
"agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token"
) ;
} else {
diagnostics . push ( {
code : "openclaw_gateway_auth_header_configured" ,
level : "info" ,
message : "Gateway auth token configured."
} ) ;
}
if ( isPlainObject ( defaults . payloadTemplate ) ) {
normalized . payloadTemplate = defaults . payloadTemplate ;
}
const parsedDisableDeviceAuth = parseBooleanLike ( defaults . disableDeviceAuth ) ;
if ( parsedDisableDeviceAuth !== null ) {
normalized . disableDeviceAuth = parsedDisableDeviceAuth ;
}
const waitTimeoutMs =
typeof defaults . waitTimeoutMs === "number" &&
Number . isFinite ( defaults . waitTimeoutMs )
? Math . floor ( defaults . waitTimeoutMs )
: typeof defaults . waitTimeoutMs === "string"
? Number . parseInt ( defaults . waitTimeoutMs . trim ( ) , 10 )
: NaN ;
if ( Number . isFinite ( waitTimeoutMs ) && waitTimeoutMs > 0 ) {
normalized . waitTimeoutMs = waitTimeoutMs ;
}
const timeoutSec =
typeof defaults . timeoutSec === "number" && Number . isFinite ( defaults . timeoutSec )
? Math . floor ( defaults . timeoutSec )
: typeof defaults . timeoutSec === "string"
? Number . parseInt ( defaults . timeoutSec . trim ( ) , 10 )
: NaN ;
if ( Number . isFinite ( timeoutSec ) && timeoutSec > 0 ) {
normalized . timeoutSec = timeoutSec ;
}
const sessionKeyStrategy = nonEmptyTrimmedString ( defaults . sessionKeyStrategy ) ;
if (
sessionKeyStrategy === "fixed" ||
sessionKeyStrategy === "issue" ||
sessionKeyStrategy === "run"
) {
normalized . sessionKeyStrategy = sessionKeyStrategy ;
}
const sessionKey = nonEmptyTrimmedString ( defaults . sessionKey ) ;
if ( sessionKey ) {
normalized . sessionKey = sessionKey ;
}
const role = nonEmptyTrimmedString ( defaults . role ) ;
if ( role ) {
normalized . role = role ;
}
if ( Array . isArray ( defaults . scopes ) ) {
const scopes = defaults . scopes
. filter ( ( entry ) : entry is string = > typeof entry === "string" )
. map ( ( entry ) = > entry . trim ( ) )
. filter ( Boolean ) ;
if ( scopes . length > 0 ) {
normalized . scopes = scopes ;
}
}
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_gateway_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_gateway_paperclip_api_url_configured" ,
level : "info" ,
message : ` paperclipApiUrl set to ${ parsedPaperclipApiUrl . toString ( ) } `
} ) ;
}
} catch {
diagnostics . push ( {
code : "openclaw_gateway_paperclip_api_url_invalid" ,
level : "warn" ,
message : ` Invalid paperclipApiUrl: ${ rawPaperclipApiUrl } `
} ) ;
}
}
return { normalized , diagnostics , fatalErrors } ;
2026-03-02 16:43:59 -06:00
}
if ( ! isPlainObject ( input . defaultsPayload ) ) {
diagnostics . push ( {
code : "openclaw_callback_config_missing" ,
level : "warn" ,
2026-03-06 13:54:58 -06:00
message :
"No OpenClaw callback config was provided in agentDefaultsPayload." ,
2026-03-06 15:15:24 -06:00
hint : "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval."
2026-03-02 16:43:59 -06:00
} ) ;
2026-03-07 16:01:19 -06:00
return {
normalized : null as Record < string , unknown > | null ,
diagnostics ,
fatalErrors
} ;
2026-03-02 16:43:59 -06:00
}
const defaults = input . defaultsPayload as Record < string , unknown > ;
2026-03-06 15:15:24 -06:00
const streamTransportInput = defaults . streamTransport ? ? defaults . transport ;
const streamTransport = normalizeOpenClawTransport ( streamTransportInput ) ;
2026-03-05 15:54:55 -06:00
const normalized : Record < string , unknown > = { streamTransport : "sse" } ;
2026-03-06 15:15:24 -06:00
if ( ! streamTransport ) {
diagnostics . push ( {
code : "openclaw_stream_transport_unsupported" ,
level : "warn" ,
message : ` Unsupported streamTransport: ${ String ( streamTransportInput ) } ` ,
hint : "Use streamTransport=sse or streamTransport=webhook."
} ) ;
} else {
normalized . streamTransport = streamTransport ;
}
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-06 15:15:24 -06:00
hint : "Set agentDefaultsPayload.url to your OpenClaw endpoint."
2026-03-02 16:43:59 -06:00
} ) ;
} else {
try {
callbackUrl = new URL ( rawUrl ) ;
2026-03-06 13:54:58 -06:00
if (
callbackUrl . protocol !== "http:" &&
callbackUrl . protocol !== "https:"
) {
2026-03-02 16:43:59 -06:00
diagnostics . push ( {
code : "openclaw_callback_url_protocol" ,
level : "warn" ,
message : ` Unsupported callback protocol: ${ callbackUrl . protocol } ` ,
2026-03-06 13:54:58 -06:00
hint : "Use http:// or https://."
2026-03-02 16:43:59 -06:00
} ) ;
} else {
normalized . url = callbackUrl . toString ( ) ;
diagnostics . push ( {
code : "openclaw_callback_url_configured" ,
level : "info" ,
2026-03-06 13:54:58 -06:00
message : ` Callback endpoint set to ${ callbackUrl . toString ( ) } `
2026-03-02 16:43:59 -06:00
} ) ;
}
2026-03-06 15:15:24 -06:00
if ( ( streamTransport ? ? "sse" ) === "sse" && isWakePath ( callbackUrl . pathname ) ) {
2026-03-05 15:54:55 -06:00
diagnostics . push ( {
code : "openclaw_callback_wake_path_incompatible" ,
level : "warn" ,
2026-03-06 13:54:58 -06:00
message :
2026-03-06 15:15:24 -06:00
"Configured callback path targets /hooks/wake, which is not stream-capable for SSE transport." ,
2026-03-06 13:54:58 -06:00
hint : "Use an endpoint that returns text/event-stream for the full run duration."
2026-03-05 15:54:55 -06:00
} ) ;
}
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." ,
2026-03-06 13:54:58 -06:00
hint : "Use a reachable hostname/IP when OpenClaw runs on another machine."
2026-03-02 16:43:59 -06:00
} ) ;
}
} catch {
diagnostics . push ( {
code : "openclaw_callback_url_invalid" ,
level : "warn" ,
2026-03-06 13:54:58 -06:00
message : ` Invalid callback URL: ${ rawUrl } `
2026-03-02 16:43:59 -06:00
} ) ;
}
}
2026-03-06 13:54:58 -06:00
const rawMethod =
typeof defaults . method === "string"
? defaults . method . trim ( ) . toUpperCase ( )
: "" ;
2026-03-02 16:43:59 -06:00
normalized . method = rawMethod || "POST" ;
2026-03-06 13:54:58 -06:00
if (
typeof defaults . timeoutSec === "number" &&
Number . isFinite ( defaults . timeoutSec )
) {
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 ;
2026-03-06 13:54:58 -06:00
if (
typeof defaults . webhookAuthHeader === "string" &&
defaults . webhookAuthHeader . trim ( )
) {
2026-03-02 16:43:59 -06:00
normalized . webhookAuthHeader = defaults . webhookAuthHeader . trim ( ) ;
}
2026-03-06 13:54:58 -06:00
const openClawAuthHeader = headers
2026-03-06 16:50:20 -06:00
? headerMapGetIgnoreCase ( headers , "x-openclaw-token" ) ? ?
headerMapGetIgnoreCase ( headers , "x-openclaw-auth" )
2026-03-06 13:54:58 -06:00
: null ;
2026-03-06 11:00:44 -06:00
if ( openClawAuthHeader ) {
diagnostics . push ( {
code : "openclaw_auth_header_configured" ,
level : "info" ,
2026-03-06 16:50:20 -06:00
message :
"Gateway auth token received via headers.x-openclaw-token (or legacy x-openclaw-auth)."
2026-03-06 11:00:44 -06:00
} ) ;
} else {
diagnostics . push ( {
code : "openclaw_auth_header_missing" ,
level : "warn" ,
message : "Gateway auth token is missing from agent defaults." ,
2026-03-06 16:50:20 -06:00
hint :
"Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth) to the token your OpenClaw endpoint requires."
2026-03-06 11:00:44 -06:00
} ) ;
}
2026-03-02 16:43:59 -06:00
if ( isPlainObject ( defaults . payloadTemplate ) ) {
normalized . payloadTemplate = defaults . payloadTemplate ;
}
2026-03-06 13:54:58 -06:00
const rawPaperclipApiUrl =
typeof defaults . paperclipApiUrl === "string"
? defaults . paperclipApiUrl . trim ( )
: "" ;
2026-03-06 08:39:29 -06:00
if ( rawPaperclipApiUrl ) {
try {
const parsedPaperclipApiUrl = new URL ( rawPaperclipApiUrl ) ;
2026-03-06 13:54:58 -06:00
if (
parsedPaperclipApiUrl . protocol !== "http:" &&
parsedPaperclipApiUrl . protocol !== "https:"
) {
2026-03-06 08:39:29 -06:00
diagnostics . push ( {
code : "openclaw_paperclip_api_url_protocol" ,
level : "warn" ,
2026-03-06 13:54:58 -06:00
message : ` paperclipApiUrl must use http:// or https:// (got ${ parsedPaperclipApiUrl . protocol } ). `
2026-03-06 08:39:29 -06:00
} ) ;
} else {
normalized . paperclipApiUrl = parsedPaperclipApiUrl . toString ( ) ;
diagnostics . push ( {
code : "openclaw_paperclip_api_url_configured" ,
level : "info" ,
2026-03-06 13:54:58 -06:00
message : ` paperclipApiUrl set to ${ parsedPaperclipApiUrl . toString ( ) } `
2026-03-06 08:39:29 -06:00
} ) ;
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." ,
2026-03-06 13:54:58 -06:00
hint : "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments."
2026-03-06 08:39:29 -06:00
} ) ;
}
}
} catch {
diagnostics . push ( {
code : "openclaw_paperclip_api_url_invalid" ,
level : "warn" ,
2026-03-06 13:54:58 -06:00
message : ` Invalid paperclipApiUrl: ${ rawPaperclipApiUrl } `
2026-03-06 08:39:29 -06:00
} ) ;
}
}
2026-03-02 16:43:59 -06:00
diagnostics . push (
. . . buildJoinConnectivityDiagnostics ( {
deploymentMode : input.deploymentMode ,
deploymentExposure : input.deploymentExposure ,
bindHost : input.bindHost ,
allowedHostnames : input.allowedHostnames ,
2026-03-06 13:54:58 -06:00
callbackUrl
} )
2026-03-02 16:43:59 -06:00
) ;
2026-03-07 16:01:19 -06:00
return { normalized , diagnostics , fatalErrors } ;
2026-03-02 16:43:59 -06:00
}
2026-03-06 13:54:58 -06:00
function toInviteSummaryResponse (
req : Request ,
token : string ,
invite : typeof invites . $inferSelect
) {
2026-02-26 16:33:20 -06:00
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 ,
2026-03-06 13:54:58 -06:00
onboardingTextUrl : baseUrl
? ` ${ baseUrl } ${ onboardingTextPath } `
: onboardingTextPath ,
2026-02-26 16:33:20 -06:00
skillIndexPath : "/api/skills/index" ,
2026-03-06 13:54:58 -06:00
skillIndexUrl : baseUrl
? ` ${ baseUrl } /api/skills/index `
: "/api/skills/index" ,
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 ) )
2026-03-06 13:54:58 -06:00
. filter ( ( entry ) : entry is string = > Boolean ( entry ) )
2026-03-04 16:29:14 -06:00
) ;
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." ,
2026-03-06 13:54:58 -06:00
hint : "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain)."
2026-03-04 16:29:14 -06:00
} ) ;
}
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." ,
2026-03-06 13:54:58 -06:00
hint : "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding."
2026-03-04 16:29:14 -06:00
} ) ;
}
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. ` ,
2026-03-06 13:54:58 -06:00
hint : ` Run pnpm paperclipai allowed-hostname ${ apiHost } `
2026-03-04 16:29:14 -06:00
} ) ;
}
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-03-06 13:54:58 -06:00
}
2026-03-02 16:43:59 -06:00
) {
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 ` ;
2026-03-06 13:54:58 -06:00
const registrationEndpointUrl = baseUrl
? ` ${ baseUrl } ${ registrationEndpointPath } `
: registrationEndpointPath ;
2026-03-04 16:29:14 -06:00
const onboardingTextPath = ` /api/invites/ ${ token } /onboarding.txt ` ;
2026-03-06 13:54:58 -06:00
const onboardingTextUrl = baseUrl
? ` ${ baseUrl } ${ onboardingTextPath } `
: onboardingTextPath ;
2026-03-04 16:29:14 -06:00
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics ( {
apiBaseUrl : baseUrl ,
deploymentMode : opts.deploymentMode ,
deploymentExposure : opts.deploymentExposure ,
bindHost : opts.bindHost ,
2026-03-06 13:54:58 -06:00
allowedHostnames : opts.allowedHostnames
2026-03-04 16:29:14 -06:00
} ) ;
2026-03-05 12:28:27 -06:00
const connectionCandidates = buildOnboardingConnectionCandidates ( {
apiBaseUrl : baseUrl ,
bindHost : opts.bindHost ,
2026-03-06 13:54:58 -06:00
allowedHostnames : opts.allowedHostnames
2026-03-05 12:28:27 -06:00
} ) ;
2026-02-26 16:33:20 -06:00
return {
invite : toInviteSummaryResponse ( req , token , invite ) ,
onboarding : {
instructions :
2026-03-07 15:39:12 -06:00
"Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)." ,
2026-03-05 12:10:01 -06:00
inviteMessage : extractInviteMessage ( invite ) ,
2026-03-07 15:39:12 -06:00
recommendedAdapterType : "openclaw_gateway" ,
2026-02-26 16:33:20 -06:00
requiredFields : {
requestType : "agent" ,
agentName : "Display name for this agent" ,
2026-03-07 15:39:12 -06:00
adapterType : "Use 'openclaw_gateway' for OpenClaw Gateway agents" ,
2026-02-26 16:33:20 -06:00
capabilities : "Optional capability summary" ,
agentDefaultsPayload :
2026-03-07 15:39:12 -06:00
"Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth."
2026-02-26 16:33:20 -06:00
} ,
registrationEndpoint : {
method : "POST" ,
path : registrationEndpointPath ,
2026-03-06 13:54:58 -06:00
url : registrationEndpointUrl
2026-02-26 16:33:20 -06:00
} ,
claimEndpointTemplate : {
method : "POST" ,
path : "/api/join-requests/{requestId}/claim-api-key" ,
body : {
2026-03-06 13:54:58 -06:00
claimSecret :
"one-time claim secret returned when the join request is created"
}
2026-02-26 16:33:20 -06:00
} ,
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-04 16:29:14 -06:00
diagnostics : discoveryDiagnostics ,
2026-03-02 16:43:59 -06:00
guidance :
2026-03-06 13:54:58 -06:00
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-07 15:39:12 -06:00
: "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls."
2026-03-02 16:43:59 -06:00
} ,
2026-03-04 16:29:14 -06:00
textInstructions : {
path : onboardingTextPath ,
url : onboardingTextUrl ,
2026-03-06 13:54:58 -06:00
contentType : "text/plain"
2026-03-04 16:29:14 -06:00
} ,
2026-02-26 16:33:20 -06:00
skill : {
name : "paperclip" ,
path : skillPath ,
url : skillUrl ,
2026-03-06 13:54:58 -06:00
installPath : "~/.openclaw/skills/paperclip/SKILL.md"
}
}
2026-02-26 16:33:20 -06:00
} ;
}
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 [ ] ;
2026-03-06 13:54:58 -06:00
}
2026-03-04 16:29:14 -06:00
) {
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
: [ ] ;
2026-03-06 13:54:58 -06:00
const lines : string [ ] = [ ] ;
const appendBlock = ( block : string ) = > {
const trimmed = block . replace ( /^\n/ , "" ) . replace ( /\n\s*$/ , "" ) ;
const lineIndentation = trimmed
. split ( "\n" )
. filter ( ( line ) = > line . trim ( ) . length > 0 )
. map ( ( line ) = > line . match ( /^(\s*)/ ) ? . [ 0 ] . length ? ? 0 ) ;
const minIndent =
lineIndentation . length > 0 ? Math . min ( . . . lineIndentation ) : 0 ;
for ( const line of trimmed . split ( "\n" ) ) {
lines . push ( line . slice ( minIndent ) ) ;
}
} ;
appendBlock ( `
2026-03-07 15:39:12 -06:00
# Paperclip OpenClaw Gateway Onboarding
2026-03-06 13:54:58 -06:00
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 ) {
2026-03-06 13:54:58 -06:00
appendBlock ( `
# # Message from inviter
$ { onboarding . inviteMessage }
` );
2026-03-05 12:10:01 -06:00
}
2026-03-06 13:54:58 -06:00
appendBlock ( `
# # Step 0
Get the OpenClaw gateway auth token ( THIS MUST BE SENT )
Token lives in :
~ / . o p e n c l a w / o p e n c l a w . j s o n - > g a t e w a y . a u t h . t o k e n
Extract :
TOKEN = "$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
test - n "$TOKEN" || ( echo "Missing TOKEN" && exit 1 )
2026-03-07 16:01:19 -06:00
test "\${#TOKEN}" - ge 16 || ( echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1 )
2026-03-06 13:54:58 -06:00
3 ) IMPORTANT : Don ' t accidentally drop the token when generating JSON
If you build JSON with Node , pass the token explicitly ( argv ) , don ' t rely on an un - exported env var .
Safe payload build looks sort of like this ( substitute where necessary ) :
BODY = " $ ( node - e '
const token = process . argv [ 1 ] ;
if ( ! token ) process . exit ( 2 ) ;
const body = {
requestType : "agent" ,
agentName : "OpenClaw" ,
2026-03-07 15:39:12 -06:00
adapterType : "openclaw_gateway" ,
2026-03-06 13:54:58 -06:00
capabilities : "OpenClaw agent adapter" ,
agentDefaultsPayload : {
2026-03-07 15:39:12 -06:00
url : "ws://127.0.0.1:18789" ,
2026-03-06 13:54:58 -06:00
paperclipApiUrl : "http://host.docker.internal:3100" ,
2026-03-07 15:39:12 -06:00
headers : { "x-openclaw-token" : token } ,
waitTimeoutMs : 120000 ,
sessionKeyStrategy : "fixed" ,
sessionKey : "paperclip" ,
2026-03-07 16:32:49 -06:00
disableDeviceAuth : true ,
2026-03-07 15:39:12 -06:00
role : "operator" ,
scopes : [ "operator.admin" ]
2026-03-06 13:54:58 -06:00
}
} ;
process . stdout . write ( JSON . stringify ( body ) ) ;
' "$TOKEN" ) "
# # Step 1 : Submit agent join request
$ { onboarding . registrationEndpoint . method } $ {
onboarding . registrationEndpoint . url
}
2026-03-07 15:39:12 -06:00
IMPORTANT : You MUST include agentDefaultsPayload . headers . x - openclaw - token with your gateway token .
Legacy x - openclaw - auth is also accepted , but x - openclaw - token is preferred .
Use adapterType "openclaw_gateway" and a ws : // or wss:// gateway URL.
2026-03-07 16:32:49 -06:00
Pairing mode requirement :
- For smoke / dev , set "disableDeviceAuth" : true to avoid interactive pairing blocks .
- If device auth remains enabled , set a stable "devicePrivateKeyPem" ; otherwise each run may generate a new device identity and trigger pairing again .
2026-03-07 15:39:12 -06:00
Do NOT use / v1 / responses or / hooks / * in this gateway join flow .
2026-03-06 13:54:58 -06:00
Body ( JSON ) :
{
"requestType" : "agent" ,
"agentName" : "My OpenClaw Agent" ,
2026-03-07 15:39:12 -06:00
"adapterType" : "openclaw_gateway" ,
2026-03-06 13:54:58 -06:00
"capabilities" : "Optional summary" ,
"agentDefaultsPayload" : {
2026-03-07 15:39:12 -06:00
"url" : "wss://your-openclaw-gateway.example" ,
2026-03-06 13:54:58 -06:00
"paperclipApiUrl" : "https://paperclip-hostname-your-agent-can-reach:3100" ,
2026-03-07 15:39:12 -06:00
"headers" : { "x-openclaw-token" : "replace-me" } ,
"waitTimeoutMs" : 120000 ,
"sessionKeyStrategy" : "fixed" ,
"sessionKey" : "paperclip" ,
2026-03-07 16:32:49 -06:00
"disableDeviceAuth" : true ,
2026-03-07 15:39:12 -06:00
"role" : "operator" ,
"scopes" : [ "operator.admin" ]
2026-03-06 13:54:58 -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>"
}
On successful claim , save the full JSON response to :
- ~ / . o p e n c l a w / w o r k s p a c e / p a p e r c l i p - c l a i m e d - a p i - k e y . j s o n
chmod 600 ~ / . o p e n c l a w / w o r k s p a c e / p a p e r c l i p - c l a i m e d - a p i - k e y . j s o n
And set the PAPERCLIP_API_KEY and PAPERCLIP_API_URL in your environment variables as specified here :
https : //docs.openclaw.ai/help/environment
e . g .
{
env : {
PAPERCLIP_API_KEY : "..." ,
PAPERCLIP_API_URL : "..." ,
} ,
}
Then set PAPERCLIP_API_KEY and PAPERCLIP_API_URL from the saved token field for every heartbeat run .
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 }
2026-03-06 14:16:39 -06:00
Be sure to prepend your PAPERCLIP_API_URL to the top of your skill and note the path to your PAPERCLIP_API_URL
2026-03-06 13:54:58 -06:00
# # Text onboarding URL
$ { onboarding . textInstructions . url }
# # Connectivity guidance
$ {
onboarding . connectivity ? . guidance ? ?
"Ensure Paperclip is reachable from your OpenClaw runtime."
}
` );
2026-03-04 16:29:14 -06:00
2026-03-06 13:54:58 -06:00
const connectionCandidates = Array . isArray (
onboarding . connectivity ? . connectionCandidates
)
? onboarding . connectivity . connectionCandidates . filter (
( entry ) : entry is string = > Boolean ( entry )
)
2026-03-05 12:28:27 -06:00
: [ ] ;
if ( connectionCandidates . length > 0 ) {
2026-03-06 13:54:58 -06:00
lines . push ( "## Suggested Paperclip base URLs to try" ) ;
2026-03-05 12:28:27 -06:00
for ( const candidate of connectionCandidates ) {
lines . push ( ` - ${ candidate } ` ) ;
}
2026-03-06 13:54:58 -06:00
appendBlock ( `
Test each candidate with :
- GET < candidate > / api / health
- set the first reachable candidate as agentDefaultsPayload . paperclipApiUrl when submitting your join request
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-05 12:28:27 -06:00
}
2026-03-04 16:29:14 -06:00
if ( diagnostics . length > 0 ) {
2026-03-06 13:54:58 -06:00
lines . push ( "## Connectivity diagnostics" ) ;
2026-03-04 16:29:14 -06:00
for ( const diag of diagnostics ) {
lines . push ( ` - [ ${ diag . level } ] ${ diag . message } ` ) ;
if ( diag . hint ) lines . push ( ` hint: ${ diag . hint } ` ) ;
}
}
2026-03-06 13:54:58 -06:00
appendBlock ( `
# # Helpful endpoints
$ { onboarding . registrationEndpoint . path }
$ { onboarding . claimEndpointTemplate . path }
$ { onboarding . skill . path }
$ { manifest . invite . onboardingPath }
` );
2026-03-04 16:29:14 -06:00
return ` ${ lines . join ( "\n" ) } \ n ` ;
}
2026-03-06 13:54:58 -06:00
function extractInviteMessage (
invite : typeof invites . $inferSelect
) : string | null {
2026-03-05 12:10:01 -06:00
const rawDefaults = invite . defaultsPayload ;
2026-03-06 13:54:58 -06:00
if (
! rawDefaults ||
typeof rawDefaults !== "object" ||
Array . isArray ( rawDefaults )
) {
2026-03-05 12:10:01 -06:00
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 ,
2026-03-06 13:54:58 -06:00
agentMessage : string | null
2026-03-05 12:10:01 -06:00
) : Record < string , unknown > | null {
2026-03-06 13:54:58 -06:00
const merged =
defaultsPayload && typeof defaultsPayload === "object"
? { . . . defaultsPayload }
: { } ;
2026-03-05 12:10:01 -06:00
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 ,
2026-03-06 13:54:58 -06:00
key : "human" | "agent"
) : Array < {
permissionKey : ( typeof PERMISSION_KEYS ) [ number ] ;
scope : Record < string , unknown > | null ;
} > {
2026-02-23 14:40:32 -06:00
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 :
2026-03-06 13:54:58 -06:00
record . scope &&
typeof record . scope === "object" &&
! Array . isArray ( record . scope )
2026-02-23 14:40:32 -06:00
? ( record . scope as Record < string , unknown > )
2026-03-06 13:54:58 -06:00
: null
2026-02-23 14:40:32 -06:00
} ) ;
}
return result ;
}
2026-03-06 11:22:24 -06:00
type JoinRequestManagerCandidate = {
id : string ;
role : string ;
reportsTo : string | null ;
} ;
export function resolveJoinRequestAgentManagerId (
2026-03-06 13:54:58 -06:00
candidates : JoinRequestManagerCandidate [ ]
2026-03-06 11:22:24 -06:00
) : string | null {
2026-03-06 13:54:58 -06:00
const ceoCandidates = candidates . filter (
( candidate ) = > candidate . role === "ceo"
) ;
2026-03-06 11:22:24 -06:00
if ( ceoCandidates . length === 0 ) return null ;
2026-03-06 13:54:58 -06:00
const rootCeo = ceoCandidates . find (
( candidate ) = > candidate . reportsTo === null
) ;
2026-03-06 11:22:24 -06:00
return ( rootCeo ? ? ceoCandidates [ 0 ] ? ? null ) ? . id ? ? null ;
}
2026-03-05 12:52:39 -06:00
function isInviteTokenHashCollisionError ( error : unknown ) {
const candidates = [
error ,
2026-03-06 13:54:58 -06:00
( error as { cause? : unknown } | null ) ? . cause ? ? null
2026-03-05 12:52:39 -06:00
] ;
for ( const candidate of candidates ) {
if ( ! candidate || typeof candidate !== "object" ) continue ;
2026-03-06 13:54:58 -06:00
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 ;
2026-03-05 12:52:39 -06:00
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 ;
} ;
2026-03-06 13:54:58 -06:00
async function probeInviteResolutionTarget (
url : URL ,
timeoutMs : number
) : Promise < InviteResolutionProbe > {
2026-03-05 13:05:04 -06:00
const startedAt = Date . now ( ) ;
const controller = new AbortController ( ) ;
const timeout = setTimeout ( ( ) = > controller . abort ( ) , timeoutMs ) ;
try {
const response = await fetch ( url , {
method : "HEAD" ,
redirect : "manual" ,
2026-03-06 13:54:58 -06:00
signal : controller.signal
2026-03-05 13:05:04 -06:00
} ) ;
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 ,
2026-03-06 13:54:58 -06:00
message : ` Webhook endpoint responded to HEAD with HTTP ${ response . status } . `
2026-03-05 13:05:04 -06:00
} ;
}
return {
status : "unreachable" ,
method : "HEAD" ,
durationMs ,
httpStatus : response.status ,
2026-03-06 13:54:58 -06:00
message : ` Webhook endpoint probe returned HTTP ${ response . status } . `
2026-03-05 13:05:04 -06:00
} ;
} catch ( error ) {
const durationMs = Date . now ( ) - startedAt ;
if ( isAbortError ( error ) ) {
return {
status : "timeout" ,
method : "HEAD" ,
durationMs ,
httpStatus : null ,
2026-03-06 13:54:58 -06:00
message : ` Webhook endpoint probe timed out after ${ timeoutMs } ms. `
2026-03-05 13:05:04 -06:00
} ;
}
return {
status : "unreachable" ,
method : "HEAD" ,
durationMs ,
httpStatus : null ,
2026-03-06 13:54:58 -06:00
message :
error instanceof Error
? error . message
: "Webhook endpoint probe failed."
2026-03-05 13:05:04 -06:00
} ;
} 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-03-06 13:54:58 -06:00
}
2026-03-02 16:43:59 -06:00
) {
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 ( ) ;
2026-03-06 13:54:58 -06:00
const code =
typeof req . query . code === "string" ? req . query . code . trim ( ) : undefined ;
2026-02-23 16:25:31 -06:00
if ( ! token ) throw notFound ( "Board claim challenge not found" ) ;
const challenge = inspectBoardClaimChallenge ( token , code ) ;
2026-03-06 13:54:58 -06:00
if ( challenge . status === "invalid" )
throw notFound ( "Board claim challenge not found" ) ;
2026-02-23 16:25:31 -06:00
res . json ( challenge ) ;
} ) ;
router . post ( "/board-claim/:token/claim" , async ( req , res ) = > {
const token = ( req . params . token as string ) . trim ( ) ;
2026-03-06 13:54:58 -06:00
const code =
typeof req . body ? . code === "string" ? req . body . code . trim ( ) : undefined ;
2026-02-23 16:25:31 -06:00
if ( ! token ) throw notFound ( "Board claim challenge not found" ) ;
if ( ! code ) throw badRequest ( "Claim code is required" ) ;
2026-03-06 13:54:58 -06:00
if (
req . actor . type !== "board" ||
req . actor . source !== "session" ||
! req . actor . userId
) {
2026-02-23 16:25:31 -06:00
throw unauthorized ( "Sign in before claiming board ownership" ) ;
}
const claimed = await claimBoardOwnership ( db , {
token ,
code ,
2026-03-06 13:54:58 -06:00
userId : req.actor.userId
2026-02-23 16:25:31 -06:00
} ) ;
2026-03-06 13:54:58 -06:00
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."
) ;
2026-02-23 16:25:31 -06:00
if ( claimed . status === "claimed" ) {
2026-03-06 13:54:58 -06:00
res . json ( {
claimed : true ,
userId : claimed.claimedByUserId ? ? req . actor . userId
} ) ;
2026-02-23 16:25:31 -06:00
return ;
}
throw conflict ( "Board claim challenge is no longer available" ) ;
} ) ;
2026-03-06 13:54:58 -06:00
async function assertCompanyPermission (
req : Request ,
companyId : string ,
permissionKey : any
) {
2026-02-23 14:40:32 -06:00
assertCompanyAccess ( req , companyId ) ;
if ( req . actor . type === "agent" ) {
if ( ! req . actor . agentId ) throw forbidden ( ) ;
2026-03-06 13:54:58 -06:00
const allowed = await access . hasPermission (
companyId ,
"agent" ,
req . actor . agentId ,
permissionKey
) ;
2026-02-23 14:40:32 -06:00
if ( ! allowed ) throw forbidden ( "Permission denied" ) ;
return ;
}
if ( req . actor . type !== "board" ) throw unauthorized ( ) ;
if ( isLocalImplicit ( req ) ) return ;
2026-03-06 13:54:58 -06:00
const allowed = await access . canUser (
companyId ,
req . actor . userId ,
permissionKey
) ;
2026-02-23 14:40:32 -06:00
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" } ,
2026-03-06 13:54:58 -06:00
{
name : "paperclip-create-agent" ,
path : "/api/skills/paperclip-create-agent"
}
]
2026-02-26 16:33:20 -06:00
} ) ;
} ) ;
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-06 13:54:58 -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 ,
2026-03-06 13:54:58 -06:00
defaultsPayload : mergeInviteDefaults (
req . body . defaultsPayload ? ? null ,
normalizedAgentMessage
) ,
2026-03-06 10:10:23 -06:00
expiresAt : companyInviteExpiresAt ( ) ,
2026-03-06 13:54:58 -06:00
invitedByUserId : req.actor.userId ? ? null
2026-03-05 12:52:39 -06:00
} ;
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 ,
2026-03-06 13:54:58 -06:00
tokenHash : hashToken ( candidateToken )
2026-03-05 12:52:39 -06:00
} )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ) ;
token = candidateToken ;
created = row ;
break ;
} catch ( error ) {
if ( ! isInviteTokenHashCollisionError ( error ) ) {
throw error ;
}
}
}
if ( ! token || ! created ) {
2026-03-06 13:54:58 -06:00
throw conflict (
"Failed to generate a unique invite token. Please retry."
) ;
2026-03-05 12:52:39 -06:00
}
2026-02-23 14:40:32 -06:00
await logActivity ( db , {
companyId ,
actorType : req.actor.type === "agent" ? "agent" : "user" ,
2026-03-06 13:54:58 -06:00
actorId :
req . actor . type === "agent"
? req . actor . agentId ? ? "unknown-agent"
: req . actor . userId ? ? "board" ,
2026-02-23 14:40:32 -06:00
action : "invite.created" ,
entityType : "invite" ,
entityId : created.id ,
details : {
inviteType : created.inviteType ,
allowedJoinTypes : created.allowedJoinTypes ,
expiresAt : created.expiresAt.toISOString ( ) ,
2026-03-06 13:54:58 -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 ,
2026-03-06 13:54:58 -06:00
inviteMessage : inviteSummary.inviteMessage
2026-02-23 14:40:32 -06:00
} ) ;
2026-03-06 13:54:58 -06:00
}
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 ) ;
2026-03-06 13:54:58 -06:00
if (
! invite ||
invite . revokedAt ||
invite . acceptedAt ||
inviteExpired ( invite )
) {
2026-02-23 14:40:32 -06:00
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" ) ;
}
2026-03-06 13:54:58 -06:00
res
. type ( "text/plain; charset=utf-8" )
. send ( buildInviteOnboardingTextDocument ( req , token , invite , opts ) ) ;
2026-03-04 16:29:14 -06:00
} ) ;
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" ) ;
}
2026-03-06 13:54:58 -06:00
const rawUrl =
typeof req . query . url === "string" ? req . query . url . trim ( ) : "" ;
2026-03-05 13:05:04 -06:00
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" ) ;
}
2026-03-06 13:54:58 -06:00
const parsedTimeoutMs =
typeof req . query . timeoutMs === "string"
? Number ( req . query . timeoutMs )
: NaN ;
2026-03-05 13:05:04 -06:00
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 ,
2026-03-06 13:54:58 -06:00
. . . probe
2026-03-05 13:05:04 -06:00
} ) ;
} ) ;
2026-03-06 13:54:58 -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" ) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
const invite = await db
2026-03-06 11:59:13 -06:00
. select ( )
2026-03-06 13:54:58 -06:00
. 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-02-23 14:40:32 -06:00
}
2026-03-06 13:54:58 -06:00
const inviteAlreadyAccepted = Boolean ( invite . acceptedAt ) ;
const existingJoinRequestForInvite = inviteAlreadyAccepted
? await db
. select ( )
. from ( joinRequests )
. where ( eq ( joinRequests . inviteId , invite . id ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null )
: null ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
if ( invite . inviteType === "bootstrap_ceo" ) {
if ( inviteAlreadyAccepted ) throw notFound ( "Invite not found" ) ;
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 ;
2026-03-06 11:59:13 -06:00
}
2026-03-06 13:54:58 -06:00
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 ` ) ;
}
2026-03-06 11:59:13 -06:00
2026-03-06 13:54:58 -06:00
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 ) {
if (
! inviteAlreadyAccepted ||
! existingJoinRequestForInvite ? . agentName
) {
throw badRequest ( "agentName is required for agent join requests" ) ;
}
}
2026-03-06 09:36:20 -06:00
2026-03-06 13:54:58 -06:00
const adapterType = req . body . adapterType ? ? null ;
if (
inviteAlreadyAccepted &&
! canReplayOpenClawInviteAccept ( {
2026-03-06 09:36:20 -06:00
requestType ,
2026-03-06 11:59:13 -06:00
adapterType ,
2026-03-06 13:54:58 -06:00
existingJoinRequest : existingJoinRequestForInvite
} )
) {
throw notFound ( "Invite not found" ) ;
}
const replayJoinRequestId = inviteAlreadyAccepted
? existingJoinRequestForInvite ? . id ? ? null
: null ;
if ( inviteAlreadyAccepted && ! replayJoinRequestId ) {
throw conflict ( "Join request not found" ) ;
}
2026-02-26 16:33:20 -06:00
2026-03-06 13:54:58 -06:00
const replayMergedDefaults = inviteAlreadyAccepted
? mergeJoinDefaultsPayloadForReplay (
existingJoinRequestForInvite ? . agentDefaultsPayload ? ? null ,
req . body . agentDefaultsPayload ? ? null
)
: req . body . agentDefaultsPayload ? ? null ;
const openClawDefaultsPayload =
requestType === "agent"
? buildJoinDefaultsPayloadForAccept ( {
adapterType ,
defaultsPayload : replayMergedDefaults ,
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 ,
2026-03-06 16:50:20 -06:00
inboundOpenClawAuthHeader : req.header ( "x-openclaw-auth" ) ? ? null ,
inboundOpenClawTokenHeader : req.header ( "x-openclaw-token" ) ? ? null
2026-03-06 13:54:58 -06:00
} )
: null ;
2026-03-06 11:59:13 -06:00
2026-03-06 13:54:58 -06:00
if ( requestType === "agent" && adapterType === "openclaw" ) {
logger . info (
{
2026-03-06 11:59:13 -06:00
inviteId : invite.id ,
requestType ,
2026-03-06 13:54:58 -06:00
adapterType ,
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
) ,
2026-03-06 16:50:20 -06:00
inboundOpenClawTokenHeader : summarizeSecretForLog (
req . header ( "x-openclaw-token" ) ? ? null
) ,
2026-03-06 13:54:58 -06:00
rawAgentDefaults : summarizeOpenClawDefaultsForLog (
req . body . agentDefaultsPayload ? ? null
) ,
mergedAgentDefaults : summarizeOpenClawDefaultsForLog (
openClawDefaultsPayload
)
} ,
"invite accept received OpenClaw join payload"
) ;
}
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
const joinDefaults =
requestType === "agent"
? normalizeAgentDefaultsForJoin ( {
adapterType ,
defaultsPayload : openClawDefaultsPayload ,
deploymentMode : opts.deploymentMode ,
deploymentExposure : opts.deploymentExposure ,
bindHost : opts.bindHost ,
allowedHostnames : opts.allowedHostnames
} )
: {
normalized : null as Record < string , unknown > | null ,
2026-03-07 16:01:19 -06:00
diagnostics : [ ] as JoinDiagnostic [ ] ,
fatalErrors : [ ] as string [ ]
2026-03-06 13:54:58 -06:00
} ;
2026-03-06 11:59:13 -06:00
2026-03-07 16:01:19 -06:00
if ( requestType === "agent" && joinDefaults . fatalErrors . length > 0 ) {
throw badRequest ( joinDefaults . fatalErrors . join ( "; " ) ) ;
}
2026-03-06 13:54:58 -06:00
if ( requestType === "agent" && adapterType === "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-03-06 11:59:13 -06:00
}
2026-03-07 16:01:19 -06:00
if ( requestType === "agent" && adapterType === "openclaw_gateway" ) {
logger . info (
{
inviteId : invite.id ,
joinRequestDiagnostics : joinDefaults.diagnostics.map ( ( diag ) = > ( {
code : diag.code ,
level : diag.level
} ) ) ,
normalizedAgentDefaults : summarizeOpenClawGatewayDefaultsForLog (
joinDefaults . normalized
)
} ,
"invite accept normalized OpenClaw gateway defaults"
) ;
}
2026-03-06 13:54:58 -06:00
const claimSecret =
requestType === "agent" && ! inviteAlreadyAccepted
? createClaimSecret ( )
: null ;
const claimSecretHash = claimSecret ? hashToken ( claimSecret ) : null ;
const claimSecretExpiresAt = claimSecret
? new Date ( Date . now ( ) + 7 * 24 * 60 * 60 * 1000 )
: null ;
2026-03-06 10:14:57 -06:00
2026-03-06 13:54:58 -06:00
const actorEmail =
requestType === "human" ? await resolveActorEmail ( db , req ) : null ;
const created = ! inviteAlreadyAccepted
? 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" ? adapterType : null ,
capabilities :
requestType === "agent"
? req . body . capabilities ? ? null
: null ,
agentDefaultsPayload :
requestType === "agent" ? joinDefaults.normalized : null ,
claimSecretHash ,
claimSecretExpiresAt
} )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ) ;
return row ;
} )
: await db
. update ( joinRequests )
. set ( {
requestIp : requestIp ( req ) ,
agentName :
requestType === "agent"
? req . body . agentName ? ?
existingJoinRequestForInvite ? . agentName ? ?
null
: null ,
capabilities :
requestType === "agent"
? req . body . capabilities ? ?
existingJoinRequestForInvite ? . capabilities ? ?
null
: null ,
adapterType : requestType === "agent" ? adapterType : null ,
agentDefaultsPayload :
requestType === "agent" ? joinDefaults.normalized : null ,
updatedAt : new Date ( )
} )
. where ( eq ( joinRequests . id , replayJoinRequestId as string ) )
. returning ( )
. then ( ( rows ) = > rows [ 0 ] ) ;
if ( ! created ) {
throw conflict ( "Join request not found" ) ;
2026-03-06 10:14:57 -06:00
}
2026-03-06 13:54:58 -06:00
if (
inviteAlreadyAccepted &&
requestType === "agent" &&
adapterType === "openclaw" &&
created . status === "approved" &&
created . createdAgentId
) {
const existingAgent = await agents . getById ( created . createdAgentId ) ;
if ( ! existingAgent ) {
throw conflict ( "Approved join request agent not found" ) ;
}
const existingAdapterConfig = isPlainObject ( existingAgent . adapterConfig )
? ( existingAgent . adapterConfig as Record < string , unknown > )
: { } ;
const nextAdapterConfig = {
. . . existingAdapterConfig ,
. . . ( joinDefaults . normalized ? ? { } )
} ;
const updatedAgent = await agents . update ( created . createdAgentId , {
adapterType ,
adapterConfig : nextAdapterConfig
} ) ;
if ( ! updatedAgent ) {
throw conflict ( "Approved join request agent not found" ) ;
}
await logActivity ( db , {
companyId ,
actorType : req.actor.type === "agent" ? "agent" : "user" ,
actorId :
req . actor . type === "agent"
? req . actor . agentId ? ? "invite-agent"
: req . actor . userId ? ? "board" ,
action : "agent.updated_from_join_replay" ,
entityType : "agent" ,
entityId : updatedAgent.id ,
details : { inviteId : invite.id , joinRequestId : created.id }
} ) ;
2026-03-06 10:14:57 -06:00
}
2026-03-06 13:54:58 -06:00
if ( requestType === "agent" && adapterType === "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
) {
2026-03-06 16:50:20 -06:00
missingPersistedFields . push (
"headers.x-openclaw-token|headers.x-openclaw-auth"
) ;
2026-03-06 13:54:58 -06:00
}
if (
expectedDefaults . headerKeys . length > 0 &&
persistedDefaults . headerKeys . length === 0
) {
missingPersistedFields . push ( "headers" ) ;
}
2026-03-06 10:14:57 -06:00
2026-03-06 13:54:58 -06:00
logger . info (
2026-03-06 10:14:57 -06:00
{
inviteId : invite.id ,
joinRequestId : created.id ,
2026-03-06 13:54:58 -06:00
joinRequestStatus : created.status ,
expectedDefaults ,
persistedDefaults ,
diagnostics : joinDefaults.diagnostics.map ( ( diag ) = > ( {
code : diag.code ,
level : diag.level ,
message : diag.message ,
hint : diag.hint ? ? null
} ) )
2026-03-06 10:14:57 -06:00
} ,
2026-03-06 13:54:58 -06:00
"invite accept persisted OpenClaw join request"
2026-03-06 10:14:57 -06:00
) ;
2026-03-06 13:54:58 -06:00
if ( missingPersistedFields . length > 0 ) {
logger . warn (
{
inviteId : invite.id ,
joinRequestId : created.id ,
missingPersistedFields
} ,
"invite accept detected missing persisted OpenClaw defaults"
) ;
}
2026-03-06 10:14:57 -06:00
}
2026-03-06 13:54:58 -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 : inviteAlreadyAccepted
? "join.request_replayed"
: "join.requested" ,
entityType : "join_request" ,
entityId : created.id ,
details : {
requestType ,
requestIp : created.requestIp ,
inviteReplay : inviteAlreadyAccepted
}
} ) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
const response = toJoinRequestResponse ( created ) ;
if ( claimSecret ) {
const onboardingManifest = buildInviteOnboardingManifest (
req ,
token ,
invite ,
opts
) ;
res . status ( 202 ) . json ( {
. . . response ,
claimSecret ,
claimApiKeyPath : ` /api/join-requests/ ${ created . id } /claim-api-key ` ,
onboarding : onboardingManifest.onboarding ,
diagnostics : joinDefaults.diagnostics
} ) ;
return ;
}
2026-02-26 16:33:20 -06:00
res . status ( 202 ) . json ( {
. . . response ,
2026-03-06 13:54:58 -06:00
. . . ( joinDefaults . diagnostics . length > 0
? { diagnostics : joinDefaults.diagnostics }
: { } )
2026-02-26 16:33:20 -06:00
} ) ;
}
2026-03-06 13:54:58 -06:00
) ;
2026-02-23 14:40:32 -06:00
router . post ( "/invites/:inviteId/revoke" , async ( req , res ) = > {
const id = req . params . inviteId as string ;
2026-03-06 13:54:58 -06:00
const invite = await db
. select ( )
. from ( invites )
. where ( eq ( invites . id , id ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
2026-02-23 14:40:32 -06:00
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" ,
2026-03-06 13:54:58 -06:00
actorId :
req . actor . type === "agent"
? req . actor . agentId ? ? "unknown-agent"
: req . actor . userId ? ? "board" ,
2026-02-23 14:40:32 -06:00
action : "invite.revoked" ,
entityType : "invite" ,
2026-03-06 13:54:58 -06:00
entityId : id
2026-02-23 14:40:32 -06:00
} ) ;
}
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 ;
2026-03-06 13:54:58 -06:00
if ( query . requestType && row . requestType !== query . requestType )
return false ;
2026-02-23 14:40:32 -06:00
return true ;
} ) ;
2026-02-26 16:33:20 -06:00
res . json ( filtered . map ( toJoinRequestResponse ) ) ;
2026-02-23 14:40:32 -06:00
} ) ;
2026-03-06 13:54:58 -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" ) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
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 existingAgents = await agents . list ( companyId ) ;
const managerId = resolveJoinRequestAgentManagerId ( existingAgents ) ;
if ( ! managerId ) {
throw conflict (
"Join request cannot be approved because this company has no active CEO"
) ;
}
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
const agentName = deduplicateAgentName (
existing . agentName ? ? "New Agent" ,
2026-03-06 14:16:39 -06:00
existingAgents . map ( ( a ) = > ( {
id : a.id ,
name : a.name ,
status : a.status
} ) )
2026-03-06 13:54:58 -06:00
) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
const created = await agents . create ( companyId , {
name : agentName ,
role : "general" ,
title : null ,
status : "idle" ,
reportsTo : managerId ,
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
) ;
2026-03-06 11:22:24 -06:00
}
2026-03-06 13:54:58 -06:00
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 ] ) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
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-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
if ( createdAgentId ) {
void notifyHireApproved ( db , {
companyId ,
agentId : createdAgentId ,
source : "join_request" ,
sourceId : requestId ,
approvedAt : new Date ( )
} ) . catch ( ( ) = > { } ) ;
}
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
res . json ( toJoinRequestResponse ( approved ) ) ;
2026-03-06 08:17:42 -06:00
}
2026-03-06 13:54:58 -06:00
) ;
2026-03-06 08:17:42 -06:00
2026-03-06 13:54:58 -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" ) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
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 ] ) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
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-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
res . json ( toJoinRequestResponse ( rejected ) ) ;
}
) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
router . post (
"/join-requests/:requestId/claim-api-key" ,
validate ( claimJoinRequestApiKeySchema ) ,
async ( req , res ) = > {
const requestId = req . params . requestId as string ;
const presentedClaimSecretHash = hashToken ( req . body . claimSecret ) ;
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" ) ;
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
2026-03-06 13:54:58 -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-23 14:40:32 -06:00
2026-03-06 13:54:58 -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" ) ;
const created = await agents . createApiKey (
joinRequest . createdAgentId ,
"initial-join-key"
) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
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
}
} ) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
res . status ( 201 ) . json ( {
keyId : created.id ,
token : created.token ,
agentId : joinRequest.createdAgentId ,
createdAt : created.createdAt
} ) ;
}
) ;
2026-02-23 14:40:32 -06:00
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 ? ? [ ] ,
2026-03-06 13:54:58 -06:00
req . actor . userId ? ? null
2026-02-23 14:40:32 -06:00
) ;
if ( ! updated ) throw notFound ( "Member not found" ) ;
res . json ( updated ) ;
2026-03-06 13:54:58 -06:00
}
2026-02-23 14:40:32 -06:00
) ;
2026-03-06 13:54:58 -06:00
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 ) ;
}
) ;
2026-02-23 14:40:32 -06:00
2026-03-06 13:54:58 -06:00
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 ) ;
}
) ;
2026-02-23 14:40:32 -06:00
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 ;
2026-03-06 13:54:58 -06:00
const memberships = await access . setUserCompanyAccess (
userId ,
req . body . companyIds ? ? [ ]
) ;
2026-02-23 14:40:32 -06:00
res . json ( memberships ) ;
2026-03-06 13:54:58 -06:00
}
2026-02-23 14:40:32 -06:00
) ;
return router ;
}