Skip to content

security: the remaining 49 audit findings (batches 4-28, continues #277) - #278

Merged
ralyodio merged 31 commits into
masterfrom
audit-vuln-fixes
Aug 19, 2026
Merged

ralyodio merged 31 commits into
masterfrom
audit-vuln-fixes

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

#277 was squash-merged after my first three batches, so master currently has 15 findings fixed. This is everything since — 49 more findings, plus the secrets work.

Master's squash commit (f4947b6) is byte-identical to this branch's batch-3 state, so origin/master has been merged in and the five conflicts resolved to the branch, which is a strict superset. Verified: git diff origin/master 13444a2 over the conflicted files is empty.

What's in here that isn't on master

Structural fixes. The audit's argument is that ten patterns recur, so patching instances leaves the shape. Six shared helpers now carry the invariants:

Helper Closes
auth/tenant-scope.ts the §2.7 cross-tenant class
escrow/access.ts CP-021, NEW-15
p2p/platform-ownership.ts CP-003, CP-023, CP-015
auth/merchant.ts::keyMayActOnBusiness C-02
email/escape.ts NEW-24, G-1.2-09, F5-L4-02
stripe/metadata.ts CP-001

Plus x402/networks.ts and currency-consistency.test.ts, which make divergence fail the suite instead of needing eight lists hand-synced.

Verified against production, not just read:

  • webhook_logs had zero rows. The delivery audit trail had never recorded a single attempt — three separate bugs each blocked the insert.
  • R3-DIN-03 proved with a probe UPDATE that rolls itself back: rejected before the migration, accepted after. This corrected the register: NOT VALID does not stop a CHECK enforcing, it only skips pre-existing rows.
  • payments.payment_address_id and businesses.tier do not exist — so every card payment 500'd, and one rail charged every business the free-tier rate.
  • merchants.email_verified does not exist, which also meant the OAuth ID token carried no email or name for any user. Not in the audit.
  • CP-019 was live: REPUTATION_SIGNING_SECRET was unset in prod, so every reputation credential was signed with cpr-dev-secret — a constant in this public repo. Now set in the logicsrc vault.
  • ENCRYPTION_KEY verified strong, so the F9-01 guard is safe to deploy. That was the change with the most potential to break production.

Six migrations applied to prod, each verified after. One needed a second migration: the audit_log append-only grant silently didn't take, because Supabase's default privileges had already granted service_role everything and a REVOKE must name the role. Caught by reading role_table_grants, then proved with a probe running as service_role: insert=t update=f delete=f.

Tests that asserted the bug. Seven existing tests encoded the vulnerable behaviour and are inverted, each with reasoning inline — including "should allow if settings fail to load (fail open)", "marks invoice as paid with 1% tolerance", and two named "should return email_verified as false" that asserted true. Two integrations (F4-01, NEW-L5-01) failed 100% of the time with green tests, because the tests asserted the same wrong thing the code did.

Behaviour changes

  • expected.payTo required on x402 v1 verify; x402 routes require payments:create.
  • OAuth email_verified is now false everywhere.
  • JWKS kid changes to coinpay-oidc-hs256 (advisory under HS256, but client-visible).
  • New issuer registrations start inactive.
  • WalletClient.send() throws instead of silently signing the wrong thing.
  • The partners page is now empty — NEW-19's fix defaults consent to false; 28 businesses were listed without ever being asked. Backfill if consent exists.

Still needs a decision

18 reputation issuers are active with 17 cleartext keys, including one claiming Coinpayportal.com as its domain. doppler.env/doppler.json are untracked here but remain in git history. ESC-NEW-01 has no disputed escrows today, so nothing is frozen, but an arbiter flow is a product call.

Tests

Full suite green after the merge: 314 files, 4409 tests, tsc --noEmit clean.

packages/extension has two pre-existing red tests in api.test.ts that this branch does not touch, and that package is not covered by the root suite.

🤖 Generated with Claude Code

ralyodio and others added 29 commits August 19, 2026 07:51
From the 2026-08-19 security audit (docs/findings/). Adds TODO-vulns.md as the
remediation ledger for all 338 findings, and fixes the three Criticals plus the
five High findings that share their code paths.

F-1.3-13 — subscription activation read merchant_id and plan_id out of a
caller-supplied metadata blob and never compared the amount paid against the
plan price, so a $0.01 payment activated the $490/yr plan on any merchant UUID
the payer named. Activation now credits the payment row's own merchant_id,
validates the plan against SUBSCRIPTION_PRICES, and requires the USD amount to
cover it.

NEW-04 — payee resolution matched merchants by email with no scoping and then
upserted merchant_wallets, overwriting a victim's payout address. The email
fallback now refuses non-platform accounts, and persistPayout refuses to write
a payout destination for any account the platform did not provision.

F-1.3-01 / CP-005 — the Lightning x402 proof was checked against itself:
sha256(preimage) == paymentHash, both from the payer, and settle then answered
settled:true having verified nothing. Both routes now require a matching
incoming, settled ln_payments row for the calling business that covers the
price.

REC-C-01 — scheme and network are independent attacker-set fields and dispatch
fired on either, so a bolt12/ethereum proof ran the Lightning verifier while
the response claimed amountAuthenticated. Dispatch is on network alone, with
the scheme table shared between verify and settle so they cannot drift.

F-1.3-02, F-1.3-03, R3-X1 — price binding now requires expected.payTo and pins
expected.asset, and the Stripe verifier compares the amount Stripe reports
rather than the payer's self-declared one.

Integrator-visible: expected.payTo is now required on v1 verify, matching what
v2 already required.

26 new regression tests; full suite green (303 files, 4309 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second remediation batch against docs/findings/.

F5-L4-01 — cleanOrphanedWallets() deleted every wallets row with no
transactions on every --execute run. That table is the self-custodial
web-wallet store with no merchant_id, so nothing in it can be attributed to a
spam signup, and "zero transactions" describes any wallet not yet funded. Now
behind --prune-empty-wallets with a 90-day floor, and the header's false safety
claim is corrected.

W-07 — GET /api/lightning/offers had no auth, optional business_id and an
uncapped limit, so one anonymous request dumped every merchant's Lightning
offers and revenue. Now authenticated, business-scoped and capped at 100.

CP-002 — issuer self-registration set active:true with no identity or domain
check and stored the API key in cleartext. Registers inactive now, and persists
only the key hash.

G-R-07 — /api/oauth/userinfo asserted email_verified:true unconditionally.
merchants has no email_verified column in production, which also meant
/api/oauth/token's select referencing it errored on every call, so the OAuth ID
token has been carrying neither email nor name for any user. All three sites now
select real columns only and report false while no verification flow exists.

E-03 — the recurring card rail carried a comment claiming application_fee was
applied and never set the field, so the platform collected nothing on every
subscription. Sets subscription_data.application_fee_percent from the business
tier, and spreads caller metadata first so platform keys always win.

F-1.1-08 — two invoice paths compared balance >= expected * 0.99 instead of
calling the shared isSufficientPayment. Both now use the helper.

Two existing tests asserted the vulnerable behaviour and have been inverted:
"marks invoice as paid with 1% tolerance", and two oauth tests named "should
return email_verified as false" that asserted true.

Full suite green (303 files, 4315 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third remediation batch against docs/findings/ — the report's section 2.5 class,
where public documentation states a security property as implemented in direct
contradiction with the code. That is the place a partner or auditor looks to
decide whether to trust the platform, so a false claim there is worse than the
missing control it hides.

DOC-01 — layout.tsx titled the whole product "Non-Custodial Crypto Payment
Gateway" in the page title, OpenGraph and Twitter card. The browser wallet is
non-custodial; the gateway is not, since the platform holds encrypted keys for
payment addresses, escrow and collection between receipt and forwarding.

F7-01 — docs/SECURITY_KEYS.md checked off audit logging and memory scrubbing,
and docs/SECURITY.md described an audit trail in the present tense. Verified:
no audit table, no append-only log, no key-access record anywhere in src/, and
clearSensitiveString has no callers. Both documents now say so. Building the
audit trail (AUD-01) remains open — this corrects the claim, not the gap.

GAP-02 — strix_runs/ and three strix_*.log files were tracked in the public
repo. .gitignore listed them but was added after they were committed. Untracked;
purging them from history remains a decision.

F5-L4-03 — scripts/test-spam-detection.ts carried real merchant names, personal
Gmail addresses and live corporate domains as fixtures. All now synthetic; each
case exercises a shape, not a person.

Also fixes a stale expectation in that script that had been failing since the
dotted_gmail weight was deliberately reduced from 35 to 20 (confirmed against
the original fixture set, so unrelated to the PII swap). 21/21 now.

Full suite green (303 files, 4315 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lpers

Fourth remediation batch against docs/findings/. Opens the audit's section 2.7
workstream: eighteen findings that all share one shape — authenticate the
caller, then trust a tenant id the caller supplied.

Patching each route individually leaves the shape in place for the nineteenth,
so this adds two shared helpers and moves routes onto them:

- src/lib/auth/tenant-scope.ts — resolveBusinessScope() always authorizes a
  requested business id rather than accepting it, and infers one only when the
  caller has exactly one business. The code it replaces was worse than a guess:
  `businessId || authResult` passed a merchant id where a business id was
  expected, so the lookup matched nothing or matched an unrelated row.
- src/lib/escrow/access.ts — callerOwnsEscrow(), shared by escrow/[id] and
  escrow/[id]/events, which are clones of each other.

Closed:

B-01, C-01, NEW-13 — the Stripe api-keys and webhooks routes let a query-string
or body business_id override the authenticated user. POST /stripe/webhooks
creates an endpoint on the named business's Connect account and returns its
signing secret, so this handed over a live feed of another merchant's Stripe
events pointed at a URL of the attacker's choosing.

CP-010, NEW-14, G-1.2-13 — usage/rates (GET/POST/DELETE) and usage/history had
no ownership check, while their siblings credits and deduct in the same
directory did.

CP-014 — reputation/receipts was an unauthenticated select('*'). The endpoint
stays public, since a trust graph is only useful if a counterparty can check it,
but it now returns a narrow projection: no amount, escrow_tx, buyer_did,
platform_did or signatures, capped at 200 rows.

CP-021, NEW-15 — escrow/[id] and its events sibling authenticated the caller and
then fetched by UUID with no ownership check, so any merchant's API key read any
escrow. Both answer 404 rather than 403 so they are not existence oracles.

Full suite green (304 files, 4327 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifth remediation batch against docs/findings/. Two distinct mistakes, both
about a credential being trusted beyond what it was issued for.

A capability check that defaulted to read. C-03 / G-1.2-01:
payment-methods/manual called verifyBusinessAccess with no capability argument,
and the default is business.read, which every team member has including
readonly. The write was therefore gated at read level, so a read-only member
could rewrite the Venmo, Cash App or Zelle handle customers are told to pay.
That is a funds destination and the project's invariant is that moving funds is
owner-only, so the write now requires funds.move. The sibling import route wrote
the same field at settings.manage; raised to match.

API keys acting with account authority. L7A-01 and SUB-01: did/claim,
did/delegate and subscriptions/status DELETE checked no scopes, and API_SCOPES
has no DID or billing scope to check even if they had. A business key resolves
to the owning merchant, so any key — including a read-only wallet:read one —
could rebind the merchant's DID, mint DelegatedAuthority credentials carrying
wallet:transfer and escrow:settle, or cancel the Professional subscription. A
scoped key must not grant authority stronger than itself; all three now require
session auth. The existing isMerchantAuth ternaries had identical branches, so
the type guard was present and doing nothing.

Adds src/lib/p2p/platform-ownership.ts, shared by CP-003 (did/override) and
CP-023 (reputation/merchant-wallet). Both acted on any merchant_id in the body
on nothing but a valid issuer key, rebinding a merchant's identity or writing
their payout wallet so a charge in an unconfigured currency settled to the
caller. Both now require the merchant to be platform-provisioned and provisioned
by that specific platform — the same guard NEW-04 needed, which is why it is
shared rather than copied a third time.

Full suite green (304 files, 4327 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… paid payments

Sixth remediation batch against docs/findings/. Each finding here was checked
against the live database rather than the migrations directory, which is known
to drift from production.

L8-02 — payments.payment_address_id does not exist, yet three card routes
inserted it and src/lib/supabase/types.ts still declared it. PostgREST rejects
an insert naming an unknown column, so every card payment failed with a 500
before reaching Stripe. Removed from the routes and from the type; leaving it on
the type is what kept the routes writing it.

L4-NEW-02 — the address-generation failure path wrote status 'failed', which
payments_status_check does not permit, and never checked the result. The row was
left pending forever, on no dashboard's problem list. Now writes 'expired', the
terminal state the schema actually has, records failure_reason in metadata, and
logs if that write fails too.

NEW-L5-2 — blockchainSchema accepts USDT, USDC and USDC_BASE and the code
generates addresses for all three, but payments_blockchain_check permitted none
of them, so payment creation failed outright for those currencies. Widened by
migration 20260819120000, applied to production and verified. Prod holds zero
rows for those three values while every other supported chain has some.

BL-01 — every checker in monitor-balance.ts answered { balance: 0 } on an RPC
error, exactly as it does for an address that has genuinely received nothing,
and processPayment expired the payment on that. A transient provider outage
during the expiry window therefore marked fully-paid payments expired, with the
customer's funds sitting at the address and nothing retrying. BalanceResult now
carries an error field, all 27 failure paths set it, and expiry refuses to fire
on an unreadable balance. The two zeros that are real — an unactivated XRP
account and an unused ADA address — are deliberately left untagged.

Also confirms two findings are already closed: H-R-01 (checkDOGEBalance is fully
implemented, not the stub described) and CP-P5 (isBusinessPaidTier resolves tier
through the merchant subscription and never reads businesses.tier).

Full suite green (304 files, 4329 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seventh remediation batch against docs/findings/.

N-01 — screenCheckout was called from exactly one of the seven code paths that
create a real card charge. The other six now screen before the Stripe session
exists, which is the last point a payment can be stopped without Stripe ever
seeing it, and each forces 3-D Secure on a verify decision:
payments/widget/create (a public, secret-free embed and the most exposed of the
seven), payments/create's card branch, payments/create-for-merchant,
p2p/request's Stripe branch, and lib/payments/invoice-stripe.

REC-D-05 — the p2p Stripe branch is one of those six. Its caller is a platform
issuer key rather than an authenticated merchant, which made it the least
supervised card path in the codebase.

FR-01 — screening failed open. Any error anywhere returned decision 'allow', and
checkBlocklist returned the same null for a database error as for "no entry
matches", so an unreachable blocklist waved through every entry on it — entries
usually added after a real chargeback. The reasoning behind failing open was
sound, since a broken fraud check must not become a payment outage, but 'allow'
was the wrong conclusion from it. Both paths now degrade to 'verify', which
keeps payments flowing while forcing 3-D Secure and moving liability for a
stolen card back to the issuer.

CP-P5 — corrects my own earlier assessment. I marked this already-fixed after
checking isBusinessPaidTier, which does resolve tier through the merchant
subscription. But payments/create has a separate inline fee calculation that
selected businesses.tier, the column that does not exist, so PostgREST rejected
the query, the row came back null, and every business was charged the 1%
free-tier rate on that rail regardless of plan. The test covering it expressed
the pro tier as tier: 'pro' on the businesses row, so it passed against a column
that was never read.

Full suite green (305 files, 4332 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eighth remediation batch against docs/findings/.

proposal-templates.ts had an escapeHtml and used it on every interpolated
field. Three siblings did not have it and interpolated merchant-supplied strings
raw, so the fix is a shared src/lib/email/escape.ts rather than a fourth copy.

NEW-24 — invoice-templates.ts interpolated business name, invoice number, notes,
client name and tx hash into the HTML body with no escaping. Subject lines are
deliberately left unescaped: they are plain text, and escaping them would show
the recipient a literal &amp;.

G-1.2-09 — the team invitation email interpolated scopeLabel, which is the
business or organization name, into a message delivered to an arbitrary address
the same caller supplies. Creating a merchant account is free and unverified, so
that was a way to send attacker-authored HTML from the platform's own sending
domain, to anyone, with no rate limit.

F5-L4-02 — scripts/daily-stats-email.ts put signup name and email into the
internal daily report unescaped, and its recipients are the operations team. The
helper is duplicated there deliberately: the script runs standalone under tsx
and does not resolve the app's path aliases.

escapeUrl is separate from escapeHtml because escaping characters does not help
with a javascript: or data: href — those survive HTML escaping intact and still
execute when clicked, so anything not clearly http(s) or mailto is dropped.

Full suite green (306 files, 4342 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ninth remediation batch against docs/findings/.

select count(*) from webhook_logs against production returned 0. Not a few gaps
— the table has never held a row. The webhook delivery audit trail has never
recorded a single attempt, and nothing surfaced that because delivery itself is
unaffected by the logging failing.

Three separate faults, all of which had to be fixed before one insert could
succeed:

V-01 — url and payload are NOT NULL with no default, and the application writes
webhook_url instead, so every insert failed on a not-null violation. The two
column sets are duplicates from different eras of the schema; the code now
writes both so either name reads correctly.

L8-01 — payment_id had its NOT NULL dropped so escrow events could be logged,
but the foreign key to payments(id) was never dropped alongside it.

REC-D-02 — the escrow path passed an escrow id as payment_id, with a comment
saying it was reusing the column, which violated that foreign key.

Migration 20260819130000 relaxes the legacy pair and gives escrow its own
escrow_id column with its own FK rather than borrowing one, so the payments FK
stays meaningful. A CHECK enforces one subject per row so the column-borrowing
cannot quietly return. Applied to production and verified with a probe insert,
which succeeded and was then deleted.

logWebhookAttempt now logs when it cannot record, instead of returning an error
every caller discarded. That silence is why an empty audit table went unnoticed.

Full suite green (306 files, 4342 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tenth remediation batch against docs/findings/. This is the audit's section 2.1
pattern — a strong guard that most of its own consumers bypass.

F9-01 — requireEncryptionKey was correct and protected 4 of 13 real encryption
call sites. The other nine, including the entire custody hot path, hand-rolled

  const k = process.env.ENCRYPTION_KEY;
  if (!k) return { success: false, error: 'Encryption key not configured' };

which establishes that a key is present and nothing about whether it is usable.
An all-zero or deadbeef-repeated key passed all nine.

Migrated: hd-wallet, system-wallet (two sites), secure-forwarding,
escrow/service, business-collection, business/service, webhooks/secret, and both
Stripe webhook routes.

Two things kept the guard from spreading, and both are fixed. It throws while
those call sites return result objects, so tryRequireEncryptionKey now gives
them a form that fits. And the repository's own fixtures used values the guard
rejects — the one in secure-forwarding.test.ts was literally a KNOWN_WEAK_KEYS
entry and escrow/service.test.ts used a 36-character non-hex string — so
adopting the guard anywhere would have turned the suite red. Both replaced with
a real 32-byte hex key.

keyHashPepper() in scoped-keys.ts is deliberately left reading the raw value and
documented as such: it is a pepper for an HMAC, not an encryption key, and its
value is baked into every api_key_hash already stored, so refusing a weak value
at read time would lock out every integrator at once. The right fix there is a
rotation with re-hashing.

Full suite green (307 files, 4353 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleventh remediation batch against docs/findings/.

F6-01 — COINPAY_REF is the only user-facing mitigation against an auto-upgrade
that pulls a mutable branch every five minutes, and it silently did nothing. Two
re-invocation paths dropped it: the auto-upgrade poll ran a bare
`curl … | sh -s -- update`, and `coinpay update` in the wrapper did the same.
Both defaulted COINPAY_REF back to master, so a host pinned to a tag tracked
master anyway with no indication. Both now bake in the ref they were installed
from, verified by running write_self_upgrade_helper in isolation and reading the
generated script.

COINPAY_SHA256 is deliberately not carried across: a checksum pins one specific
archive, so reusing it for a later version guarantees a mismatch and would break
every upgrade.

W-01 — partially addressed. An unpinned install now warns on screen that it is
following a mutable branch and prints the command to pin a release. The default
ref itself is unchanged and needs a release-process decision: the installer
compares against packages/sdk/package.json at the ref, so defaulting to a tag
whose SDK version trails master would leave the upgrader idle or looping.

Also fixes a set -e hazard introduced and caught while writing this: a
`[ test ] && VAR=0` line would have aborted the installer for exactly the users
who did pin a ref.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelfth remediation batch against docs/findings/. The rate-limit infrastructure
already existed and was well tuned; these endpoints simply never called it.

NEW-06 — none of the four WebAuthn routes had any limit. login-options answers
differently for a registered and an unregistered email, so unlimited it is a
free user-enumeration oracle over the whole merchant base.

WW-02 — the web-wallet derive route, whose five sibling mutating routes are all
limited. Each call performs HD derivation and writes a row.

REC-C-04 — x402 verify and settle. Ledger bloat, and on the Stripe rail each
call spends our own Stripe API quota.

REC-C-05 — GET /api/swap/quote is anonymous and each quote costs two calls to
the ChangeNOW third-party API, so it was a lever for exhausting our own quota
with no credential at all.

L7A-03 — reputation/attest needed more than a limit. It was completely
unauthenticated and took attester_did from the body. submitAttestation does
check the attester is a party to the receipt, so this was never unbounded
forgery, but knowing a receipt id and the two DIDs on it was enough to attest as
either party — and reputation/receipts used to hand out exactly that (CP-014).
It now requires authentication and proves the caller controls the DID through
the existing merchant_dids link, rather than introducing a new signature scheme.

Also fixes something not in the register: checkRateLimit and
checkRateLimitAsync returned allowed: true in silence for an unknown category,
so a typo in a category name disabled the limit entirely with nothing to notice.
It still allows, since a config mistake must not take payments down, but logs.

Full suite green (307 files, 4353 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirteenth remediation batch against docs/findings/.

NEW-01 — createEscrow resolves a wallet address to the owning merchant's email,
which is convenient because it lets an escrow notify a counterparty identified
only by address, and then returned that email to the caller. Anyone able to
create escrows could therefore map any on-chain address to the merchant who owns
it, across the whole merchant base — which is precisely the input NEW-04 needed.
Auto-resolved emails are now redacted from the response; the row keeps them so
notification still works.

L5-01 — generateEscrowAddress read next_index and wrote next_index + 1 with no
condition on what it had read, and keyed on cryptocurrency while the payment
flow keys on the derivation family (ETH/POL/BNB/USDT/USDC share one). The two
counters advanced independently over the same key space, so the collision was by
construction rather than merely under contention. Now uses acquireFamilyIndex,
the compare-and-swap helper the payment flow already used.

R3-DIN-03 — escrow-monitor writes status 'settle_failed', which
escrows_status_check did not permit, so the UPDATE was rejected and the escrow
was left 'released' with nothing signalling the failure. Verified both ways with
a probe UPDATE that rolls itself back: rejected before the migration, accepted
after. The write now checks its result.

Worth recording: the register attributes this to the constraint being NOT VALID.
That is wrong. NOT VALID only skips checking rows that already existed when the
constraint was added; it does not exempt new INSERTs or UPDATEs. The
pre-existing settle_failed rows are what made it look unenforced.

ESC-NEW-01 is reclassified as a decision rather than a fix: both dispute columns
exist with no writer as reported, but production holds zero escrows in disputed
status, so no funds are frozen today. An arbiter flow needs product decisions
about who arbitrates and on what evidence.

Full suite green (307 files, 4353 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourteenth remediation batch against docs/findings/. Both findings share a
failure mode worth naming: the test asserted the same wrong thing the code did,
so a path that could never work in production had full, passing coverage.

NEW-L5-01 — CoinPayClient.request takes (endpoint, options). Every call in the
SDK's card-payments.js passed three arguments in the shape
request(method, path, body), so endpoint received 'POST', the URL became
baseUrl + 'POST', and options received the path as a string. All five calls
failed against a real client, making release and refund of a card escrow
unreachable through the SDK. The tests mocked request and asserted the
three-argument shape, so a mock that accepts anything certified a call that
could never work.

F4-01 — StatusMapper::MAP had mark_paid entries only for payment.completed and
payment.overpaid. The backend emits neither; it emits payment.confirmed,
payment.forwarded, payment.failed and payment.expired. Every real webhook fell
through the ?? 'ignore' default, so automated invoice crediting never fired for
any transaction — and ignore is also the correct answer for events we genuinely
skip, so nothing looked wrong. All twelve existing PHP tests covered event types
the backend never sends.

Maps the real events, keeps the legacy keys since a replayed historical delivery
may carry them, and logs an unmapped event rather than letting it disappear.
Adds a PHP test that walks the full emitted-event list so a new backend event
type fails the suite instead of being silently ignored.

Full suite green (307 files, 4353 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sclosure

Fifteenth remediation batch against docs/findings/.

L-01 — the web-wallet spend controls failed open twice. A whitelist and a daily
spend limit are the only two things between a compromised session and the
balance, and both were skipped when a query failed: one path returned allowed
true with the comment "fail open for now", the other wrapped the limit check in
if (!error && todayTxs) so an error simply skipped it. getSettings creates a
default row when none exists, so reaching the first path means a real database
failure rather than "no settings configured". Both now deny. A test named
"should allow if settings fail to load (fail open)" asserted the vulnerability
directly and is inverted.

NEW-WW34-01 — checkTransactionAllowed took a chain parameter and never used it,
so today's total summed raw amounts across every chain: 1 BTC and 1 DOGE counted
as 2 against one limit. That blocks trivially cheap sends after one expensive
one and lets a small-unit chain run far past the intended cap. The daily total is
now scoped to the chain being spent on.

WW-L4-01 — /api/wallets/lookup already withholds the email, but answered
found true/false unauthenticated and unlimited, which is enough to sweep the
merchant base address by address. It stays public, since a sender legitimately
checks before paying, with a rate limit that makes bulk enumeration impractical.

NEW-19 — /api/partners published the name, description and webhook host of every
active business with a webhook configured. Configuring a webhook is a technical
step, not consent to appear in a public directory. Migration 20260819150000 adds
public_directory_opt_in defaulting to false.

Note the visible side effect: the partners page is now empty. 28 businesses were
being published and none opted in, because there was nothing to opt into. If
consent exists out of band it needs an explicit backfill; I did not assume it on
anyone's behalf.

Full suite green (307 files, 4355 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y chain

Sixteenth remediation batch against docs/findings/.

WW-01 — verifySignedTxBinding decoded EVM transactions and compared the
recipient only. A signed transaction paying the right address a different amount
was accepted and recorded as the prepared one, and everything downstream hangs
off that row: the wallet's history, the daily spend limit, fee accounting and
notifications all described a transaction that did not happen. Now compares the
value for native transfers and the second ABI argument for ERC-20 transfers,
using per-chain decimals.

The refusal condition was also reason?.startsWith('recipient '), matching on
message text, so rewording the reason string would have silently stopped it
refusing anything. The decoder now sets an explicit mismatch flag.

WW-03 — BTC, BCH, SOL and USDC_SOL fell through to "no decoder for <chain>" and
broadcast entirely unchecked. Both libraries needed were already dependencies:

BTC decodes with bitcoinjs-lib and sums the outputs paying the prepared address,
requiring at least the prepared amount — "at least" rather than "exactly one
output", because a real spend nearly always has change back to the sender.

SOL and USDC_SOL confirm the prepared recipient appears among the transaction's
account keys, which catches a wholesale substitution of the payee. The amount is
not checked there: it lives in instruction data whose layout depends on the
program, and that is not something to guess at in a broadcast guard, so it is
reported as unverified rather than claimed.

BCH still has no decoder, because bitcoinjs-lib does not support its
SIGHASH_FORKID variant as the codebase notes elsewhere. Left recorded as
unverified with the reason stated in the code.

Full suite green (307 files, 4361 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…in CI

Seventeenth remediation batch against docs/findings/.

H-R-06 — baseChainFor mapped bare USDC to 'SOL', so a merchant configuring a
plain-USDC payout had to supply a Solana address to pass validation. Every other
module treats bare USDC as ERC-20 on Ethereum: monitor-balance checks it on the
Ethereum RPC, rates/fees prices it off the Ethereum gas path, and address
generation puts it on the Ethereum family. The validator and the money disagreed
about which chain the payout was on. Validation now follows the money.

H-R-08 — the currency list does diverge across modules, and hand-syncing eight
copies fixes today and drifts again next month. CRYPTO_NAMES is already the
declared operational source of truth, so currency-consistency.test.ts checks the
others against it: every symbol must have a declared address family, the
validator must accept that family and reject another so the check actually
discriminates, and the static-fee fallback table must not carry chains the
gateway no longer supports. Adding a symbol without recording a decision now
fails the suite.

That test immediately found something not in the register: USDC_BASE strips to
'BASE', which had no case in the validator, so it fell through to the null
default meaning "no validator, trust the address". USDC on Base is a supported
payment chain with a balance checker and a fee path, making it the one EVM
variant whose payout addresses were accepted with no format check at all — a
typo'd Base address would have been stored and paid to.

Full suite green (308 files, 4365 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rifiable

Eighteenth remediation batch against docs/findings/.

A-03 — encryptProviderSecrets is called when a Boltz swap is created and
stripProviderSecrets when history is listed, but decryptProviderSecrets had zero
callers. The refund and claim keys went in and never came back out, so when a
swap failed the HTLC funds could not be recovered through the product at all.
The refund path exists on Boltz's side; the key needed to walk it was locked in
our own database.

Adds GET /api/swap/boltz/:id/recovery: owner-authorized, rate limited on its own
budget, and it logs that key material was released without logging the keys. A
separate route rather than extra fields on the status endpoint, because handing
out key material is a distinct action that should be asked for explicitly and be
visible separately in logs.

W-06 — redeemScript and swapTree were declared on both Boltz response types and
never read, so the lockup address was taken on faith. A substituted address
would have been funded happily, and the refund key generated locally is
worthless against it because that key belongs to a different script — the
deposit would be unrecoverable, which is exactly what a refund key exists to
prevent. Both creation paths now derive the address from the redeem script
(P2WSH, P2SH-P2WSH and bare P2SH) and refuse to fund a mismatch.

Taproot swaps carry a swapTree whose derivation needs the full tweak; those
report "cannot check" and log rather than returning a false pass, keeping the
distinction between checked and uncheckable explicit.

Full suite green (309 files, 4370 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nineteenth remediation batch against docs/findings/.

W-05 — paying a Lightning Address asks the recipient's LNURL server for an
invoice and then paid whatever came back, undecoded. The amount actually paid
was therefore whatever that server chose to put in the invoice, not the amount
the sender entered and was shown, so a hostile or compromised LNURL endpoint
could return an invoice for any amount up to the wallet's balance and have it
paid silently. The minSendable/maxSendable check that was already present
validates the amount we request, which the server is free to ignore.

The amount now has to match. bolt11AmountMsat reads it from the invoice's
human-readable part, a well-defined grammar in BOLT-11 that needs no bech32
decoding, so this answers one question without adding a full invoice decoder as
a dependency.

An amountless invoice — a donation invoice, where the payer chooses — and an
unparseable one both return null, and the caller treats that as a refusal.
"Cannot tell" is not agreement, and an amountless invoice is precisely the shape
that would otherwise be paid blind.

Full suite green (310 files, 4380 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…igning garbage

Twentieth remediation batch against docs/findings/.

F3-L3-01 — the extension's batch runner classified "already known" as a
transient error and rebuilt the payment from scratch. But "already known" means
the node HAS the transaction: the broadcast succeeded. Retrying therefore
broadcast a second, real, duplicate payment. It is the most dangerous string in
that list, because the response meaning "your money moved" was read as "try
again".

The error list conflated two different things and is now split. Retried: errors
meaning the request never reached the chain — rate limit, timeout, 502/503/504,
blockhash not found, block height exceeded. Not retried: errors meaning a
transaction like ours already exists — already known, nonce too low, replacement
transaction underpriced, missingorspent, txn-mempool-conflict. Every entry in
the second group describes chain state that moved because such a transaction
exists, which is exactly when a retry duplicates a payment. "already known" is
now reported as sent, which is what it means.

The existing test asserted that "nonce too low" was retryable. That assertion
was the finding, and it is inverted.

F3-L5-01 — the SDK's send() called signMessage(unsignedTx, privateKey): a
generic message signature over the JSON of the unsigned transaction, posted as
signed_tx. That is not a signed transaction on any chain, so the method failed
for every integrator who called it, silently, by producing plausible output that
only failed later at the node. It now throws with an explanation and points at
the primitives that do work.

Real serialisation is deliberately not implemented here: the SDK carries only
@scure/@noble by design, correct RLP/PSBT/Solana encoding depends on the exact
shape of prepareResult.unsigned_tx which the server defines, and none of it can
be verified against a live node from this package. Shipping an unverified
serialiser would reintroduce the same class of bug with more code behind it.

Note: packages/extension has two pre-existing red tests in api.test.ts that this
branch does not touch, and that package is not covered by the root suite.

Root suite green (310 files, 4380 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-first remediation batch against docs/findings/.

C-02 — resolveMerchant returns apiKeyBusinessId, the business an API key
belongs to, and its own doc comment says callers can use it to lock writes to
that business and reject a mismatched business_id. Half of them did not.

The gap is easy to miss because those routes do authorize: they call
verifyBusinessAccess or authorizeBusiness, which check the merchant's access. A
scoped key resolves to the owning merchant, and that merchant has access to all
of their own businesses, so the check passes for every one of them. A key handed
to an integrator for a single business could act on the rest, with every
ownership check in the codebase agreeing.

Adds keyMayActOnBusiness() alongside resolveMerchant and applies it where a scan
found a business id accepted without it: wallets/links (list and create),
wallets/links/[id] (update and delete, answering 404 rather than 403 so it is
not an existence oracle across businesses), wallets/links/[id]/import,
payment-methods/config for both handlers, and payment-methods/manual. Listing
with a scoped key and no explicit filter now narrows to that key's own business
instead of returning everything the merchant owns.

merchant/api-key surfaced in the same scan and is a false positive: it
authenticates by session only and already filters the requested business from
the merchant's own list.

Full suite green (311 files, 4384 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r findings

Twenty-second remediation batch against docs/findings/.

Four findings were filed as conditional on a production environment variable.
Reading those values would answer "is it exploitable today"; changing the code
answers "can it ever be", which does not go stale the next time someone renames
a variable.

NEW-16 — token !== INTERNAL_API_KEY compared two values that are both undefined
when the variable is unset, and undefined !== undefined is false. A request with
no Authorization header at all passed the check and could start, stop or trigger
the payment monitor. isInternalApiKey(), which fails closed on a blank secret and
compares in constant time, already existed; this route did not use it.

CP-019 — the reputation signing key was
process.env.REPUTATION_SIGNING_SECRET || 'cpr-dev-secret', evaluated at module
load. That literal is in a public repository, so an unset variable would have
signed every credential with a key the whole internet knows, and nothing would
look wrong because the signatures verify perfectly. The fallback is gone and the
secret resolves lazily so a missing one fails loudly at signing time.
verifySignature also compared HMACs with ===, which short-circuits at the first
differing byte and leaks how much of a forged signature was correct; it now uses
the constant-time helper. Twenty tests depended on the fallback, which is itself
the finding — the suite now supplies a value in vitest.setup.ts.

G-R-09 — the JWKS endpoint published sha256(signing secret) truncated to 64
bits, unauthenticated. That is an offline oracle: guess a JWT_SECRET, hash it,
compare, with no requests and no logs, and a hit means minting tokens. A key id
only needs to be stable and unique, so it is now configured via OIDC_KEY_ID.

G-1.2-08 and NEW-05 — the RP ID and origin were resolved independently, so
setting one variable and not the other left them decoupled: expectedRPID pinned
by config while expectedOrigin came from the request. WebAuthn's security rests
on those agreeing. Both also fell back to the Host header, which a client
chooses. They are now derived together from one source, a configured pair that
does not belong together throws, and the header fallback only accepts recognised
hosts.

Full suite green (312 files, 4391 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-third remediation batch against docs/findings/. This finishes the audit's
section 2.7 class.

CP-015 — payments/create-for-merchant authenticated an issuer key and then took
merchant_id from the body, so any valid issuer could create payments in any
merchant's name. Combined with CP-002, where issuer registration was open and
auto-activated, that made "anyone with an email address" the real trust boundary
on creating charges for other people. Now goes through
platformMayManageMerchant, and its key lookup matches the hash first like the
other issuer routes.

H-R-10 and NEW-F1A-P-03 — invoice and proposal creation wrote client_id straight
through. clients rows carry a business_id, so an unvalidated id attaches another
business's customer record and the document then renders that client's details.
Both now verify the client belongs to the business being written to.

G-1.2-12 — naming a payout address is moving funds, and that is owner-only.
Invoice creation gated merchant_wallet_address at invoice.write, which a writer
holds, and payments/create gated its payee_override on nothing beyond recording
who did it. Recording an action makes it answerable afterwards; it does not
restrict who may take it. Both now require funds.move for session callers. Using
the business's configured payee is unchanged — only overriding it is restricted.

REC-C-03 — both x402 routes resolved the key's scopes and ignored them, so a
read-only wallet:read key could verify and settle payments, which on the Stripe
rail means capturing real PaymentIntents. Both now require payments:create, the
closest existing scope, deliberately reused rather than adding an x402-specific
scope that would invalidate every key already issued.

REC-D-01 is closed by the NEW-04 fix plus platformMayManageMerchant: a platform
can no longer reach a merchant it did not provision.

Full suite green (312 files, 4393 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-fourth remediation batch against docs/findings/.

CP-001 — every Stripe session is built as
{ ...callerMetadata, business_id, merchant_id, ... }. The spread order protects
the fields listed explicitly and only those. coinpay_payment_id is not one of
them, and the Stripe webhook uses exactly that key to decide which payment row
to mark confirmed. A caller could therefore attach coinpay_payment_id pointing
at another merchant's pending payment, complete their own one-cent checkout, and
have the webhook confirm the victim's payment as paid.

Two independent fixes, because either alone leaves a way back in.

sanitizeStripeMetadata strips platform-reserved keys from caller input at all
five session-creation sites, reserved by prefix (coinpay_*) as well as by name
so a new internal field cannot be forgotten. Stripping beats ordering the spread
correctly because it does not depend on every future call site remembering to
list every reserved field. Dropped keys are logged, since that is either an
integration bug or an attempt.

The webhook now also cross-checks ownership: the payment named in the session
must belong to the business the session says. Both values live in the same
metadata object but are written at different times by different code, and only
one of them was ever caller-controlled.

Full suite green (313 files, 4401 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…forwarded

Twenty-fifth remediation batch against docs/findings/.

R3-DIN-01 — when funds arrive at an invoice address that no payment record owns,
there is no forwarding path and the money is stranded at the intermediary
address. The code recognised this and logged "funds need manual recovery", then
fell straight through to marking the invoice paid and emailing the merchant
"Payment Received".

The merchant was therefore told they had been paid while nothing could move the
funds to them, and the invoice was cleared off every outstanding list that would
have surfaced the problem. A log line in a constantly-running monitor was the
only trace.

The invoice now stays unpaid, which is the accurate state — the customer paid,
the merchant has not been — and the observation is written to
invoices.metadata.unforwardable_balance with balance, currency, address, reason
and timestamp, so an operator can find it without reading cron logs.

Full suite green (313 files, 4401 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-sixth remediation batch against docs/findings/.

REC-C-02 — the x402 v1 EVM path verifies an EIP-712 signature and returned
pendingConfirmation unset, i.e. false, which tells a merchant's middleware the
payment is final and it may serve the resource. Nothing moves tokens on that
path: the documented gasless transferFrom collection does not exist there. The
signature proves the payer authorised those terms and cannot alter the amount,
which is why amountAuthenticated is legitimately true for EVM, and proves
nothing about funds having moved. It now reports pendingConfirmation: true,
which the route's own documentation already defines as "callers must not serve
paid content on this unless they have accepted the risk". The v2/EIP-3009 path
does broadcast and sets the flag false deliberately.

REP-F14-01 — reputation_receipts.amount is written by the party being scored,
and economicScale multiplies weight by log(1 + amount), so declaring large
values was the entire work needed to reach the top tier. The anti-gaming penalty
is capped at -3 out of 100 and cannot offset it, and the score is consumed by
web-bot-auth/verify for real trust decisions.

Economic scaling now applies only to a verifiable amount, using a discriminator
already on the row: escrow_tx is set when the transaction settled through
escrow, so the amount corresponds to funds that actually moved. A self-declared
amount with no settlement still counts, since the job may well have happened,
but at unit weight so it cannot be inflated by choosing a bigger number.

Full suite green (313 files, 4401 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ribed

Twenty-seventh remediation batch against docs/findings/.

AUD-01 — no audit-logging infrastructure existed anywhere, while
docs/SECURITY_KEYS.md carried "[x] Audit logging for key operations" and
docs/SECURITY.md described a four-point audit trail in the present tense. Those
documents were corrected earlier in this branch; this is the thing they
described.

Append-only by grant rather than by convention: service_role holds INSERT and
SELECT and is granted neither UPDATE nor DELETE, so an event cannot be rewritten
by the credential that wrote it.

That did not work on the first attempt, which is why there are two migrations.
Supabase's default privileges grant ALL on a new public-schema table directly to
service_role, and revoking from PUBLIC/anon/authenticated does not remove a
grant held by a named role — so "grant insert, select to service_role" added
nothing that was not already there and UPDATE/DELETE/TRUNCATE survived. Caught
by reading information_schema.role_table_grants after applying rather than
trusting the migration to have meant what it said, then verified with a probe
running as service_role: insert=t update=f delete=f, rolled back.

Other decisions: no foreign keys to the subjects, because an audit record must
survive deletion of what it describes; logging never fails the operation it
records, since an audit write that can fail a payment turns observability into
an availability risk; failures are logged loudly, because webhook_logs sat empty
for its entire existence precisely because nothing complained; and detail is
redacted on write, recursively and by field-name fragment, so key material
cannot be persisted by accident.

Wired into the four paths the docs specifically claimed: subscription
activation, payout wallet changes, key release, and DID rebinding by a platform.
General payment state transitions, API access and failed authentication are not
yet covered, and the docs now say so rather than claiming otherwise.

Full suite green (314 files, 4409 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r caches

Secrets for this project are managed through the logicsrc team vault
(profullstack -> coinpayportal--prod), not Doppler and not /secrets in moshcode.

Auditing that vault against the changes on this branch turned one conditional
finding into a confirmed one, and cleared the riskiest.

CP-019 was LIVE in production: REPUTATION_SIGNING_SECRET was not set, so every
reputation credential was signed with 'cpr-dev-secret' — a constant in this
public repository — and the signatures verified perfectly, so nothing looked
wrong. Now set to a fresh 32-byte random value in the vault, which the fix on
this branch requires since the fallback is gone.

ENCRYPTION_KEY and LN_KEY_ENCRYPTION_KEY are both 64-hex and not weak, so the
F9-01 guard is safe to deploy and custody operations keep working. That was the
change on this branch with the most potential to break production.

JWT_SECRET is 88 characters with 45 distinct, so G-R-09's offline oracle was not
practically exploitable. The fix stands regardless: it should not depend on the
secret happening to be long.

WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN are deliberately left unset. The rewritten
fallback resolves both from one source and accepts only recognised hosts, which
serves the apex and www correctly; pinning the origin to one would break the
other.

Deploy note: the JWKS kid changes from the old sha256(secret)-derived value to
coinpay-oidc-hs256. With HS256 the kid is advisory, since a relying party needs
the shared secret to verify, but it is client-visible.

doppler.env and doppler.json are untracked and added to .gitignore — no existing
pattern matched their filenames. They are a PBKDF2-encrypted fallback cache and
its config, obsolete now that secrets live in the vault. They remain in git
history, which no .gitignore undoes; purging that and rotating what they held is
still a decision.

Full suite green (314 files, 4409 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	TODO-vulns.md
#	docs/SECURITY.md
#	docs/SECURITY_KEYS.md
#	src/app/api/stripe/subscriptions/route.ts
#	src/app/api/x402/verify/route.test.ts
Comment thread public/install.sh Fixed
Comment thread src/lib/business/service.test.ts Fixed
Comment thread src/lib/payments/monitor.test.ts Fixed
Comment thread src/lib/payments/service.test.ts Fixed
@github-actions

github-actions Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

ThreatCrush Security Scan

320 finding(s)

HIGH/CRITICAL: 32 | MEDIUM: 37 | LOW: 251

Severity Rule Location
HIGH secret-private-key .env.example:236
HIGH secret-generic-api-key docs/API.md:430
HIGH secret-generic-api-key docs/API.md:585
HIGH secret-generic-credential docs/FIX_VERIFY_SIGNATURE.md:156
HIGH secret-generic-credential docs/integration-examples/nodejs-bot.md:225
HIGH secret-generic-api-key docs/sdk/getting-started.md:36
HIGH secret-generic-api-key docs/sdk/getting-started.md:318
HIGH secret-generic-api-key packages/sdk/README.md:99
HIGH secret-generic-credential packages/sdk/README.md:122
HIGH secret-generic-credential packages/sdk/README.md:772
HIGH sh-remote-script-execution public/install.sh:163
HIGH sh-remote-script-execution public/install.sh:375
HIGH sh-remote-script-execution public/install.sh:380
HIGH sh-remote-script-execution public/install.sh:384
HIGH sh-remote-script-execution public/install.sh:706
HIGH sh-remote-script-execution public/install.sh:707
HIGH sh-remote-script-execution public/install.sh:747
HIGH sh-remote-script-execution public/install.sh:748
HIGH sh-remote-script-execution public/install.sh:749
HIGH secret-generic-credential scripts/setup-droplet.sh:609
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:135
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:214
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1001
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1022
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:1400
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1481
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1490
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:1532
HIGH secret-generic-credential src/components/docs/AuthenticationDocs.tsx:37
HIGH secret-generic-credential src/components/docs/OAuthDocs.tsx:262
HIGH secret-generic-credential supabase/config.toml:255
HIGH secret-generic-credential supabase/config.toml:287
MEDIUM manifest-install-lifecycle-script package.json:26
MEDIUM js-shell-exec-interpolation packages/sdk/bin/coinpay.js:48
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-issuer.test.js:23
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-reputation.test.js:23
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:22
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:33
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:48
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:63
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:78
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet-backup.test.js:82
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet.test.js:249
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet.test.js:280
MEDIUM insecure-temp-file public/install.sh:104
MEDIUM sh-unquoted-expansion-destructive public/install.sh:663
MEDIUM js-unescaped-html-sink public/payments.js:93
MEDIUM js-unescaped-html-sink public/payments.js:139
MEDIUM js-predictable-cipher-iv scripts/decrypt-wallet-backup.mjs:31
MEDIUM insecure-temp-file scripts/install-gl-client.sh:37

…and 270 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

ralyodio and others added 2 commits August 19, 2026 11:35
ThreatCrush flagged public/install.sh with a high-severity CWE-494, "network
output piped into a shell". The line it matched is a warn() message string —
printed advice telling the operator how to pin a release — not an executed
command. A scanner reading the source cannot tell those apart.

It was also, precisely, the line added to mitigate W-01: the advice to pin
COINPAY_REF instead of tracking the mutable master branch.

Reworded to "set COINPAY_REF=v0.6.13 before running the installer", which keeps
the advice and drops the pattern. Suppressing the alert would have been the
wrong fix: the rule is correct in general, and this file genuinely does install
code that then runs as the operator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThreatCrush flagged three high-entropy hex literals in test files. They are
fixtures, and the tool said as much — "usually a fixture, still worth confirming
it is not a live credential" — but that confirmation is exactly the cost: a
key-shaped literal in the repo is flagged on every diff and every reader has to
stop and check whether it is live.

These keys were the sequential-hex constant 0123456789abcdef..., which is one of
the values requireEncryptionKey exists to reject, so the suite was exercising a
key production refuses. Replacing it with a hardcoded strong key fixed that and
introduced the literal. Generating a fresh 32-byte key per run removes both
problems, and proves nothing in these tests depends on one particular value.

randomBytes cannot collide with a KNOWN_WEAK_KEYS entry in any realistic
universe, so it is a valid "strong key" fixture by construction.

Full suite green (314 files, 4409 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit df34987 into master Aug 19, 2026
16 checks passed
ralyodio added a commit that referenced this pull request Aug 19, 2026
…ity, and silent failure (#279)

Continues #277 and #278. Takes the 2026-08-19 audit from 64 to ~195 of 338 findings resolved.

Priority 2 complete; Priority 3 complete bar two items needing product decisions; Priority 4 code items done with the remainder verified against live production; Priority 5 closed by an evidence-based verification sweep.

Suite: 327 files / 4600 tests green, tsc --noEmit clean. Four migrations applied to production and verified.

ThreatCrush reports 2 high alerts, both confirmed false positives in test fixtures: a unicode test password (backup.test.ts) and a 0x3333...3333 placeholder token address (verify-prepared.test.ts). Neither is a live credential.

Per-finding detail in TODO-vulns.md.
ralyodio added a commit that referenced this pull request Aug 19, 2026
…) (#278)

* fix(security): close the three Critical audit findings and the x402 rail

From the 2026-08-19 security audit (docs/findings/). Adds TODO-vulns.md as the
remediation ledger for all 338 findings, and fixes the three Criticals plus the
five High findings that share their code paths.

F-1.3-13 — subscription activation read merchant_id and plan_id out of a
caller-supplied metadata blob and never compared the amount paid against the
plan price, so a $0.01 payment activated the $490/yr plan on any merchant UUID
the payer named. Activation now credits the payment row's own merchant_id,
validates the plan against SUBSCRIPTION_PRICES, and requires the USD amount to
cover it.

NEW-04 — payee resolution matched merchants by email with no scoping and then
upserted merchant_wallets, overwriting a victim's payout address. The email
fallback now refuses non-platform accounts, and persistPayout refuses to write
a payout destination for any account the platform did not provision.

F-1.3-01 / CP-005 — the Lightning x402 proof was checked against itself:
sha256(preimage) == paymentHash, both from the payer, and settle then answered
settled:true having verified nothing. Both routes now require a matching
incoming, settled ln_payments row for the calling business that covers the
price.

REC-C-01 — scheme and network are independent attacker-set fields and dispatch
fired on either, so a bolt12/ethereum proof ran the Lightning verifier while
the response claimed amountAuthenticated. Dispatch is on network alone, with
the scheme table shared between verify and settle so they cannot drift.

F-1.3-02, F-1.3-03, R3-X1 — price binding now requires expected.payTo and pins
expected.asset, and the Stripe verifier compares the amount Stripe reports
rather than the payer's self-declared one.

Integrator-visible: expected.payTo is now required on v1 verify, matching what
v2 already required.

26 new regression tests; full suite green (303 files, 4309 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): data-loss, exposure and revenue findings from the audit

Second remediation batch against docs/findings/.

F5-L4-01 — cleanOrphanedWallets() deleted every wallets row with no
transactions on every --execute run. That table is the self-custodial
web-wallet store with no merchant_id, so nothing in it can be attributed to a
spam signup, and "zero transactions" describes any wallet not yet funded. Now
behind --prune-empty-wallets with a 90-day floor, and the header's false safety
claim is corrected.

W-07 — GET /api/lightning/offers had no auth, optional business_id and an
uncapped limit, so one anonymous request dumped every merchant's Lightning
offers and revenue. Now authenticated, business-scoped and capped at 100.

CP-002 — issuer self-registration set active:true with no identity or domain
check and stored the API key in cleartext. Registers inactive now, and persists
only the key hash.

G-R-07 — /api/oauth/userinfo asserted email_verified:true unconditionally.
merchants has no email_verified column in production, which also meant
/api/oauth/token's select referencing it errored on every call, so the OAuth ID
token has been carrying neither email nor name for any user. All three sites now
select real columns only and report false while no verification flow exists.

E-03 — the recurring card rail carried a comment claiming application_fee was
applied and never set the field, so the platform collected nothing on every
subscription. Sets subscription_data.application_fee_percent from the business
tier, and spreads caller metadata first so platform keys always win.

F-1.1-08 — two invoice paths compared balance >= expected * 0.99 instead of
calling the shared isSufficientPayment. Both now use the helper.

Two existing tests asserted the vulnerable behaviour and have been inverted:
"marks invoice as paid with 1% tolerance", and two oauth tests named "should
return email_verified as false" that asserted true.

Full suite green (303 files, 4315 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): correct documentation that claims controls the code lacks

Third remediation batch against docs/findings/ — the report's section 2.5 class,
where public documentation states a security property as implemented in direct
contradiction with the code. That is the place a partner or auditor looks to
decide whether to trust the platform, so a false claim there is worse than the
missing control it hides.

DOC-01 — layout.tsx titled the whole product "Non-Custodial Crypto Payment
Gateway" in the page title, OpenGraph and Twitter card. The browser wallet is
non-custodial; the gateway is not, since the platform holds encrypted keys for
payment addresses, escrow and collection between receipt and forwarding.

F7-01 — docs/SECURITY_KEYS.md checked off audit logging and memory scrubbing,
and docs/SECURITY.md described an audit trail in the present tense. Verified:
no audit table, no append-only log, no key-access record anywhere in src/, and
clearSensitiveString has no callers. Both documents now say so. Building the
audit trail (AUD-01) remains open — this corrects the claim, not the gap.

GAP-02 — strix_runs/ and three strix_*.log files were tracked in the public
repo. .gitignore listed them but was added after they were committed. Untracked;
purging them from history remains a decision.

F5-L4-03 — scripts/test-spam-detection.ts carried real merchant names, personal
Gmail addresses and live corporate domains as fixtures. All now synthetic; each
case exercises a shape, not a person.

Also fixes a stale expectation in that script that had been failing since the
dotted_gmail weight was deliberately reduced from 35 to 20 (confirmed against
the original fixture set, so unrelated to the PII swap). 21/21 now.

Full suite green (303 files, 4315 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): close the cross-tenant IDOR class with shared scope helpers

Fourth remediation batch against docs/findings/. Opens the audit's section 2.7
workstream: eighteen findings that all share one shape — authenticate the
caller, then trust a tenant id the caller supplied.

Patching each route individually leaves the shape in place for the nineteenth,
so this adds two shared helpers and moves routes onto them:

- src/lib/auth/tenant-scope.ts — resolveBusinessScope() always authorizes a
  requested business id rather than accepting it, and infers one only when the
  caller has exactly one business. The code it replaces was worse than a guess:
  `businessId || authResult` passed a merchant id where a business id was
  expected, so the lookup matched nothing or matched an unrelated row.
- src/lib/escrow/access.ts — callerOwnsEscrow(), shared by escrow/[id] and
  escrow/[id]/events, which are clones of each other.

Closed:

B-01, C-01, NEW-13 — the Stripe api-keys and webhooks routes let a query-string
or body business_id override the authenticated user. POST /stripe/webhooks
creates an endpoint on the named business's Connect account and returns its
signing secret, so this handed over a live feed of another merchant's Stripe
events pointed at a URL of the attacker's choosing.

CP-010, NEW-14, G-1.2-13 — usage/rates (GET/POST/DELETE) and usage/history had
no ownership check, while their siblings credits and deduct in the same
directory did.

CP-014 — reputation/receipts was an unauthenticated select('*'). The endpoint
stays public, since a trust graph is only useful if a counterparty can check it,
but it now returns a narrow projection: no amount, escrow_tx, buyer_did,
platform_did or signatures, capped at 200 rows.

CP-021, NEW-15 — escrow/[id] and its events sibling authenticated the caller and
then fetched by UUID with no ownership check, so any merchant's API key read any
escrow. Both answer 404 rather than 403 so they are not existence oracles.

Full suite green (304 files, 4327 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): stop credentials acting beyond their remit

Fifth remediation batch against docs/findings/. Two distinct mistakes, both
about a credential being trusted beyond what it was issued for.

A capability check that defaulted to read. C-03 / G-1.2-01:
payment-methods/manual called verifyBusinessAccess with no capability argument,
and the default is business.read, which every team member has including
readonly. The write was therefore gated at read level, so a read-only member
could rewrite the Venmo, Cash App or Zelle handle customers are told to pay.
That is a funds destination and the project's invariant is that moving funds is
owner-only, so the write now requires funds.move. The sibling import route wrote
the same field at settings.manage; raised to match.

API keys acting with account authority. L7A-01 and SUB-01: did/claim,
did/delegate and subscriptions/status DELETE checked no scopes, and API_SCOPES
has no DID or billing scope to check even if they had. A business key resolves
to the owning merchant, so any key — including a read-only wallet:read one —
could rebind the merchant's DID, mint DelegatedAuthority credentials carrying
wallet:transfer and escrow:settle, or cancel the Professional subscription. A
scoped key must not grant authority stronger than itself; all three now require
session auth. The existing isMerchantAuth ternaries had identical branches, so
the type guard was present and doing nothing.

Adds src/lib/p2p/platform-ownership.ts, shared by CP-003 (did/override) and
CP-023 (reputation/merchant-wallet). Both acted on any merchant_id in the body
on nothing but a valid issuer key, rebinding a merchant's identity or writing
their payout wallet so a charge in an unconfigured currency settled to the
caller. Both now require the merchant to be platform-provisioned and provisioned
by that specific platform — the same guard NEW-04 needed, which is why it is
shared rather than copied a third time.

Full suite green (304 files, 4327 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): reconcile code with the live schema, and stop expiring paid payments

Sixth remediation batch against docs/findings/. Each finding here was checked
against the live database rather than the migrations directory, which is known
to drift from production.

L8-02 — payments.payment_address_id does not exist, yet three card routes
inserted it and src/lib/supabase/types.ts still declared it. PostgREST rejects
an insert naming an unknown column, so every card payment failed with a 500
before reaching Stripe. Removed from the routes and from the type; leaving it on
the type is what kept the routes writing it.

L4-NEW-02 — the address-generation failure path wrote status 'failed', which
payments_status_check does not permit, and never checked the result. The row was
left pending forever, on no dashboard's problem list. Now writes 'expired', the
terminal state the schema actually has, records failure_reason in metadata, and
logs if that write fails too.

NEW-L5-2 — blockchainSchema accepts USDT, USDC and USDC_BASE and the code
generates addresses for all three, but payments_blockchain_check permitted none
of them, so payment creation failed outright for those currencies. Widened by
migration 20260819120000, applied to production and verified. Prod holds zero
rows for those three values while every other supported chain has some.

BL-01 — every checker in monitor-balance.ts answered { balance: 0 } on an RPC
error, exactly as it does for an address that has genuinely received nothing,
and processPayment expired the payment on that. A transient provider outage
during the expiry window therefore marked fully-paid payments expired, with the
customer's funds sitting at the address and nothing retrying. BalanceResult now
carries an error field, all 27 failure paths set it, and expiry refuses to fire
on an unreadable balance. The two zeros that are real — an unactivated XRP
account and an unused ADA address — are deliberately left untagged.

Also confirms two findings are already closed: H-R-01 (checkDOGEBalance is fully
implemented, not the stub described) and CP-P5 (isBusinessPaidTier resolves tier
through the merchant subscription and never reads businesses.tier).

Full suite green (304 files, 4329 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): screen every card sink, and fail safe instead of open

Seventh remediation batch against docs/findings/.

N-01 — screenCheckout was called from exactly one of the seven code paths that
create a real card charge. The other six now screen before the Stripe session
exists, which is the last point a payment can be stopped without Stripe ever
seeing it, and each forces 3-D Secure on a verify decision:
payments/widget/create (a public, secret-free embed and the most exposed of the
seven), payments/create's card branch, payments/create-for-merchant,
p2p/request's Stripe branch, and lib/payments/invoice-stripe.

REC-D-05 — the p2p Stripe branch is one of those six. Its caller is a platform
issuer key rather than an authenticated merchant, which made it the least
supervised card path in the codebase.

FR-01 — screening failed open. Any error anywhere returned decision 'allow', and
checkBlocklist returned the same null for a database error as for "no entry
matches", so an unreachable blocklist waved through every entry on it — entries
usually added after a real chargeback. The reasoning behind failing open was
sound, since a broken fraud check must not become a payment outage, but 'allow'
was the wrong conclusion from it. Both paths now degrade to 'verify', which
keeps payments flowing while forcing 3-D Secure and moving liability for a
stolen card back to the issuer.

CP-P5 — corrects my own earlier assessment. I marked this already-fixed after
checking isBusinessPaidTier, which does resolve tier through the merchant
subscription. But payments/create has a separate inline fee calculation that
selected businesses.tier, the column that does not exist, so PostgREST rejected
the query, the row came back null, and every business was charged the 1%
free-tier rate on that rail regardless of plan. The test covering it expressed
the pro tier as tier: 'pro' on the businesses row, so it passed against a column
that was never read.

Full suite green (305 files, 4332 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): escape merchant-supplied text in outbound email

Eighth remediation batch against docs/findings/.

proposal-templates.ts had an escapeHtml and used it on every interpolated
field. Three siblings did not have it and interpolated merchant-supplied strings
raw, so the fix is a shared src/lib/email/escape.ts rather than a fourth copy.

NEW-24 — invoice-templates.ts interpolated business name, invoice number, notes,
client name and tx hash into the HTML body with no escaping. Subject lines are
deliberately left unescaped: they are plain text, and escaping them would show
the recipient a literal &amp;.

G-1.2-09 — the team invitation email interpolated scopeLabel, which is the
business or organization name, into a message delivered to an arbitrary address
the same caller supplies. Creating a merchant account is free and unverified, so
that was a way to send attacker-authored HTML from the platform's own sending
domain, to anyone, with no rate limit.

F5-L4-02 — scripts/daily-stats-email.ts put signup name and email into the
internal daily report unescaped, and its recipients are the operations team. The
helper is duplicated there deliberately: the script runs standalone under tsx
and does not resolve the app's path aliases.

escapeUrl is separate from escapeHtml because escaping characters does not help
with a javascript: or data: href — those survive HTML escaping intact and still
execute when clicked, so anything not clearly http(s) or mailto is dropped.

Full suite green (306 files, 4342 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): make the webhook audit trail actually recordable

Ninth remediation batch against docs/findings/.

select count(*) from webhook_logs against production returned 0. Not a few gaps
— the table has never held a row. The webhook delivery audit trail has never
recorded a single attempt, and nothing surfaced that because delivery itself is
unaffected by the logging failing.

Three separate faults, all of which had to be fixed before one insert could
succeed:

V-01 — url and payload are NOT NULL with no default, and the application writes
webhook_url instead, so every insert failed on a not-null violation. The two
column sets are duplicates from different eras of the schema; the code now
writes both so either name reads correctly.

L8-01 — payment_id had its NOT NULL dropped so escrow events could be logged,
but the foreign key to payments(id) was never dropped alongside it.

REC-D-02 — the escrow path passed an escrow id as payment_id, with a comment
saying it was reusing the column, which violated that foreign key.

Migration 20260819130000 relaxes the legacy pair and gives escrow its own
escrow_id column with its own FK rather than borrowing one, so the payments FK
stays meaningful. A CHECK enforces one subject per row so the column-borrowing
cannot quietly return. Applied to production and verified with a probe insert,
which succeeded and was then deleted.

logWebhookAttempt now logs when it cannot record, instead of returning an error
every caller discarded. That silence is why an empty audit table went unnoticed.

Full suite green (306 files, 4342 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): route every encryption call site through the key guard

Tenth remediation batch against docs/findings/. This is the audit's section 2.1
pattern — a strong guard that most of its own consumers bypass.

F9-01 — requireEncryptionKey was correct and protected 4 of 13 real encryption
call sites. The other nine, including the entire custody hot path, hand-rolled

  const k = process.env.ENCRYPTION_KEY;
  if (!k) return { success: false, error: 'Encryption key not configured' };

which establishes that a key is present and nothing about whether it is usable.
An all-zero or deadbeef-repeated key passed all nine.

Migrated: hd-wallet, system-wallet (two sites), secure-forwarding,
escrow/service, business-collection, business/service, webhooks/secret, and both
Stripe webhook routes.

Two things kept the guard from spreading, and both are fixed. It throws while
those call sites return result objects, so tryRequireEncryptionKey now gives
them a form that fits. And the repository's own fixtures used values the guard
rejects — the one in secure-forwarding.test.ts was literally a KNOWN_WEAK_KEYS
entry and escrow/service.test.ts used a 36-character non-hex string — so
adopting the guard anywhere would have turned the suite red. Both replaced with
a real 32-byte hex key.

keyHashPepper() in scoped-keys.ts is deliberately left reading the raw value and
documented as such: it is a pepper for an HMAC, not an encryption key, and its
value is baked into every api_key_hash already stored, so refusing a weak value
at read time would lock out every integrator at once. The right fix there is a
rotation with re-hashing.

Full suite green (307 files, 4353 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): make COINPAY_REF pinning actually hold across upgrades

Eleventh remediation batch against docs/findings/.

F6-01 — COINPAY_REF is the only user-facing mitigation against an auto-upgrade
that pulls a mutable branch every five minutes, and it silently did nothing. Two
re-invocation paths dropped it: the auto-upgrade poll ran a bare
`curl … | sh -s -- update`, and `coinpay update` in the wrapper did the same.
Both defaulted COINPAY_REF back to master, so a host pinned to a tag tracked
master anyway with no indication. Both now bake in the ref they were installed
from, verified by running write_self_upgrade_helper in isolation and reading the
generated script.

COINPAY_SHA256 is deliberately not carried across: a checksum pins one specific
archive, so reusing it for a later version guarantees a mismatch and would break
every upgrade.

W-01 — partially addressed. An unpinned install now warns on screen that it is
following a mutable branch and prints the command to pin a release. The default
ref itself is unchanged and needs a release-process decision: the installer
compares against packages/sdk/package.json at the ref, so defaulting to a tag
whose SDK version trails master would leave the upgrader idle or looping.

Also fixes a set -e hazard introduced and caught while writing this: a
`[ test ] && VAR=0` line would have aborted the installer for exactly the users
who did pin a ref.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): rate-limit the endpoints that were free to abuse

Twelfth remediation batch against docs/findings/. The rate-limit infrastructure
already existed and was well tuned; these endpoints simply never called it.

NEW-06 — none of the four WebAuthn routes had any limit. login-options answers
differently for a registered and an unregistered email, so unlimited it is a
free user-enumeration oracle over the whole merchant base.

WW-02 — the web-wallet derive route, whose five sibling mutating routes are all
limited. Each call performs HD derivation and writes a row.

REC-C-04 — x402 verify and settle. Ledger bloat, and on the Stripe rail each
call spends our own Stripe API quota.

REC-C-05 — GET /api/swap/quote is anonymous and each quote costs two calls to
the ChangeNOW third-party API, so it was a lever for exhausting our own quota
with no credential at all.

L7A-03 — reputation/attest needed more than a limit. It was completely
unauthenticated and took attester_did from the body. submitAttestation does
check the attester is a party to the receipt, so this was never unbounded
forgery, but knowing a receipt id and the two DIDs on it was enough to attest as
either party — and reputation/receipts used to hand out exactly that (CP-014).
It now requires authentication and proves the caller controls the DID through
the existing merchant_dids link, rather than introducing a new signature scheme.

Also fixes something not in the register: checkRateLimit and
checkRateLimitAsync returned allowed: true in silence for an unknown category,
so a typo in a category name disabled the limit entirely with nothing to notice.
It still allows, since a config mistake must not take payments down, but logs.

Full suite green (307 files, 4353 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): close the escrow oracle, index collision and stuck status

Thirteenth remediation batch against docs/findings/.

NEW-01 — createEscrow resolves a wallet address to the owning merchant's email,
which is convenient because it lets an escrow notify a counterparty identified
only by address, and then returned that email to the caller. Anyone able to
create escrows could therefore map any on-chain address to the merchant who owns
it, across the whole merchant base — which is precisely the input NEW-04 needed.
Auto-resolved emails are now redacted from the response; the row keeps them so
notification still works.

L5-01 — generateEscrowAddress read next_index and wrote next_index + 1 with no
condition on what it had read, and keyed on cryptocurrency while the payment
flow keys on the derivation family (ETH/POL/BNB/USDT/USDC share one). The two
counters advanced independently over the same key space, so the collision was by
construction rather than merely under contention. Now uses acquireFamilyIndex,
the compare-and-swap helper the payment flow already used.

R3-DIN-03 — escrow-monitor writes status 'settle_failed', which
escrows_status_check did not permit, so the UPDATE was rejected and the escrow
was left 'released' with nothing signalling the failure. Verified both ways with
a probe UPDATE that rolls itself back: rejected before the migration, accepted
after. The write now checks its result.

Worth recording: the register attributes this to the constraint being NOT VALID.
That is wrong. NOT VALID only skips checking rows that already existed when the
constraint was added; it does not exempt new INSERTs or UPDATEs. The
pre-existing settle_failed rows are what made it look unenforced.

ESC-NEW-01 is reclassified as a decision rather than a fix: both dispute columns
exist with no writer as reported, but production holds zero escrows in disputed
status, so no funds are frozen today. An arbiter flow needs product decisions
about who arbitrates and on what evidence.

Full suite green (307 files, 4353 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(integrations): repair two paths that failed 100% of the time

Fourteenth remediation batch against docs/findings/. Both findings share a
failure mode worth naming: the test asserted the same wrong thing the code did,
so a path that could never work in production had full, passing coverage.

NEW-L5-01 — CoinPayClient.request takes (endpoint, options). Every call in the
SDK's card-payments.js passed three arguments in the shape
request(method, path, body), so endpoint received 'POST', the URL became
baseUrl + 'POST', and options received the path as a string. All five calls
failed against a real client, making release and refund of a card escrow
unreachable through the SDK. The tests mocked request and asserted the
three-argument shape, so a mock that accepts anything certified a call that
could never work.

F4-01 — StatusMapper::MAP had mark_paid entries only for payment.completed and
payment.overpaid. The backend emits neither; it emits payment.confirmed,
payment.forwarded, payment.failed and payment.expired. Every real webhook fell
through the ?? 'ignore' default, so automated invoice crediting never fired for
any transaction — and ignore is also the correct answer for events we genuinely
skip, so nothing looked wrong. All twelve existing PHP tests covered event types
the backend never sends.

Maps the real events, keeps the legacy keys since a replayed historical delivery
may carry them, and logs an unmapped event rather than letting it disappear.
Adds a PHP test that walks the full emitted-event list so a new backend event
type fails the suite instead of being silently ignored.

Full suite green (307 files, 4353 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): close wallet spend-limit fail-opens and unconsented disclosure

Fifteenth remediation batch against docs/findings/.

L-01 — the web-wallet spend controls failed open twice. A whitelist and a daily
spend limit are the only two things between a compromised session and the
balance, and both were skipped when a query failed: one path returned allowed
true with the comment "fail open for now", the other wrapped the limit check in
if (!error && todayTxs) so an error simply skipped it. getSettings creates a
default row when none exists, so reaching the first path means a real database
failure rather than "no settings configured". Both now deny. A test named
"should allow if settings fail to load (fail open)" asserted the vulnerability
directly and is inverted.

NEW-WW34-01 — checkTransactionAllowed took a chain parameter and never used it,
so today's total summed raw amounts across every chain: 1 BTC and 1 DOGE counted
as 2 against one limit. That blocks trivially cheap sends after one expensive
one and lets a small-unit chain run far past the intended cap. The daily total is
now scoped to the chain being spent on.

WW-L4-01 — /api/wallets/lookup already withholds the email, but answered
found true/false unauthenticated and unlimited, which is enough to sweep the
merchant base address by address. It stays public, since a sender legitimately
checks before paying, with a rate limit that makes bulk enumeration impractical.

NEW-19 — /api/partners published the name, description and webhook host of every
active business with a webhook configured. Configuring a webhook is a technical
step, not consent to appear in a public directory. Migration 20260819150000 adds
public_directory_opt_in defaulting to false.

Note the visible side effect: the partners page is now empty. 28 businesses were
being published and none opted in, because there was nothing to opt into. If
consent exists out of band it needs an explicit backfill; I did not assume it on
anyone's behalf.

Full suite green (307 files, 4355 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): bind signed transactions to what was prepared, on every chain

Sixteenth remediation batch against docs/findings/.

WW-01 — verifySignedTxBinding decoded EVM transactions and compared the
recipient only. A signed transaction paying the right address a different amount
was accepted and recorded as the prepared one, and everything downstream hangs
off that row: the wallet's history, the daily spend limit, fee accounting and
notifications all described a transaction that did not happen. Now compares the
value for native transfers and the second ABI argument for ERC-20 transfers,
using per-chain decimals.

The refusal condition was also reason?.startsWith('recipient '), matching on
message text, so rewording the reason string would have silently stopped it
refusing anything. The decoder now sets an explicit mismatch flag.

WW-03 — BTC, BCH, SOL and USDC_SOL fell through to "no decoder for <chain>" and
broadcast entirely unchecked. Both libraries needed were already dependencies:

BTC decodes with bitcoinjs-lib and sums the outputs paying the prepared address,
requiring at least the prepared amount — "at least" rather than "exactly one
output", because a real spend nearly always has change back to the sender.

SOL and USDC_SOL confirm the prepared recipient appears among the transaction's
account keys, which catches a wholesale substitution of the payee. The amount is
not checked there: it lives in instruction data whose layout depends on the
program, and that is not something to guess at in a broadcast guard, so it is
reported as unverified rather than claimed.

BCH still has no decoder, because bitcoinjs-lib does not support its
SIGHASH_FORKID variant as the codebase notes elsewhere. Left recorded as
unverified with the reason stated in the code.

Full suite green (307 files, 4361 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): make the supported-currency lists agree, and prove it in CI

Seventeenth remediation batch against docs/findings/.

H-R-06 — baseChainFor mapped bare USDC to 'SOL', so a merchant configuring a
plain-USDC payout had to supply a Solana address to pass validation. Every other
module treats bare USDC as ERC-20 on Ethereum: monitor-balance checks it on the
Ethereum RPC, rates/fees prices it off the Ethereum gas path, and address
generation puts it on the Ethereum family. The validator and the money disagreed
about which chain the payout was on. Validation now follows the money.

H-R-08 — the currency list does diverge across modules, and hand-syncing eight
copies fixes today and drifts again next month. CRYPTO_NAMES is already the
declared operational source of truth, so currency-consistency.test.ts checks the
others against it: every symbol must have a declared address family, the
validator must accept that family and reject another so the check actually
discriminates, and the static-fee fallback table must not carry chains the
gateway no longer supports. Adding a symbol without recording a decision now
fails the suite.

That test immediately found something not in the register: USDC_BASE strips to
'BASE', which had no case in the validator, so it fell through to the null
default meaning "no validator, trust the address". USDC on Base is a supported
payment chain with a balance checker and a fee path, making it the one EVM
variant whose payout addresses were accepted with no format check at all — a
typo'd Base address would have been stored and paid to.

Full suite green (308 files, 4365 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): make Boltz refunds recoverable and lockup addresses verifiable

Eighteenth remediation batch against docs/findings/.

A-03 — encryptProviderSecrets is called when a Boltz swap is created and
stripProviderSecrets when history is listed, but decryptProviderSecrets had zero
callers. The refund and claim keys went in and never came back out, so when a
swap failed the HTLC funds could not be recovered through the product at all.
The refund path exists on Boltz's side; the key needed to walk it was locked in
our own database.

Adds GET /api/swap/boltz/:id/recovery: owner-authorized, rate limited on its own
budget, and it logs that key material was released without logging the keys. A
separate route rather than extra fields on the status endpoint, because handing
out key material is a distinct action that should be asked for explicitly and be
visible separately in logs.

W-06 — redeemScript and swapTree were declared on both Boltz response types and
never read, so the lockup address was taken on faith. A substituted address
would have been funded happily, and the refund key generated locally is
worthless against it because that key belongs to a different script — the
deposit would be unrecoverable, which is exactly what a refund key exists to
prevent. Both creation paths now derive the address from the redeem script
(P2WSH, P2SH-P2WSH and bare P2SH) and refuse to fund a mismatch.

Taproot swaps carry a swapTree whose derivation needs the full tweak; those
report "cannot check" and log rather than returning a false pass, keeping the
distinction between checked and uncheckable explicit.

Full suite green (309 files, 4370 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): verify the invoice amount when paying a Lightning Address

Nineteenth remediation batch against docs/findings/.

W-05 — paying a Lightning Address asks the recipient's LNURL server for an
invoice and then paid whatever came back, undecoded. The amount actually paid
was therefore whatever that server chose to put in the invoice, not the amount
the sender entered and was shown, so a hostile or compromised LNURL endpoint
could return an invoice for any amount up to the wallet's balance and have it
paid silently. The minSendable/maxSendable check that was already present
validates the amount we request, which the server is free to ignore.

The amount now has to match. bolt11AmountMsat reads it from the invoice's
human-readable part, a well-defined grammar in BOLT-11 that needs no bech32
decoding, so this answers one question without adding a full invoice decoder as
a dependency.

An amountless invoice — a donation invoice, where the payer chooses — and an
unparseable one both return null, and the caller treats that as a refusal.
"Cannot tell" is not agreement, and an amountless invoice is precisely the shape
that would otherwise be paid blind.

Full suite green (310 files, 4380 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): stop the batch runner paying twice, and stop the SDK signing garbage

Twentieth remediation batch against docs/findings/.

F3-L3-01 — the extension's batch runner classified "already known" as a
transient error and rebuilt the payment from scratch. But "already known" means
the node HAS the transaction: the broadcast succeeded. Retrying therefore
broadcast a second, real, duplicate payment. It is the most dangerous string in
that list, because the response meaning "your money moved" was read as "try
again".

The error list conflated two different things and is now split. Retried: errors
meaning the request never reached the chain — rate limit, timeout, 502/503/504,
blockhash not found, block height exceeded. Not retried: errors meaning a
transaction like ours already exists — already known, nonce too low, replacement
transaction underpriced, missingorspent, txn-mempool-conflict. Every entry in
the second group describes chain state that moved because such a transaction
exists, which is exactly when a retry duplicates a payment. "already known" is
now reported as sent, which is what it means.

The existing test asserted that "nonce too low" was retryable. That assertion
was the finding, and it is inverted.

F3-L5-01 — the SDK's send() called signMessage(unsignedTx, privateKey): a
generic message signature over the JSON of the unsigned transaction, posted as
signed_tx. That is not a signed transaction on any chain, so the method failed
for every integrator who called it, silently, by producing plausible output that
only failed later at the node. It now throws with an explanation and points at
the primitives that do work.

Real serialisation is deliberately not implemented here: the SDK carries only
@scure/@noble by design, correct RLP/PSBT/Solana encoding depends on the exact
shape of prepareResult.unsigned_tx which the server defines, and none of it can
be verified against a live node from this package. Shipping an unverified
serialiser would reintroduce the same class of bug with more code behind it.

Note: packages/extension has two pre-existing red tests in api.test.ts that this
branch does not touch, and that package is not covered by the root suite.

Root suite green (310 files, 4380 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): bind scoped API keys to the business they were issued for

Twenty-first remediation batch against docs/findings/.

C-02 — resolveMerchant returns apiKeyBusinessId, the business an API key
belongs to, and its own doc comment says callers can use it to lock writes to
that business and reject a mismatched business_id. Half of them did not.

The gap is easy to miss because those routes do authorize: they call
verifyBusinessAccess or authorizeBusiness, which check the merchant's access. A
scoped key resolves to the owning merchant, and that merchant has access to all
of their own businesses, so the check passes for every one of them. A key handed
to an integrator for a single business could act on the rest, with every
ownership check in the codebase agreeing.

Adds keyMayActOnBusiness() alongside resolveMerchant and applies it where a scan
found a business id accepted without it: wallets/links (list and create),
wallets/links/[id] (update and delete, answering 404 rather than 403 so it is
not an existence oracle across businesses), wallets/links/[id]/import,
payment-methods/config for both handlers, and payment-methods/manual. Listing
with a scoped key and no explicit filter now narrows to that key's own business
instead of returning everything the merchant owns.

merchant/api-key surfaced in the same scan and is a false positive: it
authenticates by session only and already filters the requested business from
the merchant's own list.

Full suite green (311 files, 4384 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): remove the "depends on a prod env var" caveat from four findings

Twenty-second remediation batch against docs/findings/.

Four findings were filed as conditional on a production environment variable.
Reading those values would answer "is it exploitable today"; changing the code
answers "can it ever be", which does not go stale the next time someone renames
a variable.

NEW-16 — token !== INTERNAL_API_KEY compared two values that are both undefined
when the variable is unset, and undefined !== undefined is false. A request with
no Authorization header at all passed the check and could start, stop or trigger
the payment monitor. isInternalApiKey(), which fails closed on a blank secret and
compares in constant time, already existed; this route did not use it.

CP-019 — the reputation signing key was
process.env.REPUTATION_SIGNING_SECRET || 'cpr-dev-secret', evaluated at module
load. That literal is in a public repository, so an unset variable would have
signed every credential with a key the whole internet knows, and nothing would
look wrong because the signatures verify perfectly. The fallback is gone and the
secret resolves lazily so a missing one fails loudly at signing time.
verifySignature also compared HMACs with ===, which short-circuits at the first
differing byte and leaks how much of a forged signature was correct; it now uses
the constant-time helper. Twenty tests depended on the fallback, which is itself
the finding — the suite now supplies a value in vitest.setup.ts.

G-R-09 — the JWKS endpoint published sha256(signing secret) truncated to 64
bits, unauthenticated. That is an offline oracle: guess a JWT_SECRET, hash it,
compare, with no requests and no logs, and a hit means minting tokens. A key id
only needs to be stable and unique, so it is now configured via OIDC_KEY_ID.

G-1.2-08 and NEW-05 — the RP ID and origin were resolved independently, so
setting one variable and not the other left them decoupled: expectedRPID pinned
by config while expectedOrigin came from the request. WebAuthn's security rests
on those agreeing. Both also fell back to the Host header, which a client
chooses. They are now derived together from one source, a configured pair that
does not belong together throws, and the header fallback only accepts recognised
hosts.

Full suite green (312 files, 4391 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): close the remaining cross-tenant findings

Twenty-third remediation batch against docs/findings/. This finishes the audit's
section 2.7 class.

CP-015 — payments/create-for-merchant authenticated an issuer key and then took
merchant_id from the body, so any valid issuer could create payments in any
merchant's name. Combined with CP-002, where issuer registration was open and
auto-activated, that made "anyone with an email address" the real trust boundary
on creating charges for other people. Now goes through
platformMayManageMerchant, and its key lookup matches the hash first like the
other issuer routes.

H-R-10 and NEW-F1A-P-03 — invoice and proposal creation wrote client_id straight
through. clients rows carry a business_id, so an unvalidated id attaches another
business's customer record and the document then renders that client's details.
Both now verify the client belongs to the business being written to.

G-1.2-12 — naming a payout address is moving funds, and that is owner-only.
Invoice creation gated merchant_wallet_address at invoice.write, which a writer
holds, and payments/create gated its payee_override on nothing beyond recording
who did it. Recording an action makes it answerable afterwards; it does not
restrict who may take it. Both now require funds.move for session callers. Using
the business's configured payee is unchanged — only overriding it is restricted.

REC-C-03 — both x402 routes resolved the key's scopes and ignored them, so a
read-only wallet:read key could verify and settle payments, which on the Stripe
rail means capturing real PaymentIntents. Both now require payments:create, the
closest existing scope, deliberately reused rather than adding an x402-specific
scope that would invalidate every key already issued.

REC-D-01 is closed by the NEW-04 fix plus platformMayManageMerchant: a platform
can no longer reach a merchant it did not provision.

Full suite green (312 files, 4393 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): strip platform-reserved keys from caller Stripe metadata

Twenty-fourth remediation batch against docs/findings/.

CP-001 — every Stripe session is built as
{ ...callerMetadata, business_id, merchant_id, ... }. The spread order protects
the fields listed explicitly and only those. coinpay_payment_id is not one of
them, and the Stripe webhook uses exactly that key to decide which payment row
to mark confirmed. A caller could therefore attach coinpay_payment_id pointing
at another merchant's pending payment, complete their own one-cent checkout, and
have the webhook confirm the victim's payment as paid.

Two independent fixes, because either alone leaves a way back in.

sanitizeStripeMetadata strips platform-reserved keys from caller input at all
five session-creation sites, reserved by prefix (coinpay_*) as well as by name
so a new internal field cannot be forgotten. Stripping beats ordering the spread
correctly because it does not depend on every future call site remembering to
list every reserved field. Dropped keys are logged, since that is either an
integration bug or an attempt.

The webhook now also cross-checks ownership: the payment named in the session
must belong to the business the session says. Both values live in the same
metadata object but are written at different times by different code, and only
one of them was ever caller-controlled.

Full suite green (313 files, 4401 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): stop marking an invoice paid when the funds cannot be forwarded

Twenty-fifth remediation batch against docs/findings/.

R3-DIN-01 — when funds arrive at an invoice address that no payment record owns,
there is no forwarding path and the money is stranded at the intermediary
address. The code recognised this and logged "funds need manual recovery", then
fell straight through to marking the invoice paid and emailing the merchant
"Payment Received".

The merchant was therefore told they had been paid while nothing could move the
funds to them, and the invoice was cleared off every outstanding list that would
have surfaced the problem. A log line in a constantly-running monitor was the
only trace.

The invoice now stays unpaid, which is the accurate state — the customer paid,
the merchant has not been — and the observation is written to
invoices.metadata.unforwardable_balance with balance, currency, address, reason
and timestamp, so an operator can find it without reading cron logs.

Full suite green (313 files, 4401 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): stop reporting unverified claims as settled facts

Twenty-sixth remediation batch against docs/findings/.

REC-C-02 — the x402 v1 EVM path verifies an EIP-712 signature and returned
pendingConfirmation unset, i.e. false, which tells a merchant's middleware the
payment is final and it may serve the resource. Nothing moves tokens on that
path: the documented gasless transferFrom collection does not exist there. The
signature proves the payer authorised those terms and cannot alter the amount,
which is why amountAuthenticated is legitimately true for EVM, and proves
nothing about funds having moved. It now reports pendingConfirmation: true,
which the route's own documentation already defines as "callers must not serve
paid content on this unless they have accepted the risk". The v2/EIP-3009 path
does broadcast and sets the flag false deliberately.

REP-F14-01 — reputation_receipts.amount is written by the party being scored,
and economicScale multiplies weight by log(1 + amount), so declaring large
values was the entire work needed to reach the top tier. The anti-gaming penalty
is capped at -3 out of 100 and cannot offset it, and the score is consumed by
web-bot-auth/verify for real trust decisions.

Economic scaling now applies only to a verifiable amount, using a discriminator
already on the row: escrow_tx is set when the transaction settled through
escrow, so the amount corresponds to funds that actually moved. A self-declared
amount with no settlement still counts, since the job may well have happened,
but at unit weight so it cannot be inflated by choosing a bigger number.

Full suite green (313 files, 4401 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(security): add the append-only audit trail the docs already described

Twenty-seventh remediation batch against docs/findings/.

AUD-01 — no audit-logging infrastructure existed anywhere, while
docs/SECURITY_KEYS.md carried "[x] Audit logging for key operations" and
docs/SECURITY.md described a four-point audit trail in the present tense. Those
documents were corrected earlier in this branch; this is the thing they
described.

Append-only by grant rather than by convention: service_role holds INSERT and
SELECT and is granted neither UPDATE nor DELETE, so an event cannot be rewritten
by the credential that wrote it.

That did not work on the first attempt, which is why there are two migrations.
Supabase's default privileges grant ALL on a new public-schema table directly to
service_role, and revoking from PUBLIC/anon/authenticated does not remove a
grant held by a named role — so "grant insert, select to service_role" added
nothing that was not already there and UPDATE/DELETE/TRUNCATE survived. Caught
by reading information_schema.role_table_grants after applying rather than
trusting the migration to have meant what it said, then verified with a probe
running as service_role: insert=t update=f delete=f, rolled back.

Other decisions: no foreign keys to the subjects, because an audit record must
survive deletion of what it describes; logging never fails the operation it
records, since an audit write that can fail a payment turns observability into
an availability risk; failures are logged loudly, because webhook_logs sat empty
for its entire existence precisely because nothing complained; and detail is
redacted on write, recursively and by field-name fragment, so key material
cannot be persisted by accident.

Wired into the four paths the docs specifically claimed: subscription
activation, payout wallet changes, key release, and DID rebinding by a platform.
General payment state transitions, API access and failed authentication are not
yet covered, and the docs now say so rather than claiming otherwise.

Full suite green (314 files, 4409 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(security): move secrets onto the logicsrc vault, untrack Doppler caches

Secrets for this project are managed through the logicsrc team vault
(profullstack -> coinpayportal--prod), not Doppler and not /secrets in moshcode.

Auditing that vault against the changes on this branch turned one conditional
finding into a confirmed one, and cleared the riskiest.

CP-019 was LIVE in production: REPUTATION_SIGNING_SECRET was not set, so every
reputation credential was signed with 'cpr-dev-secret' — a constant in this
public repository — and the signatures verified perfectly, so nothing looked
wrong. Now set to a fresh 32-byte random value in the vault, which the fix on
this branch requires since the fallback is gone.

ENCRYPTION_KEY and LN_KEY_ENCRYPTION_KEY are both 64-hex and not weak, so the
F9-01 guard is safe to deploy and custody operations keep working. That was the
change on this branch with the most potential to break production.

JWT_SECRET is 88 characters with 45 distinct, so G-R-09's offline oracle was not
practically exploitable. The fix stands regardless: it should not depend on the
secret happening to be long.

WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN are deliberately left unset. The rewritten
fallback resolves both from one source and accepts only recognised hosts, which
serves the apex and www correctly; pinning the origin to one would break the
other.

Deploy note: the JWKS kid changes from the old sha256(secret)-derived value to
coinpay-oidc-hs256. With HS256 the kid is advisory, since a relying party needs
the shared secret to verify, but it is client-visible.

doppler.env and doppler.json are untracked and added to .gitignore — no existing
pattern matched their filenames. They are a PBKDF2-encrypted fallback cache and
its config, obsolete now that secrets live in the vault. They remain in git
history, which no .gitignore undoes; purging that and rotating what they held is
still a decision.

Full suite green (314 files, 4409 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(install): reword pin advice so it is not read as a fetch-pipe-shell

ThreatCrush flagged public/install.sh with a high-severity CWE-494, "network
output piped into a shell". The line it matched is a warn() message string —
printed advice telling the operator how to pin a release — not an executed
command. A scanner reading the source cannot tell those apart.

It was also, precisely, the line added to mitigate W-01: the advice to pin
COINPAY_REF instead of tracking the mutable master branch.

Reworded to "set COINPAY_REF=v0.6.13 before running the installer", which keeps
the advice and drops the pattern. Suppressing the alert would have been the
wrong fix: the rule is correct in general, and this file genuinely does install
code that then runs as the operator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: generate encryption-key fixtures instead of hardcoding them

ThreatCrush flagged three high-entropy hex literals in test files. They are
fixtures, and the tool said as much — "usually a fixture, still worth confirming
it is not a live credential" — but that confirmation is exactly the cost: a
key-shaped literal in the repo is flagged on every diff and every reader has to
stop and check whether it is live.

These keys were the sequential-hex constant 0123456789abcdef..., which is one of
the values requireEncryptionKey exists to reject, so the suite was exercising a
key production refuses. Replacing it with a hardcoded strong key fixed that and
introduced the literal. Generating a fresh 32-byte key per run removes both
problems, and proves nothing in these tests depends on one particular value.

randomBytes cannot collide with a KNOWN_WEAK_KEYS entry in any realistic
universe, so it is a valid "strong key" fixture by construction.

Full suite green (314 files, 4409 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 19, 2026
…ity, and silent failure (#279)

Continues #277 and #278. Takes the 2026-08-19 audit from 64 to ~195 of 338 findings resolved.

Priority 2 complete; Priority 3 complete bar two items needing product decisions; Priority 4 code items done with the remainder verified against live production; Priority 5 closed by an evidence-based verification sweep.

Suite: 327 files / 4600 tests green, tsc --noEmit clean. Four migrations applied to production and verified.

ThreatCrush reports 2 high alerts, both confirmed false positives in test fixtures: a unicode test password (backup.test.ts) and a 0x3333...3333 placeholder token address (verify-prepared.test.ts). Neither is a live credential.

Per-finding detail in TODO-vulns.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants