diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 59fd2add..068e729e 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -32,8 +32,18 @@ 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; + function createInviteToken() { - return `pcp_invite_${randomBytes(24).toString("hex")}`; + 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() { @@ -718,6 +728,25 @@ function grantsFromDefaults( 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; +} + export function accessRoutes( db: Db, opts: { @@ -811,21 +840,40 @@ export function accessRoutes( 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: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000), + invitedByUserId: req.actor.userId ?? null, + }; - const token = createInviteToken(); - const created = await db - .insert(invites) - .values({ - companyId, - inviteType: "company_join", - tokenHash: hashToken(token), - allowedJoinTypes: req.body.allowedJoinTypes, - defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage), - expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000), - invitedByUserId: req.actor.userId ?? null, - }) - .returning() - .then((rows) => rows[0]); + 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, diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 91aae044..4497a541 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -10,10 +10,9 @@ import { Settings, Check, Copy } from "lucide-react"; import { CompanyPatternIcon } from "../components/CompanyPatternIcon"; import { Field, ToggleField, HintIcon } from "../components/agent-config-primitives"; -type AgentFallbackSnippetInput = { +type AgentSnippetInput = { onboardingTextUrl: string; inviteMessage?: string | null; - guidance?: string | null; connectionCandidates?: string[] | null; }; @@ -94,17 +93,15 @@ export function CompanySettings() { setSnippetCopyDelightId(0); try { const manifest = await accessApi.getInviteOnboarding(invite.token); - setInviteSnippet(buildAgentFallbackSnippet({ + setInviteSnippet(buildAgentSnippet({ onboardingTextUrl: absoluteUrl, inviteMessage: nextInviteMessage, - guidance: manifest.onboarding.connectivity?.guidance ?? null, connectionCandidates: manifest.onboarding.connectivity?.connectionCandidates ?? null, })); } catch { - setInviteSnippet(buildAgentFallbackSnippet({ + setInviteSnippet(buildAgentSnippet({ onboardingTextUrl: absoluteUrl, inviteMessage: nextInviteMessage, - guidance: null, connectionCandidates: null, })); } @@ -303,13 +300,13 @@ export function CompanySettings() {
- Generate an agent onboarding link (`.txt`) for OpenClaw-style join flows. + Generate an agent onboarding link (`.txt`) for agent join flows.