diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 19569c4..c5a4a82 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -20,6 +20,7 @@ import { import { clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput, } from './repos-payments' +import { getShare, listShares, mintShare, revokeShare, type MintShareOpts } from './repos-shares' import { createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, updateRecurringPlan, type CreateRecurringInput, type RecurringPatch, @@ -280,7 +281,7 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { const emails = db.prepare( `SELECT * FROM email_log WHERE document_id=? ORDER BY id`, ).all(id) - res.json({ ok: true, document, events: listDocumentEvents(db, id), emails }) + res.json({ ok: true, document, events: listDocumentEvents(db, id), emails, shares: listShares(db, id) }) }) const docAction = (fn: (docId: string, res: Response, req: Request) => unknown): RequestHandler => @@ -340,6 +341,40 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { return createCreditNote(db, staffId(res), id, lines) })) + // ---------- shareable links ---------- + // Owner mints an opaque public token for one document (default +30d expiry). The + // token is returned to the authenticated owner (they build the link) but never logged. + r.post('/documents/:id/share', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getDocument(db, id) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + try { + const body = req.body as { expiresDays?: unknown } + const opts: MintShareOpts = {} + if (typeof body.expiresDays === 'number') opts.expiresDays = body.expiresDays + else if (body.expiresDays === null) opts.expiresDays = null + const share = mintShare(db, staffId(res), id, opts) + res.json({ ok: true, share, shares: listShares(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/documents/:id/share/:shareId/revoke', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getDocument(db, id) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + const shareId = String(req.params['shareId'] ?? '') + const existing = getShare(db, shareId) + // Guard the pairing: a share is only revocable through the document it belongs to. + if (existing === null || existing.documentId !== id) { + res.status(404).json({ ok: false, error: 'Share not found' }); return + } + try { + const share = revokeShare(db, staffId(res), shareId) + res.json({ ok: true, share, shares: listShares(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- email ---------- r.get('/email/status', requireAuth, (_req, res) => { res.json({ ok: true, ...emailStatus(db) }) diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 9431583..2c3a733 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -66,6 +66,12 @@ CREATE TABLE IF NOT EXISTS document_event ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, at_wall TEXT NOT NULL, kind TEXT NOT NULL, meta TEXT NOT NULL DEFAULT '{}' ); +CREATE TABLE IF NOT EXISTS document_share ( + id TEXT PRIMARY KEY, document_id TEXT NOT NULL, token TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL, created_at TEXT NOT NULL, + expires_at TEXT, -- NULL = never expires + revoked INTEGER NOT NULL DEFAULT 0 +); CREATE TABLE IF NOT EXISTS payment ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, received_on TEXT NOT NULL, mode TEXT NOT NULL CHECK (mode IN ('bank','upi','cheque','cash','other')), diff --git a/apps/hq/src/repos-shares.ts b/apps/hq/src/repos-shares.ts new file mode 100644 index 0000000..84032c1 --- /dev/null +++ b/apps/hq/src/repos-shares.ts @@ -0,0 +1,100 @@ +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 function getShare(db: DB, id: string): Share | null { + const row = db.prepare(`SELECT * FROM document_share WHERE id=?`).get(id) as ShareRow | undefined + return row === undefined ? null : toShare(row) +} + +export function listShares(db: DB, documentId: string): Share[] { + const rows = db.prepare( + `SELECT * FROM document_share WHERE document_id=? ORDER BY id DESC`, // uuidv7 ids: newest first + ).all(documentId) as ShareRow[] + return rows.map(toShare) +} + +export interface MintShareOpts { + /** Days until the link expires; default 30. `null` = never expires. */ + expiresDays?: number | null +} + +export function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Share { + if (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 ? 30 : opts.expiresDays + const expiresAt = expiresDays === null + ? null + : new Date(now.getTime() + expiresDays * 86_400_000).toISOString() + db.prepare( + `INSERT INTO document_share (id, document_id, token, created_by, created_at, expires_at, revoked) + VALUES (?, ?, ?, ?, ?, ?, 0)`, + ).run(id, documentId, token, userId, createdAt, expiresAt) + // Audit the mint — but NEVER the token itself (a live share secret). + writeAudit(db, userId, 'create', 'document_share', id, undefined, { documentId, expiresAt }) + return 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 function validateShare(db: DB, token: string): Doc | null { + const row = db.prepare(`SELECT * FROM document_share WHERE token=?`).get(token) as ShareRow | undefined + 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 function revokeShare(db: DB, userId: string, shareId: string): Share { + const before = getShare(db, shareId) + if (before === null) throw new Error('Share not found') + db.prepare(`UPDATE document_share SET revoked=1 WHERE id=?`).run(shareId) + const after = getShare(db, shareId)! + writeAudit(db, userId, 'revoke', 'document_share', shareId, + { revoked: before.revoked }, { revoked: after.revoked }) + return after +} diff --git a/apps/hq/test/shares.test.ts b/apps/hq/test/shares.test.ts new file mode 100644 index 0000000..de0c90a --- /dev/null +++ b/apps/hq/test/shares.test.ts @@ -0,0 +1,177 @@ +// apps/hq/test/shares.test.ts +import { describe, it, expect, afterAll } from 'vitest' +import express from 'express' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft } from '../src/repos-documents' +import { mintShare, validateShare, revokeShare, listShares } from '../src/repos-shares' +import { apiRouter } from '../src/api' + +function setup() { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const doc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + return { db, c, m, doc } +} + +describe('repos-shares (mint / validate / revoke / list)', () => { + it('mintShare produces a 64-hex token, unique per mint, with a +30d default expiry', () => { + const { db, doc } = setup() + const s1 = mintShare(db, 'u1', doc.id) + const s2 = mintShare(db, 'u1', doc.id) + expect(s1.token).toMatch(/^[0-9a-f]{64}$/) + expect(s2.token).toMatch(/^[0-9a-f]{64}$/) + expect(s1.token).not.toBe(s2.token) // unique + expect(s1.revoked).toBe(false) + expect(s1.expiresAt).not.toBeNull() + // default is +30 days from now + const days = (new Date(s1.expiresAt!).getTime() - Date.now()) / 86_400_000 + expect(days).toBeGreaterThan(29) + expect(days).toBeLessThan(31) + }) + + it('mintShare writes an audit row that NEVER contains the token (secrets never logged)', () => { + const { db, doc } = setup() + const s = mintShare(db, 'u1', doc.id) + const rows = db.prepare( + `SELECT * FROM audit_log WHERE entity='document_share' AND action='create'`, + ).all() as { user_id: string; entity_id: string; before_json: string | null; after_json: string | null }[] + expect(rows.length).toBe(1) + expect(rows[0]!.user_id).toBe('u1') + expect(rows[0]!.entity_id).toBe(s.id) + // The token must not leak into the audit trail (any column). + expect(JSON.stringify(rows[0])).not.toContain(s.token) + }) + + it('mintShare throws for an unknown document', () => { + const { db } = setup() + expect(() => mintShare(db, 'u1', 'nope')).toThrow(/not found/i) + }) + + it('validateShare returns the one document for a live token', () => { + const { db, doc } = setup() + const s = mintShare(db, 'u1', doc.id) + const out = validateShare(db, s.token) + expect(out).not.toBeNull() + expect(out!.id).toBe(doc.id) + expect(out!.payablePaise).toBe(11_800_00) + }) + + it('validateShare returns null for an unknown token', () => { + const { db } = setup() + expect(validateShare(db, 'deadbeef')).toBeNull() + }) + + it('validateShare returns null for an expired token', () => { + const { db, doc } = setup() + const s = mintShare(db, 'u1', doc.id, { expiresDays: -1 }) // already expired + expect(s.expiresAt).not.toBeNull() + expect(validateShare(db, s.token)).toBeNull() + }) + + it('validateShare returns null for a revoked token', () => { + const { db, doc } = setup() + const s = mintShare(db, 'u1', doc.id) + revokeShare(db, 'u1', s.id) + expect(validateShare(db, s.token)).toBeNull() + }) + + it('revokeShare flips revoked, writes an audit row, and is idempotent-safe', () => { + const { db, doc } = setup() + const s = mintShare(db, 'u1', doc.id) + const after = revokeShare(db, 'u1', s.id) + expect(after.revoked).toBe(true) + const rows = db.prepare( + `SELECT * FROM audit_log WHERE entity='document_share' AND action='revoke'`, + ).all() as unknown[] + expect(rows.length).toBe(1) + }) + + it('revokeShare throws for an unknown share', () => { + const { db } = setup() + expect(() => revokeShare(db, 'u1', 'nope')).toThrow(/not found/i) + }) + + it('listShares returns every share for the document (scoped to that doc)', () => { + const { db, doc, m, c } = setup() + const s1 = mintShare(db, 'u1', doc.id) + const s2 = mintShare(db, 'u1', doc.id) + // A share on a different document must not leak into this list. + const other = createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + mintShare(db, 'u1', other.id) + const list = listShares(db, doc.id) + expect(list.length).toBe(2) + expect(new Set(list.map((s) => s.id))).toEqual(new Set([s1.id, s2.id])) + }) +}) + +function appWith() { + const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18 + createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) + const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const doc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + return { db, server, base, doc } +} + +describe('share routes (requireAuth)', () => { + const ctx = appWith() + afterAll(() => ctx.server.close()) + const login = async () => + (await (await fetch(`${ctx.base}/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }), + })).json() as any).token + const post = async (path: string, token: string | null, body?: unknown) => { + const res = await fetch(`${ctx.base}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify(body ?? {}), + }) + return { status: res.status, json: await res.json() as any } + } + + it('POST /documents/:id/share requires auth', async () => { + expect((await post(`/documents/${ctx.doc.id}/share`, null)).status).toBe(401) + }) + + it('POST /documents/:id/share mints a share with a 64-hex token', async () => { + const token = await login() + const out = await post(`/documents/${ctx.doc.id}/share`, token) + expect(out.status).toBe(200) + expect(out.json.ok).toBe(true) + expect(out.json.share.token).toMatch(/^[0-9a-f]{64}$/) + expect(out.json.share.revoked).toBe(false) + }) + + it('POST /documents/:id/share 404s for an unknown document', async () => { + const token = await login() + const out = await post(`/documents/nope/share`, token) + expect(out.status).toBe(404) + }) + + it('POST /documents/:id/share/:shareId/revoke revokes the share', async () => { + const token = await login() + const minted = await post(`/documents/${ctx.doc.id}/share`, token) + const shareId = minted.json.share.id + const out = await post(`/documents/${ctx.doc.id}/share/${shareId}/revoke`, token) + expect(out.status).toBe(200) + expect(out.json.ok).toBe(true) + // the token no longer resolves to a document + expect(validateShare(ctx.db, minted.json.share.token)).toBeNull() + }) +})