Skip to content

Commit 8d4c973

Browse files
authored
Merge branch 'staging' into feature/345-346-transactions-metrics-and-env-validation
2 parents 63489ad + 28cad96 commit 8d4c973

42 files changed

Lines changed: 2463 additions & 355 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,9 @@ All webhook payloads are signed with HMAC-SHA256. The `X-Webhook-Signature` head
475475

476476
## Wallets API
477477

478+
Endpoint semantics, idempotency, lifecycle events, dependency retries, and
479+
metrics are documented in [docs/WALLET-API.md](docs/WALLET-API.md).
480+
478481
- `POST /wallets` - create wallet
479482
- `GET /wallets` - list all wallets
480483
- `GET /wallets/user/:userId` - list wallets by userId (#189)
@@ -515,4 +518,3 @@ Testing
515518

516519
- Unit tests are under `src/**/*spec.ts`.
517520
- E2E tests are under `test/` and use Jest + Supertest.
518-

TEST_VERIFICATION_GUIDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ These test files were updated to use the new `/v1` prefix:
3333

3434
4. **test/wallets.e2e-spec.ts**
3535
- Tests wallet endpoint: `GET /v1/wallets/protected`
36+
- Tests wallet creation and wallet status paths
37+
- Verifies `x-request-id` propagation in headers
3638
- Verifies API key authentication with prefix
3739

3840
### New Test File

docs/WALLET-API.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Wallet API behavior
2+
3+
All Wallet API routes require a valid API key and are rate-limited. The API
4+
never returns encrypted key material on read endpoints. The only operation
5+
that returns a `privateKey` is a successful first wallet-creation response;
6+
clients must consume it immediately and must not expect it to be replayed.
7+
8+
## Endpoints
9+
10+
| Method | Route | Behavior |
11+
| --- | --- | --- |
12+
| `POST` | `/wallets` | Creates one active wallet per user/network pair. Duplicate user/network requests return `409`. |
13+
| `GET` | `/wallets` | Lists wallets. |
14+
| `GET` | `/wallets/:id` | Returns a wallet or `404`. |
15+
| `GET` | `/wallets/:id/status` | Returns lifecycle status without decrypting the private key. |
16+
| `PATCH` | `/wallets/:id` | Updates wallet lifecycle status. |
17+
| `PATCH` | `/wallets/:id/activate` | Activates a `PROVISIONING` wallet. Any other current state is rejected. |
18+
| `DELETE` | `/wallets/:id` | Removes a wallet record. |
19+
| `POST` | `/wallets/orchestration/create` | Runs the provisioning flow and accepts an optional `idempotencyKey`. |
20+
| `GET` | `/wallets/orchestration/user/:userId/:network` | Returns the wallet for a user/network pair or `404`. |
21+
| `GET` | `/wallets/orchestration/validate/:userId/:network` | Reports whether a new wallet may be created. |
22+
23+
`network` is `TESTNET` or `MAINNET`. `POST /wallets/orchestration/create`
24+
creates a wallet as `PROVISIONING`, then promotes it to `ACTIVE` in the same
25+
database transaction. Testnet funding is best effort: a disconnected or
26+
failed Friendbot call is logged and does not undo a committed wallet.
27+
28+
## Idempotency
29+
30+
For orchestration creation, an `idempotencyKey` is scoped to one
31+
`userId`/`network` operation for 24 hours.
32+
33+
- Repeating the same operation returns the cached wallet result with
34+
`privateKey: ""`.
35+
- Reusing the key for another user or network returns `409`.
36+
- Expired keys are treated as new requests.
37+
38+
## Lifecycle events
39+
40+
The API emits webhook domain events after state has been durably persisted:
41+
`wallet.created`, `wallet.activated`, `wallet.suspended`, and
42+
`wallet.rotated`. Event dispatch is asynchronous; a webhook outage is logged
43+
but never changes the response or rolls back wallet state. Creation events
44+
from the orchestration endpoint are emitted only after its database
45+
transaction commits, and are not repeated for idempotency replays.
46+
47+
## Dependency retries and metrics
48+
49+
Before any wallet write, transient key-management and testnet-funding failures
50+
are retried with capped exponential backoff. Invalid requests and non-transient
51+
4xx responses are not retried. Configure this behavior with:
52+
53+
| Variable | Default |
54+
| --- | --- |
55+
| `WALLET_API_RETRY_MAX_ATTEMPTS` | `3` |
56+
| `WALLET_API_RETRY_BASE_DELAY_MS` | `100` |
57+
| `WALLET_API_RETRY_MAX_DELAY_MS` | `2000` |
58+
59+
Wallet operations write structured `[wallet-api-metrics]` log records with
60+
operation, outcome, duration, and network. Metrics intentionally exclude user
61+
and wallet identifiers so they are safe to aggregate as low-cardinality
62+
telemetry.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,4 @@
9696
"^.+/generated/prisma/client$": "<rootDir>/__mocks__/generated/prisma/client.ts"
9797
}
9898
}
99-
}
99+
}

pnpm-workspace.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
allowBuilds:
2+
'@nestjs/core': set this to true or false
3+
'@prisma/engines': set this to true or false
4+
'@scarf/scarf': set this to true or false
5+
prisma: set this to true or false
6+
sodium-native: set this to true or false
7+
unrs-resolver: set this to true or false
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { AuthMetricsController } from './auth-metrics.controller';
3+
import { AuthMetricsService, AuthMetricsSnapshot } from './auth-metrics.service';
4+
5+
const makeSnapshot = (overrides: Partial<AuthMetricsSnapshot> = {}): AuthMetricsSnapshot => ({
6+
totalAttempts: 0,
7+
outcomes: {
8+
success_new_user: 0,
9+
success_returning_user: 0,
10+
failure_invalid_payload: 0,
11+
failure_user_inactive: 0,
12+
failure_wallet_error: 0,
13+
failure_unknown: 0,
14+
},
15+
rateLimitHits: 0,
16+
averageLatencyMs: 0,
17+
p95LatencyMs: 0,
18+
lastResetAt: new Date('2026-01-01T00:00:00Z'),
19+
...overrides,
20+
});
21+
22+
describe('AuthMetricsController', () => {
23+
let controller: AuthMetricsController;
24+
let metricsService: jest.Mocked<AuthMetricsService>;
25+
26+
beforeEach(async () => {
27+
metricsService = {
28+
recordAttempt: jest.fn(),
29+
recordRateLimitHit: jest.fn(),
30+
getSnapshot: jest.fn(),
31+
reset: jest.fn(),
32+
} as unknown as jest.Mocked<AuthMetricsService>;
33+
34+
const module: TestingModule = await Test.createTestingModule({
35+
controllers: [AuthMetricsController],
36+
providers: [{ provide: AuthMetricsService, useValue: metricsService }],
37+
}).compile();
38+
39+
controller = module.get(AuthMetricsController);
40+
});
41+
42+
it('should be defined', () => {
43+
expect(controller).toBeDefined();
44+
});
45+
46+
describe('getMetrics()', () => {
47+
it('delegates to AuthMetricsService.getSnapshot()', () => {
48+
const snap = makeSnapshot({ totalAttempts: 42, rateLimitHits: 3 });
49+
metricsService.getSnapshot.mockReturnValue(snap);
50+
51+
const result = controller.getMetrics();
52+
53+
expect(metricsService.getSnapshot).toHaveBeenCalledTimes(1);
54+
expect(result).toEqual(snap);
55+
});
56+
57+
it('returns a snapshot with all expected fields', () => {
58+
const snap = makeSnapshot({
59+
totalAttempts: 10,
60+
averageLatencyMs: 120,
61+
p95LatencyMs: 300,
62+
outcomes: {
63+
success_new_user: 3,
64+
success_returning_user: 5,
65+
failure_invalid_payload: 1,
66+
failure_user_inactive: 0,
67+
failure_wallet_error: 0,
68+
failure_unknown: 1,
69+
},
70+
});
71+
metricsService.getSnapshot.mockReturnValue(snap);
72+
73+
const result = controller.getMetrics();
74+
expect(result.totalAttempts).toBe(10);
75+
expect(result.averageLatencyMs).toBe(120);
76+
expect(result.p95LatencyMs).toBe(300);
77+
expect(result.outcomes.success_new_user).toBe(3);
78+
});
79+
80+
it('returns zero-state snapshot when no auth has occurred', () => {
81+
metricsService.getSnapshot.mockReturnValue(makeSnapshot());
82+
const result = controller.getMetrics();
83+
expect(result.totalAttempts).toBe(0);
84+
expect(result.rateLimitHits).toBe(0);
85+
});
86+
});
87+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
2+
import { AuthMetricsService, AuthMetricsSnapshot } from './auth-metrics.service';
3+
4+
/**
5+
* Exposes read-only auth metrics.
6+
*
7+
* Route: GET /auth/metrics
8+
*
9+
* This endpoint requires a valid API key (inherits the global ApiKeyGuard).
10+
* It is intentionally NOT marked @Public() so that raw metric data is not
11+
* accessible without authentication.
12+
*/
13+
@Controller('auth')
14+
export class AuthMetricsController {
15+
constructor(private readonly authMetrics: AuthMetricsService) {}
16+
17+
/**
18+
* Returns a point-in-time snapshot of auth instrumentation counters.
19+
*
20+
* Response shape mirrors {@link AuthMetricsSnapshot}.
21+
*/
22+
@Get('metrics')
23+
@HttpCode(HttpStatus.OK)
24+
getMetrics(): AuthMetricsSnapshot {
25+
return this.authMetrics.getSnapshot();
26+
}
27+
}

0 commit comments

Comments
 (0)