Enforce 10-minute TTL for generated company invites
This commit is contained in:
@@ -9,7 +9,6 @@ import {
|
|||||||
|
|
||||||
export const createCompanyInviteSchema = z.object({
|
export const createCompanyInviteSchema = z.object({
|
||||||
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
|
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
|
||||||
expiresInHours: z.number().int().min(1).max(24 * 30).optional().default(72),
|
|
||||||
defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
|
defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||||
agentMessage: z.string().max(4000).optional().nullable(),
|
agentMessage: z.string().max(4000).optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ if [[ -z "$COMPANY_ID" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "creating agent-only invite for company ${COMPANY_ID}"
|
log "creating agent-only invite for company ${COMPANY_ID}"
|
||||||
INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent",expiresInHours:24}')"
|
INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent"}')"
|
||||||
api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD"
|
api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD"
|
||||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||||
fail_board_auth_required "Invite creation"
|
fail_board_auth_required "Invite creation"
|
||||||
|
|||||||
10
server/src/__tests__/invite-expiry.test.ts
Normal file
10
server/src/__tests__/invite-expiry.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { companyInviteExpiresAt } from "../routes/access.js";
|
||||||
|
|
||||||
|
describe("companyInviteExpiresAt", () => {
|
||||||
|
it("sets invite expiration to 10 minutes after invite creation time", () => {
|
||||||
|
const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z");
|
||||||
|
const expiresAt = companyInviteExpiresAt(createdAtMs);
|
||||||
|
expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ const INVITE_TOKEN_PREFIX = "pcp_invite_";
|
|||||||
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
|
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
|
||||||
const INVITE_TOKEN_MAX_RETRIES = 5;
|
const INVITE_TOKEN_MAX_RETRIES = 5;
|
||||||
|
const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
function createInviteToken() {
|
function createInviteToken() {
|
||||||
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
|
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
|
||||||
@@ -51,6 +52,10 @@ function createClaimSecret() {
|
|||||||
return `pcp_claim_${randomBytes(24).toString("hex")}`;
|
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) {
|
function tokenHashesMatch(left: string, right: string) {
|
||||||
const leftBytes = Buffer.from(left, "utf8");
|
const leftBytes = Buffer.from(left, "utf8");
|
||||||
const rightBytes = Buffer.from(right, "utf8");
|
const rightBytes = Buffer.from(right, "utf8");
|
||||||
@@ -1102,7 +1107,7 @@ export function accessRoutes(
|
|||||||
inviteType: "company_join" as const,
|
inviteType: "company_join" as const,
|
||||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||||
defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage),
|
defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage),
|
||||||
expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
|
expiresAt: companyInviteExpiresAt(),
|
||||||
invitedByUserId: req.actor.userId ?? null,
|
invitedByUserId: req.actor.userId ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export const accessApi = {
|
|||||||
companyId: string,
|
companyId: string,
|
||||||
input: {
|
input: {
|
||||||
allowedJoinTypes?: "human" | "agent" | "both";
|
allowedJoinTypes?: "human" | "agent" | "both";
|
||||||
expiresInHours?: number;
|
|
||||||
defaultsPayload?: Record<string, unknown> | null;
|
defaultsPayload?: Record<string, unknown> | null;
|
||||||
agentMessage?: string | null;
|
agentMessage?: string | null;
|
||||||
} = {},
|
} = {},
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ export function CompanySettings() {
|
|||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
accessApi.createCompanyInvite(selectedCompanyId!, {
|
||||||
allowedJoinTypes: "agent",
|
allowedJoinTypes: "agent"
|
||||||
expiresInHours: 72
|
|
||||||
}),
|
}),
|
||||||
onSuccess: async (invite) => {
|
onSuccess: async (invite) => {
|
||||||
setInviteError(null);
|
setInviteError(null);
|
||||||
@@ -320,7 +319,7 @@ export function CompanySettings() {
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Generate an agent snippet for join flows.
|
Generate an agent snippet for join flows.
|
||||||
</span>
|
</span>
|
||||||
<HintIcon text="Creates an agent-only invite (72h) and renders a copy-ready snippet." />
|
<HintIcon text="Creates an agent-only invite (10m) and renders a copy-ready snippet." />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user