104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
/**
|
|
* Invites: making them, and checking and claiming one on registration.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import { APIErrorCode } from "../../shared/api.ts";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { db } from "../db/connection.ts";
|
|
import { isInviteRow } from "../model/invite.ts";
|
|
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
|
|
|
|
/**
|
|
* Returns a short, opaque, URL-safe invite token (128 bits of entropy → 22
|
|
* chars).
|
|
*/
|
|
function generateInviteToken(): string {
|
|
return randomBytes(16).toString("base64url");
|
|
}
|
|
|
|
/** Makes an invite from `inviterId` and returns its token. */
|
|
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), and returns the inviter's id.
|
|
*
|
|
* 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.
|
|
*
|
|
* @throws {APIException} 404 when the invite doesn't exist or has expired,
|
|
* 409 when it has been used.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Validates the token and marks it used in one step, returning the inviter's
|
|
* id.
|
|
* Claim before the (async) account creation, not after: checking first and
|
|
* marking later lets parallel registrations all pass the check on one invite.
|
|
*
|
|
* @throws {APIException} 404 when the invite doesn't exist or has expired,
|
|
* 409 when it has been used.
|
|
*/
|
|
export function claimInvite(token: string): string {
|
|
const inviterId = validateInvite(token);
|
|
const result = db.prepare(
|
|
`UPDATE invites SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
|
|
).run(new Date().toISOString(), token);
|
|
if (result.changes === 0) {
|
|
throw new APIException(
|
|
APIErrorCode.VALIDATION_ERROR,
|
|
409,
|
|
"Invite already used",
|
|
);
|
|
}
|
|
return inviterId;
|
|
}
|
|
|
|
/** Hands a claimed invite back when the account it was claimed for failed. */
|
|
export function releaseInvite(token: string): void {
|
|
db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token);
|
|
}
|