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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Account deletion (Phase 8, issue #41 — docs/adr/0010): anonymise in place (`Us
- `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient.
- Each issued order stores its selected facility and clinician-entered total in XLM stroops; a database check requires exactly one PaymentIntent order FK.
- `release`/`refund` are admin-triggered HTTP actions in v1 (`POST /payments/intents/:id/{release,refund}`) — there's no `FulfillmentRecord.status == fulfilled` to trigger off of yet. Don't treat this as the final design.
- **SEP-10 wallet-ownership proof** (issue #77): `GET /payments/sep10/challenge` + `POST /payments/sep10/verify` (see docs/api/README.md) with a dedicated `SEP10_SIGNING_SECRET` (never the payer/releaser keys). The `Sep10Challenge` nonce ledger is single-use via one conditional UPDATE — no app-level check-then-write. This is generic infrastructure for ADR 0005's wallet linking; it issues no session, DocForum's own JWT still authenticates the caller.
- **Verified wallet links** (issue #78): `POST /payments/wallet-link` accepts ONLY a signed SEP-10 challenge — never a raw address; the address is derived from the verified challenge, and link creation + nonce consumption commit in ONE transaction. Patient responses carry ADR 0005's on-chain privacy notice verbatim. Funding refuses `unverified_legacy` facility links after `WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF` (`409 WALLET_LINK_UNVERIFIED`, enforced in the funding service) and snapshots `PaymentIntent.fundedWalletAddress` so re-linking never rewrites an escrow's payee.
- The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` — dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo.

## Known unresolved design gaps (do not silently "fix" without flagging in PR description)
Expand Down
41 changes: 36 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,11 +373,42 @@ roadmap. See each phase section below for status and issue links.
sponsorship. Custodial v1 stays as a fallback. Status: Proposed, not
yet Accepted. Added to the docs site.
- 2026-10-09 — Opened issue #32: upgrade to escrow SDK 0.3.0 and the
current testnet contract.
- 2026-10-15 — Started Phase 8's auth-hardening work, scoped as issues
#34 (email verification), #35 (login brute-force throttling), #37 (CSRF
for the cookie-authenticated endpoints + per-environment CORS allowlist)
and #39 (admin account lifecycle). All four are independent of Phases
current testnet contract.- 2026-10-16 — Implemented issue #77 (SEP-10 Stellar Web Authentication server, Phase 5.5 / ADR 0005 step 4): `GET
/payments/sep10/challenge` builds a server-signed challenge (SDK
helper — Manage Data nonce, 5-minute time bounds, home domain +
web_auth_domain) and records the issuance binding; `POST
/payments/sep10/verify` walks the full SEP-10 server verification
list (structure, time bounds, server signature, client signatures
with multisig weight thresholds loaded from Horizon, and the
spec's master-key rule for unfunded accounts), then consumes the
nonce with ONE conditional UPDATE so concurrent replays can't both
succeed — same DB-level pattern as booking. New `Sep10Challenge`
model (unique nonce, userId, account, expiry) with a migration; a
dedicated `SEP10_SIGNING_SECRET` (never the payer/releaser keys,
validated at startup, never logged). Horizon is behind an injectable
seam so the 15 unit + 18 integration tests run with zero network.
Docs updated: ARCHITECTURE §4.10, essentials data-model map,
docs/api/README.md (endpoints + errors + unfunded behaviour).
- 2026-10-16 — Implemented issue #78 (verified wallet links, stacked on
#77's branch): `WalletLink` generalized from facility-only/typed-in to
an owner (patient | facility) with `verificationMethod` /
`verifiedAt` / `replacedAt`, one active link per owner enforced by a
DB unique sentinel (Prisma can't express a partial index — noted in
the schema), and the migration backfills existing facility rows as
`unverified_legacy` (proven by a real migration test that inserts an
old-shape row first). `POST /payments/wallet-link` now takes only a
signed SEP-10 challenge (strict body — a raw address is a 400) and
creates the link inside the same transaction that consumes the nonce;
the only `sep10`-row writer is that flow. Patient link responses
carry ADR 0005's on-chain privacy notice verbatim (unit-tested
against the ADR file). Funding refuses unverified legacy links after
`WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF` (default 2027-01-01,
startup-validated, `409 WALLET_LINK_UNVERIFIED`) and snapshots
`PaymentIntent.fundedWalletAddress` so re-linking can't rewrite an
escrow's payee. 8 unit + 12 integration tests (fake Horizon + fake
escrow SDK — no network). ADR 0005 unchanged (no factual conflict).
- 2026-10-15 — Started Phase 8's auth-hardening work, scoped as issues #34 (email verification), #35 (login brute-force throttling), #37 (CSRF
for the cookie-authenticated endpoints + per-environment CORS allowlist) and #39 (admin account lifecycle). All four are independent of Phases
3–7 and of each other. #34 merged as PR #137: `User.emailVerifiedAt` +
a hashed/expiring/single-use `EmailVerificationToken` table (backfilled
as verified for existing rows), `POST /auth/verify-email`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "Sep10Challenge" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"account" TEXT NOT NULL,
"nonce" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "Sep10Challenge_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "Sep10Challenge_nonce_key" ON "Sep10Challenge"("nonce");

-- CreateIndex
CREATE INDEX "Sep10Challenge_expiresAt_idx" ON "Sep10Challenge"("expiresAt");

-- AddForeignKey
ALTER TABLE "Sep10Challenge" ADD CONSTRAINT "Sep10Challenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
-- Verified wallet links (issue #78): generalize WalletLink from
-- facility-only/typed-in to an owner (patient | facility) with a
-- verification method, and snapshot the funded payee address on
-- PaymentIntent.
--
-- Hand-ordered (generated by `prisma migrate diff`, then rewritten):
-- the generated script added NOT NULL columns and dropped `facilityId`
-- before any backfill, which fails on a populated table. This version
-- adds columns nullable → backfills from `facilityId` → constrains →
-- only then drops the old shape, so it is safe to run against existing
-- data (every pre-existing facility row becomes an active
-- `unverified_legacy` link owned by its facility).
--
-- Reversibility: reversing back to the old shape is practical only for
-- `ownerType = 'facility'` rows (recreate `facilityId` from `ownerId`,
-- restore the FK/unique index); patient-era rows and verified metadata
-- have no old-shape home, so a down migration would drop them. Not
-- written for that reason — restore from backup if reversal is needed.

-- CreateEnum
CREATE TYPE "WalletLinkOwnerType" AS ENUM ('patient', 'facility');

-- CreateEnum
CREATE TYPE "WalletLinkVerificationMethod" AS ENUM ('sep10', 'unverified_legacy');

-- AlterTable: PaymentIntent gains the funding-time payee snapshot (null
-- until the next funding; no backfill possible — historical intents
-- never recorded their payee address).
ALTER TABLE "PaymentIntent" ADD COLUMN "fundedWalletAddress" TEXT;

-- AlterTable: new columns nullable first so rows can be backfilled.
ALTER TABLE "WalletLink" ADD COLUMN "activeOwnerKey" TEXT;
ALTER TABLE "WalletLink" ADD COLUMN "ownerId" TEXT;
ALTER TABLE "WalletLink" ADD COLUMN "ownerType" "WalletLinkOwnerType";
ALTER TABLE "WalletLink" ADD COLUMN "replacedAt" TIMESTAMP(3);
ALTER TABLE "WalletLink" ADD COLUMN "verificationMethod" "WalletLinkVerificationMethod";
ALTER TABLE "WalletLink" ADD COLUMN "verifiedAt" TIMESTAMP(3);

-- Backfill: every existing row was a facility link typed in by hand →
-- active, owned by its facility, explicitly marked unverified. Their
-- addresses were never SEP-10-checked, so `verifiedAt` stays NULL and
-- funding honours them only until WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF.
UPDATE "WalletLink"
SET "ownerType" = 'facility',
"ownerId" = "facilityId",
"verificationMethod" = 'unverified_legacy',
"activeOwnerKey" = 'facility:' || "facilityId"
WHERE "facilityId" IS NOT NULL;

-- Now constrain (all rows backfilled above; table empty if fresh).
ALTER TABLE "WalletLink" ALTER COLUMN "ownerType" SET NOT NULL;
ALTER TABLE "WalletLink" ALTER COLUMN "ownerId" SET NOT NULL;
ALTER TABLE "WalletLink" ALTER COLUMN "verificationMethod" SET NOT NULL;

-- Drop old facility-only shape (FK must go before the column).
ALTER TABLE "WalletLink" DROP CONSTRAINT "WalletLink_facilityId_fkey";
DROP INDEX "WalletLink_facilityId_key";
ALTER TABLE "WalletLink" DROP COLUMN "facilityId";

-- CreateIndex
-- The one-active-link-per-owner DB guarantee: unique on the sentinel
-- column that is non-NULL exactly while the link is active.
CREATE UNIQUE INDEX "WalletLink_activeOwnerKey_key" ON "WalletLink"("activeOwnerKey");

-- CreateIndex
CREATE INDEX "WalletLink_ownerType_ownerId_idx" ON "WalletLink"("ownerType", "ownerId");
115 changes: 106 additions & 9 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,14 @@ model PaymentIntent {
// release/refund against the right on-chain escrow.
escrowId BigInt?
stellarTxHash String?
// Snapshot of the payee address this intent was actually funded to
// (issue #78). Set at funding time from the facility's then-ACTIVE
// wallet link and never changed afterwards — so re-linking a
// different address later cannot rewrite which wallet an already-
// funded escrow paid, and the escrow keeps referencing its link even
// after that link is replaced. Append-only-in-spirit: written exactly
// once, with the funding transition.
fundedWalletAddress String?
// Which deployed contract this intent was funded against — this repo
// has already had two testnet contract IDs (Phase E1 vs. E2), so this
// isn't hypothetical.
Expand All @@ -845,19 +853,108 @@ model PaymentIntent {
@@index([status])
}

model WalletLink {
id String @id @default(uuid())
// Scoped to the facility being paid, not the patient — v1 is a
// custodial model (docforum-core funds escrows from its own platform
// Stellar identity; patients never hold or sign with a wallet here).
// See ADR 0004.
facilityId String @unique
facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Cascade)
// SEP-10 (Stellar Web Authentication) challenge issuance ledger —
// one row per issued challenge, created by GET /payments/sep10/challenge
// and redeemed exactly once by POST /payments/sep10/verify (issue #77,
// ADR 0005 step 4: proving the caller controls a Stellar address before
// it is ever trusted as payer or payee).
//
// The nonce is the random 64-byte value embedded in the challenge
// transaction's `"<home domain> auth"` Manage Data operation. Storing it
// (unique) is what makes a challenge single-use: verify consumes the row
// with ONE conditional UPDATE (`consumedAt IS NULL AND expiresAt > now`),
// so two concurrent replays can't both win — the same DB-level pattern
// as booking's conditional slot update (ARCHITECTURE.md §5.1).
//
// Deliberately no other fields: no XDR, no signature, no PHI. The
// challenge itself is re-derivable from the submitted XDR at verify
// time; this table only proves "we issued this nonce to this user for
// this account, it hasn't been used, and it hasn't expired."
model Sep10Challenge {
id String @id @default(uuid())
userId String
account String // the G... (or M...) account the challenge was issued for
nonce String @unique // hex/utf8 nonce from the challenge's Manage Data op
expiresAt DateTime // matches the challenge's timebounds maxTime (+ record TTL)
consumedAt DateTime?
createdAt DateTime @default(now())

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

// Pruning and the "is this row still redeemable" check both scan by
// expiry — same reason LoginAttempt indexes its window. Lookups at
// verify time go through the unique `nonce` index.
@@index([expiresAt])
}

stellarPublicKey String
// Verified wallet links (issue #78, ADR 0005). Generalized from the
// facility-only, typed-in-address shape: an owner is a PatientProfile or
// a FacilityProfile (polymorphic ownerType+ownerId — deliberately no FK
// relation, same shape as FulfillmentRecord's polymorphic order ref),
// and every link carries HOW it was verified.
//
// A link row is immutable except for `replacedAt`/`activeOwnerKey`:
// re-linking deactivates the old row and inserts a new one, so history
// (including the address escrows were actually funded to — see
// PaymentIntent.fundedWalletAddress) is preserved rather than mutated.
//
// `verificationMethod`:
// - `sep10` — created ONLY by the POST /payments/wallet-link
// verification flow (services/wallet-link.ts), inside the same
// transaction that consumes the SEP-10 nonce. No seed, script, admin
// route or internal service method may create a `sep10` row — the
// constructor for it is private to that flow.
// - `unverified_legacy` — backfilled by the add_verified_wallet_links
// migration for pre-existing facility rows (typed-in addresses).
// Usable for funding only until
// WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF passes.
//
// One ACTIVE link per owner, enforced at the DB level via
// `activeOwnerKey`: it holds "<ownerType>:<ownerId>" while active and
// NULL once replaced. A plain unique constraint on it gives exactly the
// "at most one unreplaced row per owner" guarantee — Prisma's schema DSL
// cannot express a partial unique index (`WHERE replacedAt IS NULL`),
// and a raw-SQL index would be silently dropped by the next
// `prisma migrate dev` as schema drift. Postgres treats NULLs as
// distinct, so any number of replaced rows can coexist.
enum WalletLinkOwnerType {
patient
facility
}

enum WalletLinkVerificationMethod {
sep10
unverified_legacy
}

model WalletLink {
id String @id @default(uuid())
ownerType WalletLinkOwnerType
// PatientProfile.id or FacilityProfile.id. Polymorphic — no FK on
// purpose (see note above); existence + caller ownership are checked
// in services/wallet-link.ts before any write.
ownerId String
// G… address. NEVER accepted from a client-supplied field: derived
// server-side from the account the SEP-10 challenge was issued for and
// successfully verified.
stellarPublicKey String
verificationMethod WalletLinkVerificationMethod
// Set iff verificationMethod = 'sep10' (time the challenge passed).
verifiedAt DateTime?
// Set when a newer link superseded this one. Only these two fields
// ever change on a row; stellarPublicKey/verificationMethod are
// write-once.
replacedAt DateTime?
// "<ownerType>:<ownerId>" while this is the owner's active link, NULL
// once replaced. The unique index on this column IS the one-active-
// link-per-owner DB guarantee.
activeOwnerKey String? @unique

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

// Lookups by owner (active-link read path, deactivation sweep).
@@index([ownerType, ownerId])
}

// Soroban event ingestion (issue #86). Cursors are per contract deployment;
Expand Down
11 changes: 11 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ function parseCorsOrigins(raw: string | undefined, isProduction: boolean): strin
.filter((origin) => origin.length > 0);
}

/** Issue #78: the date after which funding refuses facility wallet links
* that were never SEP-10 verified. Defaults to 2027-01-01 (UTC) — a
* deliberate fail-closed default: legacy links keep working for a
* defined grace window without any deployment needing to remember to
* set the variable, but "never enforce" is never the default. Returns
* NaN for a malformed value so startup validation can name the var. */
function parseWalletLinkCutoff(raw: string | undefined): number {
if (raw === undefined || raw.trim() === '') return Date.parse('2027-01-01T00:00:00Z');
return Date.parse(raw);
}

export const config = {
port: Number(process.env.PORT ?? 4000),
databaseUrl: requireEnv('DATABASE_URL'),
Expand Down
19 changes: 13 additions & 6 deletions backend/src/modules/payments/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,23 @@ function serializeIntent(intent: {

export async function linkWallet(req: Request, res: Response) {
if (!req.user) throw new HttpError(401, 'Authentication required.');
const facilityId = await paymentsService.getOwnFacilityProfileId(req.user.id);
const { stellarPublicKey } = linkWalletSchema.parse(req.body);
const link = await paymentsService.linkWallet(facilityId, stellarPublicKey);
res.status(200).json(link);
// strict schema: rejects any raw-address field outright.
const { transaction } = walletLinkSchema.parse(req.body);
const link = await paymentsService.linkWalletViaSep10(req.user.id, req.user.role, transaction);
// Patient links always carry the ADR 0005 on-chain privacy notice,
// verbatim, in the response body — a client cannot render success
// without it (issue #78 requirement 3). Facility links (payout
// addresses) don't have a patient privacy dimension.
res.status(200).json(
link.ownerType === 'patient'
? { ...link, privacyNotice: paymentsService.PATIENT_WALLET_LINK_PRIVACY_NOTICE }
: link,
);
}

export async function getOwnWallet(req: Request, res: Response) {
if (!req.user) throw new HttpError(401, 'Authentication required.');
const facilityId = await paymentsService.getOwnFacilityProfileId(req.user.id);
const link = await paymentsService.getWalletLink(facilityId);
const link = await paymentsService.getOwnWalletLink(req.user.id, req.user.role);
if (!link) throw new HttpError(404, 'No wallet linked yet.');
res.status(200).json(link);
}
Expand Down
22 changes: 22 additions & 0 deletions backend/src/modules/payments/controllers/sep10.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SEP-10 controllers (issue #77) — thin HTTP layer over services/sep10.
// Route-level `authenticate`/`requireVerifiedEmail` middleware runs first
// (see routes/index.ts); these handlers only parse input, call the
// service, and shape the response.
import type { Request, Response } from 'express';
import * as paymentsService from '../services';
import { sep10ChallengeQuerySchema, sep10VerifySchema } from '../validation';
import { HttpError } from '../../../http-error';

export async function sep10Challenge(req: Request, res: Response) {
if (!req.user) throw new HttpError(401, 'Authentication required.');
const { account } = sep10ChallengeQuerySchema.parse(req.query);
const challenge = await paymentsService.createSep10Challenge(req.user.id, account);
res.status(200).json(challenge);
}

export async function sep10Verify(req: Request, res: Response) {
if (!req.user) throw new HttpError(401, 'Authentication required.');
const { transaction } = sep10VerifySchema.parse(req.body);
const result = await paymentsService.verifySep10Challenge(req.user.id, transaction);
res.status(200).json({ ...result, userId: req.user.id });
}
Loading
Loading