Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ebbcead
feat(payments): SEP-10 Stellar Web Authentication server: challenge a…
affy-exe Oct 16, 2026
fda820b
feat(payments): verified wallet links for patients and facilities (re…
affy-exe Oct 16, 2026
3b5c6c8
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
afeezorobsco-cyber Oct 18, 2026
a71d3ee
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
affy-exe Oct 19, 2026
b72cad3
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
affy-exe Oct 21, 2026
d700259
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
afeezorobsco-cyber Oct 21, 2026
2800331
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
affy-exe Oct 21, 2026
c71bcde
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
affy-exe Oct 21, 2026
8ed1bfa
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
afeezorobsco-cyber Oct 21, 2026
d445f18
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
affy-exe Oct 21, 2026
1536d35
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
afeezorobsco-cyber Oct 21, 2026
554465e
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
afeezorobsco-cyber Oct 21, 2026
71232df
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
affy-exe Oct 22, 2026
68df0a3
fix(payments): fix failing CI on verified wallet links branch
affy-exe Oct 22, 2026
d8b7e61
Merge branch 'main' of https://github.com/DocForum/docforum-core into…
affy-exe Oct 23, 2026
aca931f
Merge branch 'main' of https://github.com/DocForum/docforum-core into…
affy-exe Oct 23, 2026
f5dab97
Merge branch 'main' into feat/verified-wallet-links-for-patients-and-…
afeezorobsco-cyber Oct 24, 2026
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
18 changes: 18 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,24 @@ and issue links.
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` +
Expand Down
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");
90 changes: 78 additions & 12 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ model User {
accessLogs AccessLog[]
// Phase 8 (issue #42, docs/adr/0011): dependents this user is guardian of.
guardianships Guardianship[]
orderDisputeResolutions OrderDisputeResolution[]
sep10Challenges Sep10Challenge[]
orderDisputeResolutions OrderDisputeResolution[]

@@index([role])
}
Expand Down Expand Up @@ -485,7 +485,11 @@ model FacilityProfile {
labResults LabResult[]
paymentIntents PaymentIntent[]
fulfillments FulfillmentRecord[]
walletLink WalletLink?
// NOTE: no `walletLink` relation anymore — WalletLink is generalized to
// a polymorphic owner (patient | facility) as of issue #78, so it
// carries no FK relation to either profile (same shape as
// FulfillmentRecord's polymorphic order ref, §6.1.3). Ownership is
// validated in the payments service, not by the schema.
prices FacilityPrice[]
orders Order[]
fulfillmentProofs FulfillmentProof[]
Expand Down Expand Up @@ -1032,6 +1036,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 Down Expand Up @@ -1090,7 +1102,6 @@ model Sep10Challenge {
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
Expand All @@ -1099,19 +1110,74 @@ model Sep10Challenge {
@@index([expiresAt])
}

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)
// 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
}

stellarPublicKey String
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
14 changes: 14 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,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 Expand Up @@ -289,6 +300,9 @@ export const config = {
prepareExpirySweepIntervalSeconds: Number(process.env.PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS ?? 60),
payoutPrivacyMode,
payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS,
walletLinkUnverifiedFundingCutoff: parseWalletLinkCutoff(
process.env.WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF,
),
},
// SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4).
sep10: {
Expand Down
22 changes: 15 additions & 7 deletions backend/src/modules/payments/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express';
import * as paymentsService from '../services';
import { linkWalletSchema, paymentHistoryQuerySchema } from '../validation';
import { walletLinkSchema, paymentHistoryQuerySchema } from '../validation';
import { HttpError } from '../../../http-error';
import { getParam } from '../../../request-params';
import { auditActor } from '../../../middleware/request-id';
Expand Down Expand Up @@ -38,16 +38,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 Expand Up @@ -103,3 +110,4 @@ export async function listFacilityLedger(req: Request, res: Response) {

// SEP-10 Stellar Web Authentication (issue #77) — see ./sep10.ts.
export { sep10Challenge, sep10Verify } from './sep10';

Loading
Loading