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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions TODO-vulns.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,34 @@ fired. All 6 wallets holding an LNbits admin key hold their own — they have bo
`ln_wallet_adminkey` and `ln_wallet_inkey`, and the fallback path writes only
the former (`adminkey_only = 0`). The fix is preventive, not remedial.

## Remaining-gaps pass (2026-08-19, after the audit merged)

- **`ESC-NEW-01` `FIXED`.** `dispute_resolution`/`dispute_status` now have a
writer: `resolveDispute()` plus an admin-gated
`POST /api/admin/escrows/[id]/resolve`. Also fixed the sharper half — refund
required `funded`, so raising a dispute *removed* the refund path and left
release-by-depositor as the only exit. `disputed` is now refundable, so a
beneficiary can concede.
- **`REC-D-07` `FIXED`.** Durable retry queue (`webhook_deliveries`, migration
`20260819210000`) with a dead-letter state. In-process delivery spent its
whole budget in ~3 seconds; the queue retries on 1m→12h backoff and marks a
row `dead` after 8 attempts rather than dropping the event. Payloads are
re-signed per attempt — a stored signature is bound to its timestamp, so
replaying one is either rejected or, worse, accepted indefinitely.
- **`W-01` `FIXED`.** Auto-upgrade from a mutable ref is now opt-in
(`COINPAY_AUTO_UPGRADE_UNPINNED=1`). Pinned installs keep it, since a tag can
only ever re-install the same code. Previously any merge to master executed on
every operator host within five minutes, unattended.
- **Issuer cleanup — DONE (reversible).** Deactivated 5 of 18: `evilpoc`,
`poc2`, `OutHunt` (all `.example`, RFC 2606 — cannot resolve), `X`/`X`, and
`Tounes` claiming `Coinpayportal.com`. Verified first that only `ugig.net`
(14,333 receipts) and `d0rz.com` (5) have *ever* produced anything, so none of
the deactivated rows could break live traffic. `active = true` restores any.

Still open: 6 `*.trycloudflare.com` issuers (ephemeral tunnel hostnames are a
weak identity by construction) and the cleartext `api_key` column on 17 rows.
Rotating those needs the integrators told, so it stays Anthony's.

## Priority 5 verification sweep (2026-08-19)

142 findings across `5a` (35), `5b` (25) and `5c` (82). The audit's own framing
Expand Down
27 changes: 27 additions & 0 deletions public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
# COINPAY_HOME=/path install dir (default: $HOME/.coinpay)
# COINPAY_BIN=/path/dir wrapper bin dir (default: $HOME/.local/bin)
# COINPAY_REF=branch|tag|sha git ref to install (default: master)
# COINPAY_AUTO_UPGRADE_UNPINNED=1 schedule auto-upgrade even on a mutable
# ref. Off by default: following master
# unattended means any merge runs here
# within 5 minutes (W-01).
# COINPAY_NO_AUTOUPGRADE=1 skip the 5-min poll setup
# COINPAY_API_URL=https://… pin API base (default: https://coinpayportal.com)
#
Expand Down Expand Up @@ -578,6 +582,29 @@ schedule_auto_upgrade() {
info "COINPAY_NO_AUTOUPGRADE=1 — skipping auto-upgrade scheduling"
return 0
fi

# W-01: following a MUTABLE ref unattended is now opt-in.
#
# With COINPAY_REF unpinned the ref is `master`, and this timer polls every
# ${UPGRADE_INTERVAL_SEC}s. That means anything merged to master executes on
# every installed operator host within five minutes, with no human between
# the merge and the execution — a single bad merge, or a single compromised
# push, reaches the whole fleet automatically.
#
# Pinned installs keep auto-upgrade on by default: a tag or SHA is immutable,
# so the timer can only ever re-install the same code it already has, and
# moving to a new version stays a deliberate act.
#
# An operator who genuinely wants to track master can still have it, by
# asking for it explicitly. Refusing outright would push people to write
# their own cron job, which is worse.
if [ "$COINPAY_REF_PINNED" = "0" ] && [ "${COINPAY_AUTO_UPGRADE_UNPINNED:-}" != "1" ]; then
warn "auto-upgrade not scheduled: '$COINPAY_REF' is a mutable branch"
warn " pin a release (COINPAY_REF=v0.6.13) to get automatic updates, or"
warn " set COINPAY_AUTO_UPGRADE_UNPINNED=1 to follow master unattended"
return 0
fi

case "$OS" in
macos)
schedule_launchd_agent && return 0
Expand Down
68 changes: 68 additions & 0 deletions src/app/api/admin/escrows/[id]/resolve/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { requireAdmin } from '@/lib/auth/admin-guard';
import { resolveDispute } from '@/lib/escrow/service';

/**
* POST /api/admin/escrows/[id]/resolve — arbitrate a disputed escrow.
*
* ESC-NEW-01: `dispute_resolution` and `dispute_status` existed in the schema
* with no writer anywhere, and a disputed escrow had no exit. Release required
* the depositor — the party who had just been disputed against, or who had just
* disputed — and refund required `funded`, so raising a dispute *removed* the
* refund path. Whoever raised one made their own position worse and the escrow
* sat until somebody gave up.
*
* Admin-gated on purpose. The two parties disagree by definition, so neither
* can be the one who decides; the platform is the arbiter of record, which is
* the role `arbiter_address` names for the multisig model. `requireAdmin` is
* the whole security boundary here — this moves other people's money.
*
* Body: `{ "resolution": "release" | "refund", "note": "..." }`
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const guard = await requireAdmin(req);
if (guard instanceof NextResponse) return guard;

const { id } = await params;

const body = await req.json().catch(() => ({}));
const resolution = body?.resolution;
const note = typeof body?.note === 'string' ? body.note : '';

if (resolution !== 'release' && resolution !== 'refund') {
return NextResponse.json(
{ success: false, error: "resolution must be 'release' or 'refund'" },
{ status: 400 }
);
}

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
return NextResponse.json({ success: false, error: 'Server configuration error' }, { status: 500 });
}

const supabase = createClient(supabaseUrl, supabaseKey);

const result = await resolveDispute(supabase, id, {
resolution,
note,
// Recorded as the actor on the event, so an arbitration is always
// attributable to a person rather than to "system".
resolvedBy: guard.email ?? guard.id,
});

if (!result.success) {
return NextResponse.json({ success: false, error: result.error }, { status: 400 });
}

console.log(
`[Admin] Escrow ${id} dispute resolved as ${resolution} by ${guard.email ?? guard.id}`
);

return NextResponse.json({ success: true, escrow: result.escrow });
}
13 changes: 13 additions & 0 deletions src/app/api/cron/monitor-payments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { monitorEmails } from './email-monitor';
import { runInvoiceMonitorCycle, runInvoiceSchedulerCycle } from '@/lib/payments/monitor-invoices';
import { expireEndedSubscriptions } from '@/lib/subscriptions/service';
import { isCronSecret } from '@/lib/auth/secret-compare';
import { processWebhookRetryQueue } from '@/lib/webhooks/retry-queue';
import { redeliverQueuedWebhook } from '@/lib/webhooks/service';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
Expand Down Expand Up @@ -87,6 +89,16 @@ export async function GET(request: NextRequest) {
// Process recurring invoice schedules
const invoiceSchedulerStats = await runInvoiceSchedulerCycle(supabase, now);

// REC-D-07: work the durable webhook retry queue.
//
// In-process delivery spends its whole retry budget inside one request over
// roughly three seconds, so anything that fails there is handed here and
// retried on a backoff measured in minutes. Rows that exhaust their budget
// become dead-letters rather than disappearing.
const webhookRetryStats = await processWebhookRetryQueue(supabase, (row) =>
redeliverQueuedWebhook(supabase, row)
);

// Downgrade merchants whose paid period has ended. isPaidTier also checks
// the end date on every read, so a missed sweep cannot extend a plan — this
// keeps the stored state honest as well.
Expand All @@ -103,6 +115,7 @@ export async function GET(request: NextRequest) {
emails: emailStats,
invoices: invoiceStats,
invoiceScheduler: invoiceSchedulerStats,
webhookRetries: webhookRetryStats,
subscriptionExpiry,
};

Expand Down
150 changes: 150 additions & 0 deletions src/lib/escrow/resolve-dispute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveDispute, refundEscrow } from './service';

vi.mock('../webhooks/service', () => ({ sendEscrowWebhook: vi.fn() }));

/**
* Regression tests for ESC-NEW-01 (2026-08-19 audit).
*
* `dispute_resolution` and `dispute_status` exist in the production schema and
* had no writer anywhere. `disputeEscrow` set `status = 'disputed'` and
* `dispute_reason` and stopped, so a dispute recorded a grievance and changed
* nothing else.
*
* The consequence was worse than a missing audit field: a disputed escrow could
* only be *released*, and only by the depositor. Refund required `funded`, so
* raising a dispute removed the refund path — whoever raised one made their own
* position strictly worse, and the escrow sat until someone gave up.
*/

function supabaseWith(escrow: Record<string, unknown> | null, updated: unknown = { id: 'esc-1' }) {
const chain: any = {
select: vi.fn(() => chain),
update: vi.fn(() => chain),
insert: vi.fn(() => Promise.resolve({ error: null })),
eq: vi.fn(() => chain),
single: vi.fn().mockResolvedValue(
escrow ? { data: escrow, error: null } : { data: null, error: { message: 'not found' } }
),
};
// The update chain ends in .select().single(); make that answer `updated`.
let seenUpdate = false;
chain.update = vi.fn(() => {
seenUpdate = true;
return chain;
});
chain.single = vi.fn().mockImplementation(() =>
Promise.resolve(
seenUpdate
? { data: updated, error: null }
: escrow
? { data: escrow, error: null }
: { data: null, error: { message: 'not found' } }
)
);
return { from: vi.fn(() => chain), _chain: chain } as any;
}

const DISPUTED = {
id: 'esc-1',
status: 'disputed',
business_id: 'biz-1',
depositor_address: '0xdep',
beneficiary_address: '0xben',
release_token: 'esc_tok',
beneficiary_token: 'esc_ben',
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
};

describe('resolveDispute', () => {
it('settles a disputed escrow to the beneficiary', async () => {
const db = supabaseWith(DISPUTED);
const r = await resolveDispute(db, 'esc-1', {
resolution: 'release',
note: 'Work delivered and verified against the brief.',
resolvedBy: 'admin@example.com',
});

expect(r.success).toBe(true);
expect(db._chain.update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'released', dispute_status: 'resolved' })
);
});

it('returns a disputed escrow to the depositor', async () => {
const db = supabaseWith(DISPUTED);
const r = await resolveDispute(db, 'esc-1', {
resolution: 'refund',
note: 'Nothing was delivered within the agreed window.',
resolvedBy: 'admin@example.com',
});

expect(r.success).toBe(true);
expect(db._chain.update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'refunded', dispute_status: 'resolved' })
);
});

it('writes the resolution note — the column that never had a writer', async () => {
const db = supabaseWith(DISPUTED);
await resolveDispute(db, 'esc-1', {
resolution: 'refund',
note: 'Beneficiary conceded in writing.',
resolvedBy: 'admin@example.com',
});

expect(db._chain.update).toHaveBeenCalledWith(
expect.objectContaining({ dispute_resolution: 'Beneficiary conceded in writing.' })
);
});

it('requires a substantive note', async () => {
// The note is the record of why someone else's money moved. An empty one
// makes the arbitration unauditable.
const db = supabaseWith(DISPUTED);
const r = await resolveDispute(db, 'esc-1', {
resolution: 'refund',
note: 'nope',
resolvedBy: 'admin@example.com',
});

expect(r.success).toBe(false);
expect(db.from).not.toHaveBeenCalled();
});

it('refuses an escrow that is not disputed', async () => {
// This exit exists only to break the deadlock. Pointing it at a funded
// escrow would be a way to move money with no counterparty involvement.
const db = supabaseWith({ ...DISPUTED, status: 'funded' });
const r = await resolveDispute(db, 'esc-1', {
resolution: 'release',
note: 'Trying to short-circuit the normal flow.',
resolvedBy: 'admin@example.com',
});

expect(r.success).toBe(false);
expect(r.error).toContain('Only a disputed escrow');
});

it('rejects an unknown resolution', async () => {
const db = supabaseWith(DISPUTED);
const r = await resolveDispute(db, 'esc-1', {
resolution: 'keep' as unknown as 'refund',
note: 'Neither party gets it, apparently.',
resolvedBy: 'admin@example.com',
});

expect(r.success).toBe(false);
});
});

describe('refundEscrow from disputed (ESC-NEW-01)', () => {
it('lets the beneficiary concede after a dispute is raised', async () => {
// Refund required `funded`, so raising a dispute removed the cooperative
// exit: a beneficiary who wanted to hand the money back could not.
const db = supabaseWith(DISPUTED);
const r = await refundEscrow(db, 'esc-1', 'esc_ben');

expect(r.success).toBe(true);
});
});
Loading
Loading