diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0370dc8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +# Vendored agent/skill docs and long-form docs are not part of the source +# formatting contract. +/.agents +/.claude +/docs + +# Generated / build output +/dist +/coverage diff --git a/README.md b/README.md index 64970ba..c4d42ad 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,19 @@ The server validates NFC payment requests against the `payment-request.v1` contr ## Tech stack -| Layer | Technology | -|-------|------------| -| Framework | NestJS 11 | -| Language | TypeScript 5.7 (strict) | -| ORM | Prisma + PostgreSQL | -| Database hosting | Supabase | -| Session auth | Supabase Auth (JWT) | -| Payment auth | WebAuthn (passkeys) | -| Blockchain | `@stellar/stellar-sdk` (Horizon + RPC) | -| Validation | `class-validator`, `class-transformer` | -| Config | `@nestjs/config` + Joi | -| API docs | `@nestjs/swagger` | -| Tests | Jest + Supertest | +| Layer | Technology | +| ---------------- | -------------------------------------- | +| Framework | NestJS 11 | +| Language | TypeScript 5.7 (strict) | +| ORM | Prisma + PostgreSQL | +| Database hosting | Supabase | +| Session auth | Supabase Auth (JWT) | +| Payment auth | WebAuthn (passkeys) | +| Blockchain | `@stellar/stellar-sdk` (Horizon + RPC) | +| Validation | `class-validator`, `class-transformer` | +| Config | `@nestjs/config` + Joi | +| API docs | `@nestjs/swagger` | +| Tests | Jest + Supertest | ## Prerequisites @@ -49,19 +49,19 @@ The API is versioned under `/v1`. Swagger UI is available at `/docs` when the se ## Scripts -| Command | Description | -|---------|-------------| -| `npm run start:dev` | Start with hot reload | -| `npm run start:prod` | Run compiled build | -| `npm run build` | Compile TypeScript | -| `npm run lint` | Run ESLint | -| `npm test` | Unit tests | -| `npm run test:e2e` | End-to-end tests | -| `npm run test:cov` | Coverage report | -| `npm run prisma:generate` | Generate Prisma client | -| `npm run prisma:migrate` | Create/apply migrations | -| `npm run prisma:studio` | Open Prisma Studio | -| `npm run prisma:seed` | Seed development data | +| Command | Description | +| ------------------------- | ----------------------- | +| `npm run start:dev` | Start with hot reload | +| `npm run start:prod` | Run compiled build | +| `npm run build` | Compile TypeScript | +| `npm run lint` | Run ESLint | +| `npm test` | Unit tests | +| `npm run test:e2e` | End-to-end tests | +| `npm run test:cov` | Coverage report | +| `npm run prisma:generate` | Generate Prisma client | +| `npm run prisma:migrate` | Create/apply migrations | +| `npm run prisma:studio` | Open Prisma Studio | +| `npm run prisma:seed` | Seed development data | ## Project structure @@ -91,18 +91,18 @@ ding-server/ ## Documentation -| Document | Description | -|----------|-------------| -| [docs/ding-payments.md](./docs/ding-payments.md) | Product vision and UX flows | -| [docs/server-build-plan.md](./docs/server-build-plan.md) | Full server build plan (SRV tasks) | -| [docs/server-build-plan-consolidated.md](./docs/server-build-plan-consolidated.md) | Consolidated task reference | +| Document | Description | +| ---------------------------------------------------------------------------------- | ---------------------------------- | +| [docs/ding-payments.md](./docs/ding-payments.md) | Product vision and UX flows | +| [docs/server-build-plan.md](./docs/server-build-plan.md) | Full server build plan (SRV tasks) | +| [docs/server-build-plan-consolidated.md](./docs/server-build-plan-consolidated.md) | Consolidated task reference | ## Supported assets (MVP) -| Asset | Network | Notes | -|-------|---------|-------| -| XLM | Stellar testnet | Native asset | -| USDC | Stellar testnet | Issuer via `STELLAR_USDC_ISSUER` in `.env` | +| Asset | Network | Notes | +| ----- | --------------- | ------------------------------------------ | +| XLM | Stellar testnet | Native asset | +| USDC | Stellar testnet | Issuer via `STELLAR_USDC_ISSUER` in `.env` | ## Environment variables diff --git a/docs/payment-request.v1.md b/docs/payment-request.v1.md new file mode 100644 index 0000000..d62d223 --- /dev/null +++ b/docs/payment-request.v1.md @@ -0,0 +1,165 @@ +# payment-request.v1 + +`payment-request.v1` is the canonical NFC payment request contract for Ding +mobile and server flows. Producers must emit this exact shape, and consumers +must reject invalid payloads with deterministic error codes. + +## Required Fields + +| Field | Type | Rule | +| ----------- | ------ | ----------------------------------------------------------------------------------------------------- | +| `type` | string | Must be `payment-request`. | +| `version` | number | Must be `1`. | +| `recipient` | string | Stellar public key in `G...` StrKey form: `^G[A-Z2-7]{55}$`. | +| `asset` | string | MVP supports only `XLM` and `USDC`. | +| `amount` | string | Positive decimal string with at most 7 decimal places. | +| `timestamp` | string | ISO 8601 UTC date-time within 5 minutes of server time. | +| `expiresAt` | string | ISO 8601 UTC date-time after `timestamp`, in the future, and no more than 24 hours after `timestamp`. | + +## Optional Fields + +| Field | Type | Rule | +| ----------- | ------ | ----------------------------------------------------------- | +| `memo` | string | Optional user-facing memo, up to 280 characters. | +| `requestId` | string | Optional idempotency or trace identifier, 1-128 characters. | +| `metadata` | object | Optional JSON object for non-critical integration context. | + +Unknown fields are rejected. Amounts are strings so NFC producers do not lose +precision through JSON number parsing. + +## Valid Payload + +```json +{ + "type": "payment-request", + "version": 1, + "recipient": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "asset": "XLM", + "amount": "12.3456789", + "timestamp": "2026-05-29T12:00:00.000Z", + "expiresAt": "2026-05-29T12:15:00.000Z", + "memo": "Coffee", + "requestId": "req_123", + "metadata": { + "table": 7 + } +} +``` + +## Common Invalid Payloads + +Unsupported asset: + +```json +{ + "type": "payment-request", + "version": 1, + "recipient": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "asset": "BTC", + "amount": "12.50", + "timestamp": "2026-05-29T12:00:00.000Z", + "expiresAt": "2026-05-29T12:15:00.000Z" +} +``` + +Error: + +```json +{ + "code": "PAYMENT_REQUEST_ASSET_UNSUPPORTED", + "message": "asset must be one of: XLM, USDC.", + "field": "asset" +} +``` + +Invalid amount: + +```json +{ + "type": "payment-request", + "version": 1, + "recipient": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "asset": "USDC", + "amount": "1.12345678", + "timestamp": "2026-05-29T12:00:00.000Z", + "expiresAt": "2026-05-29T12:15:00.000Z" +} +``` + +Error: + +```json +{ + "code": "PAYMENT_REQUEST_AMOUNT_INVALID", + "message": "amount must be a positive decimal string with at most 7 decimal places.", + "field": "amount" +} +``` + +Expiration before timestamp: + +```json +{ + "type": "payment-request", + "version": 1, + "recipient": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "asset": "XLM", + "amount": "12.50", + "timestamp": "2026-05-29T12:00:00.000Z", + "expiresAt": "2026-05-29T11:59:59.000Z" +} +``` + +Errors: + +```json +[ + { + "code": "PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW", + "message": "expiresAt must be in the future.", + "field": "expiresAt" + }, + { + "code": "PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW", + "message": "expiresAt must be after timestamp.", + "field": "expiresAt" + } +] +``` + +Invalid Stellar recipient: + +```json +{ + "type": "payment-request", + "version": 1, + "recipient": "not-stellar", + "asset": "XLM", + "amount": "12.50", + "timestamp": "2026-05-29T12:00:00.000Z", + "expiresAt": "2026-05-29T12:15:00.000Z" +} +``` + +Error: + +```json +{ + "code": "PAYMENT_REQUEST_RECIPIENT_INVALID", + "message": "recipient must be a Stellar public key starting with G.", + "field": "recipient" +} +``` + +## Versioning Strategy + +`type` identifies the protocol family and `version` identifies the exact +contract. Consumers should dispatch validation by `(type, version)`. + +Backward-compatible v1 changes may clarify documentation, add optional fields +that old consumers can ignore only after the unknown-field rule is intentionally +revised, or add examples without changing validation behavior. + +Breaking changes require a new version, such as `version: 2`. A future +`payment-request.v2` validator should live beside v1, keep v1 tests intact, and +allow clients and servers to negotiate or route by version during migration. diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e9f827..d45aa03 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,7 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', - "prettier/prettier": ["error", { endOfLine: "auto" }], + 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, }, ); diff --git a/package-lock.json b/package-lock.json index e78e31e..fd93898 100644 --- a/package-lock.json +++ b/package-lock.json @@ -238,7 +238,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -769,17 +768,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2192,7 +2180,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2339,7 +2326,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -2386,7 +2372,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", "license": "MIT", - "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -2482,7 +2467,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3476,7 +3460,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3647,7 +3630,6 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -4163,6 +4145,40 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -4408,7 +4424,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4470,7 +4485,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4947,7 +4961,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -5193,7 +5206,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -5251,15 +5263,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6027,7 +6037,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6088,7 +6097,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6348,7 +6356,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7495,7 +7502,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -9371,7 +9377,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -9636,7 +9641,6 @@ "integrity": "sha512-LjIqSIC5VYLzs9WedVmJ2ljNAGnU+DteIClbahu4L/DBeWjZ6iT/k1lAYyu9JUh+1xINxWadaPw/Pl63y/agAw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9696,7 +9700,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -9886,8 +9889,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -9984,7 +9986,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10634,7 +10635,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10950,7 +10950,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11133,7 +11132,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11487,6 +11485,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11505,6 +11504,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -11518,6 +11518,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11532,6 +11533,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -11541,7 +11543,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -11549,6 +11552,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/src/app.module.ts b/src/app.module.ts index 9a0606d..b06ccf5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { AppService } from './app.service'; import { DatabaseModule } from './database'; import { AuthModule } from './auth/auth.module'; import { SupabaseAuthGuard } from './auth/guards/supabase-auth.guard'; +import { PaymentRequestsModule } from './modules/payment-requests/payment-requests.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { SupabaseAuthGuard } from './auth/guards/supabase-auth.guard'; }), DatabaseModule, AuthModule, + PaymentRequestsModule, ], controllers: [AppController], providers: [ diff --git a/src/auth/decorators/current-user.decorator.spec.ts b/src/auth/decorators/current-user.decorator.spec.ts index b9f07cf..1f56894 100644 --- a/src/auth/decorators/current-user.decorator.spec.ts +++ b/src/auth/decorators/current-user.decorator.spec.ts @@ -19,7 +19,9 @@ describe('CurrentUser Decorator Logic', () => { }), } as unknown as ExecutionContext; - const request = mockContext.switchToHttp().getRequest(); + const request = mockContext + .switchToHttp() + .getRequest<{ user: AuthenticatedUser }>(); const result = request.user; expect(result).toEqual(mockUser); @@ -41,7 +43,9 @@ describe('CurrentUser Decorator Logic', () => { }), } as unknown as ExecutionContext; - const request = mockContext.switchToHttp().getRequest(); + const request = mockContext + .switchToHttp() + .getRequest<{ user: AuthenticatedUser }>(); const result = request.user; expect(result.supabaseUserId).toBe('user-456'); @@ -60,7 +64,9 @@ describe('CurrentUser Decorator Logic', () => { }), } as unknown as ExecutionContext; - const request = mockContext.switchToHttp().getRequest(); + const request = mockContext + .switchToHttp() + .getRequest<{ user: AuthenticatedUser }>(); const result = request.user; expect(result.supabaseUserId).toBe('user-789'); diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts index f090180..ca585f7 100644 --- a/src/auth/decorators/current-user.decorator.ts +++ b/src/auth/decorators/current-user.decorator.ts @@ -3,7 +3,9 @@ import { AuthenticatedUser } from '../../common/interfaces/authenticated-user.in export const CurrentUser = createParamDecorator( (data: unknown, ctx: ExecutionContext): AuthenticatedUser => { - const request = ctx.switchToHttp().getRequest(); + const request = ctx + .switchToHttp() + .getRequest<{ user: AuthenticatedUser }>(); return request.user; }, ); diff --git a/src/auth/guards/supabase-auth.guard.spec.ts b/src/auth/guards/supabase-auth.guard.spec.ts index 5f0c267..9105025 100644 --- a/src/auth/guards/supabase-auth.guard.spec.ts +++ b/src/auth/guards/supabase-auth.guard.spec.ts @@ -33,9 +33,8 @@ describe('SupabaseAuthGuard', () => { const mockHandler = () => {}; const mockClass = class {}; - const mockReflector = { - getAllAndOverride: jest.fn().mockReturnValue(true), - } as unknown as Reflector; + const getAllAndOverride = jest.fn().mockReturnValue(true); + const mockReflector = { getAllAndOverride } as unknown as Reflector; const guard = new SupabaseAuthGuard(mockReflector); @@ -44,11 +43,11 @@ describe('SupabaseAuthGuard', () => { getClass: () => mockClass, } as unknown as ExecutionContext; - guard.canActivate(mockContext); + void guard.canActivate(mockContext); - expect(mockReflector.getAllAndOverride).toHaveBeenCalledWith( - IS_PUBLIC_KEY, - [mockHandler, mockClass], - ); + expect(getAllAndOverride).toHaveBeenCalledWith(IS_PUBLIC_KEY, [ + mockHandler, + mockClass, + ]); }); }); diff --git a/src/auth/strategies/supabase.strategy.ts b/src/auth/strategies/supabase.strategy.ts index e4b98e7..d2bb4e9 100644 --- a/src/auth/strategies/supabase.strategy.ts +++ b/src/auth/strategies/supabase.strategy.ts @@ -24,8 +24,8 @@ export class SupabaseStrategy extends PassportStrategy(Strategy, 'supabase') { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secret: jwtSecret, - } as any); + secretOrKey: jwtSecret, + }); } async validate(payload: SupabaseJwtPayload): Promise { diff --git a/src/contracts/payment-request.v1.spec.ts b/src/contracts/payment-request.v1.spec.ts new file mode 100644 index 0000000..bfd6847 --- /dev/null +++ b/src/contracts/payment-request.v1.spec.ts @@ -0,0 +1,250 @@ +import { + PaymentRequestV1, + validatePaymentRequestV1, +} from './payment-request.v1'; + +const NOW = new Date('2026-05-29T12:00:00.000Z'); +const VALID_RECIPIENT = `G${'A'.repeat(55)}`; + +function validPayload( + overrides: Partial = {}, +): PaymentRequestV1 { + return { + type: 'payment-request', + version: 1, + recipient: VALID_RECIPIENT, + asset: 'XLM', + amount: '12.3456789', + timestamp: '2026-05-29T12:00:00.000Z', + expiresAt: '2026-05-29T12:15:00.000Z', + ...overrides, + }; +} + +describe('payment-request.v1 contract', () => { + it('accepts a valid minimal payment request', () => { + const result = validatePaymentRequestV1(validPayload(), NOW); + + expect(result).toEqual({ + valid: true, + value: validPayload(), + errors: [], + }); + }); + + it('accepts optional memo, requestId, and metadata fields', () => { + const payload = validPayload({ + memo: 'Coffee', + requestId: 'req_123', + metadata: { table: 7 }, + }); + + const result = validatePaymentRequestV1(payload, NOW); + + expect(result).toEqual({ + valid: true, + value: payload, + errors: [], + }); + }); + + it('rejects missing required fields deterministically', () => { + const payload: Partial = validPayload(); + delete payload.recipient; + + expect(validatePaymentRequestV1(payload, NOW)).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_FIELD_REQUIRED', + message: 'Missing required field: recipient.', + field: 'recipient', + }, + ], + }); + }); + + it('rejects unsupported contract type', () => { + expect( + validatePaymentRequestV1(validPayload({ type: 'invoice' as never }), NOW), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_TYPE_UNSUPPORTED', + message: 'type must be payment-request.', + field: 'type', + }, + ], + }); + }); + + it('rejects unsupported contract version', () => { + expect( + validatePaymentRequestV1(validPayload({ version: 2 as 1 }), NOW), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_VERSION_UNSUPPORTED', + message: 'version must be 1.', + field: 'version', + }, + ], + }); + }); + + it('rejects unsupported assets', () => { + expect( + validatePaymentRequestV1(validPayload({ asset: 'BTC' as never }), NOW), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_ASSET_UNSUPPORTED', + message: 'asset must be one of: XLM, USDC.', + field: 'asset', + }, + ], + }); + }); + + it.each(['0', '-1', '1.12345678', '1.', '01'])( + 'rejects invalid amount %s', + (amount) => { + expect(validatePaymentRequestV1(validPayload({ amount }), NOW)).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_AMOUNT_INVALID', + message: + 'amount must be a positive decimal string with at most 7 decimal places.', + field: 'amount', + }, + ], + }); + }, + ); + + it('rejects invalid Stellar recipients', () => { + expect( + validatePaymentRequestV1(validPayload({ recipient: 'not-stellar' }), NOW), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_RECIPIENT_INVALID', + message: 'recipient must be a Stellar public key starting with G.', + field: 'recipient', + }, + ], + }); + }); + + it('rejects timestamps outside the allowed server clock window', () => { + expect( + validatePaymentRequestV1( + validPayload({ timestamp: '2026-05-29T11:54:59.000Z' }), + NOW, + ), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_TIMESTAMP_OUT_OF_WINDOW', + message: 'timestamp must be within 5 minutes of server time.', + field: 'timestamp', + }, + ], + }); + }); + + it('rejects invalid timestamp formats', () => { + expect( + validatePaymentRequestV1( + validPayload({ timestamp: '2026-05-29 12:00:00' }), + NOW, + ), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_TIMESTAMP_INVALID', + message: 'timestamp must be an ISO 8601 UTC date-time string.', + field: 'timestamp', + }, + ], + }); + }); + + it('rejects expirations before the timestamp', () => { + expect( + validatePaymentRequestV1( + validPayload({ expiresAt: '2026-05-29T11:59:59.000Z' }), + NOW, + ), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW', + message: 'expiresAt must be in the future.', + field: 'expiresAt', + }, + { + code: 'PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW', + message: 'expiresAt must be after timestamp.', + field: 'expiresAt', + }, + ], + }); + }); + + it('rejects expirations more than 24 hours after the timestamp', () => { + expect( + validatePaymentRequestV1( + validPayload({ expiresAt: '2026-05-30T12:00:01.000Z' }), + NOW, + ), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW', + message: 'expiresAt must be no more than 24 hours after timestamp.', + field: 'expiresAt', + }, + ], + }); + }); + + it('rejects non-object metadata', () => { + expect( + validatePaymentRequestV1(validPayload({ metadata: [] as never }), NOW), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_METADATA_INVALID', + message: 'metadata must be a JSON object when provided.', + field: 'metadata', + }, + ], + }); + }); + + it('rejects unknown fields', () => { + expect( + validatePaymentRequestV1({ ...validPayload(), extra: true }, NOW), + ).toEqual({ + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_FIELD_UNKNOWN', + message: 'Unknown field: extra.', + field: 'payload', + }, + ], + }); + }); +}); diff --git a/src/contracts/payment-request.v1.ts b/src/contracts/payment-request.v1.ts new file mode 100644 index 0000000..036c31c --- /dev/null +++ b/src/contracts/payment-request.v1.ts @@ -0,0 +1,432 @@ +export const PAYMENT_REQUEST_V1_TYPE = 'payment-request'; +export const PAYMENT_REQUEST_V1_VERSION = 1; + +export const PAYMENT_REQUEST_V1_SUPPORTED_ASSETS = ['XLM', 'USDC'] as const; + +export type PaymentRequestV1Asset = + (typeof PAYMENT_REQUEST_V1_SUPPORTED_ASSETS)[number]; + +export interface PaymentRequestV1 { + type: typeof PAYMENT_REQUEST_V1_TYPE; + version: typeof PAYMENT_REQUEST_V1_VERSION; + recipient: string; + asset: PaymentRequestV1Asset; + amount: string; + timestamp: string; + expiresAt: string; + memo?: string; + requestId?: string; + metadata?: Record; +} + +export interface PaymentRequestValidationError { + code: PaymentRequestValidationErrorCode; + message: string; + field?: keyof PaymentRequestV1 | 'payload'; +} + +export type PaymentRequestValidationErrorCode = + | 'PAYMENT_REQUEST_PAYLOAD_INVALID' + | 'PAYMENT_REQUEST_FIELD_REQUIRED' + | 'PAYMENT_REQUEST_FIELD_UNKNOWN' + | 'PAYMENT_REQUEST_TYPE_UNSUPPORTED' + | 'PAYMENT_REQUEST_VERSION_UNSUPPORTED' + | 'PAYMENT_REQUEST_RECIPIENT_INVALID' + | 'PAYMENT_REQUEST_ASSET_UNSUPPORTED' + | 'PAYMENT_REQUEST_AMOUNT_INVALID' + | 'PAYMENT_REQUEST_TIMESTAMP_INVALID' + | 'PAYMENT_REQUEST_TIMESTAMP_OUT_OF_WINDOW' + | 'PAYMENT_REQUEST_EXPIRES_AT_INVALID' + | 'PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW' + | 'PAYMENT_REQUEST_METADATA_INVALID'; + +export type PaymentRequestValidationResult = + | { + valid: true; + value: PaymentRequestV1; + errors: []; + } + | { + valid: false; + errors: PaymentRequestValidationError[]; + }; + +export const paymentRequestV1JsonSchema = { + $id: 'https://ding-payments.dev/contracts/payment-request.v1.schema.json', + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'payment-request.v1', + type: 'object', + additionalProperties: false, + required: [ + 'type', + 'version', + 'recipient', + 'asset', + 'amount', + 'timestamp', + 'expiresAt', + ], + properties: { + type: { const: PAYMENT_REQUEST_V1_TYPE }, + version: { const: PAYMENT_REQUEST_V1_VERSION }, + recipient: { + type: 'string', + pattern: '^G[A-Z2-7]{55}$', + }, + asset: { + type: 'string', + enum: PAYMENT_REQUEST_V1_SUPPORTED_ASSETS, + }, + amount: { + type: 'string', + pattern: '^(?!0+(?:\\.0{1,7})?$)(?:0|[1-9]\\d*)(?:\\.\\d{1,7})?$', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + expiresAt: { + type: 'string', + format: 'date-time', + }, + memo: { + type: 'string', + maxLength: 280, + }, + requestId: { + type: 'string', + minLength: 1, + maxLength: 128, + }, + metadata: { + type: 'object', + additionalProperties: true, + }, + }, +} as const; + +const ALLOWED_FIELDS = new Set([ + 'type', + 'version', + 'recipient', + 'asset', + 'amount', + 'timestamp', + 'expiresAt', + 'memo', + 'requestId', + 'metadata', +]); + +const REQUIRED_FIELDS: Array = [ + 'type', + 'version', + 'recipient', + 'asset', + 'amount', + 'timestamp', + 'expiresAt', +]; + +const STELLAR_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/; +const ISO_UTC_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; +const AMOUNT_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/; +const MAX_DECIMAL_PLACES = 7; +const TIMESTAMP_CLOCK_SKEW_MS = 5 * 60 * 1000; +const MAX_EXPIRATION_WINDOW_MS = 24 * 60 * 60 * 1000; + +export function validatePaymentRequestV1( + payload: unknown, + now = new Date(), +): PaymentRequestValidationResult { + const errors: PaymentRequestValidationError[] = []; + + if (!isPlainObject(payload)) { + return { + valid: false, + errors: [ + { + code: 'PAYMENT_REQUEST_PAYLOAD_INVALID', + message: 'Payment request payload must be a JSON object.', + field: 'payload', + }, + ], + }; + } + + for (const field of Object.keys(payload).sort()) { + if (!ALLOWED_FIELDS.has(field)) { + errors.push({ + code: 'PAYMENT_REQUEST_FIELD_UNKNOWN', + message: `Unknown field: ${field}.`, + field: 'payload', + }); + } + } + + for (const field of REQUIRED_FIELDS) { + if (!(field in payload)) { + errors.push({ + code: 'PAYMENT_REQUEST_FIELD_REQUIRED', + message: `Missing required field: ${field}.`, + field, + }); + } + } + + validateType(payload.type, errors); + validateVersion(payload.version, errors); + validateRecipient(payload.recipient, errors); + validateAsset(payload.asset, errors); + validateAmount(payload.amount, errors); + validateMetadata(payload.metadata, errors); + + const timestamp = parseIsoUtcDate(payload.timestamp); + const expiresAt = parseIsoUtcDate(payload.expiresAt); + + validateTimestamp(payload.timestamp, timestamp, now, errors); + validateExpiresAt(payload.expiresAt, expiresAt, timestamp, now, errors); + + if (errors.length > 0) { + return { valid: false, errors }; + } + + return { + valid: true, + value: payload as unknown as PaymentRequestV1, + errors: [], + }; +} + +function validateType( + type: unknown, + errors: PaymentRequestValidationError[], +): void { + if (type === undefined) { + return; + } + + if (type !== PAYMENT_REQUEST_V1_TYPE) { + errors.push({ + code: 'PAYMENT_REQUEST_TYPE_UNSUPPORTED', + message: 'type must be payment-request.', + field: 'type', + }); + } +} + +function validateVersion( + version: unknown, + errors: PaymentRequestValidationError[], +): void { + if (version === undefined) { + return; + } + + if (version !== PAYMENT_REQUEST_V1_VERSION) { + errors.push({ + code: 'PAYMENT_REQUEST_VERSION_UNSUPPORTED', + message: 'version must be 1.', + field: 'version', + }); + } +} + +function validateRecipient( + recipient: unknown, + errors: PaymentRequestValidationError[], +): void { + if (recipient === undefined) { + return; + } + + if ( + typeof recipient !== 'string' || + !STELLAR_ADDRESS_PATTERN.test(recipient) + ) { + errors.push({ + code: 'PAYMENT_REQUEST_RECIPIENT_INVALID', + message: 'recipient must be a Stellar public key starting with G.', + field: 'recipient', + }); + } +} + +function validateAsset( + asset: unknown, + errors: PaymentRequestValidationError[], +): void { + if (asset === undefined) { + return; + } + + if ( + typeof asset !== 'string' || + !PAYMENT_REQUEST_V1_SUPPORTED_ASSETS.includes( + asset as PaymentRequestV1Asset, + ) + ) { + errors.push({ + code: 'PAYMENT_REQUEST_ASSET_UNSUPPORTED', + message: 'asset must be one of: XLM, USDC.', + field: 'asset', + }); + } +} + +function validateAmount( + amount: unknown, + errors: PaymentRequestValidationError[], +): void { + if (amount === undefined) { + return; + } + + if (typeof amount !== 'string' || !AMOUNT_PATTERN.test(amount)) { + errors.push({ + code: 'PAYMENT_REQUEST_AMOUNT_INVALID', + message: + 'amount must be a positive decimal string with at most 7 decimal places.', + field: 'amount', + }); + return; + } + + const [, fractional = ''] = amount.split('.'); + const numericAmount = Number(amount); + + if ( + !Number.isFinite(numericAmount) || + numericAmount <= 0 || + fractional.length > MAX_DECIMAL_PLACES + ) { + errors.push({ + code: 'PAYMENT_REQUEST_AMOUNT_INVALID', + message: + 'amount must be a positive decimal string with at most 7 decimal places.', + field: 'amount', + }); + } +} + +function validateTimestamp( + timestampValue: unknown, + timestamp: Date | undefined, + now: Date, + errors: PaymentRequestValidationError[], +): void { + if (timestampValue === undefined) { + return; + } + + if (!timestamp) { + errors.push({ + code: 'PAYMENT_REQUEST_TIMESTAMP_INVALID', + message: 'timestamp must be an ISO 8601 UTC date-time string.', + field: 'timestamp', + }); + return; + } + + const earliestTimestamp = now.getTime() - TIMESTAMP_CLOCK_SKEW_MS; + const latestTimestamp = now.getTime() + TIMESTAMP_CLOCK_SKEW_MS; + + if ( + timestamp.getTime() < earliestTimestamp || + timestamp.getTime() > latestTimestamp + ) { + errors.push({ + code: 'PAYMENT_REQUEST_TIMESTAMP_OUT_OF_WINDOW', + message: 'timestamp must be within 5 minutes of server time.', + field: 'timestamp', + }); + } +} + +function validateExpiresAt( + expiresAtValue: unknown, + expiresAt: Date | undefined, + timestamp: Date | undefined, + now: Date, + errors: PaymentRequestValidationError[], +): void { + if (expiresAtValue === undefined) { + return; + } + + if (!expiresAt) { + errors.push({ + code: 'PAYMENT_REQUEST_EXPIRES_AT_INVALID', + message: 'expiresAt must be an ISO 8601 UTC date-time string.', + field: 'expiresAt', + }); + return; + } + + if (expiresAt.getTime() <= now.getTime()) { + errors.push({ + code: 'PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW', + message: 'expiresAt must be in the future.', + field: 'expiresAt', + }); + } + + if (timestamp && expiresAt.getTime() <= timestamp.getTime()) { + errors.push({ + code: 'PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW', + message: 'expiresAt must be after timestamp.', + field: 'expiresAt', + }); + } + + if ( + timestamp && + expiresAt.getTime() - timestamp.getTime() > MAX_EXPIRATION_WINDOW_MS + ) { + errors.push({ + code: 'PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW', + message: 'expiresAt must be no more than 24 hours after timestamp.', + field: 'expiresAt', + }); + } +} + +function validateMetadata( + metadata: unknown, + errors: PaymentRequestValidationError[], +): void { + if (metadata === undefined) { + return; + } + + if (!isPlainObject(metadata)) { + errors.push({ + code: 'PAYMENT_REQUEST_METADATA_INVALID', + message: 'metadata must be a JSON object when provided.', + field: 'metadata', + }); + } +} + +function parseIsoUtcDate(value: unknown): Date | undefined { + if (typeof value !== 'string' || !ISO_UTC_PATTERN.test(value)) { + return undefined; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return undefined; + } + + return date; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} diff --git a/src/modules/payment-requests/payment-requests.controller.spec.ts b/src/modules/payment-requests/payment-requests.controller.spec.ts new file mode 100644 index 0000000..282bec1 --- /dev/null +++ b/src/modules/payment-requests/payment-requests.controller.spec.ts @@ -0,0 +1,33 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PaymentRequestsController } from './payment-requests.controller'; +import { PaymentRequestsService } from './payment-requests.service'; + +describe('PaymentRequestsController', () => { + let controller: PaymentRequestsController; + let service: PaymentRequestsService; + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [PaymentRequestsController], + providers: [PaymentRequestsService], + }).compile(); + + controller = moduleRef.get(PaymentRequestsController); + service = moduleRef.get(PaymentRequestsService); + }); + + it('delegates the payload to the service and returns its result', () => { + const expected = { + valid: true as const, + normalized: undefined, + errors: [], + }; + const spy = jest.spyOn(service, 'validate').mockReturnValue(expected); + const payload = { type: 'payment-request' }; + + const result = controller.validate(payload); + + expect(spy).toHaveBeenCalledWith(payload); + expect(result).toBe(expected); + }); +}); diff --git a/src/modules/payment-requests/payment-requests.controller.ts b/src/modules/payment-requests/payment-requests.controller.ts new file mode 100644 index 0000000..517e2af --- /dev/null +++ b/src/modules/payment-requests/payment-requests.controller.ts @@ -0,0 +1,54 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Public } from '../../auth/decorators/public.decorator'; +import { PaymentRequestsService } from './payment-requests.service'; +import type { PaymentRequestValidationResponse } from './payment-requests.service'; + +@ApiTags('payment-requests') +@Controller('payment-requests') +export class PaymentRequestsController { + constructor( + private readonly paymentRequestsService: PaymentRequestsService, + ) {} + + @Public() + @Post('validate') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Validate a payment-request.v1 payload', + description: + 'Stateless validation of an NFC payment-request.v1 payload against the canonical contract. Returns HTTP 200 for both valid and invalid payloads.', + }) + @ApiBody({ + description: 'Canonical payment-request.v1 payload.', + schema: { type: 'object' }, + examples: { + valid: { + summary: 'Valid USDC request', + value: { + type: 'payment-request', + version: 1, + recipient: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + asset: 'USDC', + amount: '25.00', + timestamp: '2026-06-17T12:00:00.000Z', + expiresAt: '2026-06-17T12:00:30.000Z', + }, + }, + }, + }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { + valid: { type: 'boolean' }, + normalized: { type: 'object', nullable: true }, + errors: { type: 'array', items: { type: 'object' } }, + }, + required: ['valid', 'errors'], + }, + }) + validate(@Body() payload: unknown): PaymentRequestValidationResponse { + return this.paymentRequestsService.validate(payload); + } +} diff --git a/src/modules/payment-requests/payment-requests.module.ts b/src/modules/payment-requests/payment-requests.module.ts new file mode 100644 index 0000000..81d7bed --- /dev/null +++ b/src/modules/payment-requests/payment-requests.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PaymentRequestsController } from './payment-requests.controller'; +import { PaymentRequestsService } from './payment-requests.service'; + +@Module({ + controllers: [PaymentRequestsController], + providers: [PaymentRequestsService], + exports: [PaymentRequestsService], +}) +export class PaymentRequestsModule {} diff --git a/src/modules/payment-requests/payment-requests.service.spec.ts b/src/modules/payment-requests/payment-requests.service.spec.ts new file mode 100644 index 0000000..fea723f --- /dev/null +++ b/src/modules/payment-requests/payment-requests.service.spec.ts @@ -0,0 +1,59 @@ +import { PaymentRequestsService } from './payment-requests.service'; +import { PaymentRequestV1 } from '../../contracts/payment-request.v1'; + +function validPayload( + overrides: Partial = {}, +): Record { + const now = Date.now(); + return { + type: 'payment-request', + version: 1, + recipient: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + asset: 'USDC', + amount: '25.00', + timestamp: new Date(now).toISOString(), + expiresAt: new Date(now + 30_000).toISOString(), + ...overrides, + }; +} + +describe('PaymentRequestsService', () => { + let service: PaymentRequestsService; + + beforeEach(() => { + service = new PaymentRequestsService(); + }); + + it('returns the normalized payload for a valid request', () => { + const payload = validPayload(); + + const result = service.validate(payload); + + expect(result).toEqual({ + valid: true, + normalized: payload, + errors: [], + }); + }); + + it('returns errors without a normalized payload for an invalid request', () => { + const result = service.validate(validPayload({ asset: 'BTC' as never })); + + expect(result.valid).toBe(false); + expect(result.normalized).toBeUndefined(); + expect(result.errors).toEqual([ + { + code: 'PAYMENT_REQUEST_ASSET_UNSUPPORTED', + message: 'asset must be one of: XLM, USDC.', + field: 'asset', + }, + ]); + }); + + it('rejects a non-object payload', () => { + const result = service.validate('not-an-object'); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe('PAYMENT_REQUEST_PAYLOAD_INVALID'); + }); +}); diff --git a/src/modules/payment-requests/payment-requests.service.ts b/src/modules/payment-requests/payment-requests.service.ts new file mode 100644 index 0000000..21e4f63 --- /dev/null +++ b/src/modules/payment-requests/payment-requests.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { + PaymentRequestV1, + PaymentRequestValidationError, + validatePaymentRequestV1, +} from '../../contracts/payment-request.v1'; + +export interface PaymentRequestValidationResponse { + valid: boolean; + normalized?: PaymentRequestV1; + errors: PaymentRequestValidationError[]; +} + +@Injectable() +export class PaymentRequestsService { + validate(payload: unknown): PaymentRequestValidationResponse { + const result = validatePaymentRequestV1(payload, new Date()); + + if (result.valid) { + return { valid: true, normalized: result.value, errors: [] }; + } + + return { valid: false, errors: result.errors }; + } +} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index febe4b7..258150a 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -12,6 +12,7 @@ import helmet from 'helmet'; import compression from 'compression'; import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter'; import { AppModule } from './../src/app.module'; +import { PrismaService } from '../src/database/prisma.service'; import type { Application as ExpressApplication } from 'express'; describe('AppController (e2e)', () => { @@ -55,7 +56,10 @@ describe('AppController (e2e)', () => { }), AppModule, ], - }).compile(); + }) + .overrideProvider(PrismaService) + .useValue({ $connect: jest.fn(), $disconnect: jest.fn() }) + .compile(); app = moduleFixture.createNestApplication(); app.use(helmet()); diff --git a/test/payment-requests-validate.e2e-spec.ts b/test/payment-requests-validate.e2e-spec.ts new file mode 100644 index 0000000..174ed9e --- /dev/null +++ b/test/payment-requests-validate.e2e-spec.ts @@ -0,0 +1,174 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; +import request from 'supertest'; +import { ConfigModule } from '@nestjs/config'; +import { envValidationSchema } from '../src/config/env.validation'; +import configuration from '../src/config/configuration'; +import helmet from 'helmet'; +import compression from 'compression'; +import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter'; +import { AppModule } from '../src/app.module'; +import { PrismaService } from '../src/database/prisma.service'; +import type { Application as ExpressApplication } from 'express'; + +const VALIDATE_ROUTE = '/v1/payment-requests/validate'; + +interface ValidationBody { + valid: boolean; + normalized?: Record; + errors: Array<{ code: string }>; +} + +function validPayload(overrides: Record = {}) { + const now = Date.now(); + return { + type: 'payment-request', + version: 1, + recipient: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + asset: 'USDC', + amount: '25.00', + timestamp: new Date(now).toISOString(), + expiresAt: new Date(now + 30_000).toISOString(), + ...overrides, + }; +} + +describe('PaymentRequests validate (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + process.env.NODE_ENV = 'test'; + process.env.PORT = '3000'; + process.env.API_PREFIX = 'v1'; + process.env.CORS_ORIGINS = 'http://localhost:8081'; + process.env.DATABASE_URL = + 'postgresql://postgres:password@localhost:5432/postgres'; + process.env.DIRECT_URL = + 'postgresql://postgres:password@localhost:5432/postgres'; + process.env.SUPABASE_URL = 'https://example.supabase.co'; + process.env.SUPABASE_JWT_SECRET = 'secret'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service_role'; + process.env.STELLAR_NETWORK = 'testnet'; + process.env.STELLAR_HORIZON_URL = 'https://horizon-testnet.stellar.org'; + process.env.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org'; + process.env.STELLAR_USDC_ISSUER = + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + process.env.STELLAR_NETWORK_PASSPHRASE = + 'Test SDF Network ; September 2015'; + process.env.WEBAUTHN_RP_ID = 'localhost'; + process.env.WEBAUTHN_RP_NAME = 'Ding Payments'; + process.env.WEBAUTHN_ORIGIN = 'http://localhost:8081'; + process.env.PAYMENT_SUBMIT_TIMEOUT_MS = '300000'; + process.env.PAYMENT_POLL_INTERVAL_MS = '2000'; + process.env.PAYMENT_POLL_MAX_ATTEMPTS = '30'; + process.env.THROTTLE_TTL_MS = '60000'; + process.env.THROTTLE_LIMIT = '100'; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: envValidationSchema, + validationOptions: { abortEarly: false, allowUnknown: false }, + load: [configuration], + }), + AppModule, + ], + }) + .overrideProvider(PrismaService) + .useValue({ $connect: jest.fn(), $disconnect: jest.fn() }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.use(helmet()); + app.use(compression()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); + app.useGlobalFilters(new HttpExceptionFilter()); + + await app.init(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + it('accepts a valid USDC payload without authentication', async () => { + const response = await request(app.getHttpServer()) + .post(VALIDATE_ROUTE) + .send(validPayload()); + + const body = response.body as ValidationBody; + expect(response.status).toBe(200); + expect(body).toMatchObject({ valid: true, errors: [] }); + expect(body.normalized).toMatchObject({ asset: 'USDC' }); + }); + + it('rejects an unsupported asset with 200 and an asset error code', async () => { + const response = await request(app.getHttpServer()) + .post(VALIDATE_ROUTE) + .send(validPayload({ asset: 'BTC' })); + + const body = response.body as ValidationBody; + expect(response.status).toBe(200); + expect(body.valid).toBe(false); + expect(body.errors).toContainEqual( + expect.objectContaining({ code: 'PAYMENT_REQUEST_ASSET_UNSUPPORTED' }), + ); + }); + + it('rejects a non-Stellar recipient', async () => { + const response = await request(app.getHttpServer()) + .post(VALIDATE_ROUTE) + .send(validPayload({ recipient: '0x1234567890abcdef' })); + + const body = response.body as ValidationBody; + expect(response.status).toBe(200); + expect(body.valid).toBe(false); + expect(body.errors).toContainEqual( + expect.objectContaining({ code: 'PAYMENT_REQUEST_RECIPIENT_INVALID' }), + ); + }); + + it('rejects an expiresAt in the past', async () => { + const past = new Date(Date.now() - 60_000).toISOString(); + const response = await request(app.getHttpServer()) + .post(VALIDATE_ROUTE) + .send(validPayload({ expiresAt: past })); + + const body = response.body as ValidationBody; + expect(response.status).toBe(200); + expect(body.valid).toBe(false); + expect(body.errors).toContainEqual( + expect.objectContaining({ + code: 'PAYMENT_REQUEST_EXPIRES_AT_OUT_OF_WINDOW', + }), + ); + }); + + it('rejects unknown fields with 200 instead of a pipe 400', async () => { + const response = await request(app.getHttpServer()) + .post(VALIDATE_ROUTE) + .send({ ...validPayload(), unexpected: true }); + + const body = response.body as ValidationBody; + expect(response.status).toBe(200); + expect(body.valid).toBe(false); + expect(body.errors).toContainEqual( + expect.objectContaining({ code: 'PAYMENT_REQUEST_FIELD_UNKNOWN' }), + ); + }); +});