Money: private Documents page with admin-only file endpoint
All checks were successful
Deploy production / deploy (push) Successful in 2m32s

Lists contracts/pension docs with written summaries, an inline viewer and
download at /trackers/money/documents. Files and summaries live on the host
(MONEY_DOCUMENTS_ROOT, bind-mounted read-only by deploy-remote.sh), never in
git or static/; the file endpoint is admin-only (401/403) and private, no-store.
This commit is contained in:
2026-10-24 14:32:28 +00:00
parent 8818dde99d
commit 35f78ec829
9 changed files with 422 additions and 1 deletions

View File

@@ -0,0 +1,52 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
listMoneyDocuments,
parseManifest,
resolveDocumentFile,
validDocumentId
} from './money-documents';
let root: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), 'money-docs-'));
await mkdir(path.join(root, 'files'), { recursive: true });
await writeFile(path.join(root, 'files', 'contract.pdf'), '%PDF-1.7');
await writeFile(path.join(root, 'secret.txt'), 'nope');
await symlink(path.join(root, 'secret.txt'), path.join(root, 'files', 'escape.pdf'));
await writeFile(
path.join(root, 'documents.json'),
JSON.stringify([
{ id: 'contract', title: 'Contract', file: 'contract.pdf', date: '2026-09-22', pages: 11 },
{ id: 'contract', title: 'Duplicate', file: 'contract.pdf' },
{ id: '../bad', title: 'Bad', file: 'x.pdf' },
{ id: 'no-file', title: 'No file' }
])
);
});
afterAll(() => rm(root, { recursive: true, force: true }));
describe('money documents', () => {
it('validates ids', () => {
expect(validDocumentId('cargill-contract-2026')).toBe(true);
expect(validDocumentId('../etc')).toBe(false);
expect(validDocumentId('')).toBe(false);
});
it('keeps only well-formed, unique manifest entries', async () => {
const docs = await listMoneyDocuments(root);
expect(docs.map((d) => d.id)).toEqual(['contract']);
expect(docs[0]).toMatchObject({ category: 'Other', date: '2026-09-22', pages: 11 });
expect(parseManifest({ not: 'an array' })).toEqual([]);
});
it('returns no documents when the manifest is missing', async () => {
expect(await listMoneyDocuments(path.join(root, 'missing'))).toEqual([]);
});
it('resolves files inside files/ only', async () => {
expect(await resolveDocumentFile('contract.pdf', root)).toMatch(/contract\.pdf$/);
expect(await resolveDocumentFile('../secret.txt', root)).toBeNull();
expect(await resolveDocumentFile('escape.pdf', root)).toBeNull();
expect(await resolveDocumentFile('/etc/passwd', root)).toBeNull();
});
});

View File

@@ -0,0 +1,91 @@
// Private money documents (employment contracts, pension rules, ...). The files and their
// written summaries live on the host, outside the repo, and are bind-mounted read-only at
// /money-documents in prod (see scripts/deploy-remote.sh). They are only ever streamed to an
// administrator through /trackers/money/documents/[id]/file; nothing is under static/ or in git.
//
// Layout of MONEY_DOCUMENTS_ROOT:
// documents.json manifest: [{ id, title, category, date, file, pages?, summary }]
// files/<file> the documents themselves (file is relative to files/)
import { existsSync } from 'node:fs';
import { readFile, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
export type MoneyDocument = {
id: string;
title: string;
category: string;
date: string | null;
file: string;
pages: number | null;
/** Markdown, written by hand for this document. */
summary: string;
};
export function moneyDocumentsRoot(): string {
if (process.env.MONEY_DOCUMENTS_ROOT) return process.env.MONEY_DOCUMENTS_ROOT;
return existsSync('/money-documents/documents.json')
? '/money-documents'
: path.join(homedir(), 'money-documents');
}
const ID = /^[a-z0-9][a-z0-9-]{0,80}$/;
export function validDocumentId(id: string): boolean {
return ID.test(id);
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/** Validates a parsed manifest; entries that are malformed are skipped, never thrown. */
export function parseManifest(raw: unknown): MoneyDocument[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const documents: MoneyDocument[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const item = entry as Record<string, unknown>;
const id = text(item.id);
const title = text(item.title);
const file = text(item.file);
if (!id || !validDocumentId(id) || seen.has(id) || !title || !file) continue;
const date = text(item.date);
seen.add(id);
documents.push({
id,
title,
category: text(item.category) ?? 'Other',
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null,
file,
pages: typeof item.pages === 'number' && item.pages > 0 ? Math.floor(item.pages) : null,
summary: typeof item.summary === 'string' ? item.summary : ''
});
}
return documents;
}
export async function listMoneyDocuments(root = moneyDocumentsRoot()): Promise<MoneyDocument[]> {
try {
return parseManifest(JSON.parse(await readFile(path.join(root, 'documents.json'), 'utf8')));
} catch {
return [];
}
}
/** Resolves a manifest file inside <root>/files, refusing anything that escapes it. */
export async function resolveDocumentFile(
relative: string,
root = moneyDocumentsRoot()
): Promise<string | null> {
if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null;
const base = path.join(root, 'files');
try {
const realBase = await realpath(base);
const real = await realpath(path.join(base, relative));
if (!real.startsWith(realBase + path.sep)) return null;
return (await stat(real)).isFile() ? real : null;
} catch {
return null;
}
}