diff --git a/src/app.ts b/src/app.ts index 81cdacc..ed7dabd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -73,6 +73,45 @@ function parseResultFormats(req: Request): Set { return set; } + +/** Production fail-closed: never invent mock-cert-* when an upstream is configured. */ +function upstreamConfigured(): boolean { + return Boolean((process.env.ZAPPIER_UPSTREAM || '').trim()); +} + +/** Dev-only local mock certificates. Allowed only when upstream is unset. */ +function allowMockCerts(): boolean { + return process.env.ALLOW_MOCK_CERTS === 'true' && !upstreamConfigured(); +} + +function rejectUpstreamTimestamp( + res: Response, + upstream: { status: number; body: unknown } | null, +): void { + if (upstreamConfigured()) { + const status = + upstream && typeof upstream.status === 'number' && upstream.status >= 400 + ? upstream.status + : 502; + const body: Record = { + upstreamStatus: upstream?.status ?? null, + }; + if (upstream?.body && typeof upstream.body === 'object' && !Array.isArray(upstream.body)) { + Object.assign(body, upstream.body as Record); + } else if (typeof upstream?.body === 'string' && upstream.body) { + body.detail = upstream.body; + } + // Edge error wins over any upstream "error" field. + body.error = 'upstream_timestamp_failed'; + res.status(status).json(body); + return; + } + res.status(503).json({ + error: 'timestamp_upstream_unconfigured', + hint: 'Set ZAPPIER_UPSTREAM for real Verae stamps, or ALLOW_MOCK_CERTS=true for local mock certificates.', + }); +} + function stampToText(row: { documentName?: string; sha256: string; @@ -430,7 +469,13 @@ export function buildApp(deps: AppDeps = {}): { return; } - // Local mock when upstream unset/error + // Fail closed when upstream is configured (or mock not explicitly allowed). + if (!allowMockCerts()) { + rejectUpstreamTimestamp(res, upstream); + return; + } + + // Dev-only local mock when ZAPPIER_UPSTREAM unset and ALLOW_MOCK_CERTS=true const existing = hashIndex.get(sha256); if (existing) { const mockBody = { @@ -685,7 +730,13 @@ export function buildApp(deps: AppDeps = {}): { return; } - // Local mock when upstream unset or error — Verae BatchTimestampResponse + edge extras + // Fail closed when upstream is configured (or mock not explicitly allowed). + if (!allowMockCerts()) { + rejectUpstreamTimestamp(res, upstream); + return; + } + + // Dev-only local mock when ZAPPIER_UPSTREAM unset and ALLOW_MOCK_CERTS=true const jobIds: string[] = []; const results: { sha256: string; diff --git a/src/upstream.ts b/src/upstream.ts index 5b8ba25..12539ce 100644 --- a/src/upstream.ts +++ b/src/upstream.ts @@ -65,7 +65,7 @@ export async function fetchVerae( /** * After zappier meters the call, forward Verae operations to middleware / API. - * When ZAPPIER_UPSTREAM is unset, callers keep the local mock. + * When ZAPPIER_UPSTREAM is unset, callers may use ALLOW_MOCK_CERTS=true for local mock. */ export async function proxyVerae( req: Request, diff --git a/tests/api-tokens.test.ts b/tests/api-tokens.test.ts index 440cc4c..8cd285d 100644 --- a/tests/api-tokens.test.ts +++ b/tests/api-tokens.test.ts @@ -5,6 +5,19 @@ import { InMemoryCustomerRepo } from '../src/auth'; import { InMemorySessionRepo } from '../src/accounts'; describe('xVerae- long-lived API tokens', () => { + const prevAllowMock = process.env.ALLOW_MOCK_CERTS; + const prevUpstream = process.env.ZAPPIER_UPSTREAM; + beforeAll(() => { + delete process.env.ZAPPIER_UPSTREAM; + process.env.ALLOW_MOCK_CERTS = 'true'; + }); + afterAll(() => { + if (prevAllowMock === undefined) delete process.env.ALLOW_MOCK_CERTS; + else process.env.ALLOW_MOCK_CERTS = prevAllowMock; + if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM; + else process.env.ZAPPIER_UPSTREAM = prevUpstream; + }); + it('portal creates token once; stamp auth accepts xVerae- and Bearer', async () => { const customers = new InMemoryCustomerRepo( DEFAULT_CUSTOMERS.map((c) => diff --git a/tests/app.test.ts b/tests/app.test.ts index 80d5626..9d2039d 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -4,6 +4,19 @@ import { buildApp } from '../src/app'; const KEY = 'key-ada'; // seeded free-tier customer describe('Zappier API', () => { + const prevAllowMock = process.env.ALLOW_MOCK_CERTS; + const prevUpstream = process.env.ZAPPIER_UPSTREAM; + beforeAll(() => { + delete process.env.ZAPPIER_UPSTREAM; + process.env.ALLOW_MOCK_CERTS = 'true'; + }); + afterAll(() => { + if (prevAllowMock === undefined) delete process.env.ALLOW_MOCK_CERTS; + else process.env.ALLOW_MOCK_CERTS = prevAllowMock; + if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM; + else process.env.ZAPPIER_UPSTREAM = prevUpstream; + }); + it('rejects calls without an API key', async () => { const { app } = buildApp(); const res = await request(app).get('/v1/status'); @@ -24,7 +37,8 @@ describe('Zappier API', () => { .post('/v1/timestamp') .set('x-api-key', KEY) .send({ data: 'abc' }); - expect(a.status).toBe(202); + expect(a.status).toBe(200); + expect(String(a.body.receipt_id || '')).toMatch(/^mock-cert-/); const b = await request(app) .post('/v1/timestamp') .set('x-api-key', KEY) diff --git a/tests/billing-headers.test.ts b/tests/billing-headers.test.ts index c0fdb5c..b18b73b 100644 --- a/tests/billing-headers.test.ts +++ b/tests/billing-headers.test.ts @@ -9,6 +9,19 @@ import { import { emptyChannelRates } from '../src/billing-channels'; describe('billing headers', () => { + const prevAllowMock = process.env.ALLOW_MOCK_CERTS; + const prevUpstream = process.env.ZAPPIER_UPSTREAM; + beforeAll(() => { + delete process.env.ZAPPIER_UPSTREAM; + process.env.ALLOW_MOCK_CERTS = 'true'; + }); + afterAll(() => { + if (prevAllowMock === undefined) delete process.env.ALLOW_MOCK_CERTS; + else process.env.ALLOW_MOCK_CERTS = prevAllowMock; + if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM; + else process.env.ZAPPIER_UPSTREAM = prevUpstream; + }); + it('returns spend and balance on metered calls', async () => { const table = emptyChannelRates(); table.rates.api.individual.timestamp = 10; @@ -23,7 +36,7 @@ describe('billing headers', () => { .set('x-api-key', 'key-ada') .set('x-billing-channel', 'api') .send({ sha256: '1'.repeat(64) }); - expect(res.status).toBe(202); + expect(res.status).toBe(200); expect(res.headers[BILLING_HEADER_SPEND.toLowerCase()]).toBe('10'); expect(res.headers[BILLING_HEADER_BALANCE.toLowerCase()]).toBe('500'); expect(res.headers[BILLING_HEADER_CHANNEL.toLowerCase()]).toBe('api'); diff --git a/tests/fail-closed-certs.test.ts b/tests/fail-closed-certs.test.ts new file mode 100644 index 0000000..b318672 --- /dev/null +++ b/tests/fail-closed-certs.test.ts @@ -0,0 +1,116 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; + +const KEY = 'key-ada'; + +describe('fail-closed Verae timestamp certificates', () => { + const prevAllow = process.env.ALLOW_MOCK_CERTS; + const prevUpstream = process.env.ZAPPIER_UPSTREAM; + + afterEach(() => { + if (prevAllow === undefined) delete process.env.ALLOW_MOCK_CERTS; + else process.env.ALLOW_MOCK_CERTS = prevAllow; + if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM; + else process.env.ZAPPIER_UPSTREAM = prevUpstream; + }); + + it('returns upstream status (not mock-cert) when ZAPPIER_UPSTREAM is set and upstream is non-2xx', async () => { + process.env.ZAPPIER_UPSTREAM = 'http://upstream.test'; + delete process.env.ALLOW_MOCK_CERTS; + const orig = globalThis.fetch; + globalThis.fetch = jest.fn(async () => { + return new Response(JSON.stringify({ error: 'verae_unavailable' }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + try { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/timestamp') + .set('x-api-key', KEY) + .send({ sha256: 'e'.repeat(64) }); + expect(res.status).toBe(503); + expect(res.body.error).toBe('upstream_timestamp_failed'); + expect(res.body.upstreamStatus).toBe(503); + expect(String(res.body.receipt_id || '')).not.toMatch(/^mock-cert-/); + expect(String(res.body.certificate || '')).not.toMatch(/^mock-cert-/); + } finally { + globalThis.fetch = orig; + } + }); + + it('returns 502 when upstream is configured but fetch yields no usable response body path', async () => { + process.env.ZAPPIER_UPSTREAM = 'http://upstream.test'; + delete process.env.ALLOW_MOCK_CERTS; + const orig = globalThis.fetch; + // 404 on both preferred and legacy paths → still configured, fail closed (not mock) + globalThis.fetch = jest.fn(async () => { + return new Response(JSON.stringify({ error: 'not_found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + try { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/timestamp') + .set('x-api-key', KEY) + .send({ data: 'fail-closed-404' }); + expect(res.status).toBe(404); + expect(res.body.error).toBe('upstream_timestamp_failed'); + expect(String(JSON.stringify(res.body))).not.toMatch(/mock-cert-/); + } finally { + globalThis.fetch = orig; + } + }); + + it('batch timestamps fail closed when upstream returns 500', async () => { + process.env.ZAPPIER_UPSTREAM = 'http://upstream.test'; + delete process.env.ALLOW_MOCK_CERTS; + const orig = globalThis.fetch; + globalThis.fetch = jest.fn(async () => { + return new Response(JSON.stringify({ error: 'boom' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + try { + const { app } = buildApp(); + const res = await request(app) + .post('/v1/timestamps') + .set('x-api-key', KEY) + .send({ sha256s: ['f'.repeat(64)] }); + expect(res.status).toBe(500); + expect(res.body.error).toBe('upstream_timestamp_failed'); + expect(String(JSON.stringify(res.body))).not.toMatch(/mock-cert-/); + } finally { + globalThis.fetch = orig; + } + }); + + it('without upstream, mock certs require ALLOW_MOCK_CERTS=true', async () => { + delete process.env.ZAPPIER_UPSTREAM; + delete process.env.ALLOW_MOCK_CERTS; + const { app } = buildApp(); + const res = await request(app) + .post('/v1/timestamp') + .set('x-api-key', KEY) + .send({ data: 'no-mock-without-flag' }); + expect(res.status).toBe(503); + expect(res.body.error).toBe('timestamp_upstream_unconfigured'); + expect(String(JSON.stringify(res.body))).not.toMatch(/mock-cert-/); + }); + + it('ALLOW_MOCK_CERTS=true with upstream unset still emits mock-cert for local dev', async () => { + delete process.env.ZAPPIER_UPSTREAM; + process.env.ALLOW_MOCK_CERTS = 'true'; + const { app } = buildApp(); + const res = await request(app) + .post('/v1/timestamp') + .set('x-api-key', KEY) + .send({ data: 'dev-mock-ok' }); + expect(res.status).toBe(200); + expect(String(res.body.receipt_id)).toMatch(/^mock-cert-/); + }); +}); diff --git a/tests/timestamps-batch.test.ts b/tests/timestamps-batch.test.ts index 47b542d..cf837bd 100644 --- a/tests/timestamps-batch.test.ts +++ b/tests/timestamps-batch.test.ts @@ -3,6 +3,19 @@ import { buildApp, DEFAULT_CUSTOMERS } from '../src/app'; import { emptyChannelRates } from '../src/billing-channels'; describe('POST /v1/timestamps batch', () => { + const prevAllowMock = process.env.ALLOW_MOCK_CERTS; + const prevUpstream = process.env.ZAPPIER_UPSTREAM; + beforeAll(() => { + delete process.env.ZAPPIER_UPSTREAM; + process.env.ALLOW_MOCK_CERTS = 'true'; + }); + afterAll(() => { + if (prevAllowMock === undefined) delete process.env.ALLOW_MOCK_CERTS; + else process.env.ALLOW_MOCK_CERTS = prevAllowMock; + if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM; + else process.env.ZAPPIER_UPSTREAM = prevUpstream; + }); + it('registers many hashes in one call and skips existing for free', async () => { const table = emptyChannelRates(); table.rates.zapier.individual.timestamp = 10;