Files
gerbeur/api/services/invite-service.ts
2026-06-27 19:44:35 +00:00

70 lines
2.0 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isInviteRow } from "../model/db.ts";
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */
function generateInviteToken(): string {
return randomBytes(16).toString("base64url");
}
export function createInvite(inviterId: string): string {
const token = generateInviteToken();
db.prepare(
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
).run(token, inviterId, new Date().toISOString());
return token;
}
/**
* Looks the token up, checks it has not been used and has not expired (same
* 7-day window as the startup cleanup). Returns the inviterId on success;
* throws APIException otherwise.
*
* Tokens are opaque random strings stored as the row's primary key. Invites
* created before this scheme (full JWTs) are still stored verbatim in the
* token column, so they continue to validate by direct lookup.
*/
export function validateInvite(token: string): string {
const row = db.prepare(
`SELECT token, inviter_id, used_at, created_at FROM invites WHERE token = ?;`,
).get(token);
if (!row || !isInviteRow(row)) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Invalid or expired invite",
);
}
if (row.used_at !== null) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Invite already used",
);
}
const expiresAt = new Date(row.created_at).getTime() +
UNUSED_INVITES_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (Date.now() > expiresAt) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Invalid or expired invite",
);
}
return row.inviter_id;
}
/**
* Marks the token as used. Call this only after the user has been created.
*/
export function redeemInvite(token: string): void {
db.prepare(
`UPDATE invites SET used_at = ? WHERE token = ?;`,
).run(new Date().toISOString(), token);
}