Skip to content

Commit 907d3af

Browse files
Merge branch 'LabsCrypt:main' into issue-832
2 parents a984815 + cb53cc6 commit 907d3af

16 files changed

Lines changed: 373 additions & 89 deletions

.github/dependabot.yml

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ version: 2
22

33
updates:
44
# ── npm: root workspace ────────────────────────────────────────────────────
5+
# flowfi is a single npm workspace (frontend + backend hoisted into one root
6+
# package-lock.json). Dependabot must update from the workspace root so the
7+
# root lockfile CI runs `npm ci` against stays in sync. Per-directory entries
8+
# for /frontend and /backend only touched their package.json without updating
9+
# the root lockfile, so every PR they opened failed `npm ci` with
10+
# "lock file's X does not satisfy Y". One root entry covers all workspaces.
511
- package-ecosystem: "npm"
612
directory: "/"
713
schedule:
@@ -14,32 +20,6 @@ updates:
1420
- "minor"
1521
- "patch"
1622

17-
# ── npm: frontend ──────────────────────────────────────────────────────────
18-
- package-ecosystem: "npm"
19-
directory: "/frontend"
20-
schedule:
21-
interval: "weekly"
22-
day: "monday"
23-
open-pull-requests-limit: 10
24-
groups:
25-
minor-and-patch:
26-
update-types:
27-
- "minor"
28-
- "patch"
29-
30-
# ── npm: backend ───────────────────────────────────────────────────────────
31-
- package-ecosystem: "npm"
32-
directory: "/backend"
33-
schedule:
34-
interval: "weekly"
35-
day: "monday"
36-
open-pull-requests-limit: 10
37-
groups:
38-
minor-and-patch:
39-
update-types:
40-
- "minor"
41-
- "patch"
42-
4323
# ── Cargo: contracts ───────────────────────────────────────────────────────
4424
- package-ecosystem: "cargo"
4525
directory: "/contracts"

.github/workflows/ci.yml

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,6 @@ jobs:
4242
run: npm run test:coverage
4343
working-directory: frontend
4444

45-
- name: Upload frontend coverage to Codecov
46-
uses: codecov/codecov-action@v5
47-
with:
48-
files: frontend/coverage/lcov.info
49-
flags: frontend
50-
name: frontend-coverage
51-
fail_ci_if_error: false
52-
verbose: true
53-
env:
54-
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
55-
5645
- name: Build
5746
run: npm run build
5847
working-directory: frontend

backend/Dockerfile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ COPY package*.json ./
2121
RUN npm install --omit=dev
2222

2323
COPY --from=builder /app/dist ./dist
24-
COPY --from=builder /app/src/generated ./src/generated
24+
COPY --from=builder /app/src/generated ./dist/generated
25+
COPY --from=builder /app/prisma ./prisma
26+
# Prisma 7 reads the schema location and datasource url from prisma.config.ts,
27+
# and the schema's `datasource db {}` block has no inline url. The CI health
28+
# check runs `prisma db push` inside this image, so the config must be present
29+
# too (dotenv is a runtime dependency, so the config loads).
30+
COPY prisma.config.ts ./
2531

2632
EXPOSE 3001
2733

backend/src/app.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,18 @@ app.use(requestIdMiddleware);
3232
app.disable('x-powered-by');
3333

3434
// Helmet-equivalent core headers without external dependency.
35+
// Strict CSP applied globally; the /api-docs route overrides it below for Swagger UI.
3536
app.use((req: Request, res: Response, next: NextFunction) => {
3637
res.setHeader('X-Content-Type-Options', 'nosniff');
3738
res.setHeader('X-Frame-Options', 'DENY');
3839
res.setHeader('Referrer-Policy', 'no-referrer');
3940
res.setHeader('X-DNS-Prefetch-Control', 'off');
4041
res.setHeader('X-Download-Options', 'noopen');
4142
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
42-
if (isProduction) {
43+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'");
44+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
45+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
46+
if (process.env.NODE_ENV === 'production') {
4347
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
4448
}
4549
next();
@@ -78,7 +82,11 @@ app.use(express.json());
7882
app.use(sandboxMiddleware);
7983

8084
// Swagger UI setup
81-
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
85+
// Override CSP for /api-docs only: Swagger UI requires inline scripts/styles.
86+
app.use('/api-docs', (req: Request, res: Response, next: NextFunction) => {
87+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'");
88+
next();
89+
}, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
8290
customCss: '.swagger-ui .topbar { display: none }',
8391
customSiteTitle: 'FlowFi API Documentation',
8492
}));

backend/src/controllers/sse.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export const subscribe = async (req: Request, res: Response) => {
4444
const { publicKey } = (req as AuthenticatedRequest).user;
4545
const { streams, users, all } = subscribeSchema.parse(req.query);
4646

47+
// Consistent with GET /v1/events/ (which requires requireAuth and is scoped to user's address),
48+
// SSE subscriptions are also restricted to streams owned by the authenticated user.
4749
// Scope: only streams where the authenticated user is sender or recipient
4850
const ownedStreams = await prisma.stream.findMany({
4951
where: { OR: [{ sender: publicKey }, { recipient: publicKey }] },

backend/src/middleware/auth.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,31 @@ export function verifyJwt(token: string): { publicKey: string } | null {
9898
try {
9999
const [header, body, sig] = token.split('.');
100100
if (!header || !body || !sig) return null;
101+
102+
// Verify signature
101103
const expected = crypto
102104
.createHmac('sha256', JWT_SECRET)
103105
.update(`${header}.${body}`)
104106
.digest();
105-
if (!crypto.timingSafeEqual(Buffer.from(sig, 'base64url'), expected)) return null;
107+
108+
let providedSig: Buffer;
109+
try {
110+
providedSig = Buffer.from(sig, 'base64url');
111+
} catch {
112+
return null;
113+
}
114+
115+
// Use timingSafeEqual to prevent timing attacks
116+
if (providedSig.length !== expected.length || !crypto.timingSafeEqual(providedSig, expected)) {
117+
return null;
118+
}
119+
120+
// Verify expiration
106121
const payload = JSON.parse(Buffer.from(body, 'base64url').toString());
107-
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
122+
if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) {
123+
return null;
124+
}
125+
108126
return { publicKey: payload.sub };
109127
} catch {
110128
return null;

backend/src/middleware/error.middleware.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export const errorHandler = (
1414
) => {
1515
logger.error('Unhandled error:', err);
1616

17+
if (res.headersSent) {
18+
return next(err);
19+
}
20+
1721
// Handle Zod Validation Errors
1822
if (err instanceof ZodError) {
1923
return res.status(400).json({

backend/src/routes/v1/admin.routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ router.post('/indexer/reset', async (req: Request, res: Response) => {
222222
* /v1/admin/indexer/replay:
223223
* post:
224224
* tags: [Admin]
225-
* summary: Replay events from a given ledger (idempotent)
225+
* summary: Replay events from a given ledger (StreamEvent rows deduplicated; stream mutations not idempotent — see indexerService.ts JSDoc)
226226
* security: [{ adminAuth: [] }]
227227
* parameters:
228228
* - in: query

backend/src/routes/v1/events.routes.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from 'express';
33
import { subscribe } from '../../controllers/sse.controller.js';
44
import { sseService } from '../../services/sse.service.js';
55
import { requireAuth } from '../../middleware/auth.js';
6+
import type { AuthenticatedRequest } from '../../types/auth.types.js';
67
import { prisma } from '../../lib/prisma.js';
78
import logger from '../../logger.js';
89

@@ -65,14 +66,21 @@ export const DEFAULT_EVENTS_PAGE_SIZE = 50;
6566
* 200:
6667
* description: Paginated event list
6768
*/
68-
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
69+
router.get('/', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
6970
try {
71+
const { publicKey } = (req as AuthenticatedRequest).user;
7072
const address = typeof req.query.address === 'string' ? req.query.address.trim() : '';
7173
if (!address) {
7274
res.status(400).json({ error: 'address query parameter is required' });
7375
return;
7476
}
7577

78+
// Aligned with SSE security: history queries require authentication and are scoped to the caller.
79+
if (address !== publicKey) {
80+
res.status(403).json({ error: 'Forbidden', message: 'You can only view your own event history' });
81+
return;
82+
}
83+
7684
const rawType = typeof req.query.type === 'string' ? req.query.type : '';
7785
const requested = rawType
7886
.split(',')

backend/src/services/indexerService.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,13 @@ export async function resetIndexer(toLedger: number): Promise<void> {
3838

3939
/**
4040
* Replay events from a given ledger by resetting state and triggering a poll.
41-
* Deduplication in the worker (transactionHash + eventType + ledger) ensures
42-
* no duplicate StreamEvent rows are created.
41+
* The @@unique([transactionHash, eventType]) constraint on StreamEvent
42+
* guarantees no duplicate StreamEvent rows are created on replay.
43+
*
44+
* CAVEAT: This dedup does NOT apply to stream state mutations.
45+
* Stream.withdrawnAmount (handleTokensWithdrawn, soroban-event-worker.ts:635)
46+
* is incremented unconditionally on every replay, so replay is NOT fully
47+
* idempotent. See issue #808 for the withdrawnAmount idempotency fix.
4348
*/
4449
export async function replayFromLedger(fromLedger: number): Promise<void> {
4550
await resetIndexer(fromLedger);

0 commit comments

Comments
 (0)