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 @@ -30,12 +30,14 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
`LoginAttempt` (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert).
`PatientProfile` → `PatientExport` → `PatientExportChunk` (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` — see ARCHITECTURE.md §3.1).
`FacilityProfile` → `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` → `PaymentIntent` (opaque `orderType`/`orderId` — see docs/adr/0004)
`User` → `Sep10Challenge` (SEP-10 wallet-ownership proofs, issue #77 — single-use nonce binding a signed challenge to the issuing user + Stellar account; consumed atomically)

## Payments (Phase 5.5 — see docs/adr/0004-custodial-payments-v1.md)
- **Custodial v1**: `docforum-core` holds its own Stellar payer + releaser identities (`PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` env vars). Patients never hold or sign with a wallet — the only reason this is possible without violating `docforum-web`'s "no direct Stellar calls" hard rule.
- `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient.
- `PaymentIntent.orderType`/`orderId` are opaque, unvalidated — `Prescription`/`LabOrder` aren't real records yet (Phase 4). Don't build on top of these assuming they're FK-validated.
- `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.
- 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
24 changes: 19 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ for status and issue links.
- [ ] Idempotent escrow funding + on-chain reconcile job (`fundPaymentIntent` is check-then-act — docforum-escrow threat model §5). Tracked as [issue #24](https://github.com/DocForum/docforum-core/issues/24) (High, 200 pts).
- [ ] ADR: releaser key custody — multisig, KMS, or per-period rotation (threat model T1/T2). Tracked as [issue #25](https://github.com/DocForum/docforum-core/issues/25) (Medium, 150 pts).
- [ ] Checksum-validate facility wallet keys, not just regex. Tracked as [issue #26](https://github.com/DocForum/docforum-core/issues/26) (Trivial, 100 pts).
- [x] SEP-10 Stellar Web Authentication server (ADR 0005 step 4): `GET /payments/sep10/challenge` + `POST /payments/sep10/verify`, full SEP-10 server verification list (incl. multisig thresholds from Horizon and the unfunded-account master-key rule), a single-use `Sep10Challenge` nonce ledger consumed atomically, and a dedicated `SEP10_SIGNING_SECRET` separate from the payer/releaser keys. Generic infrastructure the non-custodial wallet linking builds on — the endpoints themselves issue no session and change no custodial behaviour. Tracked as [issue #77](https://github.com/DocForum/docforum-core/issues/77) (High, 200 pts) — **Done**.
- [ ] Non-custodial patient payments (v2): patients fund escrows from their own Stellar wallet, custodial kept as fallback. **Proposed** in `docs/adr/0005-non-custodial-patient-payments.md`; implementation issues to be opened once it's Accepted.
- [ ] Upgrade to `@docforum/escrow-sdk` 0.3.0 and the current testnet contract (`refund_after`, balance-delta check, `getEscrow`) — step 1 of ADR 0005's plan, but worth doing regardless. Tracked as [issue #32](https://github.com/DocForum/docforum-core/issues/32) (Medium, 150 pts); depends on `docforum-escrow` publishing `sdk-v0.3.0`.
- [ ] Doc reconciliation: PRD §8.1.6 (facility fulfillment integrity) — not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address.
Expand Down Expand Up @@ -347,11 +348,24 @@ 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-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
22 changes: 22 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,25 @@ PLATFORM_RELEASER_SECRET=
ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM
# Native XLM SAC on testnet (stellar contract id asset --asset native --network testnet)
ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC

# SEP-10 Stellar Web Authentication (issue #77) — GET/POST /payments/sep10/*.
# The SEP10_SIGNING_SECRET is a DEDICATED identity, separate from
# PLATFORM_PAYER_SECRET/PLATFORM_RELEASER_SECRET — it only ever signs web-auth
# challenges, so it can be rotated without touching money-moving keys. All four
# SEP10_SIGNING_SECRET/SEP10_HOME_DOMAIN/SEP10_WEB_AUTH_DOMAIN are required at
# server startup (validateSep10Config) — the server refuses to boot without
# them. Secrets are never logged.
# Generate a keypair with: node -e "const {Keypair}=require('@stellar/stellar-sdk');console.log(Keypair.random().secret())"
SEP10_SIGNING_SECRET=
# Home domain of the service requiring authentication (the first Manage Data
# op key is "<home domain> auth").
SEP10_HOME_DOMAIN=docforum.github.io
# Domain of THIS server that issues the challenges (the "web_auth_domain"
# Manage Data op value) — the API host, not the frontend.
SEP10_WEB_AUTH_DOMAIN=docforum-core.onrender.com
# Optional — default to testnet, matching this repo's testnet-only payments
# constraint (docforum-escrow hard rule 4):
# SEP10_NETWORK_PASSPHRASE='Test SDF Network ; September 2015'
# SEP10_HORIZON_URL=https://horizon-testnet.stellar.org
# Challenge time bounds in seconds (default 300):
# SEP10_CHALLENGE_TIMEOUT_SECONDS=300
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;

34 changes: 34 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,40 @@ model PaymentIntent {
@@index([status])
}

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

model WalletLink {
id String @id @default(uuid())
// Scoped to the facility being paid, not the patient — v1 is a
Expand Down
33 changes: 33 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,37 @@ export const config = {
escrowContractId: process.env.ESCROW_CONTRACT_ID,
escrowTokenId: process.env.ESCROW_TOKEN_ID,
},
// SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4).
//
// The SEP-10 signing key is deliberately its own identity — never
// PLATFORM_PAYER_SECRET or PLATFORM_RELEASER_SECRET. It exists only to
// sign/verify web-auth challenges, so if it ever leaks the blast radius
// is "someone can mint challenge transactions we would accept", not
// "someone can move platform funds". Rotating it doesn't touch the
// money-moving keys and vice versa.
//
// Unlike the payer/releaser keys above, this block IS validated at
// process startup (server.ts calls validateSep10Config()): issue #77
// explicitly asks for startup validation, and a typo'd/missing web-auth
// key would otherwise only surface on the first challenge request. The
// money keys stay lazily validated per ADR 0004 — booting the app for
// auth/booking work must not require payments to be configured.
//
// Secrets are never logged: startup validation reports the ENV VAR NAME
// only, and no code path prints the secret value.
sep10: {
signingSecret: process.env.SEP10_SIGNING_SECRET,
homeDomain: process.env.SEP10_HOME_DOMAIN,
webAuthDomain: process.env.SEP10_WEB_AUTH_DOMAIN,
// Testnet defaults, matching this repo's testnet-only payments
// constraint (docforum-escrow hard rule 4). Overridable per
// environment when a deployment points elsewhere.
networkPassphrase: process.env.SEP10_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015',
horizonUrl: process.env.SEP10_HORIZON_URL ?? 'https://horizon-testnet.stellar.org',
// Challenge time bounds. SEP-10's listed verification step only
// requires *bounded* timebounds; 300s is @stellar/stellar-sdk's own
// default and what issue #77 asks for. (SEP-10's prose recommends
// 900s — 300s is the stricter choice and is what we build and test.)
challengeTimeoutSeconds: Number(process.env.SEP10_CHALLENGE_TIMEOUT_SECONDS ?? 300),
},
} as const;
3 changes: 3 additions & 0 deletions backend/src/modules/payments/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,6 @@ export async function refundIntent(req: Request, res: Response) {
const intent = await paymentsService.refundPaymentIntent(auditActor(req), id);
res.status(200).json(serializeIntent(intent));
}

// SEP-10 Stellar Web Authentication (issue #77) — see ./sep10.ts.
export { sep10Challenge, sep10Verify } from './sep10';
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 });
}
26 changes: 26 additions & 0 deletions backend/src/modules/payments/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,29 @@ export async function markStatus(
const result = await tx.paymentIntent.updateMany({ where: { id, status: from }, data: { status: to } });
return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null;
}

// --- SEP-10 (Stellar Web Authentication) — issue #77 ----------------------
// One row per issued challenge. The nonce comes from the challenge
// transaction's first Manage Data op; its uniqueness makes a challenge
// single-use at the DB level.

export function createSep10Challenge(input: { userId: string; account: string; nonce: string; expiresAt: Date }) {
return prisma.sep10Challenge.create({ data: input });
}

export function findSep10ChallengeByNonce(nonce: string) {
return prisma.sep10Challenge.findUnique({ where: { nonce } });
}

/** Atomically consumes a challenge — ONE conditional UPDATE, no
* read-then-write. Only a row that is still unconsumed AND unexpired
* moves to `consumedAt`; the returned `count` tells the caller whether
* THIS request won the race. Two concurrent replays of the same signed
* challenge can never both see `count === 1`. Same DB-level pattern as
* booking's conditional slot update (ARCHITECTURE.md §5.1). */
export function consumeSep10Challenge(nonce: string, consumedAt: Date) {
return prisma.sep10Challenge.updateMany({
where: { nonce, consumedAt: null, expiresAt: { gt: consumedAt } },
data: { consumedAt },
});
}
21 changes: 21 additions & 0 deletions backend/src/modules/payments/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,24 @@ paymentsRouter.post(
requireVerifiedEmail,
paymentsController.refundIntent,
);

// SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4): prove
// the caller controls a Stellar address before it is ever trusted as a
// payer or payee. Any authenticated role may link a wallet (patient
// wallets become the payer in v2; facility payout wallets replace the
// type-the-address form), so no role gate here — but a VERIFIED address
// is required, same as every other payments mutation in this module
// (issue #34): a wallet link gated only by a possibly-unverified email
// would be a bypass of the guard POST /payments/wallet-link already has.
paymentsRouter.get(
'/sep10/challenge',
authenticate,
requireVerifiedEmail,
paymentsController.sep10Challenge,
);
paymentsRouter.post(
'/sep10/verify',
authenticate,
requireVerifiedEmail,
paymentsController.sep10Verify,
);
13 changes: 13 additions & 0 deletions backend/src/modules/payments/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ import * as auditService from '../../audit/services';
export { setEscrowSdkForTests } from './escrow-sdk-loader';
export type { EscrowSdkModule } from './escrow-sdk-loader';

// SEP-10 Stellar Web Authentication (issue #77). Re-exported through
// this module's service surface (not imported file-by-file from outside)
// per the AGENTS.md module-boundary rule — same pattern as the auth
// module's token/throttle re-exports.
export {
createSep10Challenge,
verifySep10Challenge,
resetSep10AccountFetcher,
setSep10AccountFetcher,
validateSep10Config,
} from './sep10';
export type { Sep10AccountFetcher, Sep10AccountInfo, Sep10ChallengeResponse, Sep10Signer, Sep10VerifyResult } from './sep10';

async function requirePaymentsConfig() {
const { platformPayerSecret, platformReleaserSecret, escrowContractId, escrowTokenId } = config.payments;
const missing = Object.entries({ platformPayerSecret, platformReleaserSecret, escrowContractId, escrowTokenId })
Expand Down
Loading
Loading