Polish invite links and agent snippet UX

This commit is contained in:
Dotta
2026-03-05 12:52:39 -06:00
parent 0ed0c0abdb
commit 38b855e495
2 changed files with 139 additions and 53 deletions

View File

@@ -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,