Skip to content
Draft
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
7 changes: 5 additions & 2 deletions backend/src/utils/payment-event-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@
* 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';

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;
if (IDENTITY_INVOICE_KEY_PATTERN.test(key)) continue;
clean[key] =
value && typeof value === 'object' && !Array.isArray(value)
? redactPaymentEventData(value as Record<string, unknown>)
Expand Down
17 changes: 3 additions & 14 deletions backend/tests/invoice-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { PostgresInvoiceStorage } from '../src/storage/postgres-invoice-storage.
import { InvoiceService } from '../src/services/invoice.service.ts';
import memoryStorage from '../src/storage/memory-storage.ts';
import type { InvoiceStorage } from '../src/storage/invoice-storage.ts';
import { PUBLIC_INVOICE_FIELDS } from '../../shared/invoice.ts';
import { PUBLIC_INVOICE_FIELDS, SELLER_ONLY_INVOICE_FIELDS } from '../../shared/invoice.ts';

const SELLER_A = 'GB3Q3VRHH3OQDYITTLONDLEHWQGKB27T2BEDSFHIUMOERULVXPDXRKG4';
const SELLER_B = 'GB6IHEZ4QNOHJZRYRFLOC45P4SK3KKL6KNPI5WEG6FNVSZ2K5FS2MNY7';
Expand Down Expand Up @@ -581,18 +581,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage
});

describe('public pay DTO (issue #503)', () => {
const PII_KEYS = [
'customerName',
'customerEmail',
'sellerName',
'sellerEmail',
'payerPublicKey',
'payerName',
'payerEmail',
'description',
'metadata',
'userId',
];
const PII_KEYS = [...SELLER_ONLY_INVOICE_FIELDS];

it('returns the public shape to an anonymous caller — no client or identity fields', async () => {
const created = await createInvoice({
Expand Down Expand Up @@ -682,7 +671,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage
it('keeps the verify response on the public shape', async () => {
const created = await createInvoice({ customerEmail: 'pay@client.example' });
transaction = {
transaction: { memo: created.memo },
transaction: { memo: created.memo, created_at: new Date().toISOString() },
operations: [
{
type: 'payment',
Expand Down
25 changes: 25 additions & 0 deletions backend/tests/log-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
logReference,
requiredLogFields,
} from '../src/observability/log-events';
import { SELLER_ONLY_INVOICE_FIELDS } from '../../shared/invoice.ts';

describe('structured log event contract', () => {
it('keeps the invoice loop taxonomy stable', () => {
Expand Down Expand Up @@ -80,4 +81,28 @@ describe('structured log event contract', () => {
}
}
});

it('drops every seller-only identity key even when stuffed into fields (#559)', () => {
const stuffed: Record<string, unknown> = {
invoiceRef: 'inv-a1',
txRef: 'tx-b2',
errorCode: 'MEMO_MISMATCH',
network: 'TESTNET',
durationMs: 17,
};
for (const key of SELLER_ONLY_INVOICE_FIELDS) {
stuffed[key] = `leak-${key}`;
}
const record = buildLogRecord(
'warn',
'payment.verify.rejected',
{ requestId: 'req-559', service: 'api', environment: 'test' },
stuffed,
new Date('2026-09-13T10:00:00.000Z')
);
for (const key of SELLER_ONLY_INVOICE_FIELDS) {
assert.equal(record[key], undefined, `log record leaked ${key}`);
}
});

});
130 changes: 130 additions & 0 deletions backend/tests/public-invoice-whitelist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
PUBLIC_INVOICE_FIELDS,
SELLER_ONLY_INVOICE_FIELDS,
isPublicInvoiceField,
isSellerOnlyInvoiceField,
omitSellerOnlyFields,
toPublicInvoiceDto,
} from '../../shared/invoice.ts';

/**
* Issue #559 — the public allowlist and seller-only denylist are the contract.
* Each key is named here so removing it from the shared constant fails this file.
*/
describe('public invoice whitelist (issue #559)', () => {
const EXPECTED_PUBLIC_FIELDS = [
'id',
'sellerPublicKey',
'amount',
'assetCode',
'assetIssuer',
'memo',
'status',
'paymentTxHash',
'latePaymentWarningCode',
'settlementContext',
'priorStatus',
'createdAt',
'paidAt',
'cancelledAt',
'settledAt',
'expiresAt',
] as const;

const EXPECTED_SELLER_ONLY_FIELDS = [
'customerName',
'customerEmail',
'sellerName',
'sellerEmail',
'payerPublicKey',
'payerName',
'payerEmail',
'description',
'metadata',
'userId',
] as const;

it('PUBLIC_INVOICE_FIELDS lists every expected public pay key', () => {
assert.deepEqual([...PUBLIC_INVOICE_FIELDS], [...EXPECTED_PUBLIC_FIELDS]);
});

for (const key of EXPECTED_PUBLIC_FIELDS) {
it(`whitelist includes public key: ${key}`, () => {
assert.equal(isPublicInvoiceField(key), true, `missing public key ${key}`);
assert.ok(
(PUBLIC_INVOICE_FIELDS as readonly string[]).includes(key),
`PUBLIC_INVOICE_FIELDS dropped ${key}`
);
});
}

it('SELLER_ONLY_INVOICE_FIELDS lists every forbidden identity key', () => {
assert.deepEqual([...SELLER_ONLY_INVOICE_FIELDS], [...EXPECTED_SELLER_ONLY_FIELDS]);
});

for (const key of EXPECTED_SELLER_ONLY_FIELDS) {
it(`denylist includes seller-only key: ${key}`, () => {
assert.equal(isSellerOnlyInvoiceField(key), true, `missing seller-only key ${key}`);
assert.equal(
isPublicInvoiceField(key),
false,
`seller-only key ${key} must not be public`
);
});
}

it('toPublicInvoiceDto drops every seller-only key even when present on input', () => {
const publicDto = toPublicInvoiceDto({
id: 'inv_1',
sellerPublicKey: 'G'.padEnd(56, 'A'),
amount: 10,
assetCode: 'XLM',
memo: 'INV-TEST',
status: 'PENDING',
createdAt: '2026-09-01T00:00:00.000Z',
expiresAt: '2026-09-08T00:00:00.000Z',
customerName: 'Client Co',
customerEmail: 'pay@client.example',
sellerName: 'Studio',
sellerEmail: 'studio@example.com',
payerName: 'Percy',
payerEmail: 'percy@payer.example',
payerPublicKey: 'G'.padEnd(56, 'P'),
description: 'secret work',
metadata: { note: 'nope' },
userId: 'user_1',
} as any);

for (const key of EXPECTED_SELLER_ONLY_FIELDS) {
assert.equal(
(publicDto as Record<string, unknown>)[key],
undefined,
`public DTO leaked ${key}`
);
}
for (const key of Object.keys(publicDto)) {
assert.ok(
(PUBLIC_INVOICE_FIELDS as readonly string[]).includes(key),
`public DTO carries non-whitelisted key ${key}`
);
}
});

it('omitSellerOnlyFields strips denylisted keys from an arbitrary payload', () => {
const cleaned = omitSellerOnlyFields({
id: 'inv_1',
amount: 5,
customerEmail: 'leak@example.com',
payerName: 'Leak',
memo: 'INV-KEEP',
userId: 'u1',
});
assert.equal('customerEmail' in cleaned, false);
assert.equal('payerName' in cleaned, false);
assert.equal('userId' in cleaned, false);
assert.equal(cleaned.memo, 'INV-KEEP');
assert.equal(cleaned.amount, 5);
});
});
6 changes: 4 additions & 2 deletions docs/QUITTANCE_PROOF_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ Given identical inputs and clock injection, the serialization output must be byt
Settlement records link exactly one payer account (`G...`) to one recipient seller account (`G...`). Multi-party or list structures for payers are rejected.

### Invariant 6: Anti-Leak / Zero-PII
Proof documents and rendered exports must never contain:
Proof documents, mailto bodies, payment-event payloads, and structured logs must never contain:
- Stellar secret keys (matching regex `S[A-Z2-7]{55}`).
- Personally Identifiable Information (such as payer email addresses or real names).
- Personally Identifiable Information (client email, payer email, payer name, seller profile fields).

Issue #559: `PUBLIC_INVOICE_FIELDS` in `shared/invoice.ts` is the single allowlist for anonymous pay, payment-info, and verify responses. `SELLER_ONLY_INVOICE_FIELDS` names the identity keys that must stay off those surfaces, proof HTML, mailto bodies, and redacted event/log payloads. Seller workspace reads gated by the invoice Freighter wallet remain the only path that returns client contact.

### Invariant 7: No Inferred Ownership
Unsettled invoices or invoices lacking explicit payer keys must have `payer: null`. The system must never guess, infer, or populate default payer addresses.
Expand Down
8 changes: 0 additions & 8 deletions frontend/components/PayAmountBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,6 @@ export default function PayAmountBlock({ invoice }: { invoice: PayPageInvoice })
</div>
</div>
</div>
{invoice.description && <p className="pay-detail-panel"><span>Payment for</span>{invoice.description}</p>}
{(invoice.sellerName || invoice.sellerEmail) && (
<div className="pay-detail-panel">
<span>Seller</span>
{invoice.sellerName && <p>{invoice.sellerName}</p>}
{invoice.sellerEmail && <p>{invoice.sellerEmail}</p>}
</div>
)}
<PaymentStatus status={invoice.status} txHash={invoice.paymentTxHash ?? undefined} compact />
{invoice.status === 'PENDING' && invoice.expiresAt && (
<p className="pay-detail-panel"><span>Expires in</span>{getTimeRemaining(invoice.expiresAt)}</p>
Expand Down
23 changes: 19 additions & 4 deletions frontend/components/PayProofPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,26 @@ export default function PayProofPanel({ invoice, onDownload, onEmail }: PayProof
<section aria-label="Payment proof" className="space-y-4">
<PaymentReceipt invoice={invoice} />
<div className="card flex gap-2 print:hidden">
<button type="button" onClick={onDownload} className="btn btn-primary flex-1 flex items-center justify-center gap-2">
<FileText className="w-4 h-4" /> Download Proof
<button
type="button"
onClick={onDownload}
className="btn btn-primary flex-1 flex items-center justify-center gap-2"
>
<FileText className="w-4 h-4" aria-hidden="true" /> Download Proof
</button>
<button type="button" onClick={onEmail} disabled={!invoice.customerEmail} className="btn btn-outline px-4" aria-label="Email proof">
<Mail className="w-4 h-4" />
{/*
Public pay DTOs never carry client email (#559). Emailing proof is a
seller-workspace action after the Freighter wallet unlocks contact.
*/}
<button
type="button"
onClick={onEmail}
disabled
className="btn btn-outline px-4"
aria-label="Email proof unavailable on the public pay page"
title="Email proof from the seller workspace"
>
<Mail className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</section>
Expand Down
58 changes: 3 additions & 55 deletions frontend/components/PaymentReceipt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,6 @@ PAYMENT DETAILS
───────────────────────────────────────

Amount Paid: ${canonicalAmount(invoice.amount) ?? formatAmount(invoice.amount, 7)} ${invoice.assetCode}
${invoice.description ? `Description: ${invoice.description}` : ''}
${invoice.customerName ? `Customer: ${invoice.customerName}` : ''}
${invoice.customerEmail ? `Email: ${invoice.customerEmail}` : ''}

───────────────────────────────────────
TRANSACTION DETAILS
Expand All @@ -79,7 +76,7 @@ Transaction Hash:
${invoice.paymentTxHash}

From (Payer):
${invoice.payerPublicKey || 'N/A'}
On-chain counterparty (see transaction)

To (Recipient):
${invoice.sellerPublicKey}
Expand Down Expand Up @@ -119,7 +116,7 @@ Stellar Blockchain Payment System
invoice.paymentTxHash,
resolveExplorerNetwork(invoice)
);
const canEmail = Boolean(invoice.customerEmail);
const canEmail = canSendProofEmail(invoice as any);
const proofRecipient = getProofMailtoRecipient(invoice as any);
const emailReasonId = 'receipt-email-reason';

Expand Down Expand Up @@ -177,12 +174,6 @@ Stellar Blockchain Payment System
</div>
</div>

{invoice.description && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Payment For</p>
<p className="text-gray-800 font-medium">{invoice.description}</p>
</div>
)}

<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 rounded-lg p-4">
Expand All @@ -198,45 +189,8 @@ Stellar Blockchain Payment System
</div>
</div>

{(invoice.sellerName || invoice.sellerEmail) && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2">
<p className="text-sm text-blue-700 font-semibold">Seller Information</p>
{invoice.sellerName && (
<div>
<p className="text-xs text-blue-700">Name</p>
<p className="text-sm text-blue-800">{invoice.sellerName}</p>
</div>
)}
{invoice.sellerEmail && (
<div>
<p className="text-xs text-blue-700">Email</p>
<p className="text-sm text-blue-800">{invoice.sellerEmail}</p>
</div>
)}
</div>
)}

{invoice.sellerName && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center">
<p className="text-sm text-blue-700 font-semibold">Paid to</p>
<p className="text-lg font-bold text-blue-800">{invoice.sellerName}</p>
{invoice.sellerEmail && (
<p className="text-sm text-blue-700">{invoice.sellerEmail}</p>
)}
</div>
)}

{(invoice.payerName || invoice.payerEmail) && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
<p className="text-sm text-green-700 font-semibold">Paid by</p>
{invoice.payerName && (
<p className="text-lg font-bold text-green-800">{invoice.payerName}</p>
)}
{invoice.payerEmail && (
<p className="text-sm text-green-700">{invoice.payerEmail}</p>
)}
</div>
)}
</div>

<div className="border-t pt-6 mb-6">
Expand All @@ -248,12 +202,6 @@ Stellar Blockchain Payment System
<p className="text-xs font-mono text-gray-900 break-all">{invoice.paymentTxHash}</p>
</div>

{invoice.payerPublicKey && (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">From (Payer Address)</p>
<p className="text-xs font-mono text-gray-900 break-all">{invoice.payerPublicKey}</p>
</div>
)}

<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">To (Recipient Address)</p>
Expand Down Expand Up @@ -293,7 +241,7 @@ Stellar Blockchain Payment System
</button>
{!canEmail && (
<p id={emailReasonId} className="field-hint text-center">
Unavailable: this invoice has no client email.
Unavailable: no client email on public invoice. Emailing proof is available from the seller workspace after connecting the invoice wallet.
</p>
)}

Expand Down
Loading
Loading