Skip to content

Commit 32cfd48

Browse files
author
Caleb
committed
Merge branch 'main' into dependabot/npm_and_yarn/client/testing-library/jest-dom-7.0.0
2 parents 00a8de1 + 64e1ceb commit 32cfd48

90 files changed

Lines changed: 9105 additions & 967 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.

backend/.eslintrc.cjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,14 @@ module.exports = {
6565
},
6666
},
6767
{
68-
files: ["src/config/**/*.ts", "src/middleware/**/*.ts", "src/schemas/**/*.ts"],
68+
// Critical paths: forbid new `any` usage (issue #1027)
69+
files: [
70+
"src/config/**/*.ts",
71+
"src/middleware/**/*.ts",
72+
"src/schemas/**/*.ts",
73+
"src/routes/**/*.ts",
74+
"src/services/webhook*.ts",
75+
],
6976
rules: {
7077
"@typescript-eslint/no-explicit-any": "error",
7178
},

backend/docs/DEPLOYMENT_PROBES.md

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ livenessProbe:
5959
- Determining if service can handle requests
6060
- Checking if critical dependencies are available
6161

62+
**Critical vs. Optional Dependencies**:
63+
- **Critical** (readiness blocks): Database, Redis, RPC/Horizon, FX Provider
64+
- **Optional** (degraded allowed): Queue, Providers (external API keys), Scheduler
65+
66+
**Behavior**:
67+
- ✅ Checks critical dependencies (database, Redis, RPC/Horizon, FX provider)
68+
- ✅ Returns 503 if any critical dep is unhealthy
69+
- ✅ Allows degraded state for non-critical services
70+
- ✅ RPC/Horizon and FX provider report `degraded` when not configured (no blocking)
71+
6272
**Response (HTTP 200 if ready, 503 if not)**:
6373
```json
6474
{
@@ -85,6 +95,16 @@ livenessProbe:
8595
"name": "providers",
8696
"status": "healthy",
8797
"latency_ms": 0
98+
},
99+
{
100+
"name": "rpc_horizon",
101+
"status": "healthy",
102+
"latency_ms": 10
103+
},
104+
{
105+
"name": "fx_provider",
106+
"status": "healthy",
107+
"latency_ms": 8
88108
}
89109
]
90110
}
@@ -113,14 +133,14 @@ livenessProbe:
113133
```
114134

115135
**Critical vs. Optional Dependencies**:
116-
- **Critical** (readiness blocks): Database, Redis
117-
- **Optional** (degraded allowed): Queue, Providers
136+
- **Critical** (readiness blocks): Database, Redis, RPC/Horizon, FX Provider
137+
- **Optional** (degraded allowed): Queue, Providers (external API keys), Scheduler
118138

119139
**Behavior**:
120-
- ✅ Checks critical dependencies (database, Redis)
121-
- ✅ Returns 503 if critical deps are unhealthy
140+
- ✅ Checks critical dependencies (database, Redis, RPC/Horizon, FX provider)
141+
- ✅ Returns 503 if any critical dep is unhealthy
122142
- ✅ Allows degraded state for non-critical services
123-
- ❌ Slightly higher latency than liveness probe
143+
- ✅ RPC/Horizon and FX provider report `degraded` when not configured (no blocking)
124144

125145
**Deployment Use Case**:
126146
```yaml
@@ -329,8 +349,9 @@ To add a new dependency check:
329349

330350
1. Add a `check<Service>()` method to `DependencyHealthService`
331351
2. Include it in `checkAllDependencies()`
332-
3. Classify as critical or optional (only `database` and `redis` block readiness)
352+
3. Classify as critical or optional (critical deps: `database`, `redis`, `rpc_horizon`, `fx_provider`)
333353
4. Update this document with dependency details
354+
5. Add the check to both backend (`dependency-health-service.ts`) and client (`client/app/api/health/ready/route.ts`) readiness probes
334355

335356
Example:
336357
```typescript

backend/src/middleware/errorHandler.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,15 @@ export const errorHandler = (
6969
});
7070

7171
// Don't leak internals in production
72-
res.status(500).json({
73-
type: 'https://syncro.app/errors/internal',
74-
title: 'Internal Server Error',
75-
status: 500,
76-
detail: process.env.NODE_ENV === 'production'
77-
? 'An unexpected error occurred.'
78-
: err.message,
79-
instance,
80-
requestId,
81-
});
72+
res
73+
.status(500)
74+
.type('application/problem+json')
75+
.json({
76+
type: 'https://syncro.app/errors/internal',
77+
title: 'Internal Server Error',
78+
status: 500,
79+
detail: 'An unexpected error occurred.',
80+
instance,
81+
requestId,
82+
});
8283
};

backend/src/routes/admin/privacy-metrics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ router.get('/privacy-metrics', async (_req: AuthenticatedRequest, res: Response)
139139
* Sanitizes a CSV cell to prevent formula injection.
140140
* Cells starting with =, +, -, @, tab, or carriage return are prefixed with a single quote.
141141
*/
142-
function sanitizeCSVCell(value: any): string {
142+
function sanitizeCSVCell(value: unknown): string {
143143
if (value === null || value === undefined) return '';
144144

145145
const stringValue = String(value);

backend/src/routes/analytics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ router.get('/summary', async (req: AuthenticatedRequest, res: Response) => {
144144
*/
145145
router.get('/budgets', async (req: AuthenticatedRequest, res: Response) => {
146146
try {
147-
const { data: budgets, error } = await (analyticsService as any).getUserBudgets(req.user!.id);
147+
const { data: budgets, error } = await analyticsService.getUserBudgets(req.user!.id);
148148
if (error) throw error;
149149
res.json({ success: true, data: budgets });
150150
} catch (error) {

backend/src/routes/api-keys.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { requireRole } from '../middleware/rbac';
66
import { validate } from '../middleware/validate';
77
import logger from '../config/logger';
88
import { createApiKeySchema } from '../schemas/api-key';
9-
import { NotFoundError } from '../errors';
9+
import { NotFoundError, BadRequestError } from '../errors';
1010
import { auditApiKeyEvent } from '../services/audit-service';
1111
import { createApiKeyLimiter } from '../middleware/rate-limit-factory';
1212

@@ -66,7 +66,7 @@ router.post(
6666
return res.status(201).json({ success: true, key, scopes });
6767
} catch (error) {
6868
logger.error('Create API key error:', error);
69-
return res.status(500).json({ error: String(error) || 'Internal server error' });
69+
return res.status(500).json({ error: 'Internal server error' });
7070
}
7171
},
7272
);
@@ -139,4 +139,80 @@ router.get('/:id/usage', requireRole('owner', 'admin'), requireScope('subscripti
139139
res.json({ success: true, data });
140140
});
141141

142+
/**
143+
* POST /api/keys/:id/rotate
144+
* Atomically revoke an existing key and issue a replacement with the same
145+
* name and scopes. The old key stops working immediately; the new plaintext
146+
* key is returned once and never stored.
147+
*/
148+
router.post(
149+
'/:id/rotate',
150+
requireRole('owner', 'admin'),
151+
requireScope('subscriptions:write'),
152+
async (req: AuthenticatedRequest, res: Response) => {
153+
const { data: existing, error: fetchError } = await supabase
154+
.from('api_keys')
155+
.select('id, service_name, scopes, revoked')
156+
.eq('id', req.params.id)
157+
.eq('user_id', req.user!.id)
158+
.maybeSingle();
159+
160+
if (fetchError || !existing) {
161+
throw new NotFoundError('API key not found');
162+
}
163+
164+
if (existing.revoked) {
165+
throw new BadRequestError('Cannot rotate a revoked API key');
166+
}
167+
168+
const { key: newKey, hash: newHash } = generateApiKey();
169+
170+
// Revoke the old key
171+
const { error: revokeError } = await supabase
172+
.from('api_keys')
173+
.update({ revoked: true, updated_at: new Date().toISOString() })
174+
.eq('id', req.params.id)
175+
.eq('user_id', req.user!.id);
176+
177+
if (revokeError) {
178+
logger.error('Failed to revoke old API key during rotation', { error: revokeError });
179+
return res.status(500).json({ error: 'Internal server error' });
180+
}
181+
182+
// Insert replacement key
183+
const { error: insertError } = await supabase.from('api_keys').insert([
184+
{
185+
user_id: req.user!.id,
186+
service_name: existing.service_name,
187+
key_hash: newHash,
188+
scopes: existing.scopes,
189+
revoked: false,
190+
last_used_at: null,
191+
request_count: 0,
192+
},
193+
]);
194+
195+
if (insertError) {
196+
logger.error('Failed to insert replacement API key during rotation', { error: insertError });
197+
// Attempt to un-revoke the original to avoid a total lockout
198+
await supabase
199+
.from('api_keys')
200+
.update({ revoked: false, updated_at: new Date().toISOString() })
201+
.eq('id', req.params.id)
202+
.eq('user_id', req.user!.id);
203+
return res.status(500).json({ error: 'Internal server error' });
204+
}
205+
206+
await auditApiKeyEvent('api_key.rotated', req.user!.id, {
207+
oldKeyId: req.params.id,
208+
keyName: existing.service_name,
209+
scopes: existing.scopes,
210+
ipAddress: req.ip,
211+
userAgent: req.headers['user-agent'],
212+
});
213+
214+
return res.status(201).json({ success: true, key: newKey, scopes: existing.scopes });
215+
},
216+
);
217+
142218
export default router;

backend/src/routes/audit.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { validate } from '../middleware/validate';
77
import logger from '../config/logger';
88
import { auditBatchSchema, auditQuerySchema, auditVerifyQuerySchema } from '../schemas/audit';
99
import { PaginationError } from '../utils/pagination';
10+
import { z } from 'zod';
11+
12+
type AuditQuery = z.infer<typeof auditQuerySchema>;
1013

1114
const router: Router = Router();
1215

@@ -71,7 +74,8 @@ router.get(
7174
validate(auditQuerySchema, 'query'),
7275
async (req: Request, res: Response) => {
7376
try {
74-
const { action, resourceType, userId, limit, offset, startDate, endDate } = req.query as any;
77+
const { action, resourceType, userId, limit, offset, startDate, endDate } =
78+
req.query as AuditQuery;
7579

7680
const logs = await auditService.getAllLogs({
7781
action,
@@ -99,12 +103,12 @@ router.get(
99103
hasMore: offset + limit < total,
100104
},
101105
});
102-
} catch (error: any) {
103-
if (error.name === 'PaginationError') {
106+
} catch (error: unknown) {
107+
if (error instanceof Error && error.name === 'PaginationError') {
104108
res.status(400).json({
105109
success: false,
106110
error: error.message,
107-
code: error.code,
111+
code: (error as { code?: string }).code,
108112
});
109113
return;
110114
}

backend/src/routes/compliance.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ const exportRateLimit = RateLimiterFactory.createCustomLimiter({
2424
windowMs: 60 * 60 * 1000,
2525
max: 1,
2626
message: { error: 'Export rate limit exceeded. Try again in 1 hour.' },
27-
keyGenerator: (req: any) => req.user?.id || req.ip,
27+
keyGenerator: (req: Request) => {
28+
const authReq = req as AuthenticatedRequest;
29+
return authReq.user?.id || req.ip || 'anonymous';
30+
},
2831
endpointType: 'data-export',
2932
});
3033

@@ -70,8 +73,8 @@ async function resolveUserFromTokenOrSession(
7073
let sessionToken: string | null = null;
7174
if (authHeader?.startsWith('Bearer ')) {
7275
sessionToken = authHeader.substring(7);
73-
} else if ((req as any).cookies?.authToken) {
74-
sessionToken = (req as any).cookies.authToken;
76+
} else if (req.cookies?.authToken) {
77+
sessionToken = req.cookies.authToken;
7578
}
7679
if (!sessionToken) return null;
7780

backend/src/routes/gift-card-ledger.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ router.get('/history', async (req: AuthenticatedRequest, res: Response) => {
3535
const limit = validateLimit(req.query.limit, 100, 50);
3636
const history = await giftCardLedgerService.getHistory(req.user!.id, limit);
3737
res.json({ success: true, data: history });
38-
} catch (error: any) {
39-
if (error.name === 'PaginationError') {
38+
} catch (error: unknown) {
39+
if (error instanceof Error && error.name === 'PaginationError') {
4040
throw new BadRequestError(error.message);
4141
}
4242
throw error;
@@ -49,7 +49,7 @@ router.post('/top-up', validate(topUpSchema), async (req: AuthenticatedRequest,
4949
const { amount, description } = req.body;
5050
const entry = await giftCardLedgerService.topUp(req.user!.id, amount, description);
5151
res.status(201).json({ success: true, data: entry });
52-
} catch (err: any) {
52+
} catch (err: unknown) {
5353
const appError = parseDbError(err);
5454
if (appError) {
5555
return res.status(appError.status).json({ success: false, error: appError.message, field: appError.field });
@@ -64,8 +64,8 @@ router.post('/deduct', validate(deductSchema), async (req: AuthenticatedRequest,
6464
try {
6565
const entry = await giftCardLedgerService.deduct(req.user!.id, subscriptionId, amount, description);
6666
res.status(201).json({ success: true, data: entry });
67-
} catch (err: any) {
68-
if (err.message?.startsWith('Insufficient balance')) {
67+
} catch (err: unknown) {
68+
if (err instanceof Error && err.message?.startsWith('Insufficient balance')) {
6969
throw new BadRequestError(err.message);
7070
}
7171
throw err;

backend/src/routes/merchants.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { adminAuth } from '../middleware/admin';
66
import { createMerchantSchema, updateMerchantSchema, merchantQuerySchema } from '../schemas/merchant';
77
import { validateRequest } from '../utils/validation';
88
import { PaginationError } from '../utils/pagination';
9+
import { z } from 'zod';
10+
11+
type MerchantQuery = z.infer<typeof merchantQuerySchema>;
912

1013
const router: Router = Router();
1114

@@ -18,7 +21,7 @@ router.get(
1821
validate(merchantQuerySchema, 'query'),
1922
async (req: Request, res: Response) => {
2023
try {
21-
const { limit, offset, category } = req.query as any;
24+
const { limit, offset, category } = req.query as MerchantQuery;
2225

2326
const result = await merchantService.listMerchants({
2427
category,
@@ -31,12 +34,12 @@ router.get(
3134
data: result.merchants,
3235
pagination: { total: result.total, limit, offset },
3336
});
34-
} catch (error: any) {
35-
if (error.name === 'PaginationError') {
37+
} catch (error: unknown) {
38+
if (error instanceof Error && error.name === 'PaginationError') {
3639
res.status(400).json({
3740
success: false,
3841
error: error.message,
39-
code: error.code,
42+
code: (error as { code?: string }).code,
4043
});
4144
return;
4245
}

0 commit comments

Comments
 (0)