You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-shares.ts

126 lines
5.0 KiB
TypeScript

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<Share | null> {
const row = await db.get<ShareRow>(`SELECT * FROM document_share WHERE id=?`, id)
return row === undefined ? null : toShare(row)
}
export async function listShares(db: DB, documentId: string): Promise<Share[]> {
const rows = await db.all<ShareRow>(
`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<Share | null> {
const row = await db.get<ShareRow>(
// 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<number | null> {
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<Share> {
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<Doc | null> {
const row = await db.get<ShareRow>(`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<Share> {
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
}