import { randomBytes } from 'node:crypto' import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' import { getDocument, type Doc } from './repos-documents' /** * Shareable public links for a document (D12 portable-repo pattern). Each row is an * opaque, cryptographically-random token that resolves — while live — to exactly one * document. Owners mint, list and revoke; the public read route (mounted outside /api) * uses validateShare. The token is a secret: it is never written to the audit trail * or any log — audit rows carry only the share id + which document it points at. */ export interface Share { id: string documentId: string token: string createdBy: string createdAt: string expiresAt: string | null revoked: boolean } interface ShareRow { id: string document_id: string token: string created_by: string created_at: string expires_at: string | null revoked: number } function toShare(r: ShareRow): Share { return { id: r.id, documentId: r.document_id, token: r.token, createdBy: r.created_by, createdAt: r.created_at, expiresAt: r.expires_at, revoked: r.revoked !== 0, } } export async function getShare(db: DB, id: string): Promise { const row = await db.get(`SELECT * FROM document_share WHERE id=?`, id) return row === undefined ? null : toShare(row) } export async function listShares(db: DB, documentId: string): Promise { const rows = await db.all( `SELECT * FROM document_share WHERE document_id=? ORDER BY id DESC`, // uuidv7 ids: newest first documentId) return rows.map(toShare) } /** * The newest live (non-revoked, non-expired) share for a document, or null. * Read-only — safe for preview/context paths that must write nothing; the send * path uses it to reuse an existing link instead of minting a duplicate. */ export async function resolveLiveShare(db: DB, documentId: string): Promise { const row = await db.get( // ISO-8601 UTC strings compare lexicographically === chronologically. `SELECT * FROM document_share WHERE document_id=? AND revoked=0 AND (expires_at IS NULL OR expires_at > ?) ORDER BY id DESC LIMIT 1`, // uuidv7 ids: newest first documentId, new Date().toISOString()) return row === undefined ? null : toShare(row) } export interface MintShareOpts { /** Days until the link expires; default 30. `null` = never expires. */ expiresDays?: number | null } /** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */ async function defaultExpiryDays(db: DB): Promise { const row = await db.get<{ value: string }>( `SELECT value FROM setting WHERE key='share.default_expiry_days'`) if (row === undefined) return 30 if (row.value === 'never') return null const n = Number(row.value) return Number.isInteger(n) && n > 0 ? n : 30 } export async function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Promise { if (await getDocument(db, documentId) === null) throw new Error('Document not found') const id = uuidv7() const token = randomBytes(32).toString('hex') // 256-bit, unguessable; 64 hex chars const now = new Date() const createdAt = now.toISOString() const expiresDays = opts.expiresDays === undefined ? await defaultExpiryDays(db) : opts.expiresDays const expiresAt = expiresDays === null ? null : new Date(now.getTime() + expiresDays * 86_400_000).toISOString() await db.run( `INSERT INTO document_share (id, document_id, token, created_by, created_at, expires_at, revoked) VALUES (?, ?, ?, ?, ?, ?, 0)`, id, documentId, token, userId, createdAt, expiresAt) // Audit the mint — but NEVER the token itself (a live share secret). await writeAudit(db, userId, 'create', 'document_share', id, undefined, { documentId, expiresAt }) return (await getShare(db, id))! } /** * Resolve a public token to its one document, or null if the token is unknown, * revoked, or expired. This is the ONLY gate the public read route trusts — it * returns nothing but the single document the live token points at. */ export async function validateShare(db: DB, token: string): Promise { const row = await db.get(`SELECT * FROM document_share WHERE token=?`, token) if (row === undefined) return null // unknown if (row.revoked !== 0) return null // revoked // ISO-8601 UTC strings compare lexicographically === chronologically. if (row.expires_at !== null && row.expires_at <= new Date().toISOString()) return null // expired return getDocument(db, row.document_id) } export async function revokeShare(db: DB, userId: string, shareId: string): Promise { const before = await getShare(db, shareId) if (before === null) throw new Error('Share not found') await db.run(`UPDATE document_share SET revoked=1 WHERE id=?`, shareId) const after = (await getShare(db, shareId))! await writeAudit(db, userId, 'revoke', 'document_share', shareId, { revoked: before.revoked }, { revoked: after.revoked }) return after }