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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ DATABASE_URL=
# Redis
REDIS_URL=

# Object storage (S3-compatible). Defaults below target local MinIO from
# infra/docker-compose.yml. For AWS S3 or Cloudflare R2, swap endpoint,
# credentials, region, and set OBJECT_STORE_FORCE_PATH_STYLE=false.
OBJECT_STORE_ENDPOINT=http://localhost:9000
OBJECT_STORE_BUCKET=clicked
OBJECT_STORE_ACCESS_KEY=clicked
OBJECT_STORE_SECRET_KEY=clickedsecret
OBJECT_STORE_REGION=us-east-1
OBJECT_STORE_FORCE_PATH_STYLE=true

# AI Service
OPENAI_API_KEY=

Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"license": "ISC",
"packageManager": "pnpm@10.28.1",
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@socket.io/redis-adapter": "^8.3.0",
"@stellar/stellar-sdk": "^15.1.0",
"cors": "^2.8.6",
Expand Down
23 changes: 23 additions & 0 deletions apps/backend/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const validEnv = {
JWT_SECRET: 'test-secret',
PORT: '3001',
TOKEN_TRANSFER_CONTRACT_ID: 'CONTRACT123',
OBJECT_STORE_ENDPOINT: 'http://localhost:9000',
OBJECT_STORE_BUCKET: 'clicked',
OBJECT_STORE_ACCESS_KEY: 'clicked',
OBJECT_STORE_SECRET_KEY: 'clickedsecret',
OBJECT_STORE_REGION: 'us-east-1',
OBJECT_STORE_FORCE_PATH_STYLE: 'true',
};

describe('loadEnv', () => {
Expand All @@ -26,6 +32,12 @@ describe('loadEnv', () => {
JWT_SECRET: 'test-secret',
PORT: 3001,
TOKEN_TRANSFER_CONTRACT_ID: 'CONTRACT123',
OBJECT_STORE_ENDPOINT: 'http://localhost:9000',
OBJECT_STORE_BUCKET: 'clicked',
OBJECT_STORE_ACCESS_KEY: 'clicked',
OBJECT_STORE_SECRET_KEY: 'clickedsecret',
OBJECT_STORE_REGION: 'us-east-1',
OBJECT_STORE_FORCE_PATH_STYLE: true,
});
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
Expand Down Expand Up @@ -78,4 +90,15 @@ describe('loadEnv', () => {
const parsed = EnvSchema.parse({ ...validEnv, PORT: '8080' });
expect(parsed.PORT).toBe(8080);
});

it('coerces OBJECT_STORE_FORCE_PATH_STYLE from string to boolean', () => {
expect(
EnvSchema.parse({ ...validEnv, OBJECT_STORE_FORCE_PATH_STYLE: 'false' })
.OBJECT_STORE_FORCE_PATH_STYLE,
).toBe(false);
expect(
EnvSchema.parse({ ...validEnv, OBJECT_STORE_FORCE_PATH_STYLE: 'true' })
.OBJECT_STORE_FORCE_PATH_STYLE,
).toBe(true);
});
});
74 changes: 74 additions & 0 deletions apps/backend/src/__tests__/objectStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { createObjectStore, createObjectStoreClient } from '../lib/objectStore.js';

const config = {
OBJECT_STORE_ENDPOINT: 'http://localhost:9000',
OBJECT_STORE_BUCKET: 'clicked',
OBJECT_STORE_ACCESS_KEY: 'clicked',
OBJECT_STORE_SECRET_KEY: 'clickedsecret',
OBJECT_STORE_REGION: 'us-east-1',
OBJECT_STORE_FORCE_PATH_STYLE: true,
};

describe('createObjectStoreClient', () => {
it('configures the S3 client for path-style MinIO endpoints', () => {
const client = createObjectStoreClient(config);
expect(client).toBeInstanceOf(S3Client);
expect(client.config.endpoint).toBeDefined();
});

it('supports virtual-hosted AWS/R2 style endpoints when path style is disabled', () => {
const client = createObjectStoreClient({
...config,
OBJECT_STORE_ENDPOINT: 'https://s3.amazonaws.com',
OBJECT_STORE_FORCE_PATH_STYLE: false,
});
expect(client).toBeInstanceOf(S3Client);
});
});

describe('ObjectStore', () => {
const send = vi.fn();

beforeEach(() => {
send.mockReset();
vi.spyOn(S3Client.prototype, 'send').mockImplementation(send);
});

it('checks bucket reachability with HeadBucket', async () => {
send.mockResolvedValue({});
const store = createObjectStore(config);

await store.ensureBucketReachable();

expect(send).toHaveBeenCalledWith(expect.any(HeadBucketCommand));
});

it('uploads, reads, and deletes objects in the configured bucket', async () => {
send.mockResolvedValue({});
const store = createObjectStore(config);

await store.putObject('avatars/user.png', Buffer.from('png'), 'image/png');
await store.getObject('avatars/user.png');
await store.deleteObject('avatars/user.png');

expect(send).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
input: expect.objectContaining({
Bucket: 'clicked',
Key: 'avatars/user.png',
ContentType: 'image/png',
}),
}),
);
expect(send).toHaveBeenNthCalledWith(2, expect.any(GetObjectCommand));
expect(send).toHaveBeenNthCalledWith(3, expect.any(DeleteObjectCommand));
});
});
6 changes: 6 additions & 0 deletions apps/backend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
process.env['JWT_SECRET'] = 'test-secret-for-ci-only';
process.env['DATABASE_URL'] = 'postgres://localhost/test';
process.env['OBJECT_STORE_ENDPOINT'] = 'http://localhost:9000';
process.env['OBJECT_STORE_BUCKET'] = 'clicked';
process.env['OBJECT_STORE_ACCESS_KEY'] = 'clicked';
process.env['OBJECT_STORE_SECRET_KEY'] = 'clickedsecret';
process.env['OBJECT_STORE_REGION'] = 'us-east-1';
process.env['OBJECT_STORE_FORCE_PATH_STYLE'] = 'true';
10 changes: 10 additions & 0 deletions apps/backend/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { z } from 'zod';

const booleanEnv = z
.enum(['true', 'false', '1', '0'])
.transform((value) => value === 'true' || value === '1');

/**
* Startup environment schema. Every variable here is required for the
* backend to boot; `loadEnv` validates `process.env` against it and exits
Expand All @@ -11,6 +15,12 @@ export const EnvSchema = z.object({
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
PORT: z.coerce.number().int('PORT must be an integer').positive('PORT must be positive'),
TOKEN_TRANSFER_CONTRACT_ID: z.string().min(1, 'TOKEN_TRANSFER_CONTRACT_ID is required'),
OBJECT_STORE_ENDPOINT: z.string().min(1, 'OBJECT_STORE_ENDPOINT is required'),
OBJECT_STORE_BUCKET: z.string().min(1, 'OBJECT_STORE_BUCKET is required'),
OBJECT_STORE_ACCESS_KEY: z.string().min(1, 'OBJECT_STORE_ACCESS_KEY is required'),
OBJECT_STORE_SECRET_KEY: z.string().min(1, 'OBJECT_STORE_SECRET_KEY is required'),
OBJECT_STORE_REGION: z.string().min(1, 'OBJECT_STORE_REGION is required'),
OBJECT_STORE_FORCE_PATH_STYLE: booleanEnv,
});

export type Env = z.infer<typeof EnvSchema>;
Expand Down
16 changes: 15 additions & 1 deletion apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ import {
runForever as runStellarListener,
} from './services/stellarListener.js';
import { loadEnv } from './config.js';
import { createObjectStore } from './lib/objectStore.js';

dotenv.config();

// Validate required environment variables at boot. Exits with code 1 and
// logs the offending vars if anything is missing or malformed.
loadEnv();
const env = loadEnv();
export const objectStore = createObjectStore(env);

const httpServer = createServer(app);
const io = new Server(httpServer, {
Expand Down Expand Up @@ -123,6 +125,18 @@ httpServer.listen(PORT, () => {
// Redis is unreachable; on failure we fall back to the in-process adapter.
void attachRedisAdapter();

// #223 — Verify object storage is reachable when the API boots. Logs a
// warning instead of crashing so unit tests and partial local setups still run.
void objectStore
.ensureBucketReachable()
.then(() => {
console.log(`[object-store] bucket "${env.OBJECT_STORE_BUCKET}" reachable`);
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[object-store] bucket unreachable (${message})`);
});

// #46 — Stellar transfer event listener. Only spin up when the contract
// id is configured so local-dev and unit-test runs don't try to talk to
// Soroban RPC. The listener never throws out of runForever, so a failed
Expand Down
80 changes: 80 additions & 0 deletions apps/backend/src/lib/objectStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
PutObjectCommand,
S3Client,
type PutObjectCommandInput,
} from '@aws-sdk/client-s3';
import type { Env } from '../config.js';

export type ObjectStoreConfig = Pick<
Env,
| 'OBJECT_STORE_ENDPOINT'
| 'OBJECT_STORE_BUCKET'
| 'OBJECT_STORE_ACCESS_KEY'
| 'OBJECT_STORE_SECRET_KEY'
| 'OBJECT_STORE_REGION'
| 'OBJECT_STORE_FORCE_PATH_STYLE'
>;

/**
* Build an S3-compatible client from env. The same configuration works against
* local MinIO (path-style + custom endpoint), AWS S3, and Cloudflare R2 —
* only the env values change.
*/
export function createObjectStoreClient(config: ObjectStoreConfig): S3Client {
return new S3Client({
endpoint: config.OBJECT_STORE_ENDPOINT,
region: config.OBJECT_STORE_REGION,
credentials: {
accessKeyId: config.OBJECT_STORE_ACCESS_KEY,
secretAccessKey: config.OBJECT_STORE_SECRET_KEY,
},
forcePathStyle: config.OBJECT_STORE_FORCE_PATH_STYLE,
});
}

export class ObjectStore {
constructor(
private readonly client: S3Client,
private readonly bucket: string,
) {}

async ensureBucketReachable(): Promise<void> {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
}

async putObject(key: string, body: NonNullable<PutObjectCommandInput['Body']>, contentType?: string) {
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
...(contentType ? { ContentType: contentType } : {}),
}),
);
}

async getObject(key: string) {
return this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key,
}),
);
}

async deleteObject(key: string) {
await this.client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
}),
);
}
}

export function createObjectStore(config: ObjectStoreConfig): ObjectStore {
return new ObjectStore(createObjectStoreClient(config), config.OBJECT_STORE_BUCKET);
}
37 changes: 37 additions & 0 deletions infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,43 @@ services:
retries: 5
start_period: 2s

# #223 — S3-compatible object storage (MinIO). Swap env vars to target AWS S3
# or Cloudflare R2 in production; the backend uses the same S3 client path.
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: clicked
MINIO_ROOT_PASSWORD: clickedsecret
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s

# Create the application bucket on first boot with a private ACL (no anonymous
# read). Re-runs are idempotent via --ignore-existing.
minio-init:
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 clicked clickedsecret &&
mc mb --ignore-existing local/clicked &&
mc anonymous set none local/clicked
"
restart: "no"

volumes:
postgres_data:
redis_data:
minio_data:
Loading
Loading