Skip to content
Open
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
63 changes: 58 additions & 5 deletions backend/dockerfile
Original file line number Diff line number Diff line change
@@ -1,14 +1,67 @@
FROM node:20
# ──────────────────────────────────────────────────────────────────────────────
# Stage 1: builder
# - Installs ALL dependencies (including devDependencies)
# - Compiles TypeScript → dist/
# ──────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

# Copy manifests first to maximise layer cache reuse
COPY package*.json ./
RUN npm install

COPY . .
# Install all deps (including devDeps needed for tsc)
RUN npm ci --ignore-scripts

EXPOSE 3001
# Copy source and compile
COPY tsconfig.json ./
COPY src/ ./src/
COPY migrations/ ./migrations/

RUN npm run build

# ──────────────────────────────────────────────────────────────────────────────
# Stage 2: production dependency installer
# - Installs only production dependencies into a clean prefix
# - better-sqlite3 requires a native build step (rebuild against Node headers)
# ──────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS prod-deps

WORKDIR /app

COPY package*.json ./

# Install production dependencies only; rebuild native addons
RUN apk add --no-cache python3 make g++ \
&& npm ci --omit=dev --ignore-scripts \
&& npm rebuild better-sqlite3 \
&& apk del python3 make g++

CMD ["npm", "run", "dev"]
# ──────────────────────────────────────────────────────────────────────────────
# Stage 3: runtime
# - Minimal image — only compiled JS, prod node_modules, migrations
# - Runs as non-root user "node" (uid 1000, built into node:alpine)
# ──────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime

# Create a data directory with proper ownership for SQLite volume mount
RUN mkdir -p /app/data && chown -R node:node /app/data

WORKDIR /app

# Copy production node_modules and compiled output from earlier stages
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/migrations ./migrations
COPY --chown=node:node package.json ./

# Run as non-root
USER node

EXPOSE 3001

# Healthcheck — lightweight; relies on curl being present in node:alpine
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
CMD wget -qO- http://localhost:3001/api/health || exit 1

CMD ["node", "dist/index.js"]
38 changes: 23 additions & 15 deletions backend/src/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ import { Keypair } from "@stellar/stellar-sdk";

const TEST_DB_PATH = path.join(__dirname, "..", "data", "test-assets.db");

function removeTestDbFiles() {
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
const filePath = TEST_DB_PATH + suffix;
if (fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
} catch (err) {
// Ignore
}
}
}
}

describe("Assets API Configuration", () => {
beforeAll(() => {
process.env.DB_PATH = TEST_DB_PATH;
Expand All @@ -16,27 +29,22 @@ describe("Assets API Configuration", () => {

beforeEach(() => {
vi.resetModules();
if (fs.existsSync(TEST_DB_PATH)) {
try {
fs.unlinkSync(TEST_DB_PATH);
} catch (err) {
// Ignore
}
}
removeTestDbFiles();
});

afterEach(() => {
afterEach(async () => {
vi.unstubAllEnvs();

try {
const { getDb } = await import("./services/db");
getDb().close();
} catch (err) {
// Database was not initialized in this test
}
});

afterAll(() => {
if (fs.existsSync(TEST_DB_PATH)) {
try {
fs.unlinkSync(TEST_DB_PATH);
} catch (err) {
// Ignore
}
}
removeTestDbFiles();
});

it("should respect ALLOWED_ASSETS environment variable override and normalize", async () => {
Expand Down
86 changes: 82 additions & 4 deletions backend/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ const streamStoreMocks = vi.hoisted(() => ({
calculateProgress: vi.fn(),
cancelStream: vi.fn(),
createStream: vi.fn(),
findRecentDuplicate: vi.fn(),
getStream: vi.fn(),
initSoroban: vi.fn(),
listStreams: vi.fn(),
listStreamsBySender: vi.fn(),
nowInSeconds: vi.fn(),
syncStreams: vi.fn(),
updateStreamStartAt: vi.fn(),
}));
Expand All @@ -36,9 +38,19 @@ vi.mock("./services/streamStore", () => streamStoreMocks);
vi.mock("./services/eventHistory", () => eventHistoryMocks);
vi.mock("./services/auth", () => ({
authMiddleware: vi.fn((req: any, res: any, next: any) => next()),
adminJwtAuth: vi.fn((req: any, res: any, next: any) => next()),
generateChallenge: vi.fn(),
refreshToken: vi.fn(),
verifyChallengeAndIssueToken: vi.fn(),
getJwtSecret: vi.fn(() => "test_secret_for_integration"),
}));

vi.mock("./services/db", () => ({
getAllowedAssets: vi.fn(() => ["USDC", "XLM"]),
addAllowedAsset: vi.fn(),
removeAllowedAsset: vi.fn(),
searchStreamsFts: vi.fn(() => []),
getDb: vi.fn(),
}));

const TEST_JWT_SECRET = "test_secret_for_integration";
Expand Down Expand Up @@ -163,7 +175,7 @@ function invokeListStreamsRoute(
throw new Error("GET /api/streams route not found");
}

const handler = layer.route.stack[0].handle as (req: any, res: any) => void;
const handler = layer.route.stack[layer.route.stack.length - 1].handle as (req: any, res: any) => void;

let statusCode = 200;
let jsonBody: any;
Expand All @@ -178,6 +190,9 @@ function invokeListStreamsRoute(
jsonBody = payload;
return this;
},
set() {
return this;
},
};

handler(req, res);
Expand All @@ -197,7 +212,7 @@ function invokeSenderStreamsRoute(
throw new Error("GET /api/senders/:accountId/streams route not found");
}

const handler = layer.route.stack[0].handle as (req: any, res: any) => void;
const handler = layer.route.stack[layer.route.stack.length - 1].handle as (req: any, res: any) => void;

let statusCode = 200;
let jsonBody: any;
Expand All @@ -212,6 +227,9 @@ function invokeSenderStreamsRoute(
jsonBody = payload;
return this;
},
set() {
return this;
},
};

handler(req, res);
Expand All @@ -224,8 +242,11 @@ beforeEach(() => {
streamStoreMocks.calculateProgress.mockReset();
streamStoreMocks.createStream.mockReset();
streamStoreMocks.getStream.mockReset();
streamStoreMocks.findRecentDuplicate.mockReset();
streamStoreMocks.findRecentDuplicate.mockReturnValue(undefined);
streamStoreMocks.listStreams.mockReturnValue(streams);
streamStoreMocks.calculateProgress.mockImplementation((stream: TestStream) => progressById[stream.id]);
streamStoreMocks.nowInSeconds.mockReturnValue(500);

streamStoreMocks.listStreamsBySender.mockReset();
streamStoreMocks.listStreamsBySender.mockImplementation((sender: string) => streams.filter(s => s.sender === sender));
Expand Down Expand Up @@ -612,6 +633,62 @@ it("returns 400 when durationSeconds is below the 60-second minimum", async () =
]),
);
});

it("returns 409 with existing stream ID when a near-duplicate stream exists", async () => {
const duplicate = { ...createdStream, id: "existing-5" };
streamStoreMocks.findRecentDuplicate.mockReturnValue(duplicate);

const response = await request(app)
.post("/api/streams")
.set("Authorization", "Bearer mock_token")
.send(validPayload);

expect(response.status).toBe(409);
expect(response.body.code).toBe("DUPLICATE_STREAM");
expect(response.body.existingStreamId).toBe("existing-5");
expect(response.body.statusCode).toBe(409);
expect(streamStoreMocks.createStream).not.toHaveBeenCalled();
});

it("checks for near-duplicates only when X-Allow-Duplicate is not exactly true", async () => {
const response = await request(app)
.post("/api/streams")
.set("Authorization", "Bearer mock_token")
.set("X-Allow-Duplicate", "false")
.send(validPayload);

expect(response.status).toBe(201);
expect(streamStoreMocks.findRecentDuplicate).toHaveBeenCalledWith(validPayload);
expect(streamStoreMocks.findRecentDuplicate).toHaveBeenCalledTimes(1);
});

it("bypasses the near-duplicate check when X-Allow-Duplicate is true", async () => {
const response = await request(app)
.post("/api/streams")
.set("Authorization", "Bearer mock_token")
.set("X-Allow-Duplicate", "true")
.send(validPayload);

expect(response.status).toBe(201);
expect(streamStoreMocks.findRecentDuplicate).not.toHaveBeenCalled();
expect(streamStoreMocks.createStream).toHaveBeenCalledWith(validPayload);
});

it("allows a stream with a different recipient even within the same window", async () => {
const differentPayload = { ...validPayload, recipient: RECIPIENT_2 };
const differentStream = { ...createdStream, recipient: RECIPIENT_2 };
streamStoreMocks.createStream.mockResolvedValue(differentStream);

const response = await request(app)
.post("/api/streams")
.set("Authorization", "Bearer mock_token")
.send(differentPayload);

expect(response.status).toBe(201);
expect(response.body.data.recipient).toBe(RECIPIENT_2);
expect(streamStoreMocks.findRecentDuplicate).toHaveBeenCalledWith(differentPayload);
expect(streamStoreMocks.createStream).toHaveBeenCalledWith(differentPayload);
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -645,7 +722,7 @@ function invokeGlobalEventsRoute(
throw new Error("GET /api/events route not found");
}

const handler = layer.route.stack[0].handle as (req: any, res: any) => void;
const handler = layer.route.stack[layer.route.stack.length - 1].handle as (req: any, res: any) => void;

let statusCode = 200;
let jsonBody: any;
Expand All @@ -654,6 +731,7 @@ function invokeGlobalEventsRoute(
const res = {
status(code: number) { statusCode = code; return this; },
json(payload: any) { jsonBody = payload; return this; },
set() { return this; },
};

handler(req, res);
Expand Down Expand Up @@ -686,7 +764,7 @@ describe("GET /api/events", () => {

expect(status).toBe(200);
expect(body.total).toBe(2);
expect(eventHistoryMocks.countAllEvents).toHaveBeenCalledWith("created");
expect(eventHistoryMocks.countAllEvents).toHaveBeenCalledWith("created", undefined, undefined);

});

Expand Down
23 changes: 22 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createServer } from "http";
import { searchStreamsFts, getAllowedAssets, addAllowedAsset, removeAllowedAsset } from "./services/db";
import { initWebSocket } from "./services/websocket";
import {
buildApiErrorResponse,
normalizeUnknownApiError,
sendApiError,
sendError,
Expand Down Expand Up @@ -54,6 +55,7 @@ import {
calculateProgress,
cancelStream,
createStream,
findRecentDuplicate,
getStream,
getOnChainClaimableAmount,
getOnChainClaimableBatch,
Expand Down Expand Up @@ -663,8 +665,13 @@ app.get("/api/events", readLimiter, (req: Request, res: Response) => {

const total = countAllEvents(eventType, streamId, since);

const hasPage = req.query.page !== undefined || req.query.pageSize !== undefined;
const hasLimit = req.query.limit !== undefined || req.query.pageSize !== undefined;
const page = query.page ?? PAGINATION_DEFAULT_PAGE;
const pageSize = query.pageSize ?? query.limit ?? PAGINATION_DEFAULT_LIMIT;
const pageSize =
!hasPage && !hasLimit
? total
: (query.pageSize ?? query.limit ?? PAGINATION_DEFAULT_LIMIT);

const offset = (page - 1) * pageSize;
const data = getGlobalEvents(
Expand Down Expand Up @@ -1273,6 +1280,20 @@ app.post(
}

try {
if (req.get("X-Allow-Duplicate")?.toLowerCase() !== "true") {
const duplicate = findRecentDuplicate(parsedBody.data);
if (duplicate) {
const body = buildApiErrorResponse(
req,
409,
"A stream with the same sender, recipient, asset, and amount was already created within the last 60 seconds.",
{ code: "DUPLICATE_STREAM" },
);
res.status(409).json({ ...body, existingStreamId: duplicate.id });
return;
}
}

const stream = await createStream(parsedBody.data);
res.status(201).json({
data: {
Expand Down
Loading