feat(hq): shareable document link backend (mint/validate/revoke/list)
Add document_share table (CREATE TABLE IF NOT EXISTS; opaque token, optional expiry, revoke flag) and repos-shares.ts: - mintShare: 256-bit randomBytes hex token (64 hex, unique), default +30d expiry, audited — the token itself is never written to the audit trail or any log. - validateShare: resolves a live token to its one document; null on unknown / revoked / expired (ISO-8601 UTC lexical compare). - revokeShare: flips revoked=1, audited. - listShares: a document's shares, scoped and newest-first. Wire POST /api/documents/:id/share and POST /api/documents/:id/share/:shareId/revoke (requireAuth, document-scoped share guard) and surface shares on GET /api/documents/:id. TDD: 14 tests cover token shape/uniqueness, validate rejections, revoke, and audit-without-token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
d29025189c
commit
a1759864bf
@ -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
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue