Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 208 additions & 12 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,116 @@ openapi: 3.0.3
info:
title: ContractCheq API
version: "1.0"
description: >-
ContractCheck SA API. Free preview is returned by analyze/report endpoints;
the full paid report is only returned by /api/unlock-analysis after a
verified Paystack receipt (reference + matching email) has spent a credit.
GET /api/report/{id} returns the free preview unauthenticated; with
x-payment-reference (+ optional x-receipt-email) it returns the full report
only for a grant that has already unlocked that report, otherwise 404.
paths:
/api/upload:
/api/health:
get:
summary: Service and storage health
responses:
"200":
description: Health status JSON
/api/health/storage:
get:
summary: On-demand storage write/read/delete probe (rate limited)
responses:
"200":
description: Storage writable
"503":
description: Storage not writable
/api/config:
get:
summary: Public client configuration (prices, paystackPublicKey, limits)
responses:
"200":
description: Config JSON
/api/demo/{key}:
get:
summary: Run a built-in demo sample through the rule engine
parameters:
- name: key
in: path
required: true
schema:
type: string
responses:
"200":
description: Full demo analysis
"404":
description: Example not found
/api/events:
post:
summary: Upload a contract PDF or text
summary: Fire-and-forget telemetry event
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
props:
type: object
responses:
"200":
description: Accepted
"400":
description: name required
/api/credits:
get:
summary: Remaining credits for a payment receipt
parameters:
- name: x-payment-reference
in: header
required: true
schema:
type: string
responses:
"200":
description: Remaining credits
"401":
description: Payment receipt required
"404":
description: Receipt not found
/api/verify-payment:
post:
summary: Verify a Paystack transaction and create a report-credit grant
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [reference, email]
properties:
reference:
type: string
pattern: '^CC-[A-Za-z0-9-]{10,100}$'
email:
type: string
product:
type: string
enum: [single, pack3, pack10]
responses:
"200":
description: Verified grant JSON
"400":
description: Invalid payment reference
"402":
description: Payment not successful / product mismatch
"403":
description: Receipt email mismatch
"503":
description: Storage unavailable
/api/analyze:
post:
summary: Upload a contract file (PDF/image/txt) and return a free risk preview
requestBody:
required: true
content:
Expand All @@ -19,29 +125,119 @@ paths:
jurisdiction:
type: string
enum: [ZA, UK]
perspective:
type: string
enrich:
type: string
responses:
"200":
description: Free preview JSON (full report locked)
"400":
description: No file / validation error
"413":
description: File too large
"422":
description: Could not extract enough text
/api/analyze-text:
post:
summary: Analyze pasted contract text and return a free risk preview
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text:
type: string
fileName:
type: string
jurisdiction:
type: string
enum: [ZA, UK]
perspective:
type: string
responses:
"200":
description: Risk preview JSON
content:
application/json:
schema:
type: object
description: Free preview JSON
"400":
description: Validation error
/api/report:
"413":
description: Text too large
/api/report/{id}:
get:
summary: Resume a saved scan. Free preview only unless an unlocking grant credential is supplied.
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: x-payment-reference
in: header
required: false
description: Payment receipt reference; full report only if this grant already unlocked this report.
schema:
type: string
- name: x-receipt-email
in: header
required: false
schema:
type: string
responses:
"200":
description: Free preview, or full report for an unlocking grant
"400":
description: Missing/invalid report id
"404":
description: Report not found, expired, or not unlocked by the supplied grant (no existence leak)
/api/unlock-analysis:
post:
summary: Unlock full report (requires payment token)
summary: Spend a verified credit to unlock the full report
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [analysisId]
properties:
token:
analysisId:
type: string
reference:
type: string
grantId:
type: string
email:
type: string
reopenOnly:
type: boolean
responses:
"200":
description: Full PDF report
description: Full paid report JSON
"400":
description: Missing analysisId
"402":
description: Payment required
description: Payment receipt required / no credits remaining
"403":
description: Invalid receipt/email, or reopenOnly for a report this receipt did not unlock
"404":
description: Analysis expired
/api/cleanup:
get:
summary: Delete expired analyses (Bearer CRON_SECRET, timing-safe compared)
security:
- bearerAuth: []
responses:
"200":
description: Cleanup complete
"401":
description: Unauthorized
components:
securitySchemes:
bearerAuth:
type: apiKey
in: header
name: Authorization
description: 'Bearer <CRON_SECRET>'
25 changes: 22 additions & 3 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const { validatePerspective } = require('./reportContext');
const DEMO_SAMPLES = require('./demoSamples');
const store = require('./store');
const { SITE_ORIGIN, canonicalHost } = require('./siteConfig');
const { isBearerAuthorized } = require('./secretCompare');

const PORT = Number(process.env.PORT) || 3000;
const MAX_FILE_MB = process.env.VERCEL ? 4 : (Number(process.env.MAX_FILE_MB) || 12);
Expand Down Expand Up @@ -536,9 +537,27 @@ app.post('/api/analyze-text', uploadLimiter, async (req,res,next)=> {
app.get('/api/report/:id', async (req,res,next)=> {
try {
const id = String(req.params.id || '');
if (!id || id.length > 200) return res.status(400).json({ error: 'Missing report id' });
if (!id || id.length > 200 || /[/\\]/.test(id) || id.includes('..')) return res.status(400).json({ error: 'Missing report id' });
const stored = await store.getAnalysis(id);
if (!stored?.full) return res.status(404).json({ error: 'This preview has expired. Scan again to get a new preview.' });
// IDOR hardening (P0-3): this endpoint must never disclose a full/paid report
// to an unauthenticated caller. The frontend resume flow (public/index.html)
// calls it with NO credentials and relies on the free preview only, so full
// auth here would break the demo/resume flow. Minimal check consistent with
// the existing grant model (/api/credits + /api/unlock-analysis):
// - no credential -> free preview only (freeViewOf strips paid fields)
// - credential supplied (x-payment-reference, optional x-receipt-email)
// -> full report only if the grant exists, email matches when supplied,
// and the grant has already unlocked this analysisId; otherwise the
// same generic 404 as for a missing report (no existence leak).
const reference = String(req.headers['x-payment-reference'] || '');
if (reference) {
const grant = await store.getGrant(reference);
const email = String(req.headers['x-receipt-email'] || '').toLowerCase().trim();
const unlocked = grant && (!email || String(grant.email || '').toLowerCase().trim() === email) && Array.isArray(grant.unlocked) && grant.unlocked.includes(id);
if (!unlocked) return res.status(404).json({ error: 'This preview has expired. Scan again to get a new preview.' });
return res.json({ ...stored.full, isPaid: true });
}
res.json(freeViewOf(stored.full));
} catch (e) { next(e); }
});
Expand All @@ -562,7 +581,7 @@ app.post('/api/unlock-analysis', payLimiter, async (req,res,next)=> {
});

app.get('/api/cleanup', async (req,res,next)=> {
if (!process.env.CRON_SECRET || req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) return res.status(401).end();
if (!isBearerAuthorized(req.headers.authorization, process.env.CRON_SECRET)) return res.status(401).end();
try { await store.cleanupExpired(); res.json({ ok: true }); } catch (e) { next(e); }
});

Expand Down Expand Up @@ -594,4 +613,4 @@ if (require.main === module) app.listen(PORT, () => {
});

module.exports = app;
module.exports.__internal = { redactSensitiveText, externalAiEnabled, freeViewOf, extractFromImage };
module.exports.__internal = { redactSensitiveText, externalAiEnabled, freeViewOf, extractFromImage, isBearerAuthorized };
85 changes: 85 additions & 0 deletions server/p0Hardening.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { analyseContract } = require('./riskEngine');

test('timing-safe Bearer check handles length mismatch and unset secret', async () => {
const { isBearerAuthorized, timingSafeEqualString } = require('./secretCompare');
assert.equal(timingSafeEqualString('abc', 'abc'), true);
assert.equal(timingSafeEqualString('abc', 'abd'), false);
assert.equal(timingSafeEqualString('abc', 'abcd'), false); // must not throw
assert.equal(timingSafeEqualString('', ''), true);
assert.equal(isBearerAuthorized('Bearer s3cret', 's3cret'), true);
assert.equal(isBearerAuthorized('Bearer wrong', 's3cret'), false);
assert.equal(isBearerAuthorized('Bearer s3cret-longer', 's3cret'), false);
assert.equal(isBearerAuthorized('Bearer s3cret', ''), false);
assert.equal(isBearerAuthorized('Bearer s3cret', undefined), false);
assert.equal(isBearerAuthorized(undefined, 's3cret'), false);
assert.equal(isBearerAuthorized('Basic s3cret', 's3cret'), false);
});

test('GET /api/report/:id never leaks full report without an unlocking grant', async () => {
for (const k of ['VERCEL', 'BLOB_STORE_ID', 'BLOB_READ_WRITE_TOKEN', 'SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_TABLE']) delete process.env[k];
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-p0-'));
const store = require('./store');
const app = require('./index');
const server = app.listen(0);
await new Promise(r => server.once('listening', r));
const base = 'http://127.0.0.1:' + server.address().port;
try {
const analysis = analyseContract('EMPLOYMENT AGREEMENT. The employee shall work 55 hours per week. ' + 'Clause '.repeat(40), 'scan.txt');
analysis._id = 'p0-report-1';
analysis.isPaid = false;
await store.saveAnalysis('p0-report-1', analysis);

// Unauthenticated: free preview only, paid fields stripped.
const free = await fetch(base + '/api/report/p0-report-1');
assert.equal(free.status, 200);
const freeBody = await free.json();
assert.equal(freeBody.freePreview, true);
assert.equal(freeBody.isPaid, false);
assert.ok(!('plainLanguageGuide' in freeBody) || !freeBody.plainLanguageGuide || freeBody.issuesLocked);

// Unknown id and credentialed-but-not-unlocked are indistinguishable (404, same message).
const missing = await fetch(base + '/api/report/does-not-exist');
assert.equal(missing.status, 404);
const missingBody = await missing.json();
const bogus = await fetch(base + '/api/report/p0-report-1', { headers: { 'x-payment-reference': 'CC-NOTAREAL-123456' } });
assert.equal(bogus.status, 404);
assert.deepEqual(await bogus.json(), missingBody);

// Grant that has NOT unlocked this id: still 404, no leak.
await store.saveGrant('CC-TESTGRANT-123456', { email: 'buyer@example.test', product: 'single', amount: 1900, credits: 1 });
const notUnlocked = await fetch(base + '/api/report/p0-report-1', { headers: { 'x-payment-reference': 'CC-TESTGRANT-123456' } });
assert.equal(notUnlocked.status, 404);

// After unlock via the existing flow, the same credential returns the full report.
const unlock = await fetch(base + '/api/unlock-analysis', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ analysisId: 'p0-report-1', reference: 'CC-TESTGRANT-123456', email: 'buyer@example.test' }),
});
assert.equal(unlock.status, 200);
const full = await fetch(base + '/api/report/p0-report-1', {
headers: { 'x-payment-reference': 'CC-TESTGRANT-123456', 'x-receipt-email': 'buyer@example.test' },
});
assert.equal(full.status, 200);
const fullBody = await full.json();
assert.equal(fullBody.isPaid, true);
assert.equal(fullBody._id, 'p0-report-1');

// Wrong email for the same reference: 404, no leak.
const wrongEmail = await fetch(base + '/api/report/p0-report-1', {
headers: { 'x-payment-reference': 'CC-TESTGRANT-123456', 'x-receipt-email': 'other@example.test' },
});
assert.equal(wrongEmail.status, 404);

// Path-traversal style ids are rejected.
const trav = await fetch(base + '/api/report/' + encodeURIComponent('../secret'));
assert.equal(trav.status, 400);
} finally {
server.close();
fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });
}
});
Loading
Loading