Skip to content

Commit 315c540

Browse files
authored
Merge branch 'staging' into fix/372-webhook-domain-events
2 parents db5bc1f + 1591ff2 commit 315c540

116 files changed

Lines changed: 10267 additions & 970 deletions

File tree

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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,27 +29,30 @@
2929
"@nestjs/common": "^11.0.1",
3030
"@nestjs/config": "^4.0.2",
3131
"@nestjs/core": "^11.0.1",
32+
"@nestjs/event-emitter": "^3.1.0",
3233
"@nestjs/mapped-types": "*",
3334
"@nestjs/platform-express": "^11.0.1",
3435
"@nestjs/terminus": "^11.1.1",
3536
"@nestjs/throttler": "^6.5.0",
3637
"@prisma/adapter-pg": "^7.3.0",
3738
"@prisma/client": "^7.3.0",
39+
"@willsoto/nestjs-prometheus": "^6.1.0",
3840
"axios": "^1.6.0",
3941
"class-transformer": "^0.5.1",
4042
"class-validator": "^0.15.1",
4143
"dotenv": "^17.2.3",
4244
"pg": "^8.17.2",
45+
"prom-client": "^15.1.3",
4346
"reflect-metadata": "^0.2.2",
4447
"rxjs": "^7.8.1",
4548
"stellar-sdk": "^10.2.0"
4649
},
4750
"devDependencies": {
4851
"@eslint/eslintrc": "^3.2.0",
4952
"@eslint/js": "^9.18.0",
50-
"@nestjs/swagger": "^8.0.0",
5153
"@nestjs/cli": "^11.0.0",
5254
"@nestjs/schematics": "^11.0.0",
55+
"@nestjs/swagger": "^8.0.0",
5356
"@nestjs/testing": "^11.0.1",
5457
"@types/express": "^5.0.0",
5558
"@types/jest": "^30.0.0",
@@ -96,4 +99,4 @@
9699
"^.+/generated/prisma/client$": "<rootDir>/__mocks__/generated/prisma/client.ts"
97100
}
98101
}
99-
}
102+
}

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

src/app.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { Module } from '@nestjs/common';
22
import { APP_GUARD } from '@nestjs/core';
33
import { AppController } from './app.controller';
44
import { ConfigModule } from '@nestjs/config';
5+
import { EventEmitterModule } from '@nestjs/event-emitter';
56
import { PrismaModule } from './prisma/prisma.module';
6-
import { validateEnvironment } from './common/config/env.validation';
7+
import { MetricsModule } from './metrics/metrics.module';
78
import { AppService } from './app.service';
89
import { UsersModule } from './users/users.module';
910
import { IdempotentUserModule } from './users/idempotent-user.module';
@@ -32,6 +33,8 @@ import { HealthModule } from './health/health.module';
3233
envFilePath: '.env',
3334
validate: validateEnvironment,
3435
}),
36+
EventEmitterModule.forRoot(),
37+
MetricsModule,
3538
PrismaModule,
3639
AuthModule,
3740
RateLimitModule,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { AuthOrchestrator } from './auth-orchestrator.service';
3+
import { IdempotentUserService } from '../users/idempotent-user.service';
4+
import { WalletCreationOrchestrator } from '../wallets/wallet-creation-orchestrator.service';
5+
import { IdempotencyService } from '../common/idempotency/idempotency.service';
6+
import { WebhookEventEmitterService } from '../webhooks/webhook-event-emitter.service';
7+
import { WalletNetwork, WalletStatus } from '../wallets/domain/wallet.model';
8+
9+
const NOW = new Date('2026-01-01T00:00:00.000Z');
10+
11+
const makeUser = (overrides: Record<string, any> = {}) => ({
12+
id: 'user-abc',
13+
authId: 'auth-abc',
14+
email: 'user@example.com',
15+
displayName: 'Test User',
16+
status: 'ACTIVE',
17+
authProvider: 'GOOGLE',
18+
lastLoginAt: NOW,
19+
createdAt: NOW,
20+
updatedAt: NOW,
21+
...overrides,
22+
});
23+
24+
const makeWallet = (overrides: Record<string, any> = {}) => ({
25+
id: 'wallet-abc',
26+
userId: 'user-abc',
27+
publicKey: 'GABC1234567890',
28+
encryptedSecret: 'enc',
29+
encryptionVersion: 1,
30+
secretVersion: 1,
31+
network: WalletNetwork.TESTNET,
32+
status: WalletStatus.ACTIVE,
33+
statusChangedAt: NOW,
34+
createdAt: NOW,
35+
updatedAt: NOW,
36+
rotatedFromId: null,
37+
statusReason: null,
38+
...overrides,
39+
});
40+
41+
describe('AuthOrchestrator — domain event emission', () => {
42+
let orchestrator: AuthOrchestrator;
43+
let webhookEventEmitter: jest.Mocked<WebhookEventEmitterService>;
44+
45+
const mockUserService = {
46+
findOrCreateUser: jest.fn(),
47+
findUserByAuthId: jest.fn(),
48+
listSessions: jest.fn(),
49+
};
50+
const mockWalletOrchestrator = {
51+
getWalletByUser: jest.fn(),
52+
createWallet: jest.fn(),
53+
};
54+
const mockIdempotencyService = {
55+
getCachedResponse: jest.fn().mockResolvedValue(null),
56+
cacheResponse: jest.fn().mockResolvedValue(undefined),
57+
};
58+
const mockWebhookEventEmitter = {
59+
emitUserAuthenticated: jest.fn().mockResolvedValue(undefined),
60+
emitNewUserRegistered: jest.fn().mockResolvedValue(undefined),
61+
emitAuthenticationFailed: jest.fn().mockResolvedValue(undefined),
62+
};
63+
64+
beforeEach(async () => {
65+
const module: TestingModule = await Test.createTestingModule({
66+
providers: [
67+
AuthOrchestrator,
68+
{ provide: IdempotentUserService, useValue: mockUserService },
69+
{ provide: WalletCreationOrchestrator, useValue: mockWalletOrchestrator },
70+
{ provide: IdempotencyService, useValue: mockIdempotencyService },
71+
{ provide: WebhookEventEmitterService, useValue: mockWebhookEventEmitter },
72+
],
73+
}).compile();
74+
75+
orchestrator = module.get<AuthOrchestrator>(AuthOrchestrator);
76+
webhookEventEmitter = module.get(WebhookEventEmitterService);
77+
jest.clearAllMocks();
78+
mockIdempotencyService.getCachedResponse.mockResolvedValue(null);
79+
mockIdempotencyService.cacheResponse.mockResolvedValue(undefined);
80+
mockWebhookEventEmitter.emitUserAuthenticated.mockResolvedValue(undefined);
81+
mockWebhookEventEmitter.emitNewUserRegistered.mockResolvedValue(undefined);
82+
mockWebhookEventEmitter.emitAuthenticationFailed.mockResolvedValue(undefined);
83+
});
84+
85+
it('emits auth.new_user_registered for first-time users', async () => {
86+
const user = makeUser();
87+
const wallet = makeWallet();
88+
89+
mockUserService.findOrCreateUser.mockResolvedValue({ user, isNewUser: true });
90+
mockWalletOrchestrator.getWalletByUser.mockResolvedValue(null);
91+
mockWalletOrchestrator.createWallet.mockResolvedValue({
92+
wallet,
93+
privateKey: 'secret',
94+
isNewWallet: true,
95+
});
96+
97+
await orchestrator.handleAuthentication({ authId: 'auth-abc' });
98+
99+
// Allow best-effort async emission to complete
100+
await new Promise((r) => setTimeout(r, 10));
101+
102+
expect(mockWebhookEventEmitter.emitNewUserRegistered).toHaveBeenCalledWith(
103+
expect.objectContaining({
104+
userId: user.id,
105+
authId: user.authId,
106+
authProvider: user.authProvider,
107+
walletId: wallet.id,
108+
walletNetwork: WalletNetwork.TESTNET,
109+
}),
110+
);
111+
expect(mockWebhookEventEmitter.emitUserAuthenticated).not.toHaveBeenCalled();
112+
});
113+
114+
it('emits auth.user_authenticated for returning users', async () => {
115+
const user = makeUser();
116+
const wallet = makeWallet();
117+
118+
mockUserService.findOrCreateUser.mockResolvedValue({ user, isNewUser: false });
119+
mockWalletOrchestrator.getWalletByUser.mockResolvedValue(wallet);
120+
121+
await orchestrator.handleAuthentication({ authId: 'auth-abc' });
122+
123+
await new Promise((r) => setTimeout(r, 10));
124+
125+
expect(mockWebhookEventEmitter.emitUserAuthenticated).toHaveBeenCalledWith(
126+
expect.objectContaining({
127+
userId: user.id,
128+
authId: user.authId,
129+
authProvider: user.authProvider,
130+
isNewWallet: false,
131+
}),
132+
);
133+
expect(mockWebhookEventEmitter.emitNewUserRegistered).not.toHaveBeenCalled();
134+
});
135+
136+
it('emits auth.authentication_failed on error', async () => {
137+
mockUserService.findOrCreateUser.mockRejectedValue(new Error('DB down'));
138+
139+
await expect(
140+
orchestrator.handleAuthentication({ authId: 'auth-abc' }),
141+
).rejects.toThrow('Authentication failed');
142+
143+
await new Promise((r) => setTimeout(r, 10));
144+
145+
expect(mockWebhookEventEmitter.emitAuthenticationFailed).toHaveBeenCalledWith(
146+
expect.objectContaining({ authId: 'auth-abc', reason: 'DB down' }),
147+
);
148+
});
149+
150+
it('does not throw if event emission fails (best-effort)', async () => {
151+
const user = makeUser();
152+
const wallet = makeWallet();
153+
154+
mockUserService.findOrCreateUser.mockResolvedValue({ user, isNewUser: false });
155+
mockWalletOrchestrator.getWalletByUser.mockResolvedValue(wallet);
156+
mockWebhookEventEmitter.emitUserAuthenticated.mockRejectedValue(
157+
new Error('webhook down'),
158+
);
159+
160+
await expect(
161+
orchestrator.handleAuthentication({ authId: 'auth-abc' }),
162+
).resolves.toBeDefined();
163+
});
164+
});

0 commit comments

Comments
 (0)