Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { createHash, randomBytes } from 'node:crypto';
import type { PendingInvite, Role } from '@wellformed/shared';
import { newId } from '../engine/ids.js';
import { normaliseEmail } from './accounts.js';
import type { ControlDb } from './db.js';
/**
* Invites.
*
* The link is the credential. Wellformed sends no email — an admin creates an
* invite and passes the URL along themselves — so whoever holds the token can
* accept it. Three things keep that honest: the token is 32 random bytes and
* stored only as a hash, it expires, and it can be accepted exactly once.
*
* The invite also names an email, and accepting is only possible as that email:
* either the signed-in account already has it, or accepting creates an account
* with it. That stops a forwarded link from quietly attaching a stranger's
* existing account to the workspace.
*/
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export interface InviteRecord {
id: string;
workspaceId: string;
email: string;
role: Role;
invitedBy: string | null;
createdAt: string;
expiresAt: string;
acceptedAt: string | null;
}
interface InviteRow {
id: string;
workspace_id: string;
email: string;
role: string;
invited_by: string | null;
created_at: string;
expires_at: string;
accepted_at: string | null;
}
function toRecord(row: InviteRow): InviteRecord {
return {
id: row.id,
workspaceId: row.workspace_id,
email: row.email,
role: row.role as Role,
invitedBy: row.invited_by,
createdAt: row.created_at,
expiresAt: row.expires_at,
acceptedAt: row.accepted_at,
};
}
const COLUMNS = 'id, workspace_id, email, role, invited_by, created_at, expires_at, accepted_at';
export interface IssuedInvite {
record: InviteRecord;
/** Shown once, at creation. Never recoverable afterwards. */
token: string;
}
export function createInvite(
control: ControlDb,
input: { workspaceId: string; email: string; role: Role; invitedBy: string | null },
): IssuedInvite {
const token = randomBytes(32).toString('base64url');
const id = newId('inv');
const email = normaliseEmail(input.email);
const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString();
// One live invite per address per workspace: re-inviting replaces the old
// link rather than leaving two working tokens behind.
control.sqlite
.prepare('DELETE FROM invite WHERE workspace_id = ? AND email = ? AND accepted_at IS NULL')
.run(input.workspaceId, email);
control.sqlite
.prepare(
`INSERT INTO invite (id, token_hash, workspace_id, email, role, invited_by, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(id, hashToken(token), input.workspaceId, email, input.role, input.invitedBy, expiresAt);
return { record: getInviteById(control, id)!, token };
}
export function getInviteById(control: ControlDb, id: string): InviteRecord | null {
const row = control.sqlite
.prepare<[string], InviteRow>(`SELECT ${COLUMNS} FROM invite WHERE id = ?`)
.get(id);
return row ? toRecord(row) : null;
}
/** Look an invite up by its token. Returns null for unknown, spent or expired tokens alike. */
export function getLiveInviteByToken(control: ControlDb, token: string): InviteRecord | null {
const row = control.sqlite
.prepare<[string], InviteRow>(`SELECT ${COLUMNS} FROM invite WHERE token_hash = ?`)
.get(hashToken(token));
if (!row) return null;
const record = toRecord(row);
if (record.acceptedAt) return null;
if (new Date(record.expiresAt).getTime() <= Date.now()) return null;
return record;
}
/**
* Mark an invite spent.
*
* The `accepted_at IS NULL` guard makes this the atomic claim: two requests
* racing the same link produce exactly one winner, and `changes` says which.
*/
export function claimInvite(control: ControlDb, id: string, accountId: string): boolean {
const info = control.sqlite
.prepare(
`UPDATE invite SET accepted_at = datetime('now'), accepted_by = ?
WHERE id = ? AND accepted_at IS NULL`,
)
.run(accountId, id);
return info.changes === 1;
}
export function revokeInvite(control: ControlDb, id: string): void {
control.sqlite.prepare('DELETE FROM invite WHERE id = ? AND accepted_at IS NULL').run(id);
}
export function listPendingInvites(control: ControlDb, workspaceId: string): PendingInvite[] {
return control.sqlite
.prepare<[string], InviteRow & { inviter: string | null }>(
`SELECT i.id, i.workspace_id, i.email, i.role, i.invited_by, i.created_at, i.expires_at,
i.accepted_at, a.name AS inviter
FROM invite i LEFT JOIN account a ON a.id = i.invited_by
WHERE i.workspace_id = ? AND i.accepted_at IS NULL AND i.expires_at > datetime('now')
ORDER BY i.created_at DESC`,
)
.all(workspaceId)
.map((row) => ({
id: row.id,
email: row.email,
role: row.role as Role,
invitedBy: row.inviter,
createdAt: row.created_at,
expiresAt: row.expires_at,
}));
}
/** Housekeeping: spent and expired invites are not worth keeping forever. */
export function pruneStaleInvites(control: ControlDb): number {
const info = control.sqlite
.prepare(
`DELETE FROM invite
WHERE expires_at <= datetime('now', '-30 days')
OR accepted_at <= datetime('now', '-30 days')`,
)
.run();
return info.changes;
}