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