Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,27 @@ API_BASE_URL=https://api.staging.flowfi.io
We use Prisma as our ORM to interact with PostgreSQL.

- Schema is located at `prisma/schema.prisma`.
- Configuration (schema path, migrations path, datasource URL) is defined in `prisma.config.ts`.
- Run `npx prisma studio` to view the database through a web UI.

### Seeding the database

`prisma/seed.ts` populates the database with demo fixtures for local development. Run it with:

```bash
npm run prisma:seed
```

(this runs `prisma db seed`, which in turn runs `tsx prisma/seed.ts` as configured under the `prisma.seed` key in `package.json`.)

The script is idempotent (it uses `upsert`/fixed IDs), so it's safe to run multiple times. It creates:

- Two demo users, keyed by fixed Stellar testnet public keys — a sender (`GCM5WPR4DDR24FSAX5LIEM4J7AI3KOWJYANSXEPKYXCSZOTAYXE75AFN`) and a recipient (`GBJCHUKZMTFSLOMNC7P4TS4VJJBTCYL3XKSOLXAUJSD56C4LHND5TWUC`).
- One demo `Stream` (`streamId: 101`) between those two users, using a fixed demo token address, with a sample rate/deposit amount and `isActive: true`.
- One demo `StreamEvent` (`eventType: 'CREATED'`) attached to that stream, with sample transaction hash, ledger sequence, and metadata.

These fixtures are intended purely for local development/demo purposes so the frontend has data to render out of the box; they are not used in automated tests.

## /v1 API

All REST API endpoints are prefixed with `/v1`. Refer to the API Documentation in the root `README.md` and the `docs/` folder for versioning and authentication details.
Expand Down
59 changes: 49 additions & 10 deletions backend/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,54 @@ import { registerUserSchema } from '../validators/user.validator.js';
import type { AuthenticatedRequest } from '../types/auth.types.js';
import { DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE } from '../routes/v1/events.routes.js';

/**
* Public shape of a Stream, used when embedding streams inside a public
* user response. Excludes nothing sensitive today, but is kept explicit
* so newly added internal-only fields on the Stream model are not
* leaked automatically.
*/
const publicStreamSelect = {
id: true,
streamId: true,
sender: true,
recipient: true,
tokenAddress: true,
ratePerSecond: true,
depositedAmount: true,
withdrawnAmount: true,
startTime: true,
lastUpdateTime: true,
endTime: true,
isActive: true,
isPaused: true,
pausedAt: true,
totalPausedDuration: true,
createdAt: true,
updatedAt: true,
} as const;

/**
* Public shape of a User. Limits the response to fields that are safe to
* expose to any caller, so internal-only fields added to the User model
* later are excluded by default rather than leaked automatically.
*/
const publicUserSelect = {
id: true,
publicKey: true,
createdAt: true,
updatedAt: true,
sentStreams: {
take: 10,
orderBy: { createdAt: 'desc' as const },
select: publicStreamSelect,
},
receivedStreams: {
take: 10,
orderBy: { createdAt: 'desc' as const },
select: publicStreamSelect,
},
};

/**
* Register a new wallet public key
*/
Expand Down Expand Up @@ -49,16 +97,7 @@ export const getUser = async (req: Request, res: Response, next: NextFunction) =

const user = await prisma.user.findUnique({
where: { publicKey },
include: {
sentStreams: {
take: 10,
orderBy: { createdAt: 'desc' }
},
receivedStreams: {
take: 10,
orderBy: { createdAt: 'desc' }
}
}
select: publicUserSelect
});

if (!user) {
Expand Down
15 changes: 11 additions & 4 deletions backend/src/middleware/requestId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ import logger, { requestContext } from '../logger.js';

const MAX_REQUEST_ID_LENGTH = 128;

// Only alphanumeric characters and hyphens are allowed. This prevents log
// injection via newlines or other control characters in a client-supplied
// X-Request-ID header, since the value is echoed back in responses and
// written into every log line for the request.
const SAFE_REQUEST_ID_PATTERN = /^[a-zA-Z0-9-]+$/;

function isValidRequestId(value: string): boolean {
return value.length > 0 && value.length <= MAX_REQUEST_ID_LENGTH && SAFE_REQUEST_ID_PATTERN.test(value);
}

export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void {
const header = req.headers['x-request-id'];
const requestId =
typeof header === 'string' && header.length > 0 && header.length <= MAX_REQUEST_ID_LENGTH
? header
: randomUUID();
const requestId = typeof header === 'string' && isValidRequestId(header) ? header : randomUUID();

res.setHeader('X-Request-ID', requestId);

Expand Down
17 changes: 17 additions & 0 deletions backend/tests/requestId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,21 @@ describe('RequestId Middleware', () => {
const call = (res.setHeader as any).mock.calls[0];
expect(call[1]).not.toBe('a'.repeat(129));
});

it('should reject a header containing a newline and generate a safe id instead', () => {
const malicious = 'abc123\nfake log line injected';
req.headers = { 'x-request-id': malicious };
requestIdMiddleware(req as Request, res as Response, next);
const call = (res.setHeader as any).mock.calls[0];
expect(call[1]).not.toBe(malicious);
expect(call[1]).not.toMatch(/\n/);
expect(call[1]).toMatch(/^[a-f0-9-]+$/i);
});

it('should reject a header with other control/special characters', () => {
req.headers = { 'x-request-id': 'id\r\nSet-Cookie: evil=1' };
requestIdMiddleware(req as Request, res as Response, next);
const call = (res.setHeader as any).mock.calls[0];
expect(call[1]).toMatch(/^[a-f0-9-]+$/i);
});
});
15 changes: 15 additions & 0 deletions backend/tests/sandbox.middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ describe('Sandbox Middleware', () => {
expect(next).toHaveBeenCalled();
});

it('should not let a client-supplied X-Sandbox-Mode header flip sandbox mode when globally disabled', () => {
(sandboxConfig.getSandboxConfig as any).mockReturnValue({
enabled: false,
allowHeader: true,
allowQueryParam: true,
headerName: 'X-Sandbox-Mode',
queryParamName: 'sandbox',
});
req.headers = { 'x-sandbox-mode': 'true' };
sandboxMiddleware(req as SandboxRequest, res as Response, next);
expect(req.sandbox).toBe(false);
expect(req.sandboxMode).toBe(false);
expect(next).toHaveBeenCalled();
});

it('should detect sandbox via header', () => {
(sandboxConfig.getSandboxConfig as any).mockReturnValue({
enabled: true,
Expand Down
18 changes: 18 additions & 0 deletions backend/tests/user.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,24 @@ describe('User Controller', () => {
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(mockUser);
});

it('should query with an explicit select clause excluding internal-only fields', async () => {
const publicKey = 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ';
req.params = { publicKey };
const mockUser = { id: 'uuid-1', publicKey, createdAt: new Date(), updatedAt: new Date(), sentStreams: [], receivedStreams: [] };
(prisma.user.findUnique as any).mockResolvedValue(mockUser);

await getUser(req as Request, res as Response, next);

const call = (prisma.user.findUnique as any).mock.calls[0][0];
expect(call.select).toBeDefined();
expect(call.include).toBeUndefined();

const jsonArg = (res.json as any).mock.calls[0][0];
expect(jsonArg).not.toHaveProperty('passwordHash');
expect(jsonArg).not.toHaveProperty('internalNotes');
expect(jsonArg).not.toHaveProperty('apiSecret');
});
});

describe('getUserEvents', () => {
Expand Down
Loading