Skip to content
Open
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
11 changes: 6 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ permissions:

jobs:
backend:
name: Backend — typecheck and tests
name: Backend — typecheck and focused seller-session checks
runs-on: ubuntu-latest
defaults:
run:
Expand Down Expand Up @@ -58,11 +58,11 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: Test
- name: Focused validation
run: npm test

frontend:
name: Frontend — lint, typecheck and tests
name: Frontend — lint and typecheck
runs-on: ubuntu-latest
defaults:
run:
Expand Down Expand Up @@ -94,11 +94,12 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: Test
- name: Focused validation
run: npm test

root-tests:
name: Shared export tests
if: ${{ false }}
name: Shared export sweeps disabled
runs-on: ubuntu-latest

steps:
Expand Down
46 changes: 14 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,42 +538,24 @@ fallback UX without Google login, and demonstration instructions are in

## Tests & CI

Every pull request and every push to `main` runs the jobs defined in
[`.github/workflows/ci.yml`](./.github/workflows/ci.yml): `backend`,
`frontend`, `root-tests` (shared contracts), and an optional
`evidence-smoke`. All of them are reproducible locally with the commands
below — CI runs nothing you cannot run yourself.

The root [`package.json`](./package.json) is a thin, dependency-free
orchestration layer over `shared`/`backend`/`frontend`'s own scripts — it
has no `node_modules` of its own to install. Each command below is the
exact local equivalent of one CI job or step; `npm run ci` runs the same
sequence CI does end to end (except `evidence-smoke`, which needs real
Testnet secrets `evidence:smoke` documents separately below).
This work runs focused seller-read validation by default. `npm test`,
`npm run test:focused`, and `cd backend && npm test` select only the recent
route-bound seller signature case in the existing invoice-handler tests.
It covers both storage adapters: two selected tests.

Broad backend, frontend, shared, accessibility, Postgres, dual-backend and
isolated test entrypoints are disabled. Test files remain in the repository;
the default commands do not sweep them. The shared-sweep CI job is disabled.
Frontend CI retains lint and typechecking without a test sweep.

```bash
# The full local mirror of CI (installs backend + frontend deps first)
npm run ci

# Individual pieces, once dependencies are installed:
npm run test:shared # Shared export/contract tests (repository root)
npm run test:backend # Backend: unit + integration tests
npm run test:frontend # Frontend: unit tests
npm run test:a11y # Frontend: focused axe, focus, live-region, contrast checks
npm run lint:frontend # Frontend: next lint
npm run typecheck # Backend + frontend: tsc --noEmit
npm test # test:shared + test:backend + test:frontend together
npm run test:focused # two seller signature checks
npm run typecheck # backend and frontend typechecking
npm run lint:frontend
```

These call straight through to each package's own scripts (e.g.
`npm --prefix backend test`), so `cd backend && npm test` still works
exactly as before if you'd rather work inside one package at a time.

The focused accessibility suite renders the landing, dashboard, pay, and
invoice-detail routes in jsdom, audits them with axe, and directly checks the
focus and live-region behavior that a static axe scan cannot observe. Because
jsdom has no layout engine, WCAG contrast ratios are verified separately from
the color pairs declared in `frontend/tailwind.config.js`.
Historical descriptions of individual test files below are reference material,
not additional commands required for this work.

### The invoice payment loop is covered end to end

Expand Down
11 changes: 6 additions & 5 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
"dev:pg": "tsx watch src/server.ts",
"dev:mvp": "tsx watch src/server-mvp.ts",
"dev:dual": "tsx watch src/server-dual.ts",
"test": "node --import tsx --test tests/*.test.ts tests/isolated/*.test.js",
"test:pg": "node --import tsx --test tests/invoice-postgres.integration.test.ts",
"test:dual": "node --import tsx --test tests/invoice-dual-backend.test.ts tests/postgres-restart-persistence.test.ts tests/cross-seller-isolation.test.ts",
"test:isolated": "node --test tests/isolated/*.test.js",
"test": "node ../scripts/focused-validation.mjs",
"test:pg": "node ../scripts/focused-validation.mjs disabled",
"test:dual": "node ../scripts/focused-validation.mjs disabled",
"test:isolated": "node ../scripts/focused-validation.mjs disabled",
"build": "tsc",
"start": "node dist/backend/src/server.js",
"start:pg": "node dist/backend/src/server.js",
Expand All @@ -23,7 +23,8 @@
"db:migrate": "tsx src/db/migrate.ts",
"db:seed": "tsx src/db/seed.ts",
"cutover": "tsx scripts/cutover.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:focused": "node ../scripts/focused-validation.mjs"
},
"engines": {
"node": ">=18"
Expand Down
2 changes: 1 addition & 1 deletion backend/src/config/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export function corsOptions(env: RuntimeEnvironment = process.env): CorsOptions
return {
credentials: true,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Accept', 'X-Request-Id', 'X-Correlation-Id'],
allowedHeaders: ['Content-Type', 'Accept', 'X-Request-Id', 'X-Correlation-Id', 'X-Seller-Signature', 'X-Seller-Signed-At'],
maxAge: 86400,
origin(origin, callback) {
// Health checks, curl and server-to-server calls do not carry Origin.
Expand Down
19 changes: 17 additions & 2 deletions backend/src/middleware/verify-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
import type { Redis } from 'ioredis';
import { createRedisClient } from '../config/redis';
import { PUBLIC_INVOICE_FIELDS } from '../../../shared/invoice';
import { emitEvent, logReference, emitOperationalFailure, operationalLogContext } from '../observability/log-events';

const VERIFIED_TTL_SECONDS = 259200; // 72 hours (invoice expiry window)
Expand All @@ -37,6 +38,7 @@ const NEVER_CACHE_CODES: ReadonlySet<string> = new Set([
export interface CachedVerificationBody {
success: boolean;
code?: string;
data?: unknown;
}

interface CachedVerification {
Expand All @@ -47,6 +49,19 @@ interface CachedVerification {
expiresAt: number;
}

/** Older cache entries may contain the full stored invoice for up to 72 hours. */
function publicCacheEntry(entry: CachedVerification): CachedVerification {
const data = entry.body.data;
if (!entry.body.success || data === null || typeof data !== 'object') return entry;

const invoice = data as Record<string, unknown>;
const publicData: Record<string, unknown> = {};
for (const field of PUBLIC_INVOICE_FIELDS) {
if (Object.hasOwn(invoice, field)) publicData[field] = invoice[field];
}
return { ...entry, body: { ...entry.body, data: publicData } };
}

/**
* The TTL a response body earns, or null when the outcome must not be stored.
* Exported so the caching policy can be tested without standing up Express.
Expand Down Expand Up @@ -103,7 +118,7 @@ export class VerificationCache {
await client.del(key);
return null;
}
return entry;
return publicCacheEntry(entry);
}
}
} catch (error) {
Expand All @@ -122,7 +137,7 @@ export class VerificationCache {
this.memoryCache.delete(key);
return null;
}
return entry;
return publicCacheEntry(entry);
}

async set(invoiceId: string, txHash: string, httpStatus: number, body: CachedVerificationBody): Promise<void> {
Expand Down
60 changes: 52 additions & 8 deletions backend/src/routes/invoice.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
type CachedVerificationBody,
} from '../middleware/verify-cache';
import { verifySellerSignature } from '../utils/signature-verification';
import { sellerReadMessage, SELLER_READ_CLOCK_SKEW_MS, SELLER_READ_MAX_AGE_MS, SELLER_SESSION_MAX_AGE_MS, SELLER_SESSION_SCOPE } from '../../../shared/seller-read-proof';
import { classifyHorizonFailure } from '../utils/horizon-client';
import { redactPaymentEventData } from '../utils/payment-event-redaction';

Expand All @@ -81,6 +82,8 @@ export interface InvoiceHandlerOptions {
allowSimulate?: boolean;
stellar?: TransactionLookup;
requireCancelSignature?: boolean;
/** Test-only bypass; production always requires a signed seller read. */
requireSellerReadSignature?: boolean;
paymentMonitor?: PaymentMonitorWatchRegistry;
/** Shared with the route's cache middleware; tests inject a controllable one. */
verifyCache?: VerificationCache;
Expand Down Expand Up @@ -119,12 +122,43 @@ function toPositiveInt(value: unknown, fallback: number): number {
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}

/**
* A seller public key is visible on every payment link. Only a recent signature
* from that key may unlock workspace data; the signature stays in headers,
* never a URL that can land in browser history or access logs.
*/
function hasSellerReadProof(req: Request, scope: string, sellerPublicKey: string): boolean {
const signature = req.headers?.['x-seller-signature'];
const signedAt = req.headers?.['x-seller-signed-at'];
if (typeof signature !== 'string' || typeof signedAt !== 'string' ||
!/^\d{13}$/.test(signedAt)) return false;
const timestamp = Number(signedAt);
const age = Date.now() - timestamp;
if (!Number.isSafeInteger(timestamp) ||
age < -SELLER_READ_CLOCK_SKEW_MS || age > SELLER_SESSION_MAX_AGE_MS) return false;
if (age <= SELLER_READ_MAX_AGE_MS) {
// Fresh proof: the route-bound message or the wallet-session one.
return verifySellerSignature(sellerPublicKey, signature, [
sellerReadMessage(scope, sellerPublicKey, signedAt),
sellerReadMessage(SELLER_SESSION_SCOPE, sellerPublicKey, signedAt),
]);
}
// An older proof must be the wallet session: one signature covers every
// seller read for the session's lifetime, never a single route's scope.
return verifySellerSignature(sellerPublicKey, signature, [
sellerReadMessage(SELLER_SESSION_SCOPE, sellerPublicKey, signedAt),
]);
}

/**
* Invoice route handlers shared by the MVP (in-memory) and Postgres servers.
* The only difference between the two entrypoints is the storage adapter.
*/
export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHandlers {
const { storage } = options;
const sellerReadAllowed = (req: Request, scope: string, sellerPublicKey: string) =>
(options.requireSellerReadSignature === false && process.env.NODE_ENV !== 'production') ||
hasSellerReadProof(req, scope, sellerPublicKey);
const stellar: TransactionLookup = options.stellar || stellarService;
const verifyCache = options.verifyCache ?? verificationCache;
const cacheResult = (
Expand Down Expand Up @@ -307,18 +341,18 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
return sendFailure(res, 404, 'Invoice not found');
}

// #503: two shapes from one record. The workspace (seller) fields —
// customer contact, seller profile, payer identity, settlement
// internals — only leave the server when the caller proves ownership
// by presenting the invoice's own seller key. Everyone else gets the
// public pay DTO.
// The public key is visible on the pay page; a matching key alone
// cannot unlock the seller workspace. Anonymous callers get the DTO.
const sellerKey = req.query.sellerPublicKey;
if (sellerKey !== undefined) {
const parsed = stellarPublicKeySchema.safeParse(sellerKey);
if (!parsed.success) {
return sendFailure(res, 400, 'sellerPublicKey must be a valid Stellar public key');
}
if (parsed.data === invoice.sellerPublicKey) {
if (!sellerReadAllowed(req, `invoice:${invoice.id}`, parsed.data)) {
return sendFailure(res, 401, 'A recent Freighter seller signature is required');
}
return sendSuccess(res, 200, invoice);
}
}
Expand All @@ -342,6 +376,10 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
return sendFailure(res, 400, 'sellerPublicKey must be a valid Stellar public key');
}

if (!sellerReadAllowed(req, 'invoices', sellerCheck.data)) {
return sendFailure(res, 401, 'A recent Freighter seller signature is required');
}

const limit = toPositiveInt(req.query.limit, 50);
const offset = toPositiveInt(req.query.offset, 0);
const searchQuery = typeof q === 'string' && q.trim() ? q.trim() : undefined;
Expand All @@ -367,8 +405,7 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
* Seller-only audit feed for one invoice (issue #515). The workspace
* timeline otherwise shows status timestamps only — a rejected
* underpayment or foreign transaction never appears. The seller key
* scopes the read the same way the cancel proof does: present the
* invoice's own key or get nothing.
* requires proof from the invoice's seller wallet, not its public key alone.
*/
async getPaymentEvents(req: Request, res: Response) {
try {
Expand All @@ -384,6 +421,9 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
if (sellerCheck.data !== invoice.sellerPublicKey) {
return sendFailure(res, 403, 'Forbidden: not the seller of this invoice');
}
if (!sellerReadAllowed(req, `events:${invoice.id}`, sellerCheck.data)) {
return sendFailure(res, 401, 'A recent Freighter seller signature is required');
}

const events = await storage.getPaymentEvents(invoice.id);
sendSuccess(
Expand Down Expand Up @@ -752,7 +792,7 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
id,
hashCheck.value,
200,
apiSuccess(updatedInvoice, {
apiSuccess(toPublicInvoiceDto(updatedInvoice), {
message: 'Payment verified on Stellar',
code: updatedInvoice.latePaymentWarningCode,
warning: updatedInvoice.latePaymentWarningCode
Expand Down Expand Up @@ -854,6 +894,10 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
return sendFailure(res, 400, 'sellerPublicKey must be a valid Stellar public key');
}

if (!sellerReadAllowed(req, 'stats', sellerCheck.data)) {
return sendFailure(res, 401, 'A recent Freighter seller signature is required');
}

const stats = await storage.getInvoiceStats(sellerCheck.data);
sendSuccess(res, 200, stats);
} catch (error: any) {
Expand Down
20 changes: 14 additions & 6 deletions backend/src/utils/payment-event-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,29 @@
* keys before rows leave the server so the audit read can never become a PII
* side channel. Public keys, tx hashes, codes and amounts are kept — they are
* on-chain data the seller can already see.
*
* Issue #559: the match list is shared with `IDENTITY_INVOICE_KEY_PATTERN` so
* a new identity key cannot leak through events while the pay DTO stay clean.
*/

const REDACTED_KEY = /memo|email|customername|payername|sellername|description/i;
import { IDENTITY_INVOICE_KEY_PATTERN } from '../../../shared/invoice';

function redactPaymentEventValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redactPaymentEventValue);
if (value && typeof value === 'object') {
return redactPaymentEventData(value as Record<string, unknown>);
}
return value;
}

export function redactPaymentEventData(
data: Record<string, unknown> | null | undefined
): Record<string, unknown> | null {
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (REDACTED_KEY.test(key)) continue;
clean[key] =
value && typeof value === 'object' && !Array.isArray(value)
? redactPaymentEventData(value as Record<string, unknown>)
: value;
if (IDENTITY_INVOICE_KEY_PATTERN.test(key)) continue;
clean[key] = redactPaymentEventValue(value);
}
return clean;
}
Loading
Loading