Skip to content

Commit 3829c96

Browse files
committed
Harden lab API review follow-ups
1 parent c79344f commit 3829c96

14 files changed

+287
-12
lines changed

.env.docker.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,7 @@ WORKER_ENABLED=false
5353

5454
# Payments
5555
PAYMENTS_ENABLED=false
56+
57+
# Backup automation (used by deploy/backup.sh or the backup systemd unit)
58+
# BACKUP_ROOT=/opt/localloop-backend/backups
59+
# RETENTION_DAYS=14

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- GET endpoints for all five Loop entity types: `GET /api/v1/material/:id`,
66
`/material`, `/product/:id`, `/product`, `/offer/:id`, `/offer`,
77
`/match/:id`, `/match`, `/transfer/:id`, `/transfer`.
8+
- Minimal lab `GET /api/v1/node/info` endpoint for local node metadata.
9+
- Backup automation artifacts: `deploy/backup.sh`, systemd backup service, and timer.
810
- `category` and `status` query filters on list endpoints.
911
- Migration `010_loop_indexes.sql`: performance indexes on all loop_* tables
1012
(category, status, city columns, FK columns, created_at DESC).
@@ -18,6 +20,9 @@
1820
### Changed
1921
- `DB_POOL_SIZE` default raised from 10 to 20.
2022
- `.env.docker.example` documents new pool/timeout vars.
23+
- Federation handshake responses now default to the preferred v0.2.0 context/version.
24+
- Relay validation now restricts relayed event/entity combinations to supported lab event families.
25+
- `deploy/setup.sh` now provisions `.env.docker` for Docker-based operations.
2126

2227
### Security
2328
- Rotated all `.env.docker` secrets (postgres, minio, better-auth).

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ bun test # run all tests
4141
| `POST` | `/api/v1/material` | Register a MaterialDNA record |
4242
| `GET` | `/api/v1/material/:id` | Retrieve a material by ID |
4343
| `GET` | `/api/v1/material` | List materials (`limit`, `category`) |
44+
| `GET` | `/api/v1/node/info` | Return local node metadata for the lab backend |
4445
| `POST` | `/api/v1/product` | Register a ProductDNA record |
4546
| `GET` | `/api/v1/product/:id` | Retrieve a product by ID |
4647
| `GET` | `/api/v1/product` | List products (`limit`, `category`) |
@@ -56,13 +57,13 @@ bun test # run all tests
5657
| `POST` | `/api/v1/material-status` | Record a material status update |
5758
| `GET` | `/api/v1/events` | List loop events (`limit`) |
5859
| `GET` | `/api/v1/stream` | SSE stream for loop events |
59-
| `POST` | `/api/v1/relay` | Relay a loop event from another node |
60+
| `POST` | `/api/v1/relay` | Relay a supported lab loop event from another node |
6061

6162
### Federation
6263
| Method | Path | Description |
6364
| --- | --- | --- |
6465
| `GET` | `/api/v1/federation/nodes` | List known federation nodes |
65-
| `POST` | `/api/v1/federation/handshake` | Register a federation node |
66+
| `POST` | `/api/v1/federation/handshake` | Register a federation node (lab-only handshake) |
6667

6768
### Cities
6869
| Method | Path | Description |
@@ -80,6 +81,8 @@ bun test # run all tests
8081
| `GET` | `/docs` | Redoc UI |
8182

8283
LOOP write routes accept both `application/json` and `application/ld+json`.
84+
The backend also serves `GET /api/v1/node/info` as a minimal lab-only node metadata endpoint.
85+
Spec endpoints that remain unimplemented in this repo are `/api/v1/material/search`, `/api/v1/signals`, `/api/v1/transaction`, `/api/v1/federate/announce`, and `/api/v1/federate/offer`.
8386

8487
## Environment variables
8588

deploy/backup.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
PROJECT_DIR="${PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
6+
COMPOSE_FILE="${COMPOSE_FILE:-$PROJECT_DIR/docker-compose.yml}"
7+
BACKUP_ROOT="${BACKUP_ROOT:-$PROJECT_DIR/backups}"
8+
RETENTION_DAYS="${RETENTION_DAYS:-14}"
9+
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
10+
RUN_DIR="$BACKUP_ROOT/$STAMP"
11+
12+
require_command() {
13+
if ! command -v "$1" >/dev/null 2>&1; then
14+
echo "Missing required command: $1" >&2
15+
exit 1
16+
fi
17+
}
18+
19+
require_file() {
20+
if [[ ! -f "$1" ]]; then
21+
echo "Required file not found: $1" >&2
22+
exit 1
23+
fi
24+
}
25+
26+
require_command docker
27+
require_file "$COMPOSE_FILE"
28+
29+
mkdir -p "$RUN_DIR/postgres" "$RUN_DIR/redis" "$RUN_DIR/minio" "$RUN_DIR/manifests"
30+
31+
echo "Creating backup at $RUN_DIR"
32+
33+
cd "$PROJECT_DIR"
34+
35+
docker compose -f "$COMPOSE_FILE" exec -T postgres sh -lc \
36+
'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
37+
> "$RUN_DIR/postgres/localloop.dump"
38+
39+
docker compose -f "$COMPOSE_FILE" exec -T redis redis-cli SAVE >/dev/null
40+
cp "$PROJECT_DIR/data/redis/dump.rdb" "$RUN_DIR/redis/dump.rdb"
41+
42+
tar -czf "$RUN_DIR/minio/minio-data.tar.gz" -C "$PROJECT_DIR" data/minio
43+
44+
printf '%s\n' \
45+
"timestamp=$STAMP" \
46+
"project_dir=$PROJECT_DIR" \
47+
"compose_file=$COMPOSE_FILE" \
48+
"retention_days=$RETENTION_DAYS" \
49+
> "$RUN_DIR/manifests/backup.env"
50+
51+
ln -sfn "$RUN_DIR" "$BACKUP_ROOT/latest"
52+
53+
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d ! -name "$STAMP" -mtime +"$RETENTION_DAYS" -exec rm -rf {} +
54+
55+
echo "Backup complete: $RUN_DIR"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[Unit]
2+
Description=localLOOP Backend Backup
3+
After=docker.service network.target
4+
Requires=docker.service
5+
6+
[Service]
7+
Type=oneshot
8+
WorkingDirectory=/opt/localloop-backend
9+
EnvironmentFile=-/opt/localloop-backend/.env
10+
ExecStart=/opt/localloop-backend/deploy/backup.sh
11+
User=root
12+
Group=root
13+
NoNewPrivileges=yes
14+
ProtectSystem=strict
15+
ProtectHome=yes
16+
PrivateTmp=yes
17+
ReadWritePaths=/opt/localloop-backend/backups /opt/localloop-backend/data
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[Unit]
2+
Description=Nightly localLOOP backend backup
3+
4+
[Timer]
5+
OnCalendar=*-*-* 02:30:00
6+
Persistent=true
7+
Unit=localloop-backend-backup.service
8+
9+
[Install]
10+
WantedBy=timers.target

deploy/setup.sh

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@ fi
3232
echo "Setting up installation directory..."
3333
mkdir -p "$INSTALL_DIR"
3434
mkdir -p "$INSTALL_DIR/data"
35+
mkdir -p "$INSTALL_DIR/backups"
3536

3637
# Copy application files (if running from source directory)
3738
if [[ -f "src/index.ts" ]]; then
3839
echo "Copying application files..."
39-
cp -r src package.json bun.lock prisma prisma.config.ts "$INSTALL_DIR/"
40+
cp -r src deploy package.json bun.lock prisma prisma.config.ts docker-compose.yml .env.example .env.docker.example "$INSTALL_DIR/"
4041

4142
# Copy .env.example if .env doesn't exist
4243
if [[ ! -f "$INSTALL_DIR/.env" ]]; then
@@ -45,6 +46,13 @@ if [[ -f "src/index.ts" ]]; then
4546
echo "IMPORTANT: Edit $INSTALL_DIR/.env with your production configuration!"
4647
echo "Required: DATABASE_URL, MINIO_SECRET_KEY, BETTER_AUTH_SECRET (if auth enabled)"
4748
fi
49+
50+
if [[ ! -f "$INSTALL_DIR/.env.docker" ]]; then
51+
cp .env.docker.example "$INSTALL_DIR/.env.docker"
52+
echo ""
53+
echo "IMPORTANT: Edit $INSTALL_DIR/.env.docker before enabling Docker-based operations!"
54+
echo "Required: POSTGRES_PASSWORD, MINIO_ROOT_PASSWORD, MINIO_SECRET_KEY, DATABASE_URL"
55+
fi
4856
fi
4957

5058
# Set ownership
@@ -55,9 +63,12 @@ chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR"
5563
if [[ -f "deploy/localloop-backend.service" ]]; then
5664
echo "Installing systemd service..."
5765
cp deploy/localloop-backend.service /etc/systemd/system/localloop-backend.service
66+
cp deploy/localloop-backend-backup.service /etc/systemd/system/localloop-backend-backup.service
67+
cp deploy/localloop-backend-backup.timer /etc/systemd/system/localloop-backend-backup.timer
5868
systemctl daemon-reload
5969
echo "Service installed. Enable with: systemctl enable localloop-backend"
6070
echo "Start with: systemctl start localloop-backend"
71+
echo "Enable backups with: systemctl enable --now localloop-backend-backup.timer"
6172
fi
6273

6374
# Verify bun is installed
@@ -75,3 +86,4 @@ echo "1. Edit $INSTALL_DIR/.env with production credentials"
7586
echo "2. Run 'cd $INSTALL_DIR && bun install' as $SERVICE_USER"
7687
echo "3. Run 'systemctl enable --now localloop-backend'"
7788
echo "4. Check status with 'systemctl status localloop-backend'"
89+
echo "5. Enable nightly backups with 'systemctl enable --now localloop-backend-backup.timer'"

src/federation/registry.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@ export type NodeRecord = {
1010
lab_only: true;
1111
};
1212

13+
export function resolveNodeApiEndpoint(publicBaseUrl: string) {
14+
const trimmed = publicBaseUrl.replace(/\/+$/, '');
15+
return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`;
16+
}
17+
1318
export function getLocalNode(): NodeRecord {
1419
return {
1520
node_id: config.node.id,
1621
name: config.node.name,
17-
endpoint: config.publicBaseUrl,
22+
endpoint: resolveNodeApiEndpoint(config.publicBaseUrl),
1823
capabilities: config.node.capabilities,
1924
last_seen: new Date().toISOString(),
2025
lab_only: true,

src/routes/federation.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { FastifyInstance } from 'fastify';
22
import { config } from '../config';
33
import { incrementMetric } from '../metrics';
4-
import { getLocalNode, listNodes, upsertNode, type NodeRecord } from '../federation/registry';
4+
import { getLocalNode, listNodes, resolveNodeApiEndpoint, upsertNode, type NodeRecord } from '../federation/registry';
55
import { requireApiKey } from '../security/apiKey';
66
import { federationSchemaIds, registerFederationSchemas } from '../schemas/federationSchemas';
77
import { loopContentType } from '../protocol';
8+
import packageInfo from '../../package.json';
89

910
const listResponseSchema = {
1011
type: 'object',
@@ -28,6 +29,21 @@ const listResponseSchema = {
2829
},
2930
};
3031

32+
const nodeInfoResponseSchema = {
33+
type: 'object',
34+
required: ['@context', '@type', 'id', 'name', 'version', 'endpoint', 'capabilities', 'lab_only'],
35+
properties: {
36+
'@context': { type: 'string' },
37+
'@type': { type: 'string', const: 'NodeInfo' },
38+
id: { type: 'string' },
39+
name: { type: 'string' },
40+
version: { type: 'string' },
41+
endpoint: { type: 'string' },
42+
capabilities: { type: 'array', items: { type: 'string' } },
43+
lab_only: { type: 'boolean', const: true },
44+
},
45+
};
46+
3147
const apiKeySecurity = [{ ApiKeyAuth: [] }];
3248

3349
const writeRateLimit = {
@@ -50,6 +66,26 @@ const defaultDeps: FederationDeps = {
5066
export async function registerFederationRoutes(app: FastifyInstance, deps: FederationDeps = defaultDeps) {
5167
registerFederationSchemas(app);
5268

69+
app.get('/api/v1/node/info', {
70+
schema: {
71+
response: {
72+
200: nodeInfoResponseSchema,
73+
},
74+
},
75+
}, async () => {
76+
const local = deps.getLocalNode();
77+
return {
78+
'@context': 'https://local-loop-io.github.io/projects/loop-protocol/contexts/loop-v0.2.0.jsonld',
79+
'@type': 'NodeInfo',
80+
id: local.node_id,
81+
name: local.name,
82+
version: packageInfo.version,
83+
endpoint: resolveNodeApiEndpoint(config.publicBaseUrl),
84+
capabilities: local.capabilities,
85+
lab_only: true,
86+
};
87+
});
88+
5389
app.get('/api/v1/federation/nodes', {
5490
schema: {
5591
response: {
@@ -99,7 +135,7 @@ export async function registerFederationRoutes(app: FastifyInstance, deps: Feder
99135
reply.code(202).send({
100136
'@context': 'https://local-loop-io.github.io/projects/loop-protocol/contexts/loop-v0.2.0.jsonld',
101137
'@type': 'NodeHandshakeResponse',
102-
schema_version: '0.1.1',
138+
schema_version: '0.2.0',
103139
status: 'accepted',
104140
peer_id: local.node_id,
105141
capabilities: local.capabilities,

src/routes/loop.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ const writeRateLimit = {
9696
timeWindow: config.rateLimitWriteWindow,
9797
};
9898

99+
const allowedRelayEvents: Record<string, readonly string[]> = {
100+
material: ['material.created', 'material.status_updated'],
101+
product: ['product.created'],
102+
offer: ['offer.created'],
103+
match: ['match.created'],
104+
transfer: ['transfer.created'],
105+
};
106+
99107
type DbLikeError = Error & {
100108
code?: string;
101109
};
@@ -178,6 +186,14 @@ function sendWriteConflict(error: unknown, reply: FastifyReply) {
178186
return false;
179187
}
180188

189+
function isAllowedRelayEvent(entityType: string, eventType: string) {
190+
if (!Object.hasOwn(allowedRelayEvents, entityType)) {
191+
return false;
192+
}
193+
194+
return allowedRelayEvents[entityType]?.includes(eventType) ?? false;
195+
}
196+
181197
export async function registerLoopRoutes(app: FastifyInstance, deps: LoopDeps = defaultDeps) {
182198
app.post('/api/v1/material', {
183199
config: { rateLimit: writeRateLimit },
@@ -652,6 +668,11 @@ export async function registerLoopRoutes(app: FastifyInstance, deps: LoopDeps =
652668
source_node?: string;
653669
};
654670

671+
if (!isAllowedRelayEvent(payload.entity_type, payload.event_type)) {
672+
reply.code(400).send({ error: 'Unsupported relay event_type for entity_type' });
673+
return;
674+
}
675+
655676
const eventPayload = {
656677
...payload.payload,
657678
source_node: payload.source_node ?? 'remote',

0 commit comments

Comments
 (0)