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 { newId } from '../engine/ids.js';
import type { ControlDb } from './db.js';
/**
* One-shot account tokens.
*
* Same shape as an invite, and for the same reasons: 32 random bytes, stored
* only as a SHA-256 hash, spendable exactly once, and expiring on its own. The
* difference is what they stand for — an invite grants a role in one workspace,
* these two stand in for the *account itself*.
*
* That is why a reset lives for an hour rather than a week. An invite link that
* leaks costs one workspace; a reset link that leaks costs every workspace the
* account can open. Verification is harmless by comparison and gets a day.
*/
export type TokenPurpose = 'reset' | 'verify';
export const TOKEN_TTL_MS: Record<TokenPurpose, number> = {
reset: 60 * 60_000,
verify: 24 * 60 * 60_000,
};
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export interface AccountToken {
id: string;
accountId: string;
purpose: TokenPurpose;
createdAt: string;
expiresAt: string;
usedAt: string | null;
}
interface TokenRow {
id: string;
account_id: string;
purpose: string;
created_at: string;
expires_at: string;
used_at: string | null;
}
function toToken(row: TokenRow): AccountToken {
return {
id: row.id,
accountId: row.account_id,
purpose: row.purpose as TokenPurpose,
createdAt: row.created_at,
expiresAt: row.expires_at,
usedAt: row.used_at,
};
}
export interface IssuedToken {
record: AccountToken;
/** Returned once, at creation. Only its hash is stored. */
token: string;
}
/**
* Issue a token, replacing any live one for the same account and purpose.
*
* Replacing rather than adding matters: someone who clicks "forgot password"
* four times should end up with one working link, not four, and the newest is
* the one in the message they are actually looking at.
*/
export function issueToken(
control: ControlDb,
accountId: string,
purpose: TokenPurpose,
): IssuedToken {
const token = randomBytes(32).toString('base64url');
const id = newId('tok');
const expiresAt = new Date(Date.now() + TOKEN_TTL_MS[purpose]).toISOString();
control.sqlite
.prepare('DELETE FROM account_token WHERE account_id = ? AND purpose = ? AND used_at IS NULL')
.run(accountId, purpose);
control.sqlite
.prepare(
`INSERT INTO account_token (id, token_hash, account_id, purpose, expires_at)
VALUES (?, ?, ?, ?, ?)`,
)
.run(id, hashToken(token), accountId, purpose, expiresAt);
const record = control.sqlite
.prepare<[string], TokenRow>(
'SELECT id, account_id, purpose, created_at, expires_at, used_at FROM account_token WHERE id = ?',
)
.get(id)!;
return { record: toToken(record), token };
}
/** Null for unknown, spent, expired and wrong-purpose tokens alike. */
export function getLiveToken(
control: ControlDb,
token: string,
purpose: TokenPurpose,
): AccountToken | null {
const row = control.sqlite
.prepare<[string], TokenRow>(
'SELECT id, account_id, purpose, created_at, expires_at, used_at FROM account_token WHERE token_hash = ?',
)
.get(hashToken(token));
if (!row) return null;
const record = toToken(row);
if (record.purpose !== purpose) return null;
if (record.usedAt) return null;
if (new Date(record.expiresAt).getTime() <= Date.now()) return null;
return record;
}
/**
* Spend a token. The `used_at IS NULL` guard is the atomic claim, so two
* requests racing the same link produce exactly one winner.
*/
export function claimToken(control: ControlDb, id: string): boolean {
const info = control.sqlite
.prepare(`UPDATE account_token SET used_at = datetime('now') WHERE id = ? AND used_at IS NULL`)
.run(id);
return info.changes === 1;
}
/** Housekeeping, same as invites: spent and expired tokens are not history. */
export function pruneStaleTokens(control: ControlDb): number {
const info = control.sqlite
.prepare(
`DELETE FROM account_token
WHERE expires_at <= datetime('now', '-7 days')
OR used_at <= datetime('now', '-7 days')`,
)
.run();
return info.changes;
}