diff --git a/docs/RECURRING_WITHDRAWALS.md b/docs/RECURRING_WITHDRAWALS.md new file mode 100644 index 00000000..7151aac1 --- /dev/null +++ b/docs/RECURRING_WITHDRAWALS.md @@ -0,0 +1,32 @@ +# Recurring on-chain withdrawals + +Create an owner-scoped plan at `POST /api/v1/recurring-withdrawals` with +`confirmed: true`. WEEKLY, BIWEEKLY and MONTHLY plans send the held asset without +conversion using FIXED, YIELD_ONLY or PERCENT_OF_BALANCE amounts. List, read, +patch, cancel and `POST /:id/preview` use the same resource prefix. + +Insufficient funds or amounts below minAmount skip the occurrence and roll the +cadence forward. The scheduler calls executeWithdraw, sharing risk scoring, +approval policies and goal checks with manual withdrawals. Risk and freeze +holds pause the plan. Goal conflicts skip and notify; no unattended goal +acknowledgement is allowed. Destination changes pause the plan for review. + +Manual withdrawals may explicitly acknowledge a goal impact with +`acknowledgeGoalImpact: true`; scheduled occurrences never supply this flag. +Acknowledgement does not bypass destination risk, compliance or approvals. +Read-only external-holdings links do not establish trusted send destinations; +destination history comes from exact confirmed outbox payloads. Fractional +scheduled amounts round down to Stellar's seven decimal places. + +The shared withdrawal path reserves pending withdrawal amounts under a database +advisory lock per owner/asset before creating a transaction intent. Concurrent +manual and scheduled requests therefore check available funds after existing +reservations; an insufficient occurrence rolls forward without on-chain dispatch. + +Claims compare both the previous run timestamp and state, including null values. +An interrupted occurrence with an expired execution lease is paused for review: +check its transaction/outbox intent before resuming to avoid duplicate sends. +Approval requests advance the cadence so one occurrence cannot create repeated +requests. Holds and skips are delivered as withdrawal events on the alerts stream. + +Migration rollback is in `prisma/rollback/20261008090000_recurring_withdrawals.sql`. diff --git a/docs/SUB_ACCOUNT_BULK.md b/docs/SUB_ACCOUNT_BULK.md new file mode 100644 index 00000000..03f660ca --- /dev/null +++ b/docs/SUB_ACCOUNT_BULK.md @@ -0,0 +1,27 @@ +# Bulk sub-account management + +POST `/api/v1/sub-accounts/bulk` with `operations` and optional `atomic: true`. +Supported actions are create, update, setPermission, setLimit and revoke. +Create requires childUserId and a payload with 1–4 valid permissions. Other +actions require exactly one childUserId or subAccountId and their action-specific +payload. Limits are positive finite numbers; nullable update limits clear a cap. + +Every row uses the same management services as single create, permission, limit +and revoke calls, including ownership, chained-account and duplicate checks. +Rows execute sequentially, so later updates see earlier changes. By default, +malformed or unauthorized rows fail independently and return their index, +HTTP-equivalent status and reason while valid rows continue. + +Atomic mode wraps all rows in one database transaction. On failure, no changes +persist, and the 400 response includes failedIndex, rolledBack and each row's +failure, rollback or skipped state. Successful per-operation audit entries are +emitted only after commit, so rolled-back operations are never reported as +successful changes. + +The default maximum is 100 rows. Set `SUB_ACCOUNT_MAX_BATCH_SIZE` to a positive +integer up to 100 to lower the limit. Oversized requests are rejected before any +row executes. + +GET `/api/v1/sub-accounts/summary` reports child counts, active permission +distribution, daily/transaction-limit exposure, unlimited daily-limit children, +and transaction activity during the last 30 days. All queries are parent-scoped. diff --git a/docs/SUPPORT_TICKETS.md b/docs/SUPPORT_TICKETS.md new file mode 100644 index 00000000..0201ae9c --- /dev/null +++ b/docs/SUPPORT_TICKETS.md @@ -0,0 +1,31 @@ +# Support Ticket System Architecture (#552) + +## Overview +The General-Purpose Support Ticket System provides users with a structured, tracked mechanism to raise issues, submit questions, and interact with support admins through resolution. Structurally similar to the compliance-case system, support tickets are fully user-visible and collaborative. + +## Data Model +- **SupportTicket**: Represents a support ticket (`id`, `userId`, `subject`, `category`, `status`, `priority`, `assignedTo`, `contextRef`, `slaBreached`, `createdAt`, `updatedAt`, `resolvedAt`). +- **TicketMessage**: An append-only thread message (`id`, `ticketId`, `authorUserId`, `authorRole`, `body`, `attachmentRefs`, `internal`, `createdAt`). + +### Status State Machine +- `OPEN` → `IN_PROGRESS` → `AWAITING_USER` → `RESOLVED` → `CLOSED` +- **Auto-Reopen**: When a user replies to a ticket in `RESOLVED` status, the ticket automatically reopens to `IN_PROGRESS` with `resolvedAt` reset to `null`. + +## Security & Privacy Boundary +- **Owner-Scoped Read/Reply**: Users can only view and reply to their own tickets. +- **Internal Note Isolation**: Admin notes marked `internal: true` are filtered out at the **database query level** on all user-facing read paths (`where: { internal: false }`). +- **Audit Logging**: All admin updates (status, priority, assignment) and admin replies are recorded in audit logs. +- **Rate Limiting**: Ticket creation is protected by rate limiting to prevent abuse. + +## API Endpoints +- User-facing: + - `POST /api/v1/support/tickets` - Create new ticket + - `GET /api/v1/support/tickets` - List user tickets + - `GET /api/v1/support/tickets/:id` - View ticket thread + - `POST /api/v1/support/tickets/:id/reply` - User reply +- Admin-facing: + - `GET /api/v1/admin/support/tickets` - Filterable ticket queue (status, category, assignee, SLA) + - `PATCH /api/v1/admin/support/tickets/:id` - Manage status, priority, assignment + - `POST /api/v1/admin/support/tickets/:id/reply` - Admin reply or internal note + +Admin reads require support:read; updates and replies require support:write. Status transitions are enforced and status/assignment/priority changes commit with an audit record. SLA defaults are LOW 72h, MEDIUM 24h, HIGH 8h and URGENT 2h. Override these with SUPPORT_SLA__HOURS. Breaches are computed from creation time and exclude resolved/closed tickets. Internal notes are filtered by the database on user reads. User replies to RESOLVED or AWAITING_USER move the ticket to IN_PROGRESS. diff --git a/docs/WEBAUTHN.md b/docs/WEBAUTHN.md new file mode 100644 index 00000000..18aae9a2 --- /dev/null +++ b/docs/WEBAUTHN.md @@ -0,0 +1,46 @@ +# Optional passkeys + +Wallet-signature login remains the account identity and recovery mechanism. +Only an active session can enroll, list or remove passkeys. API keys cannot +enroll credentials. Enrollment never creates a user or replaces a wallet. + +Set `WEBAUTHN_RP_ID` to the relying-party domain and `WEBAUTHN_ORIGIN` to the +exact HTTPS frontend origin, without a trailing slash. Optionally set +`WEBAUTHN_RP_NAME`. Production/staging require explicit configuration. Development +defaults to `localhost` and `http://localhost:3000`. Request Host headers never +determine trusted RP or origin values. + +1. Call `POST /api/v1/auth/webauthn/register-options` with an active session. +2. Pass `options` to a WebAuthn browser client, then POST its serialized result + as `response` with `challengeId` and optional `deviceLabel` to `register-verify`. +3. For login, request `login-options` with optional `userId`. Omitting userId + supports discoverable passkeys. Send the assertion and challengeId to + `login-verify`. +4. List metadata at `GET /api/v1/webauthn/credentials` and delete an owned + credential at `DELETE /api/v1/webauthn/credentials/:id`. + +Challenges expire after five minutes, live in the database for multi-instance +deployments, and are consumed atomically before verification. Registration +challenges are tied to the issuing user and session. Malformed requests and +replayed, expired, cross-session or wrong-account challenges are rejected. + +SimpleWebAuthn verifies challenges, signatures, origins and RP IDs. User +verification is required. Attestation uses `none` for platform and synced +passkeys without identifying-device certificates; unsupported or invalid +attestations are rejected by the library. This policy does not claim a hardware +vendor allowlist or enterprise device trust. + +Positive signature counters must strictly increase. Stalls/regressions reject +login and trigger security alerts, including anomalies thrown by the library. +Authenticators that consistently report zero are supported, as required for +common synced passkeys; their replay defense is the single-use challenge. +Counter updates compare against the previous database value to prevent races. +Only public keys are stored; credential-list responses contain metadata only. + +Successful logins use the wallet flow's access/refresh token service, session +metadata and new-session alert. Enrolled TOTP still requires its verification +step before a session is issued. Credential changes and anomalies also publish +alerts to the authenticated user stream. + +Reference: https://simplewebauthn.dev/docs/packages/server +Rollback: `prisma/rollback/20261008092000_webauthn.sql`. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 145748b3..69984a70 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -54,6 +54,10 @@ tags: # ─── Reusable components ────────────────────────────────────────────────────── components: securitySchemes: + adminAuth: + type: http + scheme: bearer + description: Admin API key with the required resource scope bearerAuth: type: http scheme: bearer @@ -1467,6 +1471,662 @@ components: # ─── Paths ──────────────────────────────────────────────────────────────────── paths: + /sub-accounts/bulk: + post: + tags: + - Auth + operationId: bulkSubAccountManagement + summary: Sequential, individually authorized sub-account operations with partial results or atomic rollback + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + atomic: + type: boolean + default: false + operations: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: + - action + properties: + action: + type: string + enum: + - create + - update + - setPermission + - setLimit + - revoke + childUserId: &ref_0 + type: string + format: uuid + subAccountId: *ref_0 + payload: + type: object + properties: + permissions: + type: array + minItems: 1 + maxItems: 4 + items: + type: string + enum: + - VIEW + - DEPOSIT + - WITHDRAW + - REBALANCE + dailyLimit: &ref_1 + type: number + minimum: 0 + exclusiveMinimum: true + nullable: true + transactionLimit: *ref_1 + required: + - operations + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + description: Create requires childUserId and nonempty permissions. Other actions require exactly one target and action-specific payload. Invalid rows fail independently by default. Atomic errors include failedIndex, rolledBack and per-row rollback/skipped results. SUB_ACCOUNT_MAX_BATCH_SIZE can lower the default 100-row limit. + /sub-accounts/summary: + get: + tags: + - Auth + operationId: subAccountSummary + summary: Summarize owned children, permissions, daily-limit exposure, unlimited limits and 30-day activity + security: + - bearerAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /sub-accounts/{id}/limits: + parameters: + - name: id + in: path + required: true + schema: *ref_0 + patch: + tags: + - Auth + operationId: updateSubAccountLimits + summary: Update owned child limits using the same primitive as bulk setLimit + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dailyLimit: *ref_1 + transactionLimit: *ref_1 + required: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /auth/webauthn/register-options: + post: + tags: + - Auth + operationId: webAuthnRegisterOptions + summary: Generate registration options from an active wallet-established session + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + deviceLabel: + type: string + maxLength: 100 + required: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /auth/webauthn/register-verify: + post: + tags: + - Auth + operationId: webAuthnRegisterVerify + summary: Verify attestation, RP, origin, session-bound challenge and user verification + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + challengeId: &ref_1 + type: string + pattern: ^[A-Za-z0-9_-]+$ + response: &ref_2 + type: object + additionalProperties: true + description: The complete navigator.credentials response serialized as WebAuthn JSON + deviceLabel: + type: string + maxLength: 100 + required: + - challengeId + - response + responses: + '201': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /auth/webauthn/login-options: + post: + tags: + - Auth + operationId: webAuthnLoginOptions + summary: Generate account-specific or discoverable passkey login options + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + userId: &ref_0 + type: string + format: uuid + required: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /auth/webauthn/login-verify: + post: + tags: + - Auth + operationId: webAuthnLoginVerify + summary: Verify single-use assertion and counter; issue a normal session or enrolled TOTP challenge + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + userId: *ref_0 + challengeId: *ref_1 + response: *ref_2 + required: + - challengeId + - response + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /webauthn/credentials: + get: + tags: + - Auth + operationId: webAuthnListCredentials + summary: List owner-scoped passkey metadata with an active session + security: + - bearerAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /webauthn/credentials/{id}: + parameters: + - name: id + in: path + required: true + schema: *ref_0 + delete: + tags: + - Auth + operationId: webAuthnDeleteCredential + summary: Delete an owned passkey with an active session; wallet login remains available + security: + - bearerAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /support/tickets: + get: + tags: + - Auth + operationId: listSupportTickets + summary: List owned tickets excluding internal notes in the database query + security: + - bearerAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + post: + tags: + - Auth + operationId: createSupportTicket + summary: Create a rate-limited support ticket + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + subject: + type: string + minLength: 3 + maxLength: 200 + category: &ref_1 + type: string + enum: + - ACCOUNT + - TRANSACTION + - TAX + - TECHNICAL + - OTHER + priority: &ref_3 + type: string + enum: + - LOW + - MEDIUM + - HIGH + - URGENT + body: + type: string + minLength: 5 + maxLength: 5000 + contextRef: + type: string + required: + - subject + - category + - body + responses: + '201': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + '429': + description: Creation rate limit exceeded + /support/tickets/{id}: + parameters: + - &ref_0 + name: id + in: path + required: true + schema: + type: string + format: uuid + get: + tags: + - Auth + operationId: getSupportTicket + summary: Read owned ticket and public message thread + security: + - bearerAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /support/tickets/{id}/reply: + parameters: + - *ref_0 + post: + tags: + - Auth + operationId: replySupportTicket + summary: Append public reply; reopen a resolved ticket + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + body: &ref_4 + type: string + minLength: 1 + maxLength: 5000 + required: + - body + responses: + '201': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /admin/support/tickets: + get: + tags: + - Admin + operationId: adminListSupportTickets + summary: Filter support queue with SLA deadlines; requires support:read + security: + - adminAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + parameters: + - name: status + in: query + schema: &ref_2 + type: string + enum: + - OPEN + - IN_PROGRESS + - AWAITING_USER + - RESOLVED + - CLOSED + - name: category + in: query + schema: *ref_1 + - name: assignedTo + in: query + schema: + type: string + description: Admin identifier or unassigned + - name: slaBreached + in: query + schema: + type: boolean + /admin/support/tickets/{id}: + parameters: + - *ref_0 + get: + tags: + - Admin + operationId: adminGetSupportTicket + summary: Read full thread including internal notes; requires support:read + security: + - adminAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + patch: + tags: + - Admin + operationId: adminUpdateSupportTicket + summary: Update status, priority and assignment with transactional audit; requires support:write + security: + - adminAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: *ref_2 + priority: *ref_3 + assignedTo: + type: string + nullable: true + required: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + '409': + description: Invalid state transition + /admin/support/tickets/{id}/reply: + parameters: + - *ref_0 + post: + tags: + - Admin + operationId: adminReplySupportTicket + summary: Append public reply or internal note; requires support:write + security: + - adminAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + body: *ref_4 + internal: + type: boolean + default: false + required: + - body + responses: + '201': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '404': + description: Resource not found + /recurring-withdrawals: + get: + tags: [Transactions] + operationId: listRecurringWithdrawals + summary: List the current user's recurring withdrawal plans + security: [{ bearerAuth: [] }] + responses: + '200': { description: Owner-scoped plans } + '401': { description: Active session required } + post: + tags: [Transactions] + operationId: createRecurringWithdrawal + summary: Schedule a confirmed recurring on-chain withdrawal + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [userId, destinationAddress, assetSymbol, cadence, confirmed] + properties: + userId: { type: string, format: uuid } + destinationAddress: { type: string } + assetSymbol: { type: string } + amountMode: { type: string, enum: [FIXED, YIELD_ONLY, PERCENT_OF_BALANCE], default: FIXED } + amount: { type: number, minimum: 0, exclusiveMinimum: true } + percentage: { type: number, minimum: 0.01, maximum: 100 } + minAmount: { type: number, minimum: 0, exclusiveMinimum: true } + cadence: { type: string, enum: [WEEKLY, BIWEEKLY, MONTHLY] } + confirmed: { type: boolean, enum: [true] } + responses: + '201': { description: Plan created } + '400': { description: Invalid or unconfirmed schedule } + '401': { description: Active owner session required } + /recurring-withdrawals/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + get: + tags: [Transactions] + operationId: getRecurringWithdrawal + summary: Read an owner-scoped withdrawal plan + security: [{ bearerAuth: [] }] + responses: + '200': { description: Plan details } + '403': { description: Forbidden } + '404': { description: Plan not found } + patch: + tags: [Transactions] + operationId: updateRecurringWithdrawal + summary: Update or pause a plan; destination changes pause for review + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + destinationAddress: { type: string } + amountMode: { type: string, enum: [FIXED, YIELD_ONLY, PERCENT_OF_BALANCE] } + amount: { type: number, minimum: 0, exclusiveMinimum: true } + percentage: { type: number, minimum: 0.01, maximum: 100 } + cadence: { type: string, enum: [WEEKLY, BIWEEKLY, MONTHLY] } + minAmount: { type: number, minimum: 0, exclusiveMinimum: true } + status: { type: string, enum: [ACTIVE, PAUSED, CANCELLED] } + responses: + '200': { description: Updated plan } + '400': { description: Invalid update } + '403': { description: Forbidden } + '404': { description: Plan not found } + delete: + tags: [Transactions] + operationId: cancelRecurringWithdrawal + summary: Cancel a recurring withdrawal plan + security: [{ bearerAuth: [] }] + responses: + '200': { description: Plan cancelled } + '403': { description: Forbidden } + '404': { description: Plan not found } + /recurring-withdrawals/{id}/preview: + parameters: + - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + post: + tags: [Transactions] + operationId: previewRecurringWithdrawal + summary: Preview amount, minimum, destination risk and goal conflicts + security: [{ bearerAuth: [] }] + responses: + '200': { description: Projected next occurrence without sending funds } + '403': { description: Forbidden } + '404': { description: Plan not found } # ── TOTP two-factor authentication (#2FA) ───────────────────────────────────── /2fa/enroll: diff --git a/package-lock.json b/package-lock.json index 3c34ab19..1dee1c53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@prisma/instrumentation": "^7.8.0", "@sentry/node": "^10.66.0", "@sentry/profiling-node": "^10.66.0", + "@simplewebauthn/server": "^14.0.3", "@stellar/stellar-sdk": "^16.0.1", "bcryptjs": "^3.0.3", "cors": "^2.8.5", @@ -1132,6 +1133,12 @@ "node": ">=6" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1752,6 +1759,12 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@noble/ed25519": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", @@ -3812,6 +3825,239 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.10.0.tgz", + "integrity": "sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.10.0.tgz", + "integrity": "sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.10.0.tgz", + "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.10.0.tgz", + "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.10.0.tgz", + "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.10.0.tgz", + "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-rsa": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.10.0.tgz", + "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.10.0.tgz", + "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pfx": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.10.0.tgz", + "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.10.0.tgz", + "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.10.0.tgz", + "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.10.0.tgz", + "integrity": "sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4167,6 +4413,27 @@ "node": ">=18" } }, + "node_modules/@simplewebauthn/server": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz", + "integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "@peculiar/x509": "^2.1.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.12", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", @@ -5204,6 +5471,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -9765,6 +10046,24 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -9881,6 +10180,12 @@ "node": ">=4" } }, + "node_modules/reflect-metadata": { + "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" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10875,7 +11180,24 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, "node_modules/twilio": { diff --git a/package.json b/package.json index f85350a9..a7d7898a 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@prisma/instrumentation": "^7.8.0", "@sentry/node": "^10.66.0", "@sentry/profiling-node": "^10.66.0", + "@simplewebauthn/server": "^14.0.3", "@stellar/stellar-sdk": "^16.0.1", "bcryptjs": "^3.0.3", "cors": "^2.8.5", diff --git a/prisma/migrations/20261008090000_recurring_withdrawals/migration.sql b/prisma/migrations/20261008090000_recurring_withdrawals/migration.sql new file mode 100644 index 00000000..89e077d2 --- /dev/null +++ b/prisma/migrations/20261008090000_recurring_withdrawals/migration.sql @@ -0,0 +1,14 @@ +CREATE TYPE "WithdrawalAmountMode" AS ENUM ('FIXED', 'YIELD_ONLY', 'PERCENT_OF_BALANCE'); +CREATE TYPE "RecurringWithdrawalPlanStatus" AS ENUM ('ACTIVE', 'PAUSED', 'CANCELLED'); +CREATE TABLE "recurring_withdrawal_plans" ( + "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "destinationAddress" TEXT NOT NULL, + "assetSymbol" TEXT NOT NULL, "amountMode" "WithdrawalAmountMode" NOT NULL DEFAULT 'FIXED', + "amount" DECIMAL(36,18), "percentage" DECIMAL(12,6), "cadence" "DepositCadence" NOT NULL, + "nextRunAt" TIMESTAMP(3) NOT NULL, "status" "RecurringWithdrawalPlanStatus" NOT NULL DEFAULT 'ACTIVE', + "minAmount" DECIMAL(36,18), "lastRunAt" TIMESTAMP(3), "lastRunStatus" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "recurring_withdrawal_plans_pkey" PRIMARY KEY ("id"), + CONSTRAINT "recurring_withdrawal_plans_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "recurring_withdrawal_plans_userId_idx" ON "recurring_withdrawal_plans"("userId"); +CREATE INDEX "recurring_withdrawal_plans_status_nextRunAt_idx" ON "recurring_withdrawal_plans"("status", "nextRunAt"); diff --git a/prisma/migrations/20261008091000_support_tickets/migration.sql b/prisma/migrations/20261008091000_support_tickets/migration.sql new file mode 100644 index 00000000..3bc67149 --- /dev/null +++ b/prisma/migrations/20261008091000_support_tickets/migration.sql @@ -0,0 +1,28 @@ +CREATE TYPE "TicketCategory" AS ENUM ('ACCOUNT', 'TRANSACTION', 'TAX', 'TECHNICAL', 'OTHER'); +CREATE TYPE "TicketStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'AWAITING_USER', 'RESOLVED', 'CLOSED'); +CREATE TYPE "TicketPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT'); +CREATE TYPE "MessageAuthorRole" AS ENUM ('USER', 'ADMIN'); +CREATE TABLE "support_tickets" ( + "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "subject" TEXT NOT NULL, + "category" "TicketCategory" NOT NULL, "status" "TicketStatus" NOT NULL DEFAULT 'OPEN', + "priority" "TicketPriority" NOT NULL DEFAULT 'MEDIUM', "assignedTo" TEXT, "contextRef" TEXT, + "slaBreached" BOOLEAN NOT NULL DEFAULT false, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, "resolvedAt" TIMESTAMP(3), + CONSTRAINT "support_tickets_pkey" PRIMARY KEY ("id"), + CONSTRAINT "support_tickets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE TABLE "ticket_messages" ( + "id" TEXT NOT NULL, "ticketId" TEXT NOT NULL, "authorUserId" TEXT, + "authorRole" "MessageAuthorRole" NOT NULL DEFAULT 'USER', "body" TEXT NOT NULL, + "attachmentRefs" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], "internal" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ticket_messages_pkey" PRIMARY KEY ("id"), + CONSTRAINT "ticket_messages_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "support_tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "support_tickets_userId_idx" ON "support_tickets"("userId"); +CREATE INDEX "support_tickets_status_idx" ON "support_tickets"("status"); +CREATE INDEX "support_tickets_category_idx" ON "support_tickets"("category"); +CREATE INDEX "support_tickets_assignedTo_idx" ON "support_tickets"("assignedTo"); +CREATE INDEX "support_tickets_slaBreached_idx" ON "support_tickets"("slaBreached"); +CREATE INDEX "ticket_messages_ticketId_idx" ON "ticket_messages"("ticketId"); +CREATE INDEX "ticket_messages_ticketId_internal_idx" ON "ticket_messages"("ticketId", "internal"); diff --git a/prisma/migrations/20261008092000_webauthn/migration.sql b/prisma/migrations/20261008092000_webauthn/migration.sql new file mode 100644 index 00000000..a8e87637 --- /dev/null +++ b/prisma/migrations/20261008092000_webauthn/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "webauthn_credentials" ( + "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "credentialId" TEXT NOT NULL, + "publicKey" TEXT NOT NULL, "signCount" BIGINT NOT NULL DEFAULT 0, + "deviceLabel" TEXT, "transports" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "lastUsedAt" TIMESTAMP(3), + CONSTRAINT "webauthn_credentials_pkey" PRIMARY KEY ("id"), + CONSTRAINT "webauthn_credentials_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "webauthn_credentials_credentialId_key" ON "webauthn_credentials"("credentialId"); +CREATE INDEX "webauthn_credentials_userId_idx" ON "webauthn_credentials"("userId"); +CREATE INDEX "webauthn_credentials_credentialId_idx" ON "webauthn_credentials"("credentialId"); +CREATE TABLE "webauthn_challenges" ( + "id" TEXT NOT NULL, "challenge" TEXT NOT NULL, "purpose" TEXT NOT NULL, + "userId" TEXT, "sessionId" TEXT, "expiresAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "webauthn_challenges_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "webauthn_challenges_challenge_key" ON "webauthn_challenges"("challenge"); +CREATE INDEX "webauthn_challenges_expiresAt_idx" ON "webauthn_challenges"("expiresAt"); diff --git a/prisma/rollback/20261008090000_recurring_withdrawals.sql b/prisma/rollback/20261008090000_recurring_withdrawals.sql new file mode 100644 index 00000000..f95fb2bd --- /dev/null +++ b/prisma/rollback/20261008090000_recurring_withdrawals.sql @@ -0,0 +1,3 @@ +DROP TABLE "recurring_withdrawal_plans"; +DROP TYPE "RecurringWithdrawalPlanStatus"; +DROP TYPE "WithdrawalAmountMode"; diff --git a/prisma/rollback/20261008091000_support_tickets.sql b/prisma/rollback/20261008091000_support_tickets.sql new file mode 100644 index 00000000..b87dccba --- /dev/null +++ b/prisma/rollback/20261008091000_support_tickets.sql @@ -0,0 +1,6 @@ +DROP TABLE "ticket_messages"; +DROP TABLE "support_tickets"; +DROP TYPE "MessageAuthorRole"; +DROP TYPE "TicketPriority"; +DROP TYPE "TicketStatus"; +DROP TYPE "TicketCategory"; diff --git a/prisma/rollback/20261008092000_webauthn.sql b/prisma/rollback/20261008092000_webauthn.sql new file mode 100644 index 00000000..fdf49c3c --- /dev/null +++ b/prisma/rollback/20261008092000_webauthn.sql @@ -0,0 +1,2 @@ +DROP TABLE "webauthn_challenges"; +DROP TABLE "webauthn_credentials"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07e72200..0c2cc769 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -345,6 +345,9 @@ model User { emailIdentity EmailIdentity? webhookEndpoints UserWebhookEndpoint[] notificationPreferences NotificationPreference[] + recurringWithdrawalPlans RecurringWithdrawalPlan[] + supportTickets SupportTicket[] + webAuthnCredentials WebAuthnCredential[] @@map("users") } @@ -2464,3 +2467,153 @@ model NotificationPreferenceAuditLog { @@index([changedAt]) @@map("notification_preference_audit_logs") } + +// ───────────────────────────────────────────────────────────────────────────── +// Recurring Withdrawal Plans (#551) +// ───────────────────────────────────────────────────────────────────────────── + +enum WithdrawalAmountMode { + FIXED + YIELD_ONLY + PERCENT_OF_BALANCE +} + +enum RecurringWithdrawalPlanStatus { + ACTIVE + PAUSED + CANCELLED +} + +model RecurringWithdrawalPlan { + id String @id @default(uuid()) + userId String + destinationAddress String + assetSymbol String + amountMode WithdrawalAmountMode @default(FIXED) + amount Decimal? @db.Decimal(36, 18) + percentage Decimal? @db.Decimal(12, 6) + cadence DepositCadence + nextRunAt DateTime + status RecurringWithdrawalPlanStatus @default(ACTIVE) + minAmount Decimal? @db.Decimal(36, 18) + lastRunAt DateTime? + lastRunStatus String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([status, nextRunAt]) + @@map("recurring_withdrawal_plans") +} + +// ───────────────────────────────────────────────────────────────────────────── +// Support Ticket System (#552) +// ───────────────────────────────────────────────────────────────────────────── + +enum TicketCategory { + ACCOUNT + TRANSACTION + TAX + TECHNICAL + OTHER +} + +enum TicketStatus { + OPEN + IN_PROGRESS + AWAITING_USER + RESOLVED + CLOSED +} + +enum TicketPriority { + LOW + MEDIUM + HIGH + URGENT +} + +model SupportTicket { + id String @id @default(uuid()) + userId String + subject String + category TicketCategory + status TicketStatus @default(OPEN) + priority TicketPriority @default(MEDIUM) + assignedTo String? + contextRef String? + slaBreached Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resolvedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + messages TicketMessage[] + + @@index([userId]) + @@index([status]) + @@index([category]) + @@index([assignedTo]) + @@index([slaBreached]) + @@map("support_tickets") +} + +enum MessageAuthorRole { + USER + ADMIN +} + +model TicketMessage { + id String @id @default(uuid()) + ticketId String + authorUserId String? + authorRole MessageAuthorRole @default(USER) + body String @db.Text + attachmentRefs String[] @default([]) + internal Boolean @default(false) + createdAt DateTime @default(now()) + + ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + + @@index([ticketId]) + @@index([ticketId, internal]) + @@map("ticket_messages") +} + +// ───────────────────────────────────────────────────────────────────────────── +// WebAuthn / Passkey Credentials (#553) +// ───────────────────────────────────────────────────────────────────────────── + +model WebAuthnCredential { + id String @id @default(uuid()) + userId String + credentialId String @unique + publicKey String + signCount BigInt @default(0) + deviceLabel String? + transports String[] @default([]) + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([credentialId]) + @@map("webauthn_credentials") +} + +// One-time, session-bound ceremonies shared across application instances. +model WebAuthnChallenge { + id String @id @default(uuid()) + challenge String @unique + purpose String + userId String? + sessionId String? + expiresAt DateTime + + @@index([expiresAt]) + @@map("webauthn_challenges") +} + diff --git a/src/approvals/executors.ts b/src/approvals/executors.ts index 7f66921b..a98793ec 100644 --- a/src/approvals/executors.ts +++ b/src/approvals/executors.ts @@ -36,6 +36,7 @@ export type ApprovalPayload = memo?: string actingAsUserId?: string | null selectedLotIds?: string[] + acknowledgeGoalImpact?: boolean } /** @@ -79,5 +80,6 @@ export async function runApprovedPayload( actingAsUserId: payload.actingAsUserId, skipApprovalGuard: true, selectedLotIds: payload.selectedLotIds, + acknowledgeGoalImpact: payload.acknowledgeGoalImpact, }) } diff --git a/src/controllers/transaction-controller.ts b/src/controllers/transaction-controller.ts index 7e50affe..d922ac84 100644 --- a/src/controllers/transaction-controller.ts +++ b/src/controllers/transaction-controller.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express' -import { Transaction } from '@prisma/client' +import { Prisma, Transaction } from '@prisma/client' import db from '../db' import { formatDepositReply, formatWithdrawReply } from '../whatsapp/formatters' import { sendNotFound, sendUnauthorized } from '../utils/errors' @@ -13,6 +13,7 @@ import { OutboxOpKind } from '../outbox/types' import { guardOperation } from '../approvals/service' import { getFeeSnapshot } from '../stellar/feeOracle' import { invalidatePortfolioCache } from '../utils/user-cache-invalidation' +import { assessWithdrawal } from '../services/withdrawal-controls.service' /** * Persist the Transaction row (PENDING, no hash yet) and its outbox intent in @@ -40,6 +41,38 @@ async function enqueueAndDispatch(params: { selectedLotIds?: string[] }): Promise { const pending = await db.$transaction(async (tx) => { + if (params.kind === 'WITHDRAW') { + // Serialize reservations across manual and scheduled withdrawals for + // the same owner/asset before creating an intent or submitting on-chain. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${params.userId}), hashtext(${params.assetSymbol}))` + const balance = await tx.position.aggregate({ + where: { + userId: params.userId, + assetSymbol: params.assetSymbol, + status: 'ACTIVE', + }, + _sum: { currentValue: true }, + }) + const reserved = await tx.transaction.aggregate({ + where: { + userId: params.userId, + assetSymbol: params.assetSymbol, + type: 'WITHDRAWAL', + status: 'PENDING', + }, + _sum: { amount: true }, + }) + const available = new Prisma.Decimal( + balance._sum.currentValue ?? 0 + ).minus(reserved._sum.amount ?? 0) + if (available.lessThan(params.amount)) + throw Object.assign( + new Error( + 'Insufficient balance after pending withdrawal reservations' + ), + { status: 409 } + ) + } const transaction = await tx.transaction.create({ data: { userId: params.userId, @@ -260,6 +293,7 @@ export interface ExecuteWithdrawParams { // #317 — SPECIFIC_ID lot selection, WITHDRAWAL only. Persisted so the // Stellar event listener has it when the withdrawal confirms. selectedLotIds?: string[] + acknowledgeGoalImpact?: boolean } export interface ExecuteWithdrawResult { @@ -296,6 +330,7 @@ export async function executeWithdraw( actingAsUserId, skipApprovalGuard, selectedLotIds, + acknowledgeGoalImpact, } = params const user = await db.user.findUnique({ @@ -306,6 +341,32 @@ export async function executeWithdraw( throw new Error('User not found') } + const assessment = await assessWithdrawal( + userId, + walletAddress, + assetSymbol, + amount, + acknowledgeGoalImpact + ) + if (assessment.held) { + await publishUserEvent(userId, 'alerts', 'recurring_withdrawal.held', { + reason: assessment.reason, + amount, + assetSymbol, + destinationAddress: walletAddress, + }) + throw Object.assign( + new Error(`Withdrawal held for review: ${assessment.reason}`), + { + status: 409, + details: { + reason: assessment.reason, + requiresAcknowledgement: assessment.reason === 'goal_guardrail', + }, + } + ) + } + if (!skipApprovalGuard) { const guard = await guardOperation({ userId, @@ -323,6 +384,7 @@ export async function executeWithdraw( memo, actingAsUserId, selectedLotIds, + acknowledgeGoalImpact, }, }) if (!guard.allowed) { @@ -388,8 +450,15 @@ export async function processOnChainTransaction( res: Response, type: 'DEPOSIT' | 'WITHDRAWAL' ) { - const { userId, amount, assetSymbol, protocolName, memo, selectedLotIds } = - req.body + const { + userId, + amount, + assetSymbol, + protocolName, + memo, + selectedLotIds, + acknowledgeGoalImpact, + } = req.body if (!req.auth) { return sendUnauthorized(res) @@ -422,6 +491,7 @@ export async function processOnChainTransaction( memo, actingAsUserId, selectedLotIds, + acknowledgeGoalImpact, }) if (result.status === 'PENDING_APPROVAL') { diff --git a/src/events/types.ts b/src/events/types.ts index f364e75b..55df7088 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -36,6 +36,11 @@ export function isUserEventTopic(value: unknown): value is UserEventTopic { * change what an operator's configured webhook receives. */ export const SOCKET_ONLY_EVENT_TYPES = [ + 'security.passkey_changed', + 'security.passkey_anomaly', + 'recurring_withdrawal.executed', + 'recurring_withdrawal.held', + 'recurring_withdrawal.skipped', /** Emitted alongside agent.rebalanced: this user's positions moved. */ 'portfolio.updated', /** #343 — a rebalance decision was recorded; deep-linkable explanation. */ @@ -68,6 +73,11 @@ export type UserEventType = WebhookEvent | SocketOnlyEventType * emit site by hand. */ export const EVENT_TYPE_TOPIC: Record = { + 'security.passkey_changed': 'alerts', + 'security.passkey_anomaly': 'alerts', + 'recurring_withdrawal.executed': 'transactions', + 'recurring_withdrawal.held': 'alerts', + 'recurring_withdrawal.skipped': 'alerts', 'transaction.confirmed': 'transactions', 'deposit.received': 'transactions', 'withdraw.completed': 'transactions', diff --git a/src/index.ts b/src/index.ts index 7437dece..1d86e870 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ import { schedulePoolMetrics } from './jobs/poolMetrics' import { scheduleFiatReconciliation } from './jobs/fiatReconciliation' import { scheduleReferralPayout } from './jobs/referralPayout' import { scheduleRecurringDeposits } from './jobs/recurringDeposits' +import { scheduleRecurringWithdrawals } from './jobs/recurringWithdrawals' import { scheduleAlertRules } from './jobs/alertRules' import { scheduleStrategyMetrics } from './jobs/strategyMetrics' import { scheduleAllocationSuggestions } from './jobs/allocationSuggestions' @@ -94,14 +95,18 @@ import webhooksRouter from './routes/webhooks' import fiatRouter from './routes/fiat' import referralsRouter from './routes/referrals' import recurringDepositRouter from './routes/recurring-deposits' +import recurringWithdrawalRouter from './routes/recurring-withdrawals' import alertsRouter from './routes/alerts' import strategiesRouter from './routes/strategies' import subAccountsRouter from './routes/sub-accounts' +import supportTicketsRouter from './routes/support-tickets' +import adminSupportTicketsRouter from './routes/admin/support-tickets' import assistantRouter from './routes/assistant' import approvalsRouter from './routes/approvals' import approvalPoliciesRouter from './routes/approval-policies' import keysRouter from './routes/keys' import sessionsRouter from './routes/sessions' +import webAuthnRouter from './routes/webauthn' import streamRouter from './routes/stream' import notificationsRouter from './routes/notifications' import notificationDlqRouter from './routes/notification-dlq' @@ -139,6 +144,7 @@ let poolMetricsHandle: NodeJS.Timeout | null = null let fiatReconciliationHandle: NodeJS.Timeout | null = null let referralPayoutHandle: NodeJS.Timeout | null = null let recurringDepositsHandle: NodeJS.Timeout | null = null +let recurringWithdrawalsHandle: NodeJS.Timeout | null = null let alertRulesHandle: NodeJS.Timeout | null = null let strategyMetricsHandle: NodeJS.Timeout | null = null let allocationSuggestionsHandle: NodeJS.Timeout | null = null @@ -345,7 +351,9 @@ const apiRoutes: ApiRoute[] = [ { path: 'net-worth', handlers: [netWorthRouter] }, { path: 'agent/decisions', handlers: [agentDecisionsRouter] }, { path: 'agent', handlers: [internalRateLimiter, agentRouter] }, + { path: 'auth/webauthn', handlers: [authRateLimiter, webAuthnRouter] }, { path: 'auth', handlers: [authRateLimiter, authRouter] }, + { path: 'webauthn', handlers: [authRateLimiter, webAuthnRouter] }, { path: 'whatsapp', handlers: [webhookRateLimiter, whatsappRouter] }, { path: 'telegram', handlers: [webhookRateLimiter, telegramRouter] }, { path: 'portfolio', handlers: [portfolioRouter] }, @@ -367,9 +375,12 @@ const apiRoutes: ApiRoute[] = [ { path: 'fiat', handlers: [fiatRouter] }, { path: 'referrals', handlers: [referralsRouter] }, { path: 'deposit/recurring', handlers: [recurringDepositRouter] }, + { path: 'recurring-withdrawals', handlers: [recurringWithdrawalRouter] }, { path: 'alerts', handlers: [alertsRouter] }, { path: 'strategies', handlers: [strategiesRouter] }, { path: 'sub-accounts', handlers: [subAccountsRouter] }, + { path: 'support/tickets', handlers: [supportTicketsRouter] }, + { path: 'admin/support/tickets', handlers: [adminRateLimiter, adminSupportTicketsRouter] }, { path: 'assistant', handlers: [assistantRouter] }, { path: 'approvals', handlers: [approvalsRouter] }, { path: 'approval-policies', handlers: [approvalPoliciesRouter] }, @@ -454,6 +465,10 @@ async function gracefulShutdown(signal: string): Promise { recurringDepositsHandle = null logger.info('[Shutdown] Recurring deposits timer cleared') } + if (recurringWithdrawalsHandle) { + clearInterval(recurringWithdrawalsHandle) + recurringWithdrawalsHandle = null + } if (alertRulesHandle) { clearInterval(alertRulesHandle) @@ -734,6 +749,7 @@ async function main(): Promise { fiatReconciliationHandle = scheduleFiatReconciliation() referralPayoutHandle = scheduleReferralPayout() recurringDepositsHandle = scheduleRecurringDeposits() + recurringWithdrawalsHandle = scheduleRecurringWithdrawals() alertRulesHandle = scheduleAlertRules() strategyMetricsHandle = scheduleStrategyMetrics() // Ordered before the suggestion job so the first suggestion run sees freshly diff --git a/src/jobs/recurringWithdrawals.ts b/src/jobs/recurringWithdrawals.ts new file mode 100644 index 00000000..65de836c --- /dev/null +++ b/src/jobs/recurringWithdrawals.ts @@ -0,0 +1,590 @@ +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { config } from '../config/env' +import { recordBackgroundJob } from '../utils/metrics' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' +import { scheduleResilientJob } from './resilientScheduler' +import { executeWithdraw } from '../controllers/transaction-controller' +import { publishUserEvent } from '../events/publisher' +import { EVENT_TYPE_TOPIC } from '../events/types' +import { addCadence } from '../utils/cadence' +import { assessWithdrawal } from '../services/withdrawal-controls.service' + +export { addCadence } from '../utils/cadence' + +export interface RecurringWithdrawalPlan { + id: string + userId: string + destinationAddress: string + assetSymbol: string + amountMode: 'FIXED' | 'YIELD_ONLY' | 'PERCENT_OF_BALANCE' + amount: any + percentage: any + cadence: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' + nextRunAt: Date + status: 'ACTIVE' | 'PAUSED' | 'CANCELLED' + minAmount: any + lastRunAt: Date | null + lastRunStatus: string | null + createdAt: Date + updatedAt: Date +} + +export const RECURRING_WITHDRAWAL_EXECUTING_LEASE_MS = 10 * 60 * 1000 + +export function isExecutingClaimStale( + plan: { lastRunStatus: string | null; lastRunAt: Date | null }, + now: Date = new Date(), + leaseMs = RECURRING_WITHDRAWAL_EXECUTING_LEASE_MS +): boolean { + if (plan.lastRunStatus !== 'executing') return false + if (!plan.lastRunAt) return true + return now.getTime() - new Date(plan.lastRunAt).getTime() >= leaseMs +} + +async function claimDuePlan( + planId: string +): Promise { + const now = new Date() + + const plan = await (db as any).recurringWithdrawalPlan.findUnique({ + where: { id: planId }, + }) + + const staleExecuting = !!plan && isExecutingClaimStale(plan, now) + + if ( + !plan || + plan.status !== 'ACTIVE' || + plan.nextRunAt > now || + (plan.lastRunStatus === 'executing' && !staleExecuting) + ) { + return null + } + + const updated = await (db as any).recurringWithdrawalPlan.updateMany({ + where: { + id: planId, + status: 'ACTIVE', + nextRunAt: plan.nextRunAt, + lastRunAt: plan.lastRunAt, + lastRunStatus: plan.lastRunStatus, + }, + data: { + lastRunAt: now, + lastRunStatus: 'executing', + }, + }) + + if (updated.count === 0) { + return null + } + + return { ...plan, lastRunAt: now, lastRunStatus: 'executing' } +} + +export async function resolveWithdrawalAmount( + plan: RecurringWithdrawalPlan +): Promise<{ amount: number; reason?: string }> { + const positions = await db.position.findMany({ + where: { + userId: plan.userId, + assetSymbol: plan.assetSymbol, + status: 'ACTIVE', + }, + }) + + const totalValue = positions.reduce( + (sum: number, p: any) => sum + Number(p.currentValue), + 0 + ) + const totalYield = positions.reduce( + (sum: number, p: any) => sum + Number(p.yieldEarned), + 0 + ) + + if (plan.amountMode === 'FIXED') { + const requested = plan.amount ? Number(plan.amount) : 0 + return totalValue >= requested + ? { amount: requested } + : { amount: 0, reason: 'insufficient_balance' } + } + + if (plan.amountMode === 'YIELD_ONLY') { + if (totalYield <= 0) { + return { amount: 0, reason: 'insufficient_yield' } + } + return { amount: Math.floor(Math.min(totalYield, totalValue) * 1e7) / 1e7 } + } + + if (plan.amountMode === 'PERCENT_OF_BALANCE') { + const pct = plan.percentage ? Number(plan.percentage) : 0 + if (totalValue <= 0 || pct <= 0) { + return { amount: 0, reason: 'insufficient_balance' } + } + const calculated = (totalValue * pct) / 100 + return { amount: Math.floor(calculated * 1e7) / 1e7 } + } + + return { amount: plan.amount ? Number(plan.amount) : 0 } +} + +export async function checkDestinationRisk( + userId: string, + destinationAddress: string +): Promise<{ isRisk: boolean; reason?: string }> { + const priorTx = await db.outboxOp.findFirst({ + where: { + userId, + kind: 'WITHDRAW', + status: 'CONFIRMED', + payload: { path: ['userAddress'], equals: destinationAddress }, + }, + }) + + const activeCase = await db.complianceCase.findFirst({ + where: { + userId, + status: { in: ['OPEN', 'INVESTIGATING', 'ESCALATED'] }, + }, + }) + + if (activeCase) { + return { + isRisk: true, + reason: 'compliance_freeze_active_case', + } + } + + if (!priorTx) { + return { + isRisk: true, + reason: 'new_destination_unverified', + } + } + + return { isRisk: false } +} + +export async function checkGoalGuardrailConflict( + userId: string, + assetSymbol: string, + withdrawAmount: number +): Promise<{ conflict: boolean; reason?: string }> { + const activeGoals = await db.savingsGoal.findMany({ + where: { + userId, + status: 'ACTIVE', + }, + }) + + if (activeGoals.length === 0) { + return { conflict: false } + } + + const positions = await db.position.findMany({ + where: { + userId, + assetSymbol, + status: 'ACTIVE', + }, + }) + + const currentTotal = positions.reduce( + (sum: number, p: any) => sum + Number(p.currentValue), + 0 + ) + const remainingTotal = currentTotal - withdrawAmount + + for (const goal of activeGoals) { + const target = Number(goal.targetAmount) + if (remainingTotal < target) { + return { + conflict: true, + reason: `Withdrawal would drop balance (${remainingTotal.toFixed( + 2 + )}) below active goal target (${target.toFixed(2)})`, + } + } + } + + return { conflict: false } +} + +async function executePlan(plan: RecurringWithdrawalPlan): Promise { + const nextRunAt = addCadence(plan.cadence, new Date()) + + const user = await db.user.findUnique({ + where: { id: plan.userId }, + select: { isActive: true }, + }) + const complianceHold = await db.complianceCase.findFirst({ + where: { + userId: plan.userId, + status: { + in: ['OPEN', 'TRIAGE', 'INVESTIGATING', 'ESCALATED', 'PENDING_SAR'], + }, + }, + }) + if (!user?.isActive || complianceHold) { + await db.recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { + status: 'PAUSED', + lastRunStatus: 'held:compliance_freeze', + nextRunAt, + }, + }) + await publishUserEvent(plan.userId, 'alerts', 'recurring_withdrawal.held', { + planId: plan.id, + reason: 'compliance_freeze', + }) + return + } + + const { amount, reason: resolveReason } = await resolveWithdrawalAmount(plan) + const minAmt = plan.minAmount ? Number(plan.minAmount) : 0 + + if (amount <= 0 || (minAmt > 0 && amount < minAmt)) { + const skipReason = resolveReason ?? 'below_min_amount' + await (db as any).recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { + lastRunStatus: `skipped:${skipReason}`, + nextRunAt, + }, + }) + + logger.info( + '[RecurringWithdrawal] Plan skipped and rolled to next run date', + { + planId: plan.id, + userId: plan.userId, + resolvedAmount: amount, + minAmount: minAmt, + reason: skipReason, + } + ) + + publishUserEvent( + plan.userId, + EVENT_TYPE_TOPIC['recurring_withdrawal.skipped'], + 'recurring_withdrawal.skipped', + { + planId: plan.id, + userId: plan.userId, + amount, + minAmount: minAmt, + reason: skipReason, + } + ).catch(() => {}) + return + } + + const riskCheck = await checkDestinationRisk( + plan.userId, + plan.destinationAddress + ) + if (riskCheck.isRisk) { + await (db as any).recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { + lastRunStatus: `held_risk:${riskCheck.reason}`, + status: 'PAUSED', + nextRunAt, + }, + }) + + logger.warn('[RecurringWithdrawal] Plan held due to compliance risk', { + planId: plan.id, + userId: plan.userId, + reason: riskCheck.reason, + }) + + publishUserEvent( + plan.userId, + EVENT_TYPE_TOPIC['recurring_withdrawal.held'], + 'recurring_withdrawal.held', + { + planId: plan.id, + userId: plan.userId, + destinationAddress: plan.destinationAddress, + reason: riskCheck.reason, + } + ).catch(() => {}) + return + } + + const goalCheck = await checkGoalGuardrailConflict( + plan.userId, + plan.assetSymbol, + amount + ) + if (goalCheck.conflict) { + await (db as any).recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { + lastRunStatus: `held_guardrail:${goalCheck.reason}`, + nextRunAt, + }, + }) + + logger.warn( + '[RecurringWithdrawal] Plan held due to goal guardrail conflict', + { + planId: plan.id, + userId: plan.userId, + reason: goalCheck.reason, + } + ) + + publishUserEvent( + plan.userId, + EVENT_TYPE_TOPIC['recurring_withdrawal.held'], + 'recurring_withdrawal.held', + { + planId: plan.id, + userId: plan.userId, + reason: goalCheck.reason, + } + ).catch(() => {}) + return + } + + const assessment = await assessWithdrawal( + plan.userId, + plan.destinationAddress, + plan.assetSymbol, + amount + ) + if (assessment.held) { + await db.recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { + nextRunAt, + lastRunStatus: `held:${assessment.reason}`, + ...(assessment.reason !== 'goal_guardrail' ? { status: 'PAUSED' } : {}), + }, + }) + await publishUserEvent(plan.userId, 'alerts', 'recurring_withdrawal.held', { + planId: plan.id, + reason: assessment.reason, + riskScore: assessment.score?.totalScore, + }) + return + } + + try { + const result = await executeWithdraw({ + userId: plan.userId, + walletAddress: plan.destinationAddress, + amount, + assetSymbol: plan.assetSymbol, + memo: `recurring-withdrawal:${plan.id}`, + }) + + if (result.status === 'CONFIRMED' || result.status === 'PENDING') { + await (db as any).recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { + lastRunStatus: 'executed', + nextRunAt, + }, + }) + + logger.info('[RecurringWithdrawal] Plan executed successfully', { + planId: plan.id, + userId: plan.userId, + amount, + status: result.status, + }) + + publishUserEvent( + plan.userId, + EVENT_TYPE_TOPIC['recurring_withdrawal.executed'], + 'recurring_withdrawal.executed', + { + planId: plan.id, + userId: plan.userId, + amount, + assetSymbol: plan.assetSymbol, + cadence: plan.cadence, + txHash: result.transaction?.txHash, + } + ).catch(() => {}) + } else if (result.status === 'PENDING_APPROVAL') { + await (db as any).recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { lastRunStatus: 'pending_approval', nextRunAt }, + }) + + logger.info('[RecurringWithdrawal] Plan occurrence pending approval', { + planId: plan.id, + userId: plan.userId, + approvalRequestId: result.approvalRequestId, + }) + } else { + await (db as any).recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { + lastRunStatus: 'transaction_failed', + nextRunAt, + }, + }) + } + } catch (err) { + const reason = err instanceof Error ? err.message : 'unknown_error' + const isInsufficient = + reason.toLowerCase().includes('insufficient') || + reason.toLowerCase().includes('balance') + const heldReason = (err as { details?: { reason?: string } }).details + ?.reason + + await (db as any).recurringWithdrawalPlan.update({ + where: { id: plan.id }, + data: { + lastRunStatus: isInsufficient + ? 'skipped:insufficient_balance' + : heldReason + ? `held:${heldReason}` + : 'held:execution_error', + ...(!isInsufficient && heldReason !== 'goal_guardrail' + ? { status: 'PAUSED' } + : {}), + nextRunAt, + }, + }) + + await publishUserEvent( + plan.userId, + 'alerts', + isInsufficient + ? 'recurring_withdrawal.skipped' + : 'recurring_withdrawal.held', + { + planId: plan.id, + reason: isInsufficient + ? 'insufficient_balance' + : heldReason || 'execution_error', + } + ) + + logger.warn('[RecurringWithdrawal] Plan execution failed or skipped', { + planId: plan.id, + userId: plan.userId, + reason, + }) + } +} + +export async function processRecurringWithdrawals(): Promise { + const correlationId = generateCorrelationId() + return runWithCorrelationIdAsync(correlationId, async () => { + const startTime = Date.now() + const jobName = 'recurring_withdrawals' + + try { + const now = new Date() + const duePlans = await (db as any).recurringWithdrawalPlan.findMany({ + where: { + status: 'ACTIVE', + nextRunAt: { lte: now }, + }, + orderBy: { nextRunAt: 'asc' }, + }) + + if (duePlans.length === 0) { + const durationMs = Date.now() - startTime + recordJobSuccess(jobName, durationMs) + return + } + + logBackgroundJob( + jobName, + 'success', + (Date.now() - startTime) / 1000, + correlationId, + { dueCount: duePlans.length } + ) + + for (const plan of duePlans) { + if (plan.lastRunStatus === 'executing') { + if (!isExecutingClaimStale(plan, now)) continue + // Submission may have succeeded before a crash. Never resend an + // uncertain occurrence automatically; require an operator review. + const held = await db.recurringWithdrawalPlan.updateMany({ + where: { + id: plan.id, + lastRunStatus: 'executing', + lastRunAt: plan.lastRunAt, + }, + data: { + status: 'PAUSED', + lastRunStatus: 'held:uncertain_execution', + }, + }) + if (held.count) { + await publishUserEvent( + plan.userId, + 'alerts', + 'recurring_withdrawal.held', + { + planId: plan.id, + reason: 'uncertain_execution', + } + ) + } + continue + } + + const claimed = await claimDuePlan(plan.id) + if (!claimed) continue + + try { + await executePlan(claimed) + } catch (err) { + logger.error( + '[RecurringWithdrawal] Unexpected error executing plan', + { + planId: plan.id, + error: err instanceof Error ? err.message : String(err), + } + ) + } + } + + const durationMs = Date.now() - startTime + recordBackgroundJob(jobName, 'success', durationMs / 1000) + recordJobSuccess(jobName, durationMs) + } catch (error) { + const durationMs = Date.now() - startTime + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + + logBackgroundJob(jobName, 'failed', durationMs / 1000, correlationId, { + error: errorMessage, + }) + + recordBackgroundJob(jobName, 'failed', durationMs / 1000) + recordJobFailure(jobName, durationMs, error) + } + }) +} + +export function scheduleRecurringWithdrawals(): NodeJS.Timeout { + const intervalMs = config.recurringDeposits?.intervalMs ?? 5 * 60 * 1000 + const handle = scheduleResilientJob({ + jobName: 'recurring_withdrawals', + task: processRecurringWithdrawals, + intervalMs, + }) + + logger.info( + `[RecurringWithdrawal] Scheduler started (interval: ${intervalMs}ms)` + ) + return handle +} diff --git a/src/middleware/adminAuth.ts b/src/middleware/adminAuth.ts index b605ad5f..2f309cd2 100644 --- a/src/middleware/adminAuth.ts +++ b/src/middleware/adminAuth.ts @@ -16,6 +16,8 @@ const prisma = db * A `super` key implicitly includes all other scopes. */ export const ADMIN_SCOPES = [ + 'support:read', + 'support:write', 'read', 'write', 'wallet', diff --git a/src/routes/admin/support-tickets.ts b/src/routes/admin/support-tickets.ts new file mode 100644 index 00000000..23b05bbb --- /dev/null +++ b/src/routes/admin/support-tickets.ts @@ -0,0 +1,235 @@ +import { + Router, + Request, + Response, + NextFunction, + RequestHandler, +} from 'express' +import { Prisma, TicketPriority } from '@prisma/client' +import { + requireAdminAuth, + requireAdminScope, + getAdminAuth, +} from '../../middleware/adminAuth' +import { validate } from '../../middleware/validate' +import db from '../../db' +import { + supportSla, + supportSlaHours, + SUPPORT_TRANSITIONS, +} from '../../services/support-ticket.service' +import { + adminUpdateSupportTicketSchema, + adminReplySupportTicketSchema, + supportTicketIdParamSchema, + supportQueueQuerySchema, +} from '../../validators/support-ticket-validators' + +const router = Router() +const handle = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req: Request, res: Response, next: NextFunction) => { + void fn(req, res).catch(next) + } + +router.use(requireAdminAuth) +router.get( + '/', + requireAdminScope('support:read'), + validate({ query: supportQueueQuerySchema }), + handle(async (req, res) => { + const query = supportQueueQuerySchema.parse(req.query) + const where: Prisma.SupportTicketWhereInput = { + ...(query.status ? { status: query.status } : {}), + ...(query.category ? { category: query.category } : {}), + ...(query.assignedTo + ? { + assignedTo: + query.assignedTo === 'unassigned' ? null : query.assignedTo, + } + : {}), + } + const now = new Date() + const breached: Prisma.SupportTicketWhereInput = { + status: { notIn: ['RESOLVED', 'CLOSED'] }, + OR: Object.values(TicketPriority).map((priority) => ({ + priority, + createdAt: { + lt: new Date(now.getTime() - supportSlaHours(priority) * 3600000), + }, + })), + } + if (query.slaBreached === 'true') where.AND = [breached] + if (query.slaBreached === 'false') where.NOT = breached + const tickets = await db.supportTicket.findMany({ + where, + orderBy: [{ priority: 'desc' }, { createdAt: 'asc' }], + include: { + user: { select: { id: true, displayName: true, email: true } }, + messages: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }) + res.json({ + tickets: tickets.map((ticket) => ({ + ...ticket, + ...supportSla(ticket, now), + })), + }) + }) +) + +router.get( + '/:id', + requireAdminScope('support:read'), + validate({ params: supportTicketIdParamSchema }), + handle(async (req, res) => { + const ticket = await db.supportTicket.findUnique({ + where: { id: req.params.id }, + include: { messages: { orderBy: { createdAt: 'asc' } } }, + }) + if (!ticket) { + res.status(404).json({ error: 'Support ticket not found' }) + return + } + res.json({ ticket: { ...ticket, ...supportSla(ticket) } }) + }) +) + +router.patch( + '/:id', + requireAdminScope('support:write'), + validate({ + params: supportTicketIdParamSchema, + body: adminUpdateSupportTicketSchema, + }), + handle(async (req, res) => { + const admin = getAdminAuth(res)! + const existing = await db.supportTicket.findUnique({ + where: { id: req.params.id }, + }) + if (!existing) { + res.status(404).json({ error: 'Support ticket not found' }) + return + } + const { status, priority, assignedTo } = + adminUpdateSupportTicketSchema.parse(req.body) + if ( + status && + status !== existing.status && + !SUPPORT_TRANSITIONS[existing.status].includes(status) + ) { + res.status(409).json({ + error: `Invalid transition from ${existing.status} to ${status}`, + }) + return + } + const data: Prisma.SupportTicketUpdateInput = { + ...(status !== undefined + ? { + status, + resolvedAt: + status === 'RESOLVED' + ? new Date() + : status === 'CLOSED' + ? existing.resolvedAt + : null, + } + : {}), + ...(priority !== undefined ? { priority } : {}), + ...(assignedTo !== undefined ? { assignedTo } : {}), + } + const ticket = await db.$transaction(async (tx) => { + const updated = await tx.supportTicket.update({ + where: { id: existing.id, status: existing.status }, + data, + }) + await tx.adminAuditLog.create({ + data: { + adminKeyId: admin.id, + adminName: admin.name, + adminRole: admin.role, + action: 'SUPPORT_TICKET_UPDATE', + target: existing.id, + result: 'SUCCESS', + details: { + before: { + status: existing.status, + priority: existing.priority, + assignedTo: existing.assignedTo, + }, + after: { + status: updated.status, + priority: updated.priority, + assignedTo: updated.assignedTo, + }, + }, + }, + }) + return updated + }) + res.json({ ticket }) + }) +) + +router.post( + '/:id/reply', + requireAdminScope('support:write'), + validate({ + params: supportTicketIdParamSchema, + body: adminReplySupportTicketSchema, + }), + handle(async (req, res) => { + const admin = getAdminAuth(res)! + const ticket = await db.supportTicket.findUnique({ + where: { id: req.params.id }, + }) + if (!ticket) { + res.status(404).json({ error: 'Support ticket not found' }) + return + } + if (ticket.status === 'CLOSED') { + res.status(409).json({ error: 'Ticket is closed' }) + return + } + const { body, attachmentRefs, internal } = + adminReplySupportTicketSchema.parse(req.body) + const result = await db.$transaction(async (tx) => { + const message = await tx.ticketMessage.create({ + data: { + ticketId: ticket.id, + authorUserId: admin.id, + authorRole: 'ADMIN', + body, + attachmentRefs: attachmentRefs ?? [], + internal, + }, + }) + const updated = await tx.supportTicket.update({ + where: { id: ticket.id, status: ticket.status }, + data: { + updatedAt: new Date(), + ...(!internal && ['OPEN', 'IN_PROGRESS'].includes(ticket.status) + ? { status: 'AWAITING_USER' } + : {}), + }, + }) + if (updated.status !== ticket.status) { + await tx.adminAuditLog.create({ + data: { + adminKeyId: admin.id, + adminName: admin.name, + adminRole: admin.role, + action: 'SUPPORT_TICKET_STATUS', + target: ticket.id, + result: 'SUCCESS', + details: { before: ticket.status, after: updated.status }, + }, + }) + } + return { message, ticket: updated } + }) + res.status(201).json(result) + }) +) + +export default router diff --git a/src/routes/recurring-withdrawals.ts b/src/routes/recurring-withdrawals.ts new file mode 100644 index 00000000..2b9f6c87 --- /dev/null +++ b/src/routes/recurring-withdrawals.ts @@ -0,0 +1,400 @@ +import { Router, Request, Response, RequestHandler } from 'express' +import { requireAuth } from '../middleware/authenticate' +import { requireScope, requireWithdrawScope } from '../middleware/apiKeyAuth' +import { validate } from '../middleware/validate' +import db from '../db' +import { logger } from '../utils/logger' +import { addCadence } from '../utils/cadence' +import { + createRecurringWithdrawalSchema, + updateRecurringWithdrawalSchema, + recurringWithdrawalIdParamSchema, + previewRecurringWithdrawalSchema, +} from '../validators/recurring-withdrawal-validators' +import { + resolveWithdrawalAmount, + checkDestinationRisk, + checkGoalGuardrailConflict, +} from '../jobs/recurringWithdrawals' + +const router = Router() +const handle = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req, res, next) => { + void fn(req, res).catch((error) => { + if (error && error.code === 'P2025') { + res + .status(409) + .json({ error: 'Resource changed during the request; retry' }) + return + } + next(error) + }) + } + +// ── POST / — Create recurring withdrawal plan ──────────────────────────────── +router.post( + '/', + requireAuth, + requireScope('withdraw:write'), + requireWithdrawScope, + validate({ + body: createRecurringWithdrawalSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { + userId, + destinationAddress, + assetSymbol, + amountMode, + amount, + percentage, + cadence, + minAmount, + } = req.body + + if (req.auth!.userId !== userId) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + const nextRunAt = addCadence(cadence, new Date()) + + const plan = await db.recurringWithdrawalPlan.create({ + data: { + userId, + destinationAddress, + assetSymbol, + amountMode: amountMode ?? 'FIXED', + amount: amount !== undefined ? amount : null, + percentage: percentage !== undefined ? percentage : null, + cadence, + nextRunAt, + minAmount: minAmount !== undefined ? minAmount : null, + status: 'ACTIVE', + }, + }) + + // Log destination risk signal check if new address + const riskCheck = await checkDestinationRisk(userId, destinationAddress) + if (riskCheck.isRisk) { + logger.warn( + '[RecurringWithdrawal] Created plan with unverified/new destination risk signal', + { + planId: plan.id, + userId, + destinationAddress, + reason: riskCheck.reason, + } + ) + } + + logger.info('[RecurringWithdrawal] Created recurring withdrawal plan', { + planId: plan.id, + userId, + destinationAddress, + amountMode, + cadence, + }) + + res.status(201).json({ plan }) + }) +) + +// ── POST /preview or /:id/preview — Preview projected run ─────────────────── +router.post( + '/preview', + requireAuth, + validate({ + body: previewRecurringWithdrawalSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { + userId, + destinationAddress, + assetSymbol, + amountMode, + amount, + percentage, + cadence, + minAmount, + } = req.body + + if (req.auth!.userId !== userId) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + const nextRunAt = addCadence(cadence, new Date()) + + // Mock plan object for resolution + const mockPlan: any = { + userId, + assetSymbol, + amountMode: amountMode ?? 'FIXED', + amount, + percentage, + } + + const { amount: projectedAmount, reason } = + await resolveWithdrawalAmount(mockPlan) + const riskCheck = destinationAddress + ? await checkDestinationRisk(userId, destinationAddress) + : { isRisk: false } + const goalCheck = await checkGoalGuardrailConflict( + userId, + assetSymbol, + projectedAmount + ) + + res.json({ + preview: { + projectedAmount, + nextRunAt, + cadence, + amountMode, + minAmount: minAmount ?? null, + skipReason: reason ?? null, + isBelowMinAmount: + minAmount !== undefined && projectedAmount < minAmount, + riskFlagged: riskCheck.isRisk, + riskReason: riskCheck.reason ?? null, + guardrailConflict: goalCheck.conflict, + guardrailReason: goalCheck.reason ?? null, + }, + }) + }) +) + +router.post( + '/:id/preview', + requireAuth, + validate({ + params: recurringWithdrawalIdParamSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { id } = req.params + + const plan = await db.recurringWithdrawalPlan.findUnique({ where: { id } }) + if (!plan) { + res.status(404).json({ error: 'Recurring withdrawal plan not found' }) + return + } + + if (plan.userId !== req.auth!.userId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + const { amount: projectedAmount, reason } = + await resolveWithdrawalAmount(plan) + const riskCheck = await checkDestinationRisk( + plan.userId, + plan.destinationAddress + ) + const goalCheck = await checkGoalGuardrailConflict( + plan.userId, + plan.assetSymbol, + projectedAmount + ) + + res.json({ + preview: { + projectedAmount, + nextRunAt: plan.nextRunAt, + cadence: plan.cadence, + amountMode: plan.amountMode, + minAmount: plan.minAmount ? Number(plan.minAmount) : null, + skipReason: reason ?? null, + isBelowMinAmount: + plan.minAmount != null && projectedAmount < Number(plan.minAmount), + riskFlagged: riskCheck.isRisk, + riskReason: riskCheck.reason ?? null, + guardrailConflict: goalCheck.conflict, + guardrailReason: goalCheck.reason ?? null, + }, + }) + }) +) + +// ── GET / — List user's recurring withdrawal plans ─────────────────────────── +router.get( + '/', + requireAuth, + handle(async (req: Request, res: Response) => { + const userId = (req.query.userId as string) ?? req.auth!.userId + + if (userId !== req.auth!.userId) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + const plans = await db.recurringWithdrawalPlan.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }) + + res.json({ plans }) + }) +) + +// ── GET /:id — Get single plan ─────────────────────────────────────────────── +router.get( + '/:id', + requireAuth, + validate({ + params: recurringWithdrawalIdParamSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { id } = req.params + + const plan = await db.recurringWithdrawalPlan.findUnique({ where: { id } }) + if (!plan) { + res.status(404).json({ error: 'Recurring withdrawal plan not found' }) + return + } + + if (plan.userId !== req.auth!.userId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + res.json({ plan }) + }) +) + +// ── PATCH /:id — Update plan ───────────────────────────────────────────────── +router.patch( + '/:id', + requireAuth, + requireScope('withdraw:write'), + requireWithdrawScope, + validate({ + params: recurringWithdrawalIdParamSchema, + body: updateRecurringWithdrawalSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { id } = req.params + const updates = req.body + + const existing = await db.recurringWithdrawalPlan.findUnique({ + where: { id }, + }) + if (!existing) { + res.status(404).json({ error: 'Recurring withdrawal plan not found' }) + return + } + + if (existing.userId !== req.auth!.userId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + // Treat destinationAddress change as NEW_DESTINATION risk signal + const amountMode = updates.amountMode ?? existing.amountMode + if ( + (amountMode === 'FIXED' && + Number(updates.amount ?? existing.amount) <= 0) || + (amountMode === 'PERCENT_OF_BALANCE' && + Number(updates.percentage ?? existing.percentage) <= 0) + ) { + res + .status(400) + .json({ + error: + 'The selected amount mode requires a positive amount or percentage', + }) + return + } + if (existing.lastRunStatus === 'executing') { + res.status(409).json({ + error: 'A withdrawal occurrence is executing; retry after it finishes', + }) + return + } + if ( + updates.destinationAddress && + updates.destinationAddress !== existing.destinationAddress + ) { + logger.warn( + '[RecurringWithdrawal] Destination address modified; treating as new destination risk signal', + { + planId: id, + oldAddress: existing.destinationAddress, + newAddress: updates.destinationAddress, + } + ) + } + + const updated = await db.recurringWithdrawalPlan.update({ + where: { + id, + lastRunAt: existing.lastRunAt, + lastRunStatus: existing.lastRunStatus, + }, + data: { + ...updates, + ...(updates.cadence + ? { nextRunAt: addCadence(updates.cadence, new Date()) } + : {}), + ...(updates.destinationAddress && + updates.destinationAddress !== existing.destinationAddress + ? { status: 'PAUSED', lastRunStatus: 'held_risk:destination_changed' } + : {}), + }, + }) + + res.json({ plan: updated }) + }) +) + +// ── DELETE /:id — Cancel plan ──────────────────────────────────────────────── +router.delete( + '/:id', + requireAuth, + requireScope('withdraw:write'), + requireWithdrawScope, + validate({ + params: recurringWithdrawalIdParamSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { id } = req.params + + const existing = await db.recurringWithdrawalPlan.findUnique({ + where: { id }, + }) + if (!existing) { + res.status(404).json({ error: 'Recurring withdrawal plan not found' }) + return + } + + if (existing.userId !== req.auth!.userId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + const cancelled = await db.recurringWithdrawalPlan.update({ + where: { + id, + lastRunAt: existing.lastRunAt, + lastRunStatus: existing.lastRunStatus, + }, + data: { status: 'CANCELLED' }, + }) + + logger.info('[RecurringWithdrawal] Cancelled recurring withdrawal plan', { + planId: id, + userId: existing.userId, + }) + + res.json({ plan: cancelled }) + }) +) + +export default router diff --git a/src/routes/sub-accounts.ts b/src/routes/sub-accounts.ts index 3df200e6..90431212 100644 --- a/src/routes/sub-accounts.ts +++ b/src/routes/sub-accounts.ts @@ -1,190 +1,245 @@ -import { Router, Request, Response } from 'express' -import { z } from 'zod' -import { SubAccountPermission } from '@prisma/client' +import { + Router, + Request, + Response, + NextFunction, + RequestHandler, +} from 'express' +import { SubAccount, SubAccountPermission } from '@prisma/client' import { requireAuth } from '../middleware/authenticate' import { validate } from '../middleware/validate' import db from '../db' import { logger } from '../utils/logger' +import { + bulkSubAccountsSchema, + bulkSubAccountOpSchema, + MAX_BULK_BATCH_SIZE, + createSubAccountSchema, + updatePermissionsSchema, + updateLimitsSchema, +} from '../validators/sub-account-bulk-validators' +import { + createSubAccount, + updateSubAccount, + applySubAccountOperation, + SubAccountManagementError, +} from '../services/sub-account-management.service' const router = Router() +const handle = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req, res, next) => { + void fn(req, res).catch((error: unknown) => { + if (error instanceof SubAccountManagementError) + res.status(error.status).json({ error: error.message }) + else next(error) + }) + } +const audit = ( + parentUserId: string, + action: string, + subAccount: SubAccount +) => { + logger.info('[SubAccount] Management operation committed', { + parentUserId, + childUserId: subAccount.childUserId, + subAccountId: subAccount.id, + action, + permissions: subAccount.permissions, + dailyLimit: subAccount.dailyLimit?.toString(), + }) +} -const PERMISSION_VALUES = Object.values(SubAccountPermission) - -const createSubAccountSchema = z.object({ - childUserId: z.string().uuid(), - permissions: z - .array(z.enum(PERMISSION_VALUES as [string, ...string[]])) - .min(1) - .max(4), - dailyLimit: z.number().positive().optional(), - transactionLimit: z.number().positive().optional(), -}) - -const updatePermissionsSchema = z.object({ - permissions: z - .array(z.enum(PERMISSION_VALUES as [string, ...string[]])) - .min(1) - .max(4), -}) - -// ── POST / — create a sub-account relationship ────────────────────────────── -router.post( - '/', +router.get( + '/summary', requireAuth, - validate({ body: createSubAccountSchema, errorMessage: 'Validation error' }), - async (req: Request, res: Response) => { - const { childUserId, permissions, dailyLimit, transactionLimit } = req.body - const parentUserId = req.auth!.userId - - // Prevent self-referencing - if (parentUserId === childUserId) { - res.status(400).json({ error: 'Cannot create sub-account with yourself' }) - return - } - - // Verify child user exists - const childUser = await db.user.findUnique({ - where: { id: childUserId }, - select: { id: true }, + handle(async (req, res) => { + const subAccounts = await db.subAccount.findMany({ + where: { parentUserId: req.auth!.userId }, }) - if (!childUser) { - res.status(404).json({ error: 'Child user not found' }) - return + const active = subAccounts.filter((s) => s.status === 'ACTIVE') + const permissionDistribution: Record = Object.fromEntries( + Object.values(SubAccountPermission).map((p) => [p, 0]) + ) + let totalDailyLimitExposure = 0 + let totalTransactionLimitExposure = 0 + for (const sub of active) { + for (const permission of new Set(sub.permissions)) + permissionDistribution[permission]++ + totalDailyLimitExposure += Number(sub.dailyLimit || 0) + totalTransactionLimitExposure += Number(sub.transactionLimit || 0) } - - // Prevent chained sub-accounts: child must not already be a parent - const childIsParent = await db.subAccount.findFirst({ - where: { parentUserId: childUserId, status: 'ACTIVE' }, - select: { id: true }, + const recentActivityCount = active.length + ? await db.transaction.count({ + where: { + userId: { in: active.map((s) => s.childUserId) }, + createdAt: { gte: new Date(Date.now() - 30 * 86400000) }, + }, + }) + : 0 + res.json({ + summary: { + totalChildren: subAccounts.length, + activeChildren: active.length, + revokedChildren: subAccounts.length - active.length, + permissionDistribution, + totalDailyLimitExposure, + totalTransactionLimitExposure, + unlimitedDailyLimitChildren: active.filter((s) => s.dailyLimit === null) + .length, + recentActivityCount, + }, }) - if (childIsParent) { + }) +) + +router.post( + '/bulk', + requireAuth, + validate({ body: bulkSubAccountsSchema }), + handle(async (req, res) => { + const { operations, atomic } = bulkSubAccountsSchema.parse(req.body) + const parentUserId = req.auth!.userId + if (operations.length > MAX_BULK_BATCH_SIZE) { res .status(400) - .json({ error: 'Chained sub-account relationships are not allowed' }) + .json({ + error: `Batch size exceeds maximum limit of ${MAX_BULK_BATCH_SIZE} operations`, + }) return } - - // Check for existing relationship - const existing = await db.subAccount.findUnique({ - where: { - parentUserId_childUserId: { parentUserId, childUserId }, - }, - select: { id: true, status: true }, - }) - if (existing) { - if (existing.status === 'ACTIVE') { + const errorText = (err: unknown) => + err instanceof Error ? err.message : 'Operation failed' + if (atomic) { + let failedIndex = 0 + try { + const results = await db.$transaction(async (tx) => { + const rows = [] + for (let index = 0; index < operations.length; index++) { + failedIndex = index + const parsed = bulkSubAccountOpSchema.safeParse(operations[index]) + if (!parsed.success) + throw new SubAccountManagementError( + parsed.error.issues.map((i) => i.message).join('; '), + 400 + ) + rows.push({ + index, + success: true, + action: parsed.data.action, + data: await applySubAccountOperation( + parentUserId, + parsed.data, + tx + ), + }) + } + return rows + }) + for (const result of results) + audit(parentUserId, result.action, result.data) + res.json({ atomic: true, results }) + } catch (err) { res - .status(409) - .json({ error: 'Sub-account relationship already exists' }) - return + .status(400) + .json({ + atomic: true, + rolledBack: true, + failedIndex, + error: 'Atomic batch failed; transaction rolled back', + message: errorText(err), + results: operations.map((_, index) => ({ + index, + success: false, + ...(index === failedIndex + ? { error: errorText(err) } + : index < failedIndex + ? { rolledBack: true } + : { skipped: true }), + })), + }) } - // Re-activate a revoked relationship - const updated = await db.subAccount.update({ - where: { id: existing.id }, - data: { - permissions: permissions as SubAccountPermission[], - status: 'ACTIVE', - revokedAt: null, - }, - }) - - logger.info('[SubAccount] Re-activated sub-account', { - parentUserId, - childUserId, - permissions, - }) - - res.status(201).json({ subAccount: updated }) return } + const results = [] + for (let index = 0; index < operations.length; index++) { + const parsed = bulkSubAccountOpSchema.safeParse(operations[index]) + if (!parsed.success) { + results.push({ + index, + success: false, + status: 400, + error: parsed.error.issues.map((i) => i.message).join('; '), + }) + continue + } + try { + const data = await applySubAccountOperation(parentUserId, parsed.data) + audit(parentUserId, parsed.data.action, data) + results.push({ index, success: true, data }) + } catch (err) { + results.push({ + index, + success: false, + status: err instanceof SubAccountManagementError ? err.status : 500, + error: errorText(err), + }) + } + } + res.json({ atomic: false, results }) + }) +) - const subAccount = await db.subAccount.create({ - data: { - parentUserId, - childUserId, - permissions: permissions as SubAccountPermission[], - ...(dailyLimit !== undefined ? { dailyLimit } : {}), - ...(transactionLimit !== undefined ? { transactionLimit } : {}), - }, - }) - - logger.info('[SubAccount] Created sub-account', { - parentUserId, - childUserId, - permissions, - }) - +router.post( + '/', + requireAuth, + validate({ body: createSubAccountSchema, errorMessage: 'Validation error' }), + handle(async (req, res) => { + const subAccount = await createSubAccount(req.auth!.userId, req.body) + audit(req.auth!.userId, 'create', subAccount) res.status(201).json({ subAccount }) - } + }) ) - -// ── PATCH /:id/permissions — adjust permissions ──────────────────────────── router.patch( '/:id/permissions', requireAuth, validate({ body: updatePermissionsSchema, errorMessage: 'Validation error' }), - async (req: Request, res: Response) => { - const { id } = req.params - const { permissions } = req.body - const parentUserId = req.auth!.userId - - const subAccount = await db.subAccount.findUnique({ where: { id } }) - if (!subAccount) { - res.status(404).json({ error: 'Sub-account not found' }) - return - } - - if (subAccount.parentUserId !== parentUserId) { - res.status(403).json({ error: 'Forbidden' }) - return - } - - const updated = await db.subAccount.update({ - where: { id }, - data: { permissions: permissions as SubAccountPermission[] }, - }) - - logger.info('[SubAccount] Updated permissions', { - parentUserId, - childUserId: subAccount.childUserId, - oldPermissions: subAccount.permissions, - newPermissions: permissions, - }) - - res.json({ subAccount: updated }) - } + handle(async (req, res) => { + const subAccount = await updateSubAccount( + req.auth!.userId, + { subAccountId: req.params.id }, + { permissions: req.body.permissions } + ) + audit(req.auth!.userId, 'setPermission', subAccount) + res.json({ subAccount }) + }) ) - -// ── DELETE /:id — revoke ─────────────────────────────────────────────────── -router.delete('/:id', requireAuth, async (req: Request, res: Response) => { - const { id } = req.params - const parentUserId = req.auth!.userId - - const subAccount = await db.subAccount.findUnique({ where: { id } }) - if (!subAccount) { - res.status(404).json({ error: 'Sub-account not found' }) - return - } - - if (subAccount.parentUserId !== parentUserId) { - res.status(403).json({ error: 'Forbidden' }) - return - } - - const revoked = await db.subAccount.update({ - where: { id }, - data: { - status: 'REVOKED', - revokedAt: new Date(), - }, +router.patch( + '/:id/limits', + requireAuth, + validate({ body: updateLimitsSchema, errorMessage: 'Validation error' }), + handle(async (req, res) => { + const subAccount = await updateSubAccount( + req.auth!.userId, + { subAccountId: req.params.id }, + req.body + ) + audit(req.auth!.userId, 'setLimit', subAccount) + res.json({ subAccount }) }) - - logger.info('[SubAccount] Revoked sub-account', { - parentUserId, - childUserId: subAccount.childUserId, +) +router.delete( + '/:id', + requireAuth, + handle(async (req, res) => { + const subAccount = await updateSubAccount( + req.auth!.userId, + { subAccountId: req.params.id }, + { status: 'REVOKED', revokedAt: new Date() } + ) + audit(req.auth!.userId, 'revoke', subAccount) + res.json({ subAccount }) }) - - res.json({ subAccount: revoked }) -}) +) export default router diff --git a/src/routes/support-tickets.ts b/src/routes/support-tickets.ts new file mode 100644 index 00000000..ec2a4c18 --- /dev/null +++ b/src/routes/support-tickets.ts @@ -0,0 +1,215 @@ +import { Router, Request, Response, RequestHandler } from 'express' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { sensitiveRateLimiter } from '../middleware/rateLimiter' +import db from '../db' +import { logger } from '../utils/logger' +import { + createSupportTicketSchema, + replySupportTicketSchema, + supportTicketIdParamSchema, +} from '../validators/support-ticket-validators' + +const router = Router() +const handle = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req, res, next) => { + void fn(req, res).catch((error) => { + if (error && error.code === 'P2025') { + res + .status(409) + .json({ error: 'Resource changed during the request; retry' }) + return + } + next(error) + }) + } + +// ── POST / — Create a new support ticket ───────────────────────────────────── +router.post( + '/', + requireAuth, + sensitiveRateLimiter, + validate({ + body: createSupportTicketSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { subject, category, body, priority, contextRef, attachmentRefs } = + req.body + const userId = req.auth!.userId + + const result = await (db as any).$transaction(async (tx: any) => { + const ticket = await tx.supportTicket.create({ + data: { + userId, + subject, + category, + priority: priority ?? 'MEDIUM', + contextRef: contextRef ?? null, + status: 'OPEN', + }, + }) + + const message = await tx.ticketMessage.create({ + data: { + ticketId: ticket.id, + authorUserId: userId, + authorRole: 'USER', + body, + attachmentRefs: attachmentRefs ?? [], + internal: false, + }, + }) + + return { ticket, message } + }) + + logger.info('[SupportTicket] Created ticket', { + ticketId: result.ticket.id, + userId, + category, + priority, + }) + + res.status(201).json(result) + }) +) + +// ── GET / — List user's support tickets ────────────────────────────────────── +router.get( + '/', + requireAuth, + handle(async (req: Request, res: Response) => { + const userId = req.auth!.userId + + const tickets = await (db as any).supportTicket.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + include: { + messages: { + where: { internal: false }, + orderBy: { createdAt: 'asc' }, + take: 1, // first message snippet + }, + }, + }) + + res.json({ tickets }) + }) +) + +// ── GET /:id — View single support ticket thread ───────────────────────────── +router.get( + '/:id', + requireAuth, + validate({ + params: supportTicketIdParamSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { id } = req.params + const userId = req.auth!.userId + + const ticket = await (db as any).supportTicket.findUnique({ + where: { id }, + }) + + if (!ticket) { + res.status(404).json({ error: 'Support ticket not found' }) + return + } + + if (ticket.userId !== userId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + // CRITICAL: internal: false enforced strictly at DB query level + const messages = await (db as any).ticketMessage.findMany({ + where: { + ticketId: id, + internal: false, + }, + orderBy: { createdAt: 'asc' }, + }) + + res.json({ ticket, messages }) + }) +) + +// ── POST /:id/reply — User reply to ticket ─────────────────────────────────── +router.post( + '/:id/reply', + requireAuth, + validate({ + params: supportTicketIdParamSchema, + body: replySupportTicketSchema, + errorMessage: 'Validation error', + }), + handle(async (req: Request, res: Response) => { + const { id } = req.params + const { body, attachmentRefs } = req.body + const userId = req.auth!.userId + + const ticket = await (db as any).supportTicket.findUnique({ + where: { id }, + }) + + if (!ticket) { + res.status(404).json({ error: 'Support ticket not found' }) + return + } + + if (ticket.userId !== userId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + if (ticket.status === 'CLOSED') { + res.status(409).json({ error: 'Ticket is closed' }) + return + } + + const result = await (db as any).$transaction(async (tx: any) => { + const message = await tx.ticketMessage.create({ + data: { + ticketId: id, + authorUserId: userId, + authorRole: 'USER', + body, + attachmentRefs: attachmentRefs ?? [], + internal: false, + }, + }) + + // Auto-reopen if ticket status was RESOLVED + let updatedTicket = ticket + if (ticket.status === 'RESOLVED' || ticket.status === 'AWAITING_USER') { + updatedTicket = await tx.supportTicket.update({ + where: { id, status: ticket.status }, + data: { + status: 'IN_PROGRESS', + resolvedAt: null, + }, + }) + + logger.info('[SupportTicket] Reopened resolved ticket on user reply', { + ticketId: id, + userId, + }) + } else { + await tx.supportTicket.update({ + where: { id, status: ticket.status }, + data: { updatedAt: new Date() }, + }) + } + + return { message, ticket: updatedTicket } + }) + + res.status(201).json(result) + }) +) + +export default router diff --git a/src/routes/webauthn.ts b/src/routes/webauthn.ts new file mode 100644 index 00000000..71617f79 --- /dev/null +++ b/src/routes/webauthn.ts @@ -0,0 +1,391 @@ +import { + Router, + Request, + Response, + NextFunction, + RequestHandler, +} from 'express' +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + RegistrationResponseJSON, + AuthenticationResponseJSON, +} from '@simplewebauthn/server' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import db from '../db' +import { notifySecurityEvent } from '../services/security-notification.service' +import { + issueTokenPair, + newRefreshTokenFields, +} from '../services/refresh-token.service' +import { + getActiveTotpCredential, + issueTotpChallenge, +} from '../services/totp.service' +import { + webAuthnConfig, + storeWebAuthnChallenge, + consumeWebAuthnChallenge, +} from '../services/webauthn.service' +import { publishUserEvent as publish } from '../events/publisher' +import { logger } from '../utils/logger' +import { parseDeviceType } from '../utils/deviceType' +import { resolveApproxLocation } from '../utils/geoip' +import { createSessionDeepLinkToken } from '../utils/sessionDeepLink' +import { + registerOptionsSchema, + registerVerifySchema, + loginOptionsSchema, + loginVerifySchema, + credentialIdParamSchema, +} from '../validators/webauthn-validators' + +const router = Router() +const publishUserEvent = (...args: Parameters) => + publish(...args).catch((error) => { + logger.warn('[WebAuthn] Failed to publish security notification', { + error: error instanceof Error ? error.message : String(error), + }) + }) +const handle = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req, res, next) => { + void fn(req, res).catch(next) + } +const activeSession: RequestHandler = (req, res, next) => { + if (req.authKind !== 'session' || !req.auth?.sessionId) { + res + .status(401) + .json({ error: 'An active wallet-established session is required' }) + return + } + next() +} + +router.post( + '/register-options', + requireAuth, + activeSession, + validate({ body: registerOptionsSchema }), + handle(async (req, res) => { + const user = await db.user.findUnique({ where: { id: req.auth!.userId } }) + if (!user?.isActive || !user.walletAddress) { + res.status(401).json({ error: 'Wallet-established account required' }) + return + } + const { rpID, rpName } = webAuthnConfig() + const credentials = await db.webAuthnCredential.findMany({ + where: { userId: user.id }, + }) + const options = await generateRegistrationOptions({ + rpID, + rpName, + userID: new Uint8Array(Buffer.from(user.id)), + userName: user.email || user.walletAddress, + attestationType: 'none', + excludeCredentials: credentials.map((c) => ({ id: c.credentialId })), + authenticatorSelection: { + residentKey: 'required', + userVerification: 'required', + }, + }) + await storeWebAuthnChallenge( + options.challenge, + 'register', + user.id, + req.auth!.sessionId + ) + res.json({ options, challengeId: options.challenge }) + }) +) + +router.post( + '/register-verify', + requireAuth, + activeSession, + validate({ body: registerVerifySchema }), + handle(async (req, res) => { + const { challengeId, response, deviceLabel } = registerVerifySchema.parse( + req.body + ) + const userId = req.auth!.userId + if ( + !(await consumeWebAuthnChallenge( + challengeId, + 'register', + userId, + req.auth!.sessionId + )) + ) { + res.status(400).json({ error: 'Challenge expired or already used' }) + return + } + const { rpID, origin } = webAuthnConfig() + let result + try { + result = await verifyRegistrationResponse({ + response: response as RegistrationResponseJSON, + expectedChallenge: challengeId, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: true, + }) + } catch { + res + .status(400) + .json({ error: 'Passkey registration verification failed' }) + return + } + if (!result.verified || !result.registrationInfo) { + res + .status(400) + .json({ error: 'Passkey registration verification failed' }) + return + } + const { credential } = result.registrationInfo + const created = await db.webAuthnCredential.create({ + data: { + userId, + credentialId: credential.id, + publicKey: Buffer.from(credential.publicKey).toString('base64'), + signCount: BigInt(credential.counter), + deviceLabel: deviceLabel || 'Passkey', + transports: credential.transports || [], + }, + }) + await notifySecurityEvent(userId, 'passkey.registered', { + credentialId: created.credentialId, + }) + await publishUserEvent(userId, 'alerts', 'security.passkey_changed', { + action: 'registered', + credentialId: created.credentialId, + }) + res.status(201).json({ verified: true, credentialId: created.credentialId }) + }) +) + +router.post( + '/login-options', + validate({ body: loginOptionsSchema }), + handle(async (req, res) => { + const { userId } = loginOptionsSchema.parse(req.body) + const { rpID } = webAuthnConfig() + const credentials = userId + ? await db.webAuthnCredential.findMany({ where: { userId } }) + : [] + const options = await generateAuthenticationOptions({ + rpID, + userVerification: 'required', + ...(userId + ? { allowCredentials: credentials.map((c) => ({ id: c.credentialId })) } + : {}), + }) + await storeWebAuthnChallenge(options.challenge, 'login', userId) + res.json({ options, challengeId: options.challenge }) + }) +) + +router.post( + '/login-verify', + validate({ body: loginVerifySchema }), + handle(async (req, res) => { + const { userId, challengeId, response } = loginVerifySchema.parse(req.body) + const credential = await db.webAuthnCredential.findUnique({ + where: { credentialId: response.id }, + include: { user: true }, + }) + if ( + !credential?.user.isActive || + (userId && userId !== credential.userId) + ) { + res.status(401).json({ error: 'Invalid credential' }) + return + } + if ( + !(await consumeWebAuthnChallenge(challengeId, 'login', credential.userId)) + ) { + res.status(400).json({ error: 'Challenge expired or already used' }) + return + } + const { rpID, origin } = webAuthnConfig() + let result + try { + result = await verifyAuthenticationResponse({ + response: response as AuthenticationResponseJSON, + expectedChallenge: challengeId, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: true, + credential: { + id: credential.credentialId, + publicKey: new Uint8Array( + Buffer.from(credential.publicKey, 'base64') + ), + counter: Number(credential.signCount), + }, + }) + } catch (err) { + if ( + err instanceof Error && + /Response counter value .* was lower than expected/.test(err.message) + ) { + await notifySecurityEvent(credential.userId, 'passkey.anomaly', { + credentialId: credential.credentialId, + reason: 'sign_count_anomaly', + }) + await publishUserEvent( + credential.userId, + 'alerts', + 'security.passkey_anomaly', + { + credentialId: credential.credentialId, + reason: 'sign_count_anomaly', + } + ) + } + res.status(401).json({ error: 'Passkey authentication failed' }) + return + } + if (!result.verified) { + res.status(401).json({ error: 'Passkey authentication failed' }) + return + } + const newCounter = BigInt(result.authenticationInfo.newCounter) + if ( + (newCounter > 0n || credential.signCount > 0n) && + newCounter <= credential.signCount + ) { + await notifySecurityEvent(credential.userId, 'passkey.anomaly', { + credentialId: credential.credentialId, + }) + await publishUserEvent( + credential.userId, + 'alerts', + 'security.passkey_anomaly', + { credentialId: credential.credentialId } + ) + res.status(401).json({ error: 'Authenticator counter anomaly' }) + return + } + // Compare-and-swap also protects concurrent assertions for a counting authenticator. + const updated = await db.webAuthnCredential.updateMany({ + where: { id: credential.id, signCount: credential.signCount }, + data: { signCount: newCounter, lastUsedAt: new Date() }, + }) + if (!updated.count) { + res.status(401).json({ error: 'Credential was changed; retry login' }) + return + } + const totp = await getActiveTotpCredential(credential.userId) + if (totp?.verifiedAt) { + const challenge = await issueTotpChallenge(credential.userId, { + stellarPubKey: credential.user.walletAddress, + userAgent: req.headers['user-agent'] ?? null, + ipAddress: req.ip ?? null, + }) + res.json({ + requiresTotp: true, + totpChallengeToken: challenge.token, + totpExpiresAt: challenge.expiresAt.toISOString(), + }) + return + } + const pair = await issueTokenPair(credential.userId) + const userAgent = req.headers['user-agent'] ?? null + const ipAddress = req.ip ?? null + const deviceType = parseDeviceType(userAgent) + const approxLocation = resolveApproxLocation(ipAddress) + const session = await db.session.create({ + data: { + userId: credential.userId, + token: pair.accessToken, + walletAddress: credential.user.walletAddress, + network: credential.user.network, + expiresAt: pair.expiresAt, + ipAddress, + userAgent, + deviceType, + approxLocation, + lastSeenAt: new Date(), + lastSeenIp: ipAddress, + ...newRefreshTokenFields(pair), + }, + }) + const deepLinkToken = createSessionDeepLinkToken( + credential.userId, + session.id + ) + await publishUserEvent( + credential.userId, + 'alerts', + 'security.new_session', + { + sessionId: session.id, + deviceType, + approxLocation, + ipAddress: ipAddress ? `${ipAddress.slice(0, -3)}xxx` : null, + createdAt: session.createdAt.toISOString(), + revokeLink: `/sessions?highlight=${session.id}&token=${deepLinkToken}`, + } + ) + res.json({ + accessToken: pair.accessToken, + refreshToken: pair.refreshToken, + userId: credential.userId, + expiresAt: pair.expiresAt.toISOString(), + refreshExpiresAt: pair.refreshExpiresAt.toISOString(), + }) + }) +) + +router.get( + '/credentials', + requireAuth, + activeSession, + handle(async (req, res) => { + const credentials = await db.webAuthnCredential.findMany({ + where: { userId: req.auth!.userId }, + select: { + id: true, + credentialId: true, + deviceLabel: true, + transports: true, + createdAt: true, + lastUsedAt: true, + }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ credentials }) + }) +) + +router.delete( + '/credentials/:id', + requireAuth, + activeSession, + validate({ params: credentialIdParamSchema }), + handle(async (req, res) => { + const result = await db.webAuthnCredential.deleteMany({ + where: { id: req.params.id, userId: req.auth!.userId }, + }) + if (!result.count) { + res.status(404).json({ error: 'Credential not found' }) + return + } + await notifySecurityEvent(req.auth!.userId, 'passkey.deleted', { + credentialId: req.params.id, + }) + await publishUserEvent( + req.auth!.userId, + 'alerts', + 'security.passkey_changed', + { action: 'deleted', credentialId: req.params.id } + ) + res.json({ deleted: true }) + }) +) + +export default router diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts index 2166be40..247804db 100644 --- a/src/routes/withdraw.ts +++ b/src/routes/withdraw.ts @@ -21,6 +21,7 @@ const withdrawSchema = z.object({ // ignored otherwise. Enforced in src/tax/service.ts at disposal-recording // time, not here — this route has no tax-module awareness. selectedLotIds: z.array(z.string().uuid()).optional(), + acknowledgeGoalImpact: z.boolean().optional(), }) router.post( @@ -35,8 +36,12 @@ router.post( sensitiveRateLimiter, validate({ body: withdrawSchema, errorMessage: 'Validation error' }), requireSubAccountPermission('WITHDRAW'), - async (req: Request, res: Response) => { - return processOnChainTransaction(req, res, 'WITHDRAWAL') + async (req: Request, res: Response, next) => { + try { + await processOnChainTransaction(req, res, 'WITHDRAWAL') + } catch (error) { + next(error) + } } ) diff --git a/src/services/security-notification.service.ts b/src/services/security-notification.service.ts index 6de1bf8a..b6401934 100644 --- a/src/services/security-notification.service.ts +++ b/src/services/security-notification.service.ts @@ -16,6 +16,9 @@ export type SecurityEventType = | 'session.revoked' | 'session.revoke_others' | 'password.changed' + | 'passkey.registered' + | 'passkey.deleted' + | 'passkey.anomaly' /** * Fire a security notification to the user. Non-blocking — a failure is diff --git a/src/services/sub-account-management.service.ts b/src/services/sub-account-management.service.ts new file mode 100644 index 00000000..416f457e --- /dev/null +++ b/src/services/sub-account-management.service.ts @@ -0,0 +1,114 @@ +import { Prisma, SubAccountPermission } from '@prisma/client' +import db from '../db' +import { BulkSubAccountOp } from '../validators/sub-account-bulk-validators' + +type Database = typeof db | Prisma.TransactionClient +export class SubAccountManagementError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message) + } +} + +export async function createSubAccount( + parentUserId: string, + input: { + childUserId: string + permissions: SubAccountPermission[] + dailyLimit?: number + transactionLimit?: number + }, + database: Database = db +) { + const { childUserId, permissions, dailyLimit, transactionLimit } = input + if (parentUserId === childUserId) + throw new SubAccountManagementError( + 'Cannot create sub-account with yourself', + 400 + ) + const child = await database.user.findUnique({ + where: { id: childUserId }, + select: { id: true }, + }) + if (!child) throw new SubAccountManagementError('Child user not found', 404) + if ( + await database.subAccount.findFirst({ + where: { parentUserId: childUserId, status: 'ACTIVE' }, + select: { id: true }, + }) + ) + throw new SubAccountManagementError( + 'Chained sub-account relationships are not allowed', + 400 + ) + const existing = await database.subAccount.findUnique({ + where: { parentUserId_childUserId: { parentUserId, childUserId } }, + }) + if (existing?.status === 'ACTIVE') + throw new SubAccountManagementError( + 'Sub-account relationship already exists', + 409 + ) + const data = { + permissions, + ...(dailyLimit !== undefined ? { dailyLimit } : {}), + ...(transactionLimit !== undefined ? { transactionLimit } : {}), + } + return existing + ? database.subAccount.update({ + where: { id: existing.id }, + data: { ...data, status: 'ACTIVE', revokedAt: null }, + }) + : database.subAccount.create({ + data: { parentUserId, childUserId, ...data }, + }) +} + +export async function updateSubAccount( + parentUserId: string, + target: { childUserId?: string; subAccountId?: string }, + input: Prisma.SubAccountUpdateInput, + database: Database = db +) { + const subAccount = await database.subAccount.findUnique({ + where: target.subAccountId + ? { id: target.subAccountId } + : { + parentUserId_childUserId: { + parentUserId, + childUserId: target.childUserId!, + }, + }, + }) + if (!subAccount) + throw new SubAccountManagementError('Sub-account not found', 404) + if (subAccount.parentUserId !== parentUserId) + throw new SubAccountManagementError('Forbidden', 403) + return database.subAccount.update({ + where: { id: subAccount.id }, + data: input, + }) +} + +export async function applySubAccountOperation( + parentUserId: string, + op: BulkSubAccountOp, + database: Database = db +) { + if (op.action === 'create') + return createSubAccount( + parentUserId, + { childUserId: op.childUserId, ...op.payload }, + database + ) + return updateSubAccount( + parentUserId, + op, + op.action === 'revoke' + ? { status: 'REVOKED', revokedAt: new Date() } + : op.payload, + database + ) +} diff --git a/src/services/support-ticket.service.ts b/src/services/support-ticket.service.ts new file mode 100644 index 00000000..6fe71ea7 --- /dev/null +++ b/src/services/support-ticket.service.ts @@ -0,0 +1,39 @@ +import { TicketPriority, TicketStatus } from '@prisma/client' + +const DEFAULT_SLA_HOURS: Record = { + LOW: 72, + MEDIUM: 24, + HIGH: 8, + URGENT: 2, +} + +export function supportSlaHours(priority: TicketPriority): number { + const configured = Number(process.env[`SUPPORT_SLA_${priority}_HOURS`]) + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_SLA_HOURS[priority] +} + +export function supportSla( + ticket: { priority: TicketPriority; status: TicketStatus; createdAt: Date }, + now = new Date() +) { + const slaDueAt = new Date( + ticket.createdAt.getTime() + supportSlaHours(ticket.priority) * 3600000 + ) + return { + slaDueAt, + slaBreached: + ticket.status !== 'RESOLVED' && + ticket.status !== 'CLOSED' && + slaDueAt < now, + } +} + +export const SUPPORT_TRANSITIONS: Record = { + OPEN: ['IN_PROGRESS', 'AWAITING_USER', 'RESOLVED'], + IN_PROGRESS: ['AWAITING_USER', 'RESOLVED'], + AWAITING_USER: ['IN_PROGRESS', 'RESOLVED'], + RESOLVED: ['IN_PROGRESS', 'CLOSED'], + CLOSED: [], +} diff --git a/src/services/webauthn.service.ts b/src/services/webauthn.service.ts new file mode 100644 index 00000000..df769984 --- /dev/null +++ b/src/services/webauthn.service.ts @@ -0,0 +1,60 @@ +import db from '../db' + +export function webAuthnConfig() { + const production = + process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'staging' + const rpID = process.env.WEBAUTHN_RP_ID || (production ? '' : 'localhost') + const origin = + process.env.WEBAUTHN_ORIGIN || (production ? '' : 'http://localhost:3000') + if (!rpID || !origin) + throw new Error('WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN must be configured') + const url = new URL(origin) + if ( + url.origin !== origin || + (url.hostname !== rpID && !url.hostname.endsWith(`.${rpID}`)) || + (url.protocol !== 'https:' && + !(rpID === 'localhost' && url.protocol === 'http:')) + ) { + throw new Error('Invalid WebAuthn relying-party configuration') + } + return { rpID, origin, rpName: process.env.WEBAUTHN_RP_NAME || 'NeuroWealth' } +} + +export async function storeWebAuthnChallenge( + challenge: string, + purpose: 'register' | 'login', + userId?: string, + sessionId?: string +) { + await db.webAuthnChallenge.deleteMany({ + where: { expiresAt: { lte: new Date() } }, + }) + await db.webAuthnChallenge.create({ + data: { + challenge, + purpose, + userId, + sessionId, + expiresAt: new Date(Date.now() + 5 * 60000), + }, + }) +} + +export async function consumeWebAuthnChallenge( + challenge: string, + purpose: 'register' | 'login', + userId: string, + sessionId?: string +) { + const consumed = await db.webAuthnChallenge.deleteMany({ + where: { + challenge, + purpose, + expiresAt: { gt: new Date() }, + ...(purpose === 'register' + ? { userId, sessionId } + : { OR: [{ userId }, { userId: null }] }), + }, + }) + return consumed.count === 1 +} diff --git a/src/services/withdrawal-controls.service.ts b/src/services/withdrawal-controls.service.ts new file mode 100644 index 00000000..cc9bccb2 --- /dev/null +++ b/src/services/withdrawal-controls.service.ts @@ -0,0 +1,101 @@ +import db from '../db' +import { StrKey } from '@stellar/stellar-sdk' +import { scoreTransaction, TransactionTypeLike } from '../compliance/scoring' + +/** Shared pre-submission checks for HTTP, approved and scheduled withdrawals. */ +export async function assessWithdrawal( + userId: string, + destinationAddress: string, + assetSymbol: string, + amount: number, + acknowledgeGoalImpact = false +) { + if (!StrKey.isValidEd25519PublicKey(destinationAddress)) + return { held: true, reason: 'invalid_destination', score: null } + const user = await db.user.findUnique({ where: { id: userId } }) + if (!user || !user.isActive) + return { held: true, reason: 'compliance_freeze', score: null } + const activeCase = await db.complianceCase.findFirst({ + where: { + userId, + status: { + in: ['OPEN', 'TRIAGE', 'INVESTIGATING', 'ESCALATED', 'PENDING_SAR'], + }, + }, + }) + if (activeCase) + return { held: true, reason: 'compliance_freeze', score: null } + + const history = await db.transaction.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 500, + }) + const priorDestinations = await db.outboxOp.findMany({ + where: { userId, kind: 'WITHDRAW', status: 'CONFIRMED' }, + select: { payload: true }, + }) + // Net-worth links are self-reported read-only holdings, not proof that a + // withdrawal destination has been approved. + const knownDestinations = [user.walletAddress] + for (const op of priorDestinations) { + const payload = op.payload as { userAddress?: string } + if (payload.userAddress) knownDestinations.push(payload.userAddress) + } + const now = new Date() + const score = scoreTransaction({ + transaction: { + id: 'withdrawal-preview', + userId, + type: 'WITHDRAWAL', + amount, + assetSymbol, + createdAt: now, + destinationAddress, + isAgentDriven: false, + }, + account: { + userId, + accountCreatedAt: user.createdAt, + knownDestinationAddresses: knownDestinations, + transactionHistory: history + .filter((t) => + [ + 'DEPOSIT', + 'WITHDRAWAL', + 'YIELD_CLAIM', + 'REBALANCE', + 'SWAP', + 'REFERRAL_REWARD', + ].includes(t.type) + ) + .map((t) => ({ + type: t.type as TransactionTypeLike, + amount: Number(t.amount), + assetSymbol: t.assetSymbol, + createdAt: t.createdAt, + isAgentDriven: t.type === 'REBALANCE', + })), + }, + }) + if ( + !knownDestinations.includes(destinationAddress) || + score.totalScore >= 70 + ) { + return { held: true, reason: 'destination_or_transaction_risk', score } + } + const goals = await db.savingsGoal.findMany({ + where: { userId, status: 'ACTIVE' }, + }) + const positions = await db.position.findMany({ + where: { userId, assetSymbol, status: 'ACTIVE' }, + }) + const balance = positions.reduce((sum, p) => sum + Number(p.currentValue), 0) + if ( + !acknowledgeGoalImpact && + goals.some((goal) => balance - amount < Number(goal.targetAmount)) + ) { + return { held: true, reason: 'goal_guardrail', score } + } + return { held: false, reason: null, score } +} diff --git a/src/validators/recurring-withdrawal-validators.ts b/src/validators/recurring-withdrawal-validators.ts new file mode 100644 index 00000000..dc20a2ff --- /dev/null +++ b/src/validators/recurring-withdrawal-validators.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' + +const withdrawalCadenceEnum = z.enum(['WEEKLY', 'BIWEEKLY', 'MONTHLY']) +const planStatusEnum = z.enum(['ACTIVE', 'PAUSED', 'CANCELLED']) +const amountModeEnum = z.enum(['FIXED', 'YIELD_ONLY', 'PERCENT_OF_BALANCE']) + +export const createRecurringWithdrawalSchema = z + .object({ + userId: z.string().uuid(), + destinationAddress: z.string().min(1, 'Destination address is required'), + assetSymbol: z.string().min(1, 'Asset symbol is required'), + amountMode: amountModeEnum.default('FIXED'), + amount: z.number().positive().optional(), + percentage: z.number().min(0.01).max(100).optional(), + cadence: withdrawalCadenceEnum, + minAmount: z.number().positive().optional(), + confirmed: z.literal(true).refine((val) => val === true, { + message: + 'You must confirm this recurring withdrawal. Set confirmed: true after reviewing the schedule.', + }), + }) + .refine( + (data) => { + if (data.amountMode === 'FIXED') { + return data.amount !== undefined && data.amount > 0 + } + return true + }, + { + message: 'amount is required when amountMode is FIXED', + path: ['amount'], + } + ) + .refine( + (data) => { + if (data.amountMode === 'PERCENT_OF_BALANCE') { + return data.percentage !== undefined && data.percentage > 0 + } + return true + }, + { + message: 'percentage is required when amountMode is PERCENT_OF_BALANCE', + path: ['percentage'], + } + ) + +export const updateRecurringWithdrawalSchema = z.object({ + destinationAddress: z.string().min(1).optional(), + amountMode: amountModeEnum.optional(), + amount: z.number().positive().optional(), + percentage: z.number().min(0.01).max(100).optional(), + cadence: withdrawalCadenceEnum.optional(), + minAmount: z.number().positive().optional(), + status: planStatusEnum.optional(), +}) + +export const recurringWithdrawalIdParamSchema = z.object({ + id: z.string().uuid('Invalid recurring withdrawal plan ID'), +}) + +export const previewRecurringWithdrawalSchema = z.object({ + userId: z.string().uuid(), + destinationAddress: z.string().min(1).optional(), + assetSymbol: z.string().min(1), + amountMode: amountModeEnum.default('FIXED'), + amount: z.number().positive().optional(), + percentage: z.number().min(0.01).max(100).optional(), + cadence: withdrawalCadenceEnum, + minAmount: z.number().positive().optional(), +}) + +export type CreateRecurringWithdrawalInput = z.infer< + typeof createRecurringWithdrawalSchema +> +export type UpdateRecurringWithdrawalInput = z.infer< + typeof updateRecurringWithdrawalSchema +> diff --git a/src/validators/sub-account-bulk-validators.ts b/src/validators/sub-account-bulk-validators.ts new file mode 100644 index 00000000..7a7ac5ef --- /dev/null +++ b/src/validators/sub-account-bulk-validators.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' +import { SubAccountPermission } from '@prisma/client' + +export const MAX_BULK_BATCH_SIZE = (() => { + const configured = Number(process.env.SUB_ACCOUNT_MAX_BATCH_SIZE) + return Number.isInteger(configured) && configured > 0 && configured <= 100 + ? configured + : 100 +})() +const permissions = z.array(z.enum(SubAccountPermission)).min(1).max(4) +const limit = z.number().positive().finite() +export const createSubAccountSchema = z.object({ + childUserId: z.string().uuid(), + permissions, + dailyLimit: limit.optional(), + transactionLimit: limit.optional(), +}) +export const updatePermissionsSchema = z.object({ permissions }) +export const updateLimitsSchema = z + .object({ + dailyLimit: limit.nullable().optional(), + transactionLimit: limit.nullable().optional(), + }) + .refine( + (data) => + data.dailyLimit !== undefined || data.transactionLimit !== undefined, + 'At least one limit is required' + ) +const target = { + childUserId: z.string().uuid().optional(), + subAccountId: z.string().uuid().optional(), +} +const updatePayload = z + .object({ + permissions: permissions.optional(), + dailyLimit: limit.nullable().optional(), + transactionLimit: limit.nullable().optional(), + }) + .refine( + (data) => Object.keys(data).length > 0, + 'At least one update is required' + ) +export const bulkSubAccountOpSchema = z + .discriminatedUnion('action', [ + z.object({ + action: z.literal('create'), + childUserId: z.string().uuid(), + payload: createSubAccountSchema.omit({ childUserId: true }), + }), + z.object({ + action: z.literal('update'), + ...target, + payload: updatePayload, + }), + z.object({ + action: z.literal('setPermission'), + ...target, + payload: updatePermissionsSchema, + }), + z.object({ + action: z.literal('setLimit'), + ...target, + payload: updateLimitsSchema, + }), + z.object({ action: z.literal('revoke'), ...target }), + ]) + .refine( + (op) => + op.action === 'create' || + Boolean(op.childUserId) !== Boolean(op.subAccountId), + 'Specify exactly one childUserId or subAccountId' + ) + +// Rows are deliberately validated inside the batch loop, so one malformed row +// cannot prevent valid rows from succeeding in non-atomic mode. +export const bulkSubAccountsSchema = z.object({ + operations: z.array(z.unknown()).min(1), + atomic: z.boolean().default(false), +}) +export type BulkSubAccountOp = z.infer +export type BulkSubAccountsInput = z.infer diff --git a/src/validators/support-ticket-validators.ts b/src/validators/support-ticket-validators.ts new file mode 100644 index 00000000..dea99227 --- /dev/null +++ b/src/validators/support-ticket-validators.ts @@ -0,0 +1,65 @@ +import { z } from 'zod' + +const categoryEnum = z.enum([ + 'ACCOUNT', + 'TRANSACTION', + 'TAX', + 'TECHNICAL', + 'OTHER', +]) + +const statusEnum = z.enum([ + 'OPEN', + 'IN_PROGRESS', + 'AWAITING_USER', + 'RESOLVED', + 'CLOSED', +]) + +const priorityEnum = z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']) + +export const createSupportTicketSchema = z.object({ + subject: z.string().min(3).max(200), + category: categoryEnum, + body: z.string().min(5).max(5000), + priority: priorityEnum.optional().default('MEDIUM'), + contextRef: z.string().optional(), + attachmentRefs: z.array(z.string()).optional(), +}) + +export const replySupportTicketSchema = z.object({ + body: z.string().min(1).max(5000), + attachmentRefs: z.array(z.string()).optional(), +}) + +export const adminReplySupportTicketSchema = z.object({ + body: z.string().min(1).max(5000), + attachmentRefs: z.array(z.string()).optional(), + internal: z.boolean().optional().default(false), +}) + +export const adminUpdateSupportTicketSchema = z.object({ + status: statusEnum.optional(), + priority: priorityEnum.optional(), + assignedTo: z.string().nullable().optional(), +}) + +export const supportTicketIdParamSchema = z.object({ + id: z.string().uuid('Invalid ticket ID'), +}) + +export const supportQueueQuerySchema = z.object({ + status: statusEnum.optional(), + category: categoryEnum.optional(), + assignedTo: z.string().min(1).max(100).optional(), + slaBreached: z.enum(['true', 'false']).optional(), +}) + +export type CreateSupportTicketInput = z.infer +export type ReplySupportTicketInput = z.infer +export type AdminReplySupportTicketInput = z.infer< + typeof adminReplySupportTicketSchema +> +export type AdminUpdateSupportTicketInput = z.infer< + typeof adminUpdateSupportTicketSchema +> diff --git a/src/validators/webauthn-validators.ts b/src/validators/webauthn-validators.ts new file mode 100644 index 00000000..02ad1375 --- /dev/null +++ b/src/validators/webauthn-validators.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' + +const encoded = z + .string() + .min(1) + .max(200000) + .regex(/^[A-Za-z0-9_-]+$/) +const credential = { + id: encoded, + rawId: encoded, + type: z.literal('public-key'), + clientExtensionResults: z.record(z.string(), z.unknown()).default({}), + authenticatorAttachment: z.enum(['platform', 'cross-platform']).optional(), +} +export const registerOptionsSchema = z.object({ + deviceLabel: z.string().trim().min(1).max(100).optional(), +}) +export const registerVerifySchema = z.object({ + challengeId: encoded, + deviceLabel: z.string().trim().min(1).max(100).optional(), + response: z.object({ + ...credential, + response: z.object({ + clientDataJSON: encoded, + attestationObject: encoded, + transports: z + .array( + z.enum([ + 'ble', + 'cable', + 'hybrid', + 'internal', + 'nfc', + 'smart-card', + 'usb', + ]) + ) + .optional(), + }), + }), +}) +export const loginOptionsSchema = z.object({ + userId: z.string().uuid().optional(), +}) +export const loginVerifySchema = z.object({ + challengeId: encoded, + userId: z.string().uuid().optional(), + response: z.object({ + ...credential, + response: z.object({ + clientDataJSON: encoded, + authenticatorData: encoded, + signature: encoded, + userHandle: encoded.optional(), + }), + }), +}) +export const credentialIdParamSchema = z.object({ + id: z.string().uuid('Invalid credential ID'), +}) diff --git a/tests/integration/admin-support-scopes.integration.test.ts b/tests/integration/admin-support-scopes.integration.test.ts new file mode 100644 index 00000000..3a611d27 --- /dev/null +++ b/tests/integration/admin-support-scopes.integration.test.ts @@ -0,0 +1,61 @@ +import express from 'express' +import request from 'supertest' +let mockScopes: string[] = [] +let mockAuthenticated = true +jest.mock('../../src/db', () => ({ + __esModule: true, + default: { + supportTicket: { findMany: jest.fn(async () => []) }, + adminAuditLog: { create: jest.fn(async () => ({})) }, + }, +})) +jest.mock('../../src/middleware/adminAuth', () => ({ + ...jest.requireActual('../../src/middleware/adminAuth'), + requireAdminAuth: (_req: any, res: any, next: any) => { + if (!mockAuthenticated) return res.sendStatus(401) + res.locals.adminAuth = { + id: 'admin', + name: 'Support', + role: 'ADMIN', + scopes: mockScopes, + } + next() + }, +})) +import router from '../../src/routes/admin/support-tickets' +import db from '../../src/db' +const app = express() +app.use(express.json()) +app.use('/tickets', router) +describe('support admin scope enforcement', () => { + beforeEach(() => { + jest.clearAllMocks() + mockAuthenticated = true + mockScopes = [] + }) + it('rejects unauthenticated access', async () => { + mockAuthenticated = false + expect((await request(app).get('/tickets')).status).toBe(401) + expect(db.supportTicket.findMany).not.toHaveBeenCalled() + }) + it('does not grant access through generic admin read or write scopes', async () => { + mockScopes = ['read', 'write'] + expect((await request(app).get('/tickets')).status).toBe(403) + expect(db.supportTicket.findMany).not.toHaveBeenCalled() + }) + it('allows support reads but rejects mutations with read-only scope', async () => { + mockScopes = ['support:read'] + expect((await request(app).get('/tickets')).status).toBe(200) + expect( + ( + await request(app) + .patch('/tickets/11111111-1111-4111-8111-111111111111') + .send({ status: 'RESOLVED' }) + ).status + ).toBe(403) + }) + it('permits super-scoped support reads', async () => { + mockScopes = ['super'] + expect((await request(app).get('/tickets')).status).toBe(200) + }) +}) diff --git a/tests/integration/recurring-withdrawals.integration.test.ts b/tests/integration/recurring-withdrawals.integration.test.ts new file mode 100644 index 00000000..5054562c --- /dev/null +++ b/tests/integration/recurring-withdrawals.integration.test.ts @@ -0,0 +1,254 @@ +const mockUserId = '11111111-1111-4111-8111-111111111111' + +import request from 'supertest' +import express from 'express' + +jest.mock('../../src/middleware/authenticate', () => { + const requireAuth = jest.fn((req: any, _res: any, next: any) => { + req.userId = mockUserId + req.authScopes = req.headers['x-test-scopes'] + ? String(req.headers['x-test-scopes']).split(',') + : ['*'] + req.authKind = req.headers['x-test-auth-kind'] || 'session' + req.auth = { + userId: mockUserId, + walletAddress: 'GWALLET_USER_1', + network: 'TESTNET', + } + next() + }) + const enforceUserAccess = jest.fn((req: any, res: any, next: any) => { + const target = req.params.userId ?? req.body?.userId + if (target && target !== req.auth.userId) { + return res.status(401).json({ error: 'Unauthorized' }) + } + next() + }) + return { requireAuth, enforceUserAccess } +}) + +jest.mock('../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +const plans = new Map() +let planSeq = 0 + +jest.mock('../../src/db', () => ({ + __esModule: true, + default: { + recurringWithdrawalPlan: { + create: jest.fn(async ({ data }: any) => { + const id = + mockUserId.slice(0, -12) + String(++planSeq).padStart(12, '0') + const plan = { + ...data, + id, + createdAt: new Date(), + updatedAt: new Date(), + lastRunAt: null, + lastRunStatus: null, + status: data.status ?? 'ACTIVE', + } + plans.set(id, plan) + return plan + }), + findMany: jest.fn(async ({ where }: any) => { + const result = [] + for (const p of plans.values()) { + if (where?.userId && p.userId !== where.userId) continue + if (where?.status && p.status !== where.status) continue + result.push(p) + } + return result + }), + findUnique: jest.fn(async ({ where }: any) => { + return plans.get(where.id) ?? null + }), + update: jest.fn(async ({ where, data }: any) => { + const plan = plans.get(where.id) + if (!plan) throw new Error('Plan not found') + Object.assign(plan, data) + plan.updatedAt = new Date() + return plan + }), + }, + outboxOp: { findFirst: jest.fn(async () => null) }, + linkedExternalWallet: { + findFirst: jest.fn(async () => null), + }, + complianceCase: { + findFirst: jest.fn(async () => null), + }, + savingsGoal: { + findMany: jest.fn(async () => []), + }, + position: { + findMany: jest.fn(async () => []), + }, + }, +})) + +import recurringWithdrawalRouter from '../../src/routes/recurring-withdrawals' + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/v1/recurring-withdrawals', recurringWithdrawalRouter) + return app +} + +describe('Recurring Withdrawals Integration Tests', () => { + beforeEach(() => { + plans.clear() + planSeq = 0 + }) + + it('rejects read-only API keys before creating any withdrawal schedule', async () => { + const response = await request(buildApp()) + .post('/api/v1/recurring-withdrawals') + .set('x-test-auth-kind', 'api_key') + .set('x-test-scopes', 'portfolio:read') + .send({}) + expect(response.status).toBe(403) + expect(response.body.required).toBe('withdraw:write') + expect(plans.size).toBe(0) + }) + + it('honors the platform API-key withdrawal kill switch for scheduling', async () => { + const response = await request(buildApp()) + .post('/api/v1/recurring-withdrawals') + .set('x-test-auth-kind', 'api_key') + .set('x-test-scopes', 'withdraw:write') + .send({}) + expect(response.status).toBe(403) + expect(plans.size).toBe(0) + }) + + it('does not let edits replace an executing occurrence', async () => { + const app = buildApp() + const created = await request(app) + .post('/api/v1/recurring-withdrawals') + .send({ + userId: mockUserId, + destinationAddress: 'GDESTINATION', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 100, + cadence: 'MONTHLY', + confirmed: true, + }) + const plan = plans.get(created.body.plan.id) + plan.lastRunStatus = 'executing' + const edited = await request(app) + .patch(`/api/v1/recurring-withdrawals/${plan.id}`) + .send({ amount: 200 }) + expect(edited.status).toBe(409) + expect(plan.amount).toBe(100) + }) + + it('validates the merged amount mode when editing a plan', async () => { + const app = buildApp() + const created = await request(app) + .post('/api/v1/recurring-withdrawals') + .send({ + userId: mockUserId, + destinationAddress: 'GDESTINATION', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 100, + cadence: 'MONTHLY', + confirmed: true, + }) + expect( + ( + await request(app) + .patch(`/api/v1/recurring-withdrawals/${created.body.plan.id}`) + .send({ amountMode: 'PERCENT_OF_BALANCE' }) + ).status + ).toBe(400) + }) + + it('POST /api/v1/recurring-withdrawals creates a new plan when confirmed', async () => { + const app = buildApp() + + const res = await request(app).post('/api/v1/recurring-withdrawals').send({ + userId: mockUserId, + destinationAddress: 'GDESTINATION1234567890', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 100, + cadence: 'MONTHLY', + confirmed: true, + }) + + expect(res.status).toBe(201) + expect(res.body.plan).toBeDefined() + expect(res.body.plan.userId).toBe(mockUserId) + expect(res.body.plan.amountMode).toBe('FIXED') + }) + + it('POST /api/v1/recurring-withdrawals fails when confirmed is false or omitted', async () => { + const app = buildApp() + + const res = await request(app).post('/api/v1/recurring-withdrawals').send({ + userId: mockUserId, + destinationAddress: 'GDESTINATION1234567890', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 100, + cadence: 'MONTHLY', + }) + + expect(res.status).toBe(400) + }) + + it('GET /api/v1/recurring-withdrawals lists user plans', async () => { + const app = buildApp() + + await request(app).post('/api/v1/recurring-withdrawals').send({ + userId: mockUserId, + destinationAddress: 'GDESTINATION1234567890', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 50, + cadence: 'WEEKLY', + confirmed: true, + }) + + const res = await request(app).get('/api/v1/recurring-withdrawals') + + expect(res.status).toBe(200) + expect(res.body.plans).toHaveLength(1) + }) + + it('DELETE /api/v1/recurring-withdrawals/:id cancels plan', async () => { + const app = buildApp() + + const createRes = await request(app) + .post('/api/v1/recurring-withdrawals') + .send({ + userId: mockUserId, + destinationAddress: 'GDESTINATION1234567890', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 50, + cadence: 'WEEKLY', + confirmed: true, + }) + + const planId = createRes.body.plan.id + + const deleteRes = await request(app).delete( + `/api/v1/recurring-withdrawals/${planId}` + ) + + expect(deleteRes.status).toBe(200) + expect(deleteRes.body.plan.status).toBe('CANCELLED') + }) +}) diff --git a/tests/integration/sub-account-bulk.integration.test.ts b/tests/integration/sub-account-bulk.integration.test.ts new file mode 100644 index 00000000..2cfeea37 --- /dev/null +++ b/tests/integration/sub-account-bulk.integration.test.ts @@ -0,0 +1,349 @@ +const mockParentUserId = '11111111-1111-4111-8111-111111111111' +const mockChildUserId1 = '22222222-2222-4222-8222-222222222222' +const mockChildUserId2 = '33333333-3333-4333-8333-333333333333' +const mockOtherChildUserId = '44444444-4444-4444-8444-444444444444' + +import request from 'supertest' +import express from 'express' +import db from '../../src/db' +import { logger } from '../../src/utils/logger' + +jest.mock('../../src/middleware/authenticate', () => { + const requireAuth = jest.fn((req: any, _res: any, next: any) => { + req.userId = mockParentUserId + req.auth = { + userId: mockParentUserId, + walletAddress: 'GPARENT_WALLET', + network: 'TESTNET', + } + next() + }) + return { requireAuth } +}) + +jest.mock('../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +const subAccounts = new Map() +let subSeq = 0 + +jest.mock('../../src/db', () => ({ + __esModule: true, + default: { + $transaction: jest.fn(async (cb: any) => { + const snapshot = structuredClone(subAccounts) + const tx = { + user: { + findUnique: jest.fn(async ({ where }: any) => { + if ( + where.id === mockChildUserId1 || + where.id === mockChildUserId2 || + where.id === mockOtherChildUserId + ) { + return { id: where.id } + } + return null + }), + }, + subAccount: { + findFirst: jest.fn(async () => null), + findUnique: jest.fn(async ({ where }: any) => { + if (where.id) return subAccounts.get(where.id) ?? null + if (where.parentUserId_childUserId) { + const key = `${where.parentUserId_childUserId.parentUserId}:${where.parentUserId_childUserId.childUserId}` + return subAccounts.get(key) ?? null + } + return null + }), + create: jest.fn(async ({ data }: any) => { + const id = `sub-${++subSeq}` + const key = `${data.parentUserId}:${data.childUserId}` + const record = { ...data, id, status: 'ACTIVE' } + subAccounts.set(id, record) + subAccounts.set(key, record) + return record + }), + update: jest.fn(async ({ where, data }: any) => { + const record = subAccounts.get(where.id) + if (!record) throw new Error('Sub-account not found') + Object.assign(record, data) + return record + }), + }, + } + try { + return await cb(tx) + } catch (error) { + subAccounts.clear() + for (const [key, value] of snapshot) subAccounts.set(key, value) + throw error + } + }), + user: { + findUnique: jest.fn(async ({ where }: any) => { + if ( + where.id === mockChildUserId1 || + where.id === mockChildUserId2 || + where.id === mockOtherChildUserId + ) { + return { id: where.id } + } + return null + }), + }, + subAccount: { + findMany: jest.fn(async ({ where }: any) => { + const result: any[] = [] + for (const s of subAccounts.values()) { + if (s.parentUserId === where.parentUserId) { + if (!result.some((r) => r.id === s.id)) result.push(s) + } + } + return result + }), + findFirst: jest.fn(async () => null), + findUnique: jest.fn(async ({ where }: any) => { + if (where.id) return subAccounts.get(where.id) ?? null + if (where.parentUserId_childUserId) { + const key = `${where.parentUserId_childUserId.parentUserId}:${where.parentUserId_childUserId.childUserId}` + return subAccounts.get(key) ?? null + } + return null + }), + create: jest.fn(async ({ data }: any) => { + const id = `sub-${++subSeq}` + const key = `${data.parentUserId}:${data.childUserId}` + const record = { ...data, id, status: 'ACTIVE' } + subAccounts.set(id, record) + subAccounts.set(key, record) + return record + }), + update: jest.fn(async ({ where, data }: any) => { + const record = subAccounts.get(where.id) + if (!record) throw new Error('Sub-account not found') + Object.assign(record, data) + return record + }), + }, + transaction: { + count: jest.fn(async () => 5), + }, + }, +})) + +import subAccountRouter from '../../src/routes/sub-accounts' + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/v1/sub-accounts', subAccountRouter) + return app +} + +describe('Sub-Account Bulk Operations & Summary Integration Tests', () => { + beforeEach(() => { + jest.clearAllMocks() + subAccounts.clear() + subSeq = 0 + }) + + it('validates malformed rows independently and preserves deterministic order', async () => { + const app = buildApp() + const response = await request(app) + .post('/api/v1/sub-accounts/bulk') + .send({ + operations: [ + { + action: 'create', + childUserId: mockChildUserId1, + payload: { permissions: ['VIEW'] }, + }, + { + action: 'setPermission', + childUserId: mockChildUserId1, + payload: { permissions: [] }, + }, + { + action: 'setPermission', + childUserId: mockChildUserId1, + payload: { permissions: ['DEPOSIT'] }, + }, + { + action: 'setLimit', + childUserId: mockChildUserId1, + payload: { dailyLimit: 123 }, + }, + ], + }) + expect(response.status).toBe(200) + expect(response.body.results.map((r: any) => r.success)).toEqual([ + true, + false, + true, + true, + ]) + const stored = subAccounts.get(`${mockParentUserId}:${mockChildUserId1}`) + expect(stored.permissions).toEqual(['DEPOSIT']) + expect(stored.dailyLimit).toBe(123) + expect(logger.info).toHaveBeenCalledTimes(3) + }) + + it('rolls back all atomic mutations and identifies the failing operation', async () => { + const response = await request(buildApp()) + .post('/api/v1/sub-accounts/bulk') + .send({ + atomic: true, + operations: [ + { + action: 'create', + childUserId: mockChildUserId1, + payload: { permissions: ['VIEW'] }, + }, + { + action: 'setPermission', + childUserId: mockChildUserId1, + payload: { permissions: [] }, + }, + ], + }) + expect(response.status).toBe(400) + expect(response.body.rolledBack).toBe(true) + expect(response.body.failedIndex).toBe(1) + expect(response.body.results[0].rolledBack).toBe(true) + expect(subAccounts.size).toBe(0) + expect(logger.info).not.toHaveBeenCalled() + }) + + it('uses the same forbidden response for foreign children as single calls', async () => { + const foreignId = '55555555-5555-4555-8555-555555555555' + subAccounts.set(foreignId, { + id: foreignId, + parentUserId: 'another-parent', + childUserId: mockOtherChildUserId, + permissions: ['VIEW'], + status: 'ACTIVE', + }) + const response = await request(buildApp()) + .post('/api/v1/sub-accounts/bulk') + .send({ + operations: [ + { + action: 'setLimit', + subAccountId: foreignId, + payload: { dailyLimit: 1 }, + }, + ], + }) + expect(response.body.results[0]).toMatchObject({ + success: false, + status: 403, + error: 'Forbidden', + }) + expect(subAccounts.get(foreignId).dailyLimit).toBeUndefined() + }) + + it('rejects duplicate active creations rather than silently changing permissions', async () => { + const response = await request(buildApp()) + .post('/api/v1/sub-accounts/bulk') + .send({ + operations: [ + { + action: 'create', + childUserId: mockChildUserId1, + payload: { permissions: ['VIEW'] }, + }, + { + action: 'create', + childUserId: mockChildUserId1, + payload: { permissions: ['WITHDRAW'] }, + }, + ], + }) + expect(response.body.results[1]).toMatchObject({ + success: false, + status: 409, + }) + expect( + subAccounts.get(`${mockParentUserId}:${mockChildUserId1}`).permissions + ).toEqual(['VIEW']) + }) + + it('POST /api/v1/sub-accounts/bulk processes non-atomic batch with partial failures', async () => { + const app = buildApp() + + const res = await request(app) + .post('/api/v1/sub-accounts/bulk') + .send({ + atomic: false, + operations: [ + { + action: 'create', + childUserId: mockChildUserId1, + payload: { permissions: ['VIEW', 'DEPOSIT'] }, + }, + { + action: 'create', + childUserId: '00000000-0000-0000-0000-000000000000', // Non-existent child + payload: { permissions: ['VIEW'] }, + }, + ], + }) + + expect(res.status).toBe(200) + expect(res.body.atomic).toBe(false) + expect(res.body.results).toHaveLength(2) + expect(res.body.results[0].success).toBe(true) + expect(res.body.results[1].success).toBe(false) + }) + + it('POST /api/v1/sub-accounts/bulk rejects batch exceeding limit upfront', async () => { + const app = buildApp() + + const operations = Array.from({ length: 101 }, (_, i) => ({ + action: 'create', + childUserId: mockChildUserId1, + })) + + const res = await request(app) + .post('/api/v1/sub-accounts/bulk') + .send({ operations }) + + expect(res.status).toBe(400) + expect(res.body.error).toContain('Batch size exceeds maximum limit') + expect(db.subAccount.create).not.toHaveBeenCalled() + expect(db.$transaction).not.toHaveBeenCalled() + }) + + it('GET /api/v1/sub-accounts/summary returns aggregate statistics', async () => { + const app = buildApp() + + // First create a sub-account + await request(app) + .post('/api/v1/sub-accounts/bulk') + .send({ + operations: [ + { + action: 'create', + childUserId: mockChildUserId1, + payload: { + permissions: ['VIEW', 'WITHDRAW'], + dailyLimit: 250, + }, + }, + ], + }) + + const summaryRes = await request(app).get('/api/v1/sub-accounts/summary') + + expect(summaryRes.status).toBe(200) + expect(summaryRes.body.summary).toBeDefined() + expect(summaryRes.body.summary.totalChildren).toBe(1) + expect(summaryRes.body.summary.totalDailyLimitExposure).toBe(250) + }) +}) diff --git a/tests/integration/support-tickets.integration.test.ts b/tests/integration/support-tickets.integration.test.ts new file mode 100644 index 00000000..83150dee --- /dev/null +++ b/tests/integration/support-tickets.integration.test.ts @@ -0,0 +1,300 @@ +const mockUserId = '11111111-1111-4111-8111-111111111111' + +import request from 'supertest' +import express from 'express' +import db from '../../src/db' + +jest.mock('../../src/middleware/authenticate', () => { + const requireAuth = jest.fn((req: any, _res: any, next: any) => { + req.userId = mockUserId + req.auth = { + userId: mockUserId, + walletAddress: 'GWALLET_USER_1', + network: 'TESTNET', + } + next() + }) + return { requireAuth } +}) + +jest.mock('../../src/middleware/adminAuth', () => ({ + requireAdminScope: () => (_req: any, _res: any, next: any) => next(), + getAdminAuth: (res: any) => res.locals.adminAuth, + requireAdminAuth: (req: any, _res: any, next: any) => { + _res.locals.adminAuth = { + id: 'admin-1', + name: 'Support Admin', + role: 'ADMIN', + scopes: ['support:read', 'support:write'], + } + next() + }, +})) + +jest.mock('../../src/middleware/rateLimiter', () => ({ + sensitiveRateLimiter: (_req: any, _res: any, next: any) => next(), +})) + +jest.mock('../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +const tickets = new Map() +const messages = new Map() +let ticketSeq = 0 +let msgSeq = 0 + +jest.mock('../../src/db', () => ({ + __esModule: true, + default: { + $transaction: jest.fn(async (cb: any) => { + const tx = { + adminAuditLog: { create: jest.fn(async () => ({})) }, + supportTicket: { + create: jest.fn(async ({ data }: any) => { + const id = `10000000-0000-4000-8000-${String(++ticketSeq).padStart(12, '0')}` + const ticket = { + ...data, + id, + createdAt: new Date(), + updatedAt: new Date(), + resolvedAt: null, + } + tickets.set(id, ticket) + messages.set(id, []) + return ticket + }), + update: jest.fn(async ({ where, data }: any) => { + const ticket = tickets.get(where.id) + if (!ticket) throw new Error('Ticket not found') + Object.assign(ticket, data) + ticket.updatedAt = new Date() + return ticket + }), + }, + ticketMessage: { + create: jest.fn(async ({ data }: any) => { + const id = `20000000-0000-4000-8000-${String(++msgSeq).padStart(12, '0')}` + const msg = { ...data, id, createdAt: new Date() } + const thread = messages.get(data.ticketId) ?? [] + thread.push(msg) + messages.set(data.ticketId, thread) + return msg + }), + }, + } + return cb(tx) + }), + supportTicket: { + findMany: jest.fn(async ({ where }: any) => { + const result = [] + for (const t of tickets.values()) { + if (where?.userId && t.userId !== where.userId) continue + if (where?.status && t.status !== where.status) continue + result.push(t) + } + return result + }), + findUnique: jest.fn(async ({ where }: any) => { + return tickets.get(where.id) ?? null + }), + update: jest.fn(async ({ where, data }: any) => { + const ticket = tickets.get(where.id) + if (!ticket) throw new Error('Ticket not found') + Object.assign(ticket, data) + ticket.updatedAt = new Date() + return ticket + }), + }, + ticketMessage: { + findMany: jest.fn(async ({ where }: any) => { + const thread = messages.get(where.ticketId) ?? [] + return thread.filter((m) => { + if (where.internal === false && m.internal === true) return false + return true + }) + }), + create: jest.fn(async ({ data }: any) => { + const id = `20000000-0000-4000-8000-${String(++msgSeq).padStart(12, '0')}` + const msg = { ...data, id, createdAt: new Date() } + const thread = messages.get(data.ticketId) ?? [] + thread.push(msg) + messages.set(data.ticketId, thread) + return msg + }), + }, + }, +})) + +import supportTicketRouter from '../../src/routes/support-tickets' +import adminSupportTicketRouter from '../../src/routes/admin/support-tickets' + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/v1/support/tickets', supportTicketRouter) + app.use('/api/v1/admin/support/tickets', adminSupportTicketRouter) + return app +} + +describe('Support Ticket Integration Tests', () => { + jest.setTimeout(30000) + + beforeEach(() => { + tickets.clear() + messages.clear() + ticketSeq = 0 + msgSeq = 0 + }) + + it('enforces internal visibility in both user-facing database read queries', async () => { + const app = buildApp() + const created = await request(app) + .post('/api/v1/support/tickets') + .send({ + subject: 'Private notes', + category: 'OTHER', + body: 'Please help with this issue', + }) + await request(app).get('/api/v1/support/tickets') + expect(db.supportTicket.findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { userId: mockUserId }, + include: { + messages: expect.objectContaining({ where: { internal: false } }), + }, + }) + ) + await request(app).get(`/api/v1/support/tickets/${created.body.ticket.id}`) + expect(db.ticketMessage.findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { ticketId: created.body.ticket.id, internal: false }, + }) + ) + }) + + it('rejects reads and replies for another owner', async () => { + const app = buildApp() + const created = await request(app) + .post('/api/v1/support/tickets') + .send({ + subject: 'Ownership test', + category: 'OTHER', + body: 'Please help with this issue', + }) + tickets.get(created.body.ticket.id).userId = 'another-user' + expect( + ( + await request(app).get( + `/api/v1/support/tickets/${created.body.ticket.id}` + ) + ).status + ).toBe(403) + expect( + ( + await request(app) + .post(`/api/v1/support/tickets/${created.body.ticket.id}/reply`) + .send({ body: 'Reply' }) + ).status + ).toBe(403) + }) + + it('computes urgent SLA breaches and excludes closed tickets', async () => { + const app = buildApp() + const created = await request(app) + .post('/api/v1/support/tickets') + .send({ + subject: 'Urgent test', + category: 'OTHER', + priority: 'URGENT', + body: 'Please help with this issue', + }) + tickets.get(created.body.ticket.id).createdAt = new Date( + Date.now() - 3 * 3600000 + ) + const queue = await request(app).get('/api/v1/admin/support/tickets') + expect(queue.body.tickets[0].slaBreached).toBe(true) + expect(queue.body.tickets[0].slaDueAt).toBeDefined() + tickets.get(created.body.ticket.id).status = 'CLOSED' + expect( + ( + await request(app) + .post(`/api/v1/support/tickets/${created.body.ticket.id}/reply`) + .send({ body: 'Reply' }) + ).status + ).toBe(409) + }) + + it('POST /api/v1/support/tickets creates ticket + initial message', async () => { + const app = buildApp() + + const res = await request(app).post('/api/v1/support/tickets').send({ + subject: 'Tax Report Question', + category: 'TAX', + body: 'How do I download my 2025 tax summary?', + }) + + expect(res.status).toBe(201) + expect(res.body.ticket).toBeDefined() + expect(res.body.message).toBeDefined() + expect(res.body.ticket.category).toBe('TAX') + expect(res.body.message.internal).toBe(false) + }) + + it('GET /api/v1/support/tickets/:id never returns internal messages', async () => { + const app = buildApp() + + const createRes = await request(app).post('/api/v1/support/tickets').send({ + subject: 'Technical Error', + category: 'TECHNICAL', + body: 'API returning 500 error on endpoint', + }) + + const ticketId = createRes.body.ticket.id + + await request(app) + .post(`/api/v1/admin/support/tickets/${ticketId}/reply`) + .send({ + body: 'Internal note: escalated to devops team', + internal: true, + }) + + const threadRes = await request(app).get( + `/api/v1/support/tickets/${ticketId}` + ) + + expect(threadRes.status).toBe(200) + expect(threadRes.body.messages).toHaveLength(1) + expect(threadRes.body.messages.some((m: any) => m.internal === true)).toBe( + false + ) + }) + + it('User reply on RESOLVED ticket automatically reopens it to IN_PROGRESS', async () => { + const app = buildApp() + + const createRes = await request(app).post('/api/v1/support/tickets').send({ + subject: 'Deposit issue', + category: 'TRANSACTION', + body: 'Deposit missing', + }) + + const ticketId = createRes.body.ticket.id + + await request(app) + .patch(`/api/v1/admin/support/tickets/${ticketId}`) + .send({ status: 'RESOLVED' }) + + const replyRes = await request(app) + .post(`/api/v1/support/tickets/${ticketId}/reply`) + .send({ body: 'Still not showing up!' }) + + expect(replyRes.status).toBe(201) + expect(replyRes.body.ticket.status).toBe('IN_PROGRESS') + }) +}) diff --git a/tests/integration/webauthn.integration.test.ts b/tests/integration/webauthn.integration.test.ts new file mode 100644 index 00000000..0e5fc74f --- /dev/null +++ b/tests/integration/webauthn.integration.test.ts @@ -0,0 +1,438 @@ +import request from 'supertest' +import express from 'express' + +const mockUserId = '11111111-1111-4111-8111-111111111111' +const mockCredentialId = '22222222-2222-4222-8222-222222222222' +let mockAuthKind = 'session' +const mockChallenges = new Map() +const mockCredentials = new Map() +const mockUser = { + id: mockUserId, + walletAddress: 'GWALLET', + network: 'TESTNET', + email: 'user@example.com', + isActive: true, +} + +jest.mock('../../src/middleware/authenticate', () => ({ + requireAuth: (req: any, res: any, next: any) => { + if (mockAuthKind === 'none') return res.sendStatus(401) + req.authKind = mockAuthKind + req.auth = { + userId: mockUserId, + sessionId: 'session', + walletAddress: 'GWALLET', + network: 'TESTNET', + } + next() + }, +})) +jest.mock('@simplewebauthn/server', () => ({ + ...jest.requireActual('@simplewebauthn/server'), + verifyRegistrationResponse: jest.fn(), + verifyAuthenticationResponse: jest.fn(), +})) +jest.mock('../../src/services/security-notification.service', () => ({ + notifySecurityEvent: jest.fn(async () => {}), +})) +jest.mock('../../src/events/publisher', () => ({ + publishUserEvent: jest.fn(async () => {}), +})) +jest.mock('../../src/services/totp.service', () => ({ + getActiveTotpCredential: jest.fn(async () => null), + issueTotpChallenge: jest.fn(async () => ({ + token: 'totp-token', + expiresAt: new Date(Date.now() + 60000), + })), +})) +jest.mock('../../src/services/refresh-token.service', () => ({ + issueTokenPair: jest.fn(async () => ({ + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: new Date(Date.now() + 60000), + refreshExpiresAt: new Date(Date.now() + 3600000), + })), + newRefreshTokenFields: jest.fn(() => ({ + refreshTokenHash: 'hashed-refresh', + })), +})) +jest.mock('../../src/db', () => ({ + __esModule: true, + default: { + user: { findUnique: jest.fn(async () => mockUser) }, + webAuthnChallenge: { + create: jest.fn(async ({ data }: any) => { + mockChallenges.set(data.challenge, data) + return data + }), + deleteMany: jest.fn(async ({ where }: any) => { + if (!where.challenge) return { count: 0 } + const stored = mockChallenges.get(where.challenge) + if ( + !stored || + stored.purpose !== where.purpose || + stored.expiresAt <= new Date() || + (where.sessionId && stored.sessionId !== where.sessionId) || + (where.userId && stored.userId !== where.userId) || + (where.OR && + stored.userId && + !where.OR.some((clause: any) => clause.userId === stored.userId)) + ) + return { count: 0 } + mockChallenges.delete(where.challenge) + return { count: 1 } + }), + }, + webAuthnCredential: { + findMany: jest.fn(async ({ where, select }: any) => + [...mockCredentials.values()] + .filter((c) => c.userId === where.userId) + .map((c) => + select + ? Object.fromEntries( + Object.keys(select).map((key) => [key, c[key]]) + ) + : c + ) + ), + findUnique: jest.fn(async ({ where }: any) => + [...mockCredentials.values()].find( + (c) => c.credentialId === where.credentialId + ) + ), + create: jest.fn(async ({ data }: any) => { + const c = { ...data, id: mockCredentialId, user: mockUser } + mockCredentials.set(c.id, c) + return c + }), + updateMany: jest.fn(async ({ where, data }: any) => { + const c = mockCredentials.get(where.id) + if (!c || c.signCount !== where.signCount) return { count: 0 } + Object.assign(c, data) + return { count: 1 } + }), + deleteMany: jest.fn(async ({ where }: any) => { + const c = mockCredentials.get(where.id) + if (!c || c.userId !== where.userId) return { count: 0 } + mockCredentials.delete(c.id) + return { count: 1 } + }), + }, + session: { + create: jest.fn(async ({ data }: any) => ({ + ...data, + id: 'new-session', + createdAt: new Date(), + })), + }, + }, +})) + +import webAuthnRouter from '../../src/routes/webauthn' +import db from '../../src/db' +import { + verifyRegistrationResponse, + verifyAuthenticationResponse, +} from '@simplewebauthn/server' +import { notifySecurityEvent } from '../../src/services/security-notification.service' +import { getActiveTotpCredential } from '../../src/services/totp.service' + +const registration = { + id: 'credential', + rawId: 'credential', + type: 'public-key', + clientExtensionResults: {}, + response: { clientDataJSON: 'e30', attestationObject: 'e30' }, +} +const assertion = { + id: 'credential', + rawId: 'credential', + type: 'public-key', + clientExtensionResults: {}, + response: { + clientDataJSON: 'e30', + authenticatorData: 'e30', + signature: 'e30', + }, +} +const buildApp = () => { + const app = express() + app.use(express.json()) + app.use('/api/v1/auth/webauthn', webAuthnRouter) + app.use('/api/v1/webauthn', webAuthnRouter) + return app +} +const credential = (counter = 1n) => + mockCredentials.set(mockCredentialId, { + id: mockCredentialId, + userId: mockUserId, + credentialId: 'credential', + publicKey: 'cHVibGlj', + signCount: counter, + user: mockUser, + deviceLabel: 'Passkey', + }) +const login = async (app: express.Express, userId?: string) => { + const options = await request(app) + .post('/api/v1/auth/webauthn/login-options') + .send(userId ? { userId } : {}) + return { + challengeId: options.body.challengeId, + response: assertion, + ...(userId ? { userId } : {}), + } +} + +describe('passkey security and session integration', () => { + beforeEach(() => { + jest.clearAllMocks() + mockChallenges.clear() + mockCredentials.clear() + mockAuthKind = 'session' + process.env.WEBAUTHN_RP_ID = 'localhost' + process.env.WEBAUTHN_ORIGIN = 'http://localhost:3000' + ;(verifyRegistrationResponse as jest.Mock).mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: 'credential', + publicKey: new Uint8Array([1, 2]), + counter: 1, + transports: ['internal'], + }, + }, + }) + ;(verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 2 }, + }) + ;(getActiveTotpCredential as jest.Mock).mockResolvedValue(null) + }) + afterAll(() => { + delete process.env.WEBAUTHN_RP_ID + delete process.env.WEBAUTHN_ORIGIN + }) + it('requires an active session for registration and credential management', async () => { + const app = buildApp() + mockAuthKind = 'apiKey' + expect( + (await request(app).post('/api/v1/webauthn/register-options').send({})) + .status + ).toBe(401) + expect( + (await request(app).get('/api/v1/webauthn/credentials')).status + ).toBe(401) + mockAuthKind = 'none' + expect( + (await request(app).post('/api/v1/webauthn/register-options').send({})) + .status + ).toBe(401) + }) + it('registers only against configured RP/origin and a one-time session challenge', async () => { + const app = buildApp() + const options = await request(app) + .post('/api/v1/webauthn/register-options') + .set('Host', 'attacker.example') + .send({}) + const body = { + challengeId: options.body.challengeId, + response: registration, + deviceLabel: 'Face ID', + } + expect(options.body.options.rp.id).toBe('localhost') + expect( + (await request(app).post('/api/v1/webauthn/register-verify').send(body)) + .status + ).toBe(201) + expect(verifyRegistrationResponse).toHaveBeenCalledWith( + expect.objectContaining({ + expectedOrigin: 'http://localhost:3000', + expectedRPID: 'localhost', + requireUserVerification: true, + }) + ) + expect(notifySecurityEvent).toHaveBeenCalledWith( + mockUserId, + 'passkey.registered', + expect.anything() + ) + expect( + (await request(app).post('/api/v1/webauthn/register-verify').send(body)) + .status + ).toBe(400) + }) + it('rejects registration challenges issued to a different session', async () => { + const app = buildApp() + const options = await request(app) + .post('/api/v1/webauthn/register-options') + .send({}) + mockChallenges.get(options.body.challengeId).sessionId = 'another-session' + expect( + ( + await request(app) + .post('/api/v1/webauthn/register-verify') + .send({ + challengeId: options.body.challengeId, + response: registration, + }) + ).status + ).toBe(400) + expect(verifyRegistrationResponse).not.toHaveBeenCalled() + }) + it('supports discoverable login and issues a normal refreshable session once', async () => { + const app = buildApp() + credential() + const body = await login(app) + const result = await request(app) + .post('/api/v1/auth/webauthn/login-verify') + .send(body) + expect(result.status).toBe(200) + expect(result.body.refreshToken).toBe('refresh') + expect(db.session.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: mockUserId, + walletAddress: mockUser.walletAddress, + network: 'TESTNET', + refreshTokenHash: 'hashed-refresh', + deviceType: expect.any(String), + }), + }) + ) + expect(mockCredentials.get(mockCredentialId).signCount).toBe(2n) + expect( + (await request(app).post('/api/v1/auth/webauthn/login-verify').send(body)) + .status + ).toBe(400) + expect(db.session.create).toHaveBeenCalledTimes(1) + }) + it('rejects expired challenges before assertion verification', async () => { + const app = buildApp() + credential() + const body = await login(app) + mockChallenges.get(body.challengeId).expiresAt = new Date(0) + expect( + (await request(app).post('/api/v1/auth/webauthn/login-verify').send(body)) + .status + ).toBe(400) + expect(verifyAuthenticationResponse).not.toHaveBeenCalled() + }) + it('rejects a credential belonging to a different requested account', async () => { + const app = buildApp() + credential() + const body = await login(app, mockUserId) + expect( + ( + await request(app) + .post('/api/v1/auth/webauthn/login-verify') + .send({ ...body, userId: mockCredentialId }) + ).status + ).toBe(401) + expect(db.session.create).not.toHaveBeenCalled() + }) + it.each([0, 1])( + 'rejects a stalled/regressing counter %i and alerts the user', + async (counter) => { + const app = buildApp() + credential() + const body = await login(app) + ;(verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: counter }, + }) + expect( + ( + await request(app) + .post('/api/v1/auth/webauthn/login-verify') + .send(body) + ).status + ).toBe(401) + expect(notifySecurityEvent).toHaveBeenCalledWith( + mockUserId, + 'passkey.anomaly', + expect.anything() + ) + expect(db.session.create).not.toHaveBeenCalled() + } + ) + it('alerts when the standards library itself detects a counter anomaly', async () => { + const app = buildApp() + credential() + const body = await login(app) + ;(verifyAuthenticationResponse as jest.Mock).mockRejectedValue( + new Error('Response counter value 1 was lower than expected 1') + ) + expect( + (await request(app).post('/api/v1/auth/webauthn/login-verify').send(body)) + .status + ).toBe(401) + expect(notifySecurityEvent).toHaveBeenCalledWith( + mockUserId, + 'passkey.anomaly', + expect.anything() + ) + }) + it('supports synced authenticators whose counters remain zero', async () => { + const app = buildApp() + credential(0n) + const body = await login(app) + ;(verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 0 }, + }) + expect( + (await request(app).post('/api/v1/auth/webauthn/login-verify').send(body)) + .status + ).toBe(200) + }) + it('preserves enrolled TOTP without issuing a session early', async () => { + const app = buildApp() + credential() + const body = await login(app) + ;(getActiveTotpCredential as jest.Mock).mockResolvedValue({ + verifiedAt: new Date(), + }) + const result = await request(app) + .post('/api/v1/auth/webauthn/login-verify') + .send(body) + expect(result.body.requiresTotp).toBe(true) + expect(result.body.totpChallengeToken).toBe('totp-token') + expect(db.session.create).not.toHaveBeenCalled() + }) + it('hides public-key material and enforces credential ownership for deletion', async () => { + const app = buildApp() + credential() + const list = await request(app).get('/api/v1/webauthn/credentials') + expect(list.body.credentials[0].publicKey).toBeUndefined() + mockCredentials.get(mockCredentialId).userId = 'other-user' + expect( + ( + await request(app).delete( + `/api/v1/webauthn/credentials/${mockCredentialId}` + ) + ).status + ).toBe(404) + mockCredentials.get(mockCredentialId).userId = mockUserId + expect( + ( + await request(app).delete( + `/api/v1/webauthn/credentials/${mockCredentialId}` + ) + ).status + ).toBe(200) + }) + it('rejects signature/origin verification failures without updating counters', async () => { + const app = buildApp() + credential() + const body = await login(app) + ;(verifyAuthenticationResponse as jest.Mock).mockRejectedValue( + new Error('Unexpected authentication response origin') + ) + expect( + (await request(app).post('/api/v1/auth/webauthn/login-verify').send(body)) + .status + ).toBe(401) + expect(mockCredentials.get(mockCredentialId).signCount).toBe(1n) + expect(db.session.create).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/controllers/transaction-controller.test.ts b/tests/unit/controllers/transaction-controller.test.ts index 6204fa11..9a9b5a0d 100644 --- a/tests/unit/controllers/transaction-controller.test.ts +++ b/tests/unit/controllers/transaction-controller.test.ts @@ -26,6 +26,9 @@ jest.mock('../../../src/db', () => ({ jest.mock('../../../src/approvals/service', () => ({ guardOperation: jest.fn(), })) +jest.mock('../../../src/services/withdrawal-controls.service', () => ({ + assessWithdrawal: jest.fn(async () => ({ held: false })), +})) jest.mock('../../../src/outbox/service', () => ({ enqueueOutboxOp: jest.fn(), })) @@ -175,4 +178,80 @@ describe('executeWithdraw — approval gate', () => { expect.objectContaining({ permission: 'WITHDRAW' }) ) }) + + it('rejects a balance already reserved by another withdrawal before dispatch', async () => { + const create = jest.fn() + const lock = jest.fn(async () => 1) + mockDb.$transaction.mockImplementation(async (fn: any) => + fn({ + $executeRaw: lock, + position: { + aggregate: jest.fn(async () => ({ _sum: { currentValue: 100 } })), + }, + transaction: { + aggregate: jest.fn(async () => ({ _sum: { amount: 60 } })), + create, + }, + }) + ) + await expect( + executeWithdraw({ + userId: 'user-1', + walletAddress: 'G...WALLET', + amount: 50, + assetSymbol: 'USDC', + skipApprovalGuard: true, + }) + ).rejects.toThrow( + 'Insufficient balance after pending withdrawal reservations' + ) + expect(lock).toHaveBeenCalledTimes(1) + expect(create).not.toHaveBeenCalled() + expect(mockDispatchOne).not.toHaveBeenCalled() + }) + + it('persists an available withdrawal intent while holding the reservation lock', async () => { + const lock = jest.fn(async () => 1) + const create = jest.fn(async () => ({ + id: 'withdraw-tx', + status: 'PENDING', + })) + mockDb.$transaction.mockImplementation(async (fn: any) => + fn({ + $executeRaw: lock, + position: { + aggregate: jest.fn(async () => ({ _sum: { currentValue: 100 } })), + }, + transaction: { + aggregate: jest.fn(async () => ({ _sum: { amount: 20 } })), + create, + }, + }) + ) + mockEnqueue.mockResolvedValue({ id: 'outbox-withdraw' }) + mockDispatchOne.mockResolvedValue({ + hash: 'withdraw-hash', + status: 'success', + }) + mockDb.transaction.update.mockResolvedValue({ + id: 'withdraw-tx', + status: 'CONFIRMED', + txHash: 'withdraw-hash', + }) + expect( + ( + await executeWithdraw({ + userId: 'user-1', + walletAddress: 'G...WALLET', + amount: 50, + assetSymbol: 'USDC', + skipApprovalGuard: true, + }) + ).status + ).toBe('CONFIRMED') + expect(lock.mock.invocationCallOrder[0]).toBeLessThan( + create.mock.invocationCallOrder[0] + ) + expect(mockDispatchOne).toHaveBeenCalledWith('outbox-withdraw') + }) }) diff --git a/tests/unit/jobs/recurringWithdrawals.execution.test.ts b/tests/unit/jobs/recurringWithdrawals.execution.test.ts new file mode 100644 index 00000000..de6ae89b --- /dev/null +++ b/tests/unit/jobs/recurringWithdrawals.execution.test.ts @@ -0,0 +1,167 @@ +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + logBackgroundJob: jest.fn(), +})) +jest.mock('../../../src/controllers/transaction-controller', () => ({ + executeWithdraw: jest.fn(), +})) +jest.mock('../../../src/services/withdrawal-controls.service', () => ({ + assessWithdrawal: jest.fn(), +})) +jest.mock('../../../src/events/publisher', () => ({ + publishUserEvent: jest.fn(async () => {}), +})) +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + user: { findUnique: jest.fn() }, + recurringWithdrawalPlan: { + findMany: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn(), + update: jest.fn(), + }, + position: { findMany: jest.fn() }, + outboxOp: { findFirst: jest.fn() }, + linkedExternalWallet: { findFirst: jest.fn() }, + complianceCase: { findFirst: jest.fn() }, + savingsGoal: { findMany: jest.fn() }, + }, +})) + +import db from '../../../src/db' +import { processRecurringWithdrawals } from '../../../src/jobs/recurringWithdrawals' +import { executeWithdraw } from '../../../src/controllers/transaction-controller' +import { assessWithdrawal } from '../../../src/services/withdrawal-controls.service' +import { publishUserEvent } from '../../../src/events/publisher' + +describe('recurring withdrawal occurrences', () => { + let plan: any + beforeEach(() => { + jest.resetAllMocks() + plan = { + id: 'plan', + userId: 'user', + destinationAddress: 'GDEST', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 10, + cadence: 'WEEKLY', + nextRunAt: new Date(Date.now() - 1000), + status: 'ACTIVE', + lastRunStatus: null, + lastRunAt: null, + } + ;(db.recurringWithdrawalPlan.findMany as jest.Mock).mockImplementation( + async () => [{ ...plan }] + ) + ;(db.recurringWithdrawalPlan.findUnique as jest.Mock).mockImplementation( + async () => ({ ...plan }) + ) + ;(db.recurringWithdrawalPlan.updateMany as jest.Mock).mockImplementation( + async ({ where, data }) => { + if ( + where.lastRunStatus !== plan.lastRunStatus || + where.lastRunAt !== plan.lastRunAt + ) + return { count: 0 } + Object.assign(plan, data) + return { count: 1 } + } + ) + ;(db.recurringWithdrawalPlan.update as jest.Mock).mockImplementation( + async ({ data }) => Object.assign(plan, data) + ) + ;(db.position.findMany as jest.Mock).mockResolvedValue([ + { currentValue: 100, yieldEarned: 5 }, + ]) + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ isActive: true }) + ;(db.outboxOp.findFirst as jest.Mock).mockResolvedValue({ id: 'known' }) + ;(db.linkedExternalWallet.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.complianceCase.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.savingsGoal.findMany as jest.Mock).mockResolvedValue([]) + ;(assessWithdrawal as jest.Mock).mockResolvedValue({ held: false }) + ;(executeWithdraw as jest.Mock).mockResolvedValue({ + status: 'CONFIRMED', + transaction: { txHash: 'hash' }, + }) + ;(publishUserEvent as jest.Mock).mockResolvedValue(undefined) + }) + it('claims a never-run plan and uses the manual execution path once across concurrent workers', async () => { + await Promise.all([ + processRecurringWithdrawals(), + processRecurringWithdrawals(), + ]) + expect(executeWithdraw).toHaveBeenCalledTimes(1) + expect(executeWithdraw).toHaveBeenCalledWith( + expect.objectContaining({ walletAddress: 'GDEST', amount: 10 }) + ) + expect(plan.nextRunAt.getTime()).toBeGreaterThan(Date.now()) + }) + it('skips insufficient funds without a money movement attempt', async () => { + ;(db.position.findMany as jest.Mock).mockResolvedValue([ + { currentValue: 1 }, + ]) + await processRecurringWithdrawals() + expect(executeWithdraw).not.toHaveBeenCalled() + expect(plan.lastRunStatus).toBe('skipped:insufficient_balance') + expect(publishUserEvent).toHaveBeenCalledWith( + 'user', + 'alerts', + 'recurring_withdrawal.skipped', + expect.anything() + ) + }) + it('pauses unknown destinations and notifies the owner', async () => { + ;(db.outboxOp.findFirst as jest.Mock).mockResolvedValue(null) + await processRecurringWithdrawals() + expect(plan.status).toBe('PAUSED') + expect(executeWithdraw).not.toHaveBeenCalled() + expect(publishUserEvent).toHaveBeenCalledWith( + 'user', + 'alerts', + 'recurring_withdrawal.held', + expect.anything() + ) + }) + it('skips goal conflicts with an explanation', async () => { + ;(db.savingsGoal.findMany as jest.Mock).mockResolvedValue([ + { targetAmount: 95 }, + ]) + await processRecurringWithdrawals() + expect(executeWithdraw).not.toHaveBeenCalled() + expect(plan.lastRunStatus).toMatch(/^held_guardrail:/) + expect(publishUserEvent).toHaveBeenCalledWith( + 'user', + 'alerts', + 'recurring_withdrawal.held', + expect.objectContaining({ reason: expect.any(String) }) + ) + }) + it('rolls a pending approval forward without repeated requests for the occurrence', async () => { + ;(executeWithdraw as jest.Mock).mockResolvedValue({ + status: 'PENDING_APPROVAL', + approvalRequestId: 'approval', + }) + await processRecurringWithdrawals() + expect(plan.lastRunStatus).toBe('pending_approval') + expect(plan.nextRunAt.getTime()).toBeGreaterThan(Date.now()) + }) + it('holds an interrupted submission for review instead of resending', async () => { + plan.lastRunStatus = 'executing' + plan.lastRunAt = new Date(Date.now() - 3600000) + await processRecurringWithdrawals() + expect(executeWithdraw).not.toHaveBeenCalled() + expect(plan.status).toBe('PAUSED') + expect(plan.lastRunStatus).toBe('held:uncertain_execution') + }) + + it('pauses frozen users even when their current balance cannot fund a run', async () => { + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ isActive: false }) + ;(db.position.findMany as jest.Mock).mockResolvedValue([]) + await processRecurringWithdrawals() + expect(plan.status).toBe('PAUSED') + expect(plan.lastRunStatus).toBe('held:compliance_freeze') + expect(executeWithdraw).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/jobs/recurringWithdrawals.test.ts b/tests/unit/jobs/recurringWithdrawals.test.ts new file mode 100644 index 00000000..9ca6a829 --- /dev/null +++ b/tests/unit/jobs/recurringWithdrawals.test.ts @@ -0,0 +1,146 @@ +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + logBackgroundJob: jest.fn(), +})) + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + position: { + findMany: jest.fn(), + }, + outboxOp: { findFirst: jest.fn() }, + linkedExternalWallet: { + findFirst: jest.fn(), + }, + complianceCase: { + findFirst: jest.fn(), + }, + savingsGoal: { + findMany: jest.fn(), + }, + }, +})) + +import db from '../../../src/db' +import { + resolveWithdrawalAmount, + checkDestinationRisk, + checkGoalGuardrailConflict, +} from '../../../src/jobs/recurringWithdrawals' + +describe('recurringWithdrawals job helpers', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('resolveWithdrawalAmount', () => { + it('returns FIXED amount directly', async () => { + ;(db.position.findMany as jest.Mock).mockResolvedValue([ + { currentValue: 200 }, + ]) + const plan: any = { + userId: 'user-1', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 150, + } + + const res = await resolveWithdrawalAmount(plan) + expect(res.amount).toBe(150) + }) + + it('skips a fixed amount when funds are insufficient', async () => { + ;(db.position.findMany as jest.Mock).mockResolvedValue([ + { currentValue: 10 }, + ]) + const result = await resolveWithdrawalAmount({ + userId: 'user-1', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 50, + } as any) + expect(result).toEqual({ amount: 0, reason: 'insufficient_balance' }) + }) + + it('returns yield amount for YIELD_ONLY mode', async () => { + ;(db.position.findMany as jest.Mock).mockResolvedValue([ + { currentValue: 1000, yieldEarned: 50 }, + { currentValue: 2000, yieldEarned: 30 }, + ]) + + const plan: any = { + userId: 'user-1', + assetSymbol: 'USDC', + amountMode: 'YIELD_ONLY', + } + + const res = await resolveWithdrawalAmount(plan) + expect(res.amount).toBe(80) + }) + + it('returns percentage of balance for PERCENT_OF_BALANCE mode', async () => { + ;(db.position.findMany as jest.Mock).mockResolvedValue([ + { currentValue: 1000, yieldEarned: 50 }, + { currentValue: 1000, yieldEarned: 30 }, + ]) + + const plan: any = { + userId: 'user-1', + assetSymbol: 'USDC', + amountMode: 'PERCENT_OF_BALANCE', + percentage: 20, + } + + const res = await resolveWithdrawalAmount(plan) + expect(res.amount).toBe(400) // 20% of 2000 + }) + }) + + describe('checkDestinationRisk', () => { + it('returns isRisk: false if address is in prior transactions', async () => { + ;(db.outboxOp.findFirst as jest.Mock).mockResolvedValue({ id: 'tx-1' }) + ;(db.linkedExternalWallet.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.complianceCase.findFirst as jest.Mock).mockResolvedValue(null) + + const res = await checkDestinationRisk('user-1', 'GKNOWNADDRESS') + expect(res.isRisk).toBe(false) + }) + + it('returns isRisk: true if address is unknown', async () => { + ;(db.outboxOp.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.linkedExternalWallet.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.complianceCase.findFirst as jest.Mock).mockResolvedValue(null) + + const res = await checkDestinationRisk('user-1', 'GNEWDESTINATION') + expect(res.isRisk).toBe(true) + expect(res.reason).toBe('new_destination_unverified') + }) + }) + + describe('checkGoalGuardrailConflict', () => { + it('returns conflict: false if no active goal', async () => { + ;(db.savingsGoal.findMany as jest.Mock).mockResolvedValue([]) + + const res = await checkGoalGuardrailConflict('user-1', 'USDC', 100) + expect(res.conflict).toBe(false) + }) + + it('returns conflict: true if withdrawal would breach targetAmount', async () => { + ;(db.savingsGoal.findMany as jest.Mock).mockResolvedValue([ + { targetAmount: 1500 }, + ]) + ;(db.position.findMany as jest.Mock).mockResolvedValue([ + { currentValue: 1600 }, + ]) + + const res = await checkGoalGuardrailConflict('user-1', 'USDC', 200) + expect(res.conflict).toBe(true) + }) + }) +}) diff --git a/tests/unit/services/support-ticket.service.test.ts b/tests/unit/services/support-ticket.service.test.ts new file mode 100644 index 00000000..59aee480 --- /dev/null +++ b/tests/unit/services/support-ticket.service.test.ts @@ -0,0 +1,37 @@ +import { + supportSla, + supportSlaHours, + SUPPORT_TRANSITIONS, +} from '../../../src/services/support-ticket.service' + +describe('support ticket SLA and transitions', () => { + const createdAt = new Date('2026-10-01T00:00:00Z') + const now = new Date('2026-10-01T03:00:00Z') + it('breaches urgent tickets at two hours but not medium tickets', () => { + expect( + supportSla({ priority: 'URGENT', status: 'OPEN', createdAt }, now) + .slaBreached + ).toBe(true) + expect( + supportSla({ priority: 'MEDIUM', status: 'OPEN', createdAt }, now) + .slaBreached + ).toBe(false) + }) + it('does not flag completed tickets', () => { + expect( + supportSla({ priority: 'URGENT', status: 'RESOLVED', createdAt }, now) + .slaBreached + ).toBe(false) + }) + it('uses valid environment overrides and ignores malformed values', () => { + process.env.SUPPORT_SLA_HIGH_HOURS = '4' + expect(supportSlaHours('HIGH')).toBe(4) + process.env.SUPPORT_SLA_HIGH_HOURS = '-1' + expect(supportSlaHours('HIGH')).toBe(8) + delete process.env.SUPPORT_SLA_HIGH_HOURS + }) + it('allows resolution to reopen and prevents reopening a closed ticket', () => { + expect(SUPPORT_TRANSITIONS.RESOLVED).toContain('IN_PROGRESS') + expect(SUPPORT_TRANSITIONS.CLOSED).toEqual([]) + }) +}) diff --git a/tests/unit/services/withdrawal-controls.service.test.ts b/tests/unit/services/withdrawal-controls.service.test.ts new file mode 100644 index 00000000..23fa3703 --- /dev/null +++ b/tests/unit/services/withdrawal-controls.service.test.ts @@ -0,0 +1,93 @@ +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + user: { findUnique: jest.fn() }, + complianceCase: { findFirst: jest.fn() }, + transaction: { findMany: jest.fn() }, + outboxOp: { findMany: jest.fn() }, + savingsGoal: { findMany: jest.fn() }, + position: { findMany: jest.fn() }, + }, +})) +import { Keypair } from '@stellar/stellar-sdk' +import db from '../../../src/db' +import { assessWithdrawal } from '../../../src/services/withdrawal-controls.service' +describe('shared withdrawal controls', () => { + const address = Keypair.random().publicKey() + beforeEach(() => { + jest.clearAllMocks() + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ + id: 'user', + isActive: true, + walletAddress: address, + createdAt: new Date('2020-01-01'), + }) + ;(db.complianceCase.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.transaction.findMany as jest.Mock).mockResolvedValue([]) + ;(db.outboxOp.findMany as jest.Mock).mockResolvedValue([]) + ;(db.savingsGoal.findMany as jest.Mock).mockResolvedValue([]) + ;(db.position.findMany as jest.Mock).mockResolvedValue([ + { currentValue: 100 }, + ]) + }) + it('holds malformed destinations before loading financial history', async () => { + expect((await assessWithdrawal('user', 'invalid', 'USDC', 10)).reason).toBe( + 'invalid_destination' + ) + expect(db.transaction.findMany).not.toHaveBeenCalled() + }) + it('holds inactive users and active compliance cases', async () => { + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ isActive: false }) + expect((await assessWithdrawal('user', address, 'USDC', 10)).reason).toBe( + 'compliance_freeze' + ) + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ isActive: true }) + ;(db.complianceCase.findFirst as jest.Mock).mockResolvedValue({ + id: 'case', + }) + expect((await assessWithdrawal('user', address, 'USDC', 10)).reason).toBe( + 'compliance_freeze' + ) + }) + it('scores new destinations and holds them without sending', async () => { + const result = await assessWithdrawal( + 'user', + Keypair.random().publicKey(), + 'USDC', + 10 + ) + expect(result.held).toBe(true) + expect(result.score?.reasonCodes).toContain('NEW_DESTINATION_NO_HISTORY') + }) + it('uses confirmed outbox destinations as exact history rather than memo text', async () => { + const external = Keypair.random().publicKey() + ;(db.outboxOp.findMany as jest.Mock).mockResolvedValue([ + { payload: { userAddress: external } }, + ]) + expect((await assessWithdrawal('user', external, 'USDC', 10)).held).toBe( + false + ) + }) + it('requires explicit manual goal acknowledgement and never relaxes destination risk', async () => { + ;(db.savingsGoal.findMany as jest.Mock).mockResolvedValue([ + { targetAmount: 95 }, + ]) + expect((await assessWithdrawal('user', address, 'USDC', 10)).reason).toBe( + 'goal_guardrail' + ) + expect( + (await assessWithdrawal('user', address, 'USDC', 10, true)).held + ).toBe(false) + expect( + ( + await assessWithdrawal( + 'user', + Keypair.random().publicKey(), + 'USDC', + 10, + true + ) + ).held + ).toBe(true) + }) +}) diff --git a/tests/unit/validators/recurring-withdrawal-validators.test.ts b/tests/unit/validators/recurring-withdrawal-validators.test.ts new file mode 100644 index 00000000..d0b58ec0 --- /dev/null +++ b/tests/unit/validators/recurring-withdrawal-validators.test.ts @@ -0,0 +1,77 @@ +import { + createRecurringWithdrawalSchema, + updateRecurringWithdrawalSchema, +} from '../../../src/validators/recurring-withdrawal-validators' + +describe('recurring-withdrawal-validators', () => { + const validUuid = '11111111-1111-4111-8111-111111111111' + + it('validates a correct FIXED recurring withdrawal schema', () => { + const valid = { + userId: validUuid, + destinationAddress: 'GDESTINATION1234567890', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 100, + cadence: 'MONTHLY', + confirmed: true, + } + + const result = createRecurringWithdrawalSchema.safeParse(valid) + expect(result.success).toBe(true) + }) + + it('validates a correct PERCENT_OF_BALANCE recurring withdrawal schema', () => { + const valid = { + userId: validUuid, + destinationAddress: 'GDESTINATION1234567890', + assetSymbol: 'USDC', + amountMode: 'PERCENT_OF_BALANCE', + percentage: 25, + cadence: 'WEEKLY', + confirmed: true, + } + + const result = createRecurringWithdrawalSchema.safeParse(valid) + expect(result.success).toBe(true) + }) + + it('fails if confirmed is missing or false', () => { + const invalid = { + userId: validUuid, + destinationAddress: 'GDESTINATION1234567890', + assetSymbol: 'USDC', + amountMode: 'FIXED', + amount: 100, + cadence: 'MONTHLY', + confirmed: false, + } + + const result = createRecurringWithdrawalSchema.safeParse(invalid) + expect(result.success).toBe(false) + }) + + it('fails if amountMode is FIXED but amount is missing', () => { + const invalid = { + userId: validUuid, + destinationAddress: 'GDESTINATION1234567890', + assetSymbol: 'USDC', + amountMode: 'FIXED', + cadence: 'MONTHLY', + confirmed: true, + } + + const result = createRecurringWithdrawalSchema.safeParse(invalid) + expect(result.success).toBe(false) + }) + + it('validates update schema', () => { + const validUpdate = { + destinationAddress: 'GNEWDESTINATION', + status: 'PAUSED', + } + + const result = updateRecurringWithdrawalSchema.safeParse(validUpdate) + expect(result.success).toBe(true) + }) +}) diff --git a/tests/unit/validators/sub-account-bulk-validators.test.ts b/tests/unit/validators/sub-account-bulk-validators.test.ts new file mode 100644 index 00000000..9c094394 --- /dev/null +++ b/tests/unit/validators/sub-account-bulk-validators.test.ts @@ -0,0 +1,77 @@ +import { + bulkSubAccountsSchema, + bulkSubAccountOpSchema, +} from '../../../src/validators/sub-account-bulk-validators' + +describe('sub-account-bulk-validators', () => { + it('validates a correct bulk operations payload', () => { + const valid = { + atomic: true, + operations: [ + { + action: 'create', + childUserId: '11111111-1111-4111-8111-111111111111', + payload: { + permissions: ['VIEW', 'DEPOSIT'], + dailyLimit: 500, + }, + }, + { + action: 'setLimit', + subAccountId: '22222222-2222-4222-8222-222222222222', + payload: { + dailyLimit: 1000, + }, + }, + ], + } + + const res = bulkSubAccountsSchema.safeParse(valid) + expect(res.success).toBe(true) + }) + + it('fails if operations array is empty', () => { + const invalid = { + operations: [], + } + + const res = bulkSubAccountsSchema.safeParse(invalid) + expect(res.success).toBe(false) + }) + + it('validates individual operation schema', () => { + const validOp = { + action: 'revoke', + subAccountId: '11111111-1111-4111-8111-111111111111', + } + + const res = bulkSubAccountOpSchema.safeParse(validOp) + expect(res.success).toBe(true) + }) + + it('rejects empty permissions and missing action-specific data', () => { + expect( + bulkSubAccountOpSchema.safeParse({ + action: 'create', + childUserId: '11111111-1111-4111-8111-111111111111', + payload: { permissions: [] }, + }).success + ).toBe(false) + expect( + bulkSubAccountOpSchema.safeParse({ + action: 'setLimit', + childUserId: '11111111-1111-4111-8111-111111111111', + payload: {}, + }).success + ).toBe(false) + }) + + it('keeps malformed rows for independent per-operation validation', () => { + expect( + bulkSubAccountsSchema.safeParse({ + operations: [{ action: 'unsupported' }, null], + }).success + ).toBe(true) + expect(bulkSubAccountOpSchema.safeParse(null).success).toBe(false) + }) +}) diff --git a/tests/unit/validators/support-ticket-validators.test.ts b/tests/unit/validators/support-ticket-validators.test.ts new file mode 100644 index 00000000..5d9c69f3 --- /dev/null +++ b/tests/unit/validators/support-ticket-validators.test.ts @@ -0,0 +1,51 @@ +import { + createSupportTicketSchema, + replySupportTicketSchema, + adminUpdateSupportTicketSchema, +} from '../../../src/validators/support-ticket-validators' + +describe('support-ticket-validators', () => { + it('validates a valid create ticket schema', () => { + const valid = { + subject: 'Deposit not appearing', + category: 'TRANSACTION', + body: 'I sent 100 USDC but it is not showing up in my account.', + priority: 'HIGH', + contextRef: 'tx-12345', + } + + const res = createSupportTicketSchema.safeParse(valid) + expect(res.success).toBe(true) + }) + + it('fails if subject is too short', () => { + const invalid = { + subject: 'Hi', + category: 'OTHER', + body: 'Question about fees', + } + + const res = createSupportTicketSchema.safeParse(invalid) + expect(res.success).toBe(false) + }) + + it('validates reply schema', () => { + const valid = { + body: 'Here is the requested transaction hash', + } + + const res = replySupportTicketSchema.safeParse(valid) + expect(res.success).toBe(true) + }) + + it('validates admin update schema', () => { + const valid = { + status: 'RESOLVED', + priority: 'URGENT', + assignedTo: 'admin-user-1', + } + + const res = adminUpdateSupportTicketSchema.safeParse(valid) + expect(res.success).toBe(true) + }) +}) diff --git a/tests/unit/validators/webauthn-validators.test.ts b/tests/unit/validators/webauthn-validators.test.ts new file mode 100644 index 00000000..e43da81a --- /dev/null +++ b/tests/unit/validators/webauthn-validators.test.ts @@ -0,0 +1,63 @@ +import { + registerOptionsSchema, + registerVerifySchema, + loginVerifySchema, + credentialIdParamSchema, +} from '../../../src/validators/webauthn-validators' + +describe('webauthn-validators', () => { + it('validates register options schema', () => { + const valid = { deviceLabel: 'MacBook Pro FaceID' } + const res = registerOptionsSchema.safeParse(valid) + expect(res.success).toBe(true) + }) + + it('validates register verify schema', () => { + const valid = { + challengeId: 'challenge', + response: { + id: 'cred-123', + rawId: 'cred-123', + type: 'public-key', + response: { clientDataJSON: 'e30', attestationObject: 'e30' }, + }, + deviceLabel: 'YubiKey 5C', + } + const res = registerVerifySchema.safeParse(valid) + expect(res.success).toBe(true) + }) + + it('validates login verify schema', () => { + const valid = { + userId: '11111111-1111-4111-8111-111111111111', + challengeId: 'challenge', + response: { + id: 'cred-123', + rawId: 'cred-123', + type: 'public-key', + response: { + clientDataJSON: 'e30', + authenticatorData: 'e30', + signature: 'e30', + }, + }, + } + const res = loginVerifySchema.safeParse(valid) + expect(res.success).toBe(true) + }) + + it('requires a challenge and complete assertion instead of arbitrary response data', () => { + expect( + loginVerifySchema.safeParse({ response: { id: 'credential' } }).success + ).toBe(false) + expect(registerVerifySchema.safeParse({ response: null }).success).toBe( + false + ) + }) + + it('validates credential id param schema', () => { + const valid = { id: '11111111-1111-4111-8111-111111111111' } + const res = credentialIdParamSchema.safeParse(valid) + expect(res.success).toBe(true) + }) +})