Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
f5bf348
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
afeezorobsco-cyber Oct 18, 2026
6f7e388
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
affy-exe Oct 19, 2026
f2c2b8c
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
afeezorobsco-cyber Oct 19, 2026
1c30dc4
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
affy-exe Oct 21, 2026
b75a351
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
afeezorobsco-cyber Oct 21, 2026
4ab1927
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
affy-exe Oct 21, 2026
4f0029c
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
afeezorobsco-cyber Oct 21, 2026
43fa033
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
affy-exe Oct 21, 2026
9a30f8d
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
afeezorobsco-cyber Oct 21, 2026
b19049c
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
afeezorobsco-cyber Oct 21, 2026
5cb18e2
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
affy-exe Oct 22, 2026
7b10845
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
afeezorobsco-cyber Oct 22, 2026
3166c12
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
affy-exe Oct 22, 2026
880bd25
Merge branch 'main' into feat/sep-10-stellar-web-authentication-server
affy-exe Oct 23, 2026
7dc46b3
fix(ci): restore SEP-10 configs, controllers, repositories, and isola…
affy-exe Oct 23, 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
23 changes: 18 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,11 +378,24 @@ 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
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;

35 changes: 35 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ model User {
// Phase 8 (issue #42, docs/adr/0011): dependents this user is guardian of.
guardianships Guardianship[]
orderDisputeResolutions OrderDisputeResolution[]
sep10Challenges Sep10Challenge[]

@@index([role])
}
Expand Down Expand Up @@ -1064,6 +1065,40 @@ model PatientWalletLink {
patientProfile PatientProfile @relation(fields: [patientProfileId], 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])
}

model WalletLink {
id String @id @default(uuid())
// Scoped to the facility being paid, not the patient — v1 is a
Expand Down
9 changes: 9 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,15 @@ export const config = {
payoutPrivacyMode,
payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS,
},
// SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4).
sep10: {
signingSecret: process.env.SEP10_SIGNING_SECRET,
homeDomain: process.env.SEP10_HOME_DOMAIN,
webAuthDomain: process.env.SEP10_WEB_AUTH_DOMAIN,
networkPassphrase: process.env.SEP10_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015',
horizonUrl: process.env.SEP10_HORIZON_URL ?? 'https://horizon-testnet.stellar.org',
challengeTimeoutSeconds: Number(process.env.SEP10_CHALLENGE_TIMEOUT_SECONDS ?? 300),
},
// Structured logging (Phase 9). Level is configurable per environment;
// tests run quiet by default so captured output stays readable.
log: {
Expand Down
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 @@ -100,3 +100,6 @@ export async function listFacilityLedger(req: Request, res: Response) {
const filters = paymentHistoryQuerySchema.parse(req.query);
res.status(200).json(await paymentsService.listFacilityPaymentLedger(req.user.id, filters));
}

// 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 @@ -219,3 +219,29 @@ export async function markStatus(
export async function patientHasPaymentIntents(patientProfileId: string): Promise<boolean> {
return (await prisma.paymentIntent.findFirst({ where: { patientProfileId }, select: { id: true } })) !== 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 @@ -52,3 +52,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 @@ -65,6 +65,19 @@ interface PrepareCapableClient {
prepareCreateEscrow?: (params: PrepareCreateEscrowParams) => Promise<PreparedCreateEscrow>;
}

// 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() {
// The per-asset token contract comes from the intent (issue #79), so the
// legacy ESCROW_TOKEN_ID is no longer required for every payment path.
Expand Down
Loading
Loading