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
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
{
"kiroAgent.configureMCP": "Disabled"
}
80 changes: 48 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ Purpose:
Response:
- `service`, `status`, `timestamp`

### `GET /api/streams`
Purpose:
- List streams sorted by newest first, with optional filtering and pagination
### `GET /api/streams`
Purpose:
- List streams sorted by newest first, with optional filtering and pagination

Query params (optional):
- `status: scheduled | active | completed | canceled`
Expand Down Expand Up @@ -116,26 +116,42 @@ Error:

### `GET /api/recipients/:accountId/streams`
Purpose:
- Fetch all streams for a specific recipient account
- Fetch all streams for a specific recipient account with optional filtering, search, and pagination

Path parameters:
- `accountId: string` (Stellar account ID starting with G, exactly 56 characters)

Query params (optional):
- `status: scheduled | active | completed | canceled`
- `sender: string` (exact sender match, case-insensitive)
- `asset: string` (exact asset code match, case-insensitive)
- `q: string` (general search term - searches stream ID, sender, recipient, and asset code, case-insensitive)
- `page: number` (integer `>= 1`)
- `limit: number` (integer `1..100`)

Pagination behavior:
- If both `page` and `limit` are omitted, legacy mode applies and all matching rows are returned.
- If either `page` or `limit` is provided, pagination mode applies with defaults `page=1` and `limit=20`.

Validation:
- Account ID must be a valid Stellar account ID format
- Invalid `status`, `page`, or `limit` returns `400`

Response:
- `data: Stream[]` (includes computed `progress` for each stream)
- `total: number` (filtered count before pagination)
- `page: number` (applied page)
- `limit: number` (applied page size)

Error:
- `400` if account ID or query parameters are invalid

Error:
- `400` if account ID is invalid

### `GET /api/assets`
Purpose:
- Fetch the current allowed asset allowlist

Response:
- `data: string[]` (normalized asset codes)
### `GET /api/assets`
Purpose:
- Fetch the current allowed asset allowlist

Response:
- `data: string[]` (normalized asset codes)

### `POST /api/streams`
Purpose:
Expand Down Expand Up @@ -297,25 +313,25 @@ The script will:

## 8) Environment And Config

Backend:
- `PORT` (optional, defaults to `3001`)
- `CONTRACT_ID` (required for on-chain operations) - Contract ID from deployment
- `SERVER_PRIVATE_KEY` (required for on-chain operations) - Stellar account secret key
- `RPC_URL` (optional, defaults to `https://soroban-testnet.stellar.org:443`) - Soroban RPC endpoint
- `NETWORK_PASSPHRASE` (optional, defaults to testnet) - Network passphrase
- `ALLOWED_ASSETS` (optional, defaults to `USDC,XLM`) - Comma-separated list of allowed asset codes
- `DB_PATH` (optional, defaults to `backend/data/streams.db`) - SQLite database file path
- `WEBHOOK_DESTINATION_URL` (optional) - If set, stream lifecycle webhooks are delivered to this URL
- `WEBHOOK_SIGNING_SECRET` (optional) - If set, webhook payloads are HMAC-signed

Frontend:
- `VITE_API_URL` (optional, defaults to `/api`)

Webhook signing:
- Header: `X-Webhook-Signature`
- Format: `sha256=<hex-digest>`
- Digest input: raw JSON request body string
- Algorithm: HMAC-SHA256 using `WEBHOOK_SIGNING_SECRET`
Backend:
- `PORT` (optional, defaults to `3001`)
- `CONTRACT_ID` (required for on-chain operations) - Contract ID from deployment
- `SERVER_PRIVATE_KEY` (required for on-chain operations) - Stellar account secret key
- `RPC_URL` (optional, defaults to `https://soroban-testnet.stellar.org:443`) - Soroban RPC endpoint
- `NETWORK_PASSPHRASE` (optional, defaults to testnet) - Network passphrase
- `ALLOWED_ASSETS` (optional, defaults to `USDC,XLM`) - Comma-separated list of allowed asset codes
- `DB_PATH` (optional, defaults to `backend/data/streams.db`) - SQLite database file path
- `WEBHOOK_DESTINATION_URL` (optional) - If set, stream lifecycle webhooks are delivered to this URL
- `WEBHOOK_SIGNING_SECRET` (optional) - If set, webhook payloads are HMAC-signed

Frontend:
- `VITE_API_URL` (optional, defaults to `/api`)
Webhook signing:
- Header: `X-Webhook-Signature`
- Format: `sha256=<hex-digest>`
- Digest input: raw JSON request body string
- Algorithm: HMAC-SHA256 using `WEBHOOK_SIGNING_SECRET`

Ignored files:
- `node_modules`, `dist`, logs, local env files, Soroban build outputs
Expand Down
59 changes: 54 additions & 5 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
getStream,
initSoroban,
listStreams,
listStreamsByRecipient,
listStreamsBySender,
StreamStatus,
syncStreams,
updateStreamStartAt,
Expand Down Expand Up @@ -293,15 +295,63 @@ app.get("/api/recipients/:accountId/streams", (req: Request, res: Response) => {
}

const accountId = parsedParams.data.accountId;

const parsedQuery = listStreamsQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
sendValidationError(res, parsedQuery.error.issues);
return;
}
const query = parsedQuery.data;

let data = listStreams()
.filter((stream) => stream.recipient.toLowerCase() === accountId.toLowerCase())
let data = listStreamsByRecipient(accountId)
.map((stream) => ({
...stream,
progress: calculateProgress(stream),
}));

res.json({ data });
// Apply filters
if (query.status) {
data = data.filter((stream) => stream.progress.status === query.status);
}
if (query.sender) {
data = data.filter(
(stream) => stream.sender.toLowerCase() === query.sender!.toLowerCase(),
);
}
if (query.asset) {
data = data.filter(
(stream) => stream.assetCode.toLowerCase() === query.asset!.toLowerCase(),
);
}
if (query.q && query.q.length > 0) {
const searchTerm = query.q.toLowerCase();
data = data.filter((stream) => {
return (
stream.id.toLowerCase().includes(searchTerm) ||
stream.sender.toLowerCase().includes(searchTerm) ||
stream.recipient.toLowerCase().includes(searchTerm) ||
stream.assetCode.toLowerCase().includes(searchTerm)
);
});
}

// Apply pagination
const hasPage = req.query.page !== undefined;
const hasLimit = req.query.limit !== undefined;

const total = data.length;
const page = query.page ?? PAGINATION_DEFAULT_PAGE;
const limit = !hasPage && !hasLimit ? total : (query.limit ?? PAGINATION_DEFAULT_LIMIT);

const offset = (page - 1) * limit;
const paginatedData = data.slice(offset, offset + limit);

res.json({
data: paginatedData,
total,
page,
limit,
});
});

app.get("/api/senders/:accountId/streams", (req: Request, res: Response) => {
Expand All @@ -323,8 +373,7 @@ app.get("/api/senders/:accountId/streams", (req: Request, res: Response) => {
}
const query = parsedQuery.data;

let data = listStreams()
.filter((stream) => stream.sender.toLowerCase() === accountId.toLowerCase())
let data = listStreamsBySender(accountId)
.map((stream) => ({
...stream,
progress: calculateProgress(stream),
Expand Down
16 changes: 16 additions & 0 deletions backend/src/services/streamStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,22 @@ export function listStreams(): StreamRecord[] {
return rows.map(rowToRecord);
}

export function listStreamsByRecipient(recipientAddress: string): StreamRecord[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM streams WHERE recipient = ? ORDER BY created_at DESC")
.all(recipientAddress) as StreamRow[];
return rows.map(rowToRecord);
}

export function listStreamsBySender(senderAddress: string): StreamRecord[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM streams WHERE sender = ? ORDER BY created_at DESC")
.all(senderAddress) as StreamRow[];
return rows.map(rowToRecord);
}

export function getStream(id: string): StreamRecord | undefined {
const db = getDb();
const row = db.prepare("SELECT * FROM streams WHERE id = ?").get(id) as
Expand Down
80 changes: 75 additions & 5 deletions backend/src/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,21 +545,80 @@ export const swaggerDocument = {
"/api/recipients/{accountId}/streams": {
get: {
summary: "Get recipient streams",
description: "Retrieves all streams for a specific recipient.",
description: "Retrieves all streams for a specific recipient with optional filtering, search, and pagination.",
parameters: [
{
name: "accountId",
in: "path",
required: true,
description: "The Stellar account ID of the recipient.",
description: "The Stellar account ID of the recipient (starts with G, exactly 56 characters).",
schema: {
type: "string",
pattern: "^G[A-Z2-7]{55}$",
},
},
{
name: "status",
in: "query",
required: false,
description: "Filter by stream status.",
schema: {
type: "string",
enum: ["scheduled", "active", "completed", "canceled"],
},
},
{
name: "sender",
in: "query",
required: false,
description: "Filter by sender account ID (case-insensitive).",
schema: {
type: "string",
},
},
{
name: "asset",
in: "query",
required: false,
description: "Filter by asset code (case-insensitive).",
schema: {
type: "string",
},
},
{
name: "q",
in: "query",
required: false,
description: "Search term for stream ID, sender, recipient, or asset code (case-insensitive partial match).",
schema: {
type: "string",
},
},
{
name: "page",
in: "query",
required: false,
description: "Page number for pagination (defaults to 1).",
schema: {
type: "integer",
minimum: 1,
},
},
{
name: "limit",
in: "query",
required: false,
description: "Number of items per page (defaults to 20, max 100). If both page and limit are omitted, all results are returned.",
schema: {
type: "integer",
minimum: 1,
maximum: 100,
},
},
],
responses: {
"200": {
description: "A list of streams for the recipient.",
description: "A paginated list of streams for the recipient with progress data.",
content: {
"application/json": {
schema: {
Expand All @@ -571,13 +630,24 @@ export const swaggerDocument = {
$ref: "#/components/schemas/Stream",
},
},
total: {
type: "integer",
description: "Total number of streams matching the filters (before pagination).",
},
page: {
type: "integer",
description: "Current page number.",
},
limit: {
type: "integer",
description: "Number of items per page.",
},
},
},
},
},
},
"404": {
description: "Stream not found.",

content: {
"application/json": {
schema: {
Expand Down
Loading
Loading