Files
paperclip/server/src/routes/access.ts

2702 lines
85 KiB
TypeScript

import {
createHash,
generateKeyPairSync,
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,
createOpenClawInvitePromptSchema,
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,
deduplicateAgentName,
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" &&
normalized !== "paperclip-create-plugin" &&
normalized !== "para-memory-files"
)
return null;
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
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/
];
for (const skillPath of candidates) {
try {
return fs.readFileSync(skillPath, "utf8");
} catch {
// Continue to next candidate.
}
}
return null;
}
/** Resolve the Paperclip repo skills directory (built-in / managed skills). */
function resolvePaperclipSkillsDir(): string | null {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.resolve(moduleDir, "../../skills"), // published
path.resolve(process.cwd(), "skills"), // cwd (monorepo root)
path.resolve(moduleDir, "../../../skills"), // dev
];
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isDirectory()) return candidate;
} catch { /* skip */ }
}
return null;
}
/** Parse YAML frontmatter from a SKILL.md file to extract the description. */
function parseSkillFrontmatter(markdown: string): { description: string } {
const match = markdown.match(/^---\n([\s\S]*?)\n---/);
if (!match) return { description: "" };
const yaml = match[1];
// Extract description — handles both single-line and multi-line YAML values
const descMatch = yaml.match(
/^description:\s*(?:>\s*\n((?:\s{2,}[^\n]*\n?)+)|[|]\s*\n((?:\s{2,}[^\n]*\n?)+)|["']?(.*?)["']?\s*$)/m
);
if (!descMatch) return { description: "" };
const raw = descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? "";
return {
description: raw
.split("\n")
.map((l: string) => l.trim())
.filter(Boolean)
.join(" ")
.trim(),
};
}
interface AvailableSkill {
name: string;
description: string;
isPaperclipManaged: boolean;
}
/** Discover all available Claude Code skills from ~/.claude/skills/. */
function listAvailableSkills(): AvailableSkill[] {
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
const claudeSkillsDir = path.join(homeDir, ".claude", "skills");
const paperclipSkillsDir = resolvePaperclipSkillsDir();
// Build set of Paperclip-managed skill names
const paperclipSkillNames = new Set<string>();
if (paperclipSkillsDir) {
try {
for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory()) paperclipSkillNames.add(entry.name);
}
} catch { /* skip */ }
}
const skills: AvailableSkill[] = [];
try {
const entries = fs.readdirSync(claudeSkillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (entry.name.startsWith(".")) continue;
const skillMdPath = path.join(claudeSkillsDir, entry.name, "SKILL.md");
let description = "";
try {
const md = fs.readFileSync(skillMdPath, "utf8");
description = parseSkillFrontmatter(md).description;
} catch { /* no SKILL.md or unreadable */ }
skills.push({
name: entry.name,
description,
isPaperclipManaged: paperclipSkillNames.has(entry.name),
});
}
} catch { /* ~/.claude/skills/ doesn't exist */ }
skills.sort((a, b) => a.name.localeCompare(b.name));
return skills;
}
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<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";
}
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 normalizeHeaderValue(
value: unknown,
depth: number = 0
): string | null {
const direct = nonEmptyTrimmedString(value);
if (direct) return direct;
if (!isPlainObject(value) || depth >= 3) return null;
const candidateKeys = [
"value",
"token",
"secret",
"apiKey",
"api_key",
"auth",
"authToken",
"auth_token",
"accessToken",
"access_token",
"authorization",
"bearer",
"header",
"raw",
"text",
"string"
];
for (const key of candidateKeys) {
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
const normalized = normalizeHeaderValue(
(value as Record<string, unknown>)[key],
depth + 1
);
if (normalized) return normalized;
}
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;
}
}
return null;
}
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) {
const explicitValue = Object.prototype.hasOwnProperty.call(
mapped,
"value"
)
? mapped.value
: Object.prototype.hasOwnProperty.call(mapped, "token")
? mapped.token
: Object.prototype.hasOwnProperty.call(mapped, "secret")
? mapped.secret
: mapped;
entries.push([explicitKey, explicitValue]);
continue;
}
const singleEntry = Object.entries(mapped);
if (singleEntry.length === 1) {
entries.push(singleEntry[0] as [string, unknown]);
}
}
return entries;
}
function normalizeHeaderMap(
input: unknown
): Record<string, string> | undefined {
const entries = extractHeaderEntries(input);
if (entries.length === 0) return undefined;
const out: Record<string, string> = {};
for (const [key, value] of entries) {
const normalizedValue = normalizeHeaderValue(value);
if (!normalizedValue) continue;
const trimmedKey = key.trim();
const trimmedValue = normalizedValue.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<string, string>,
targetKey: string
): boolean {
const normalizedTarget = targetKey.trim().toLowerCase();
return Object.keys(headers).some(
(key) => key.trim().toLowerCase() === normalizedTarget
);
}
function headerMapGetIgnoreCase(
headers: Record<string, string>,
targetKey: string
): string | null {
const normalizedTarget = targetKey.trim().toLowerCase();
const key = Object.keys(headers).find(
(candidate) => candidate.trim().toLowerCase() === normalizedTarget
);
if (!key) return null;
const value = headers[key];
return typeof value === "string" ? value : null;
}
function 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;
}
function generateEd25519PrivateKeyPem(): string {
const generated = generateKeyPairSync("ed25519");
return generated.privateKey
.export({ type: "pkcs8", format: "pem" })
.toString();
}
export function buildJoinDefaultsPayloadForAccept(input: {
adapterType: string | null;
defaultsPayload: unknown;
paperclipApiUrl?: unknown;
inboundOpenClawAuthHeader?: string | null;
inboundOpenClawTokenHeader?: string | null;
}): unknown {
if (input.adapterType !== "openclaw_gateway") {
return input.defaultsPayload;
}
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;
}
if (Object.keys(mergedHeaders).length > 0) {
merged.headers = mergedHeaders;
} else {
delete merged.headers;
}
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;
}
return Object.keys(merged).length > 0 ? merged : null;
}
export function mergeJoinDefaultsPayloadForReplay(
existingDefaultsPayload: unknown,
nextDefaultsPayload: unknown
): unknown {
if (
!isPlainObject(existingDefaultsPayload) &&
!isPlainObject(nextDefaultsPayload)
) {
return nextDefaultsPayload ?? existingDefaultsPayload;
}
if (!isPlainObject(existingDefaultsPayload)) {
return nextDefaultsPayload;
}
if (!isPlainObject(nextDefaultsPayload)) {
return existingDefaultsPayload;
}
const merged: Record<string, unknown> = {
...(existingDefaultsPayload as Record<string, unknown>),
...(nextDefaultsPayload as Record<string, unknown>)
};
const existingHeaders = normalizeHeaderMap(
(existingDefaultsPayload as Record<string, unknown>).headers
);
const nextHeaders = normalizeHeaderMap(
(nextDefaultsPayload as Record<string, unknown>).headers
);
if (existingHeaders || nextHeaders) {
merged.headers = {
...(existingHeaders ?? {}),
...(nextHeaders ?? {})
};
} else if (Object.prototype.hasOwnProperty.call(merged, "headers")) {
delete merged.headers;
}
return merged;
}
export function canReplayOpenClawGatewayInviteAccept(input: {
requestType: "human" | "agent";
adapterType: string | null;
existingJoinRequest: Pick<
typeof joinRequests.$inferSelect,
"requestType" | "adapterType" | "status"
> | null;
}): boolean {
if (
input.requestType !== "agent" ||
input.adapterType !== "openclaw_gateway"
) {
return false;
}
if (!input.existingJoinRequest) {
return false;
}
if (
input.existingJoinRequest.requestType !== "agent" ||
input.existingJoinRequest.adapterType !== "openclaw_gateway"
) {
return false;
}
return (
input.existingJoinRequest.status === "pending_approval" ||
input.existingJoinRequest.status === "approved"
);
}
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 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,
disableDeviceAuth: defaults
? parseBooleanLike(defaults.disableDeviceAuth)
: null,
waitTimeoutMs:
defaults && typeof defaults.waitTimeoutMs === "number"
? defaults.waitTimeoutMs
: null,
devicePrivateKeyPem: defaults
? summarizeSecretForLog(defaults.devicePrivateKeyPem)
: null,
gatewayToken: summarizeSecretForLog(gatewayTokenValue)
};
}
export function normalizeAgentDefaultsForJoin(input: {
adapterType: string | null;
defaultsPayload: unknown;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
bindHost: string;
allowedHostnames: string[];
}) {
const fatalErrors: string[] = [];
const diagnostics: JoinDiagnostic[] = [];
if (input.adapterType !== "openclaw_gateway") {
const normalized = isPlainObject(input.defaultsPayload)
? (input.defaultsPayload as Record<string, unknown>)
: null;
return { normalized, diagnostics, fatalErrors };
}
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);
const disableDeviceAuth = parsedDisableDeviceAuth === true;
if (parsedDisableDeviceAuth !== null) {
normalized.disableDeviceAuth = parsedDisableDeviceAuth;
}
const configuredDevicePrivateKeyPem = nonEmptyTrimmedString(
defaults.devicePrivateKeyPem
);
if (configuredDevicePrivateKeyPem) {
normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem;
diagnostics.push({
code: "openclaw_gateway_device_key_configured",
level: "info",
message:
"Gateway device key configured. Pairing approvals should persist for this agent."
});
} else if (!disableDeviceAuth) {
try {
normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem();
diagnostics.push({
code: "openclaw_gateway_device_key_generated",
level: "info",
message:
"Generated persistent gateway device key for this join. Pairing approvals should persist for this agent."
});
} catch (err) {
diagnostics.push({
code: "openclaw_gateway_device_key_generate_failed",
level: "warn",
message: `Failed to generate gateway device key: ${
err instanceof Error ? err.message : String(err)
}`,
hint:
"Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true."
});
fatalErrors.push(
"Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true."
);
}
}
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 };
}
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<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);
}
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 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 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).",
inviteMessage: extractInviteMessage(invite),
recommendedAdapterType: "openclaw_gateway",
requiredFields: {
requestType: "agent",
agentName: "Display name for this agent",
adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents",
capabilities: "Optional capability summary",
agentDefaultsPayload:
"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, devicePrivateKeyPem."
},
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,
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 <host>`."
: "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls."
},
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: 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(`
# Paperclip OpenClaw Gateway 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) {
appendBlock(`
## Message from inviter
${onboarding.inviteMessage}
`);
}
appendBlock(`
## Step 0
Get the OpenClaw gateway auth token (THIS MUST BE SENT)
Token lives in:
~/.openclaw/openclaw.json -> gateway.auth.token
Extract:
TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1)
test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1)
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",
adapterType: "openclaw_gateway",
capabilities: "OpenClaw agent adapter",
agentDefaultsPayload: {
url: "ws://127.0.0.1:18789",
paperclipApiUrl: "http://host.docker.internal:3100",
headers: { "x-openclaw-token": token },
waitTimeoutMs: 120000,
sessionKeyStrategy: "issue",
role: "operator",
scopes: ["operator.admin"]
}
};
process.stdout.write(JSON.stringify(body));
' "$TOKEN")"
## Step 1: Submit agent join request
${onboarding.registrationEndpoint.method} ${
onboarding.registrationEndpoint.url
}
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.
Pairing mode requirement:
- Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable.
- You may set disableDeviceAuth=true only for special environments that cannot support pairing.
- First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry.
Do NOT use /v1/responses or /hooks/* in this gateway join flow.
Body (JSON):
{
"requestType": "agent",
"agentName": "My OpenClaw Agent",
"adapterType": "openclaw_gateway",
"capabilities": "Optional summary",
"agentDefaultsPayload": {
"url": "wss://your-openclaw-gateway.example",
"paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",
"headers": { "x-openclaw-token": "replace-me" },
"waitTimeoutMs": 120000,
"sessionKeyStrategy": "issue",
"role": "operator",
"scopes": ["operator.admin"]
}
}
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:
- ~/.openclaw/workspace/paperclip-claimed-api-key.json
chmod 600 ~/.openclaw/workspace/paperclip-claimed-api-key.json
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}
Be sure to prepend your PAPERCLIP_API_URL to the top of your skill and note the path to your PAPERCLIP_API_URL
## Text onboarding URL
${onboarding.textInstructions.url}
## Connectivity guidance
${
onboarding.connectivity?.guidance ??
"Ensure Paperclip is reachable from your OpenClaw runtime."
}
`);
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}`);
}
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.
`);
}
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}`);
}
}
appendBlock(`
## Helpful endpoints
${onboarding.registrationEndpoint.path}
${onboarding.claimEndpointTemplate.path}
${onboarding.skill.path}
${manifest.invite.onboardingPath}
`);
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<string, unknown>).agentMessage;
if (typeof rawMessage !== "string") {
return null;
}
const trimmed = rawMessage.trim();
return trimmed.length ? trimmed : null;
}
function mergeInviteDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
agentMessage: string | null
): Record<string, unknown> | null {
const merged =
defaultsPayload && typeof defaultsPayload === "object"
? { ...defaultsPayload }
: {};
if (agentMessage) {
merged.agentMessage = agentMessage;
}
return Object.keys(merged).length ? merged : null;
}
function requestIp(req: Request) {
const forwarded = req.header("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
return req.ip || "unknown";
}
function inviteExpired(invite: typeof invites.$inferSelect) {
return invite.expiresAt.getTime() <= Date.now();
}
function isLocalImplicit(req: Request) {
return req.actor.type === "board" && req.actor.source === "local_implicit";
}
async function resolveActorEmail(db: Db, req: Request): Promise<string | null> {
if (isLocalImplicit(req)) return "local@paperclip.local";
const userId = req.actor.userId;
if (!userId) return null;
const user = await db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.then((rows) => rows[0] ?? null);
return user?.email ?? null;
}
function grantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
key: "human" | "agent"
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
const scoped = defaultsPayload[key];
if (!scoped || typeof scoped !== "object") return [];
const grants = (scoped as Record<string, unknown>).grants;
if (!Array.isArray(grants)) return [];
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
const result: Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> = [];
for (const item of grants) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
if (typeof record.permissionKey !== "string") continue;
if (!validPermissionKeys.has(record.permissionKey)) continue;
result.push({
permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number],
scope:
record.scope &&
typeof record.scope === "object" &&
!Array.isArray(record.scope)
? (record.scope as Record<string, unknown>)
: null
});
}
return result;
}
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<InviteResolutionProbe> {
const startedAt = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "HEAD",
redirect: "manual",
signal: controller.signal
});
const durationMs = Date.now() - startedAt;
if (
response.ok ||
response.status === 401 ||
response.status === 403 ||
response.status === 404 ||
response.status === 405 ||
response.status === 422 ||
response.status === 500 ||
response.status === 501
) {
return {
status: "reachable",
method: "HEAD",
durationMs,
httpStatus: response.status,
message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`
};
}
return {
status: "unreachable",
method: "HEAD",
durationMs,
httpStatus: response.status,
message: `Webhook endpoint probe returned HTTP ${response.status}.`
};
} catch (error) {
const durationMs = Date.now() - startedAt;
if (isAbortError(error)) {
return {
status: "timeout",
method: "HEAD",
durationMs,
httpStatus: null,
message: `Webhook endpoint probe timed out after ${timeoutMs}ms.`
};
}
return {
status: "unreachable",
method: "HEAD",
durationMs,
httpStatus: null,
message:
error instanceof Error
? error.message
: "Webhook endpoint probe failed."
};
} finally {
clearTimeout(timeout);
}
}
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");
}
async function assertCanGenerateOpenClawInvitePrompt(
req: Request,
companyId: string
) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "agent") {
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role !== "ceo") {
throw forbidden("Only CEO agents can generate OpenClaw invite prompts");
}
return;
}
if (req.actor.type !== "board") throw unauthorized();
if (isLocalImplicit(req)) return;
const allowed = await access.canUser(companyId, req.actor.userId, "users:invite");
if (!allowed) throw forbidden("Permission denied");
}
async function createCompanyInviteForCompany(input: {
req: Request;
companyId: string;
allowedJoinTypes: "human" | "agent" | "both";
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
}) {
const normalizedAgentMessage =
typeof input.agentMessage === "string"
? input.agentMessage.trim() || null
: null;
const insertValues = {
companyId: input.companyId,
inviteType: "company_join" as const,
allowedJoinTypes: input.allowedJoinTypes,
defaultsPayload: mergeInviteDefaults(
input.defaultsPayload ?? null,
normalizedAgentMessage
),
expiresAt: companyInviteExpiresAt(),
invitedByUserId: input.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.");
}
return { token, created, normalizedAgentMessage };
}
router.get("/skills/available", (_req, res) => {
res.json({ skills: listAvailableSkills() });
});
router.get("/skills/index", (_req, res) => {
res.json({
skills: [
{ name: "paperclip", path: "/api/skills/paperclip" },
{
name: "para-memory-files",
path: "/api/skills/para-memory-files"
},
{
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 { token, created, normalizedAgentMessage } =
await createCompanyInviteForCompany({
req,
companyId,
allowedJoinTypes: req.body.allowedJoinTypes,
defaultsPayload: req.body.defaultsPayload ?? null,
agentMessage: req.body.agentMessage ?? null
});
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.post(
"/companies/:companyId/openclaw/invite-prompt",
validate(createOpenClawInvitePromptSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanGenerateOpenClawInvitePrompt(req, companyId);
const { token, created, normalizedAgentMessage } =
await createCompanyInviteForCompany({
req,
companyId,
allowedJoinTypes: "agent",
defaultsPayload: null,
agentMessage: req.body.agentMessage ?? null
});
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.openclaw_prompt_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 || inviteExpired(invite)) {
throw notFound("Invite not found");
}
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;
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;
}
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) {
if (
!inviteAlreadyAccepted ||
!existingJoinRequestForInvite?.agentName
) {
throw badRequest("agentName is required for agent join requests");
}
}
const adapterType = req.body.adapterType ?? null;
if (
inviteAlreadyAccepted &&
!canReplayOpenClawGatewayInviteAccept({
requestType,
adapterType,
existingJoinRequest: existingJoinRequestForInvite
})
) {
throw notFound("Invite not found");
}
const replayJoinRequestId = inviteAlreadyAccepted
? existingJoinRequestForInvite?.id ?? null
: null;
if (inviteAlreadyAccepted && !replayJoinRequestId) {
throw conflict("Join request not found");
}
const replayMergedDefaults = inviteAlreadyAccepted
? mergeJoinDefaultsPayloadForReplay(
existingJoinRequestForInvite?.agentDefaultsPayload ?? null,
req.body.agentDefaultsPayload ?? null
)
: req.body.agentDefaultsPayload ?? null;
const gatewayDefaultsPayload =
requestType === "agent"
? buildJoinDefaultsPayloadForAccept({
adapterType,
defaultsPayload: replayMergedDefaults,
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null
})
: null;
const joinDefaults =
requestType === "agent"
? normalizeAgentDefaultsForJoin({
adapterType,
defaultsPayload: gatewayDefaultsPayload,
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
bindHost: opts.bindHost,
allowedHostnames: opts.allowedHostnames
})
: {
normalized: null as Record<string, unknown> | null,
diagnostics: [] as JoinDiagnostic[],
fatalErrors: [] as string[]
};
if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) {
throw badRequest(joinDefaults.fatalErrors.join("; "));
}
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"
);
}
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;
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");
}
if (
inviteAlreadyAccepted &&
requestType === "agent" &&
adapterType === "openclaw_gateway" &&
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 }
});
}
if (requestType === "agent" && adapterType === "openclaw_gateway") {
const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog(
joinDefaults.normalized
);
const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog(
created.agentDefaultsPayload
);
const missingPersistedFields: string[] = [];
if (expectedDefaults.url && !persistedDefaults.url)
missingPersistedFields.push("url");
if (
expectedDefaults.paperclipApiUrl &&
!persistedDefaults.paperclipApiUrl
) {
missingPersistedFields.push("paperclipApiUrl");
}
if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) {
missingPersistedFields.push("headers.x-openclaw-token");
}
if (
expectedDefaults.devicePrivateKeyPem &&
!persistedDefaults.devicePrivateKeyPem
) {
missingPersistedFields.push("devicePrivateKeyPem");
}
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 gateway join request"
);
if (missingPersistedFields.length > 0) {
logger.warn(
{
inviteId: invite.id,
joinRequestId: created.id,
missingPersistedFields
},
"invite accept detected missing persisted OpenClaw gateway 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: inviteAlreadyAccepted
? "join.request_replayed"
: "join.requested",
entityType: "join_request",
entityId: created.id,
details: {
requestType,
requestIp: created.requestIp,
inviteReplay: inviteAlreadyAccepted
}
});
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<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"
);
}
const agentName = deduplicateAgentName(
existing.agentName ?? "New Agent",
existingAgents.map((a) => ({
id: a.id,
name: a.name,
status: a.status
}))
);
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
);
}
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;
}