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/ -> <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;
}

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 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<string, string> | undefined {
  if (!isPlainObject(input)) return undefined;
  const out: Record<string, string> = {};
  for (const [key, value] of Object.entries(input)) {
    if (typeof value !== "string") continue;
    const trimmedKey = key.trim();
    const trimmedValue = value.trim();
    if (!trimmedKey || !trimmedValue) continue;
    out[trimmedKey] = trimmedValue;
  }
  return Object.keys(out).length > 0 ? out : undefined;
}

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 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<string, unknown>) }
    : {} as Record<string, unknown>;

  if (!nonEmptyTrimmedString(merged.url)) {
    const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl);
    if (legacyUrl) merged.url = legacyUrl;
  }

  if (!nonEmptyTrimmedString(merged.method)) {
    const legacyMethod = nonEmptyTrimmedString(input.responsesWebhookMethod);
    if (legacyMethod) merged.method = legacyMethod.toUpperCase();
  }

  if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
    const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
    if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
  }

  if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) {
    const providedWebhookAuthHeader = nonEmptyTrimmedString(input.webhookAuthHeader);
    if (providedWebhookAuthHeader) merged.webhookAuthHeader = providedWebhookAuthHeader;
  }

  const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
  const compatibilityHeaders = normalizeHeaderMap(input.responsesWebhookHeaders);
  if (compatibilityHeaders) {
    for (const [key, value] of Object.entries(compatibilityHeaders)) {
      if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) {
        mergedHeaders[key] = value;
      }
    }
  }

  const inboundOpenClawAuthHeader = nonEmptyTrimmedString(input.inboundOpenClawAuthHeader);
  if (inboundOpenClawAuthHeader && !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")) {
    mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
  }

  if (Object.keys(mergedHeaders).length > 0) {
    merged.headers = mergedHeaders;
  } else {
    delete merged.headers;
  }

  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<string, unknown>) : null;
  const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
  const openClawAuthHeaderValue = headers
    ? Object.entries(headers).find(([key]) => key.trim().toLowerCase() === "x-openclaw-auth")?.[1] ?? null
    : null;

  return {
    present: Boolean(defaults),
    keys: defaults ? Object.keys(defaults).sort() : [],
    url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
    method: defaults ? nonEmptyTrimmedString(defaults.method) : null,
    paperclipApiUrl: defaults ? nonEmptyTrimmedString(defaults.paperclipApiUrl) : null,
    headerKeys: headers ? Object.keys(headers).sort() : [],
    webhookAuthHeader: defaults ? summarizeSecretForLog(defaults.webhookAuthHeader) : null,
    openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue),
  };
}

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 <host> 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<string, unknown>)
      : null;
    return { normalized, diagnostics };
  }

  if (!isPlainObject(input.defaultsPayload)) {
    diagnostics.push({
      code: "openclaw_callback_config_missing",
      level: "warn",
      message: "No OpenClaw callback config was provided in agentDefaultsPayload.",
      hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw SSE endpoint immediately after approval.",
    });
    return { normalized: null as Record<string, unknown> | null, diagnostics };
  }

  const defaults = input.defaultsPayload as Record<string, unknown>;
  const normalized: Record<string, unknown> = { 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();
  }

  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<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 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 agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.",
      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:
          "Optional adapter config such as url/method/headers/webhookAuthHeader and paperclipApiUrl for OpenClaw SSE endpoint",
      },
      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 <host>`."
            : "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}`,
    "",
    "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": "<one-time-claim-secret>"',
    "}",
    "",
    "Important:",
    "- claim secrets expire",
    "- claim secrets are single-use",
    "- claim fails before board approval",
    "",
    "## Step 4: Install Paperclip skill in OpenClaw",
    `GET ${onboarding.skill.url}`,
    `Install path: ${onboarding.skill.installPath}`,
    "",
    "## Text onboarding URL",
    `${onboarding.textInstructions.url}`,
    "",
    "## Connectivity guidance",
    onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.",
  );

  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 <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}`);
    }
  }

  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<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;
}

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");
  }

  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<string, unknown> | 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<string, unknown> | null, "human");
      await access.setPrincipalGrants(
        companyId,
        "user",
        existing.requestingUserId,
        grants,
        req.actor.userId ?? null,
      );
    } else {
      const created = await agents.create(companyId, {
        name: existing.agentName ?? "New Agent",
        role: "general",
        title: null,
        status: "idle",
        reportsTo: null,
        capabilities: existing.capabilities ?? null,
        adapterType: existing.adapterType ?? "process",
        adapterConfig:
          existing.agentDefaultsPayload && typeof existing.agentDefaultsPayload === "object"
            ? (existing.agentDefaultsPayload as Record<string, unknown>)
            : {},
        runtimeConfig: {},
        budgetMonthlyCents: 0,
        spentMonthlyCents: 0,
        permissions: {},
        lastHeartbeatAt: null,
        metadata: null,
      });
      createdAgentId = created.id;
      await access.ensureMembership(companyId, "agent", created.id, "member", "active");
      const grants = grantsFromDefaults(invite.defaultsPayload as Record<string, unknown> | null, "agent");
      await access.setPrincipalGrants(companyId, "agent", created.id, grants, req.actor.userId ?? null);
    }

    const approved = await db
      .update(joinRequests)
      .set({
        status: "approved",
        approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
        approvedAt: new Date(),
        createdAgentId,
        updatedAt: new Date(),
      })
      .where(eq(joinRequests.id, requestId))
      .returning()
      .then((rows) => rows[0]);

    await logActivity(db, {
      companyId,
      actorType: "user",
      actorId: req.actor.userId ?? "board",
      action: "join.approved",
      entityType: "join_request",
      entityId: requestId,
      details: { requestType: existing.requestType, createdAgentId },
    });

    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;
}
