Skip to content

Commit 0af0b36

Browse files
authored
Merge pull request #250 from Zeemnew/fix/223-provision-minio-object-storage
feat(infra): provision MinIO object storage for local S3-compatible dev
2 parents 779da9d + 1440fcc commit 0af0b36

8 files changed

Lines changed: 243 additions & 1 deletion

File tree

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ DATABASE_URL=
1313
# Redis
1414
REDIS_URL=
1515

16+
# Object storage (S3-compatible). Defaults below target local MinIO from
17+
# infra/docker-compose.yml. For AWS S3 or Cloudflare R2, swap endpoint,
18+
# credentials, region, and set OBJECT_STORE_FORCE_PATH_STYLE=false.
19+
OBJECT_STORE_ENDPOINT=http://localhost:9000
20+
OBJECT_STORE_BUCKET=clicked
21+
OBJECT_STORE_ACCESS_KEY=clicked
22+
OBJECT_STORE_SECRET_KEY=clickedsecret
23+
OBJECT_STORE_REGION=us-east-1
24+
OBJECT_STORE_FORCE_PATH_STYLE=true
25+
1626
# AI Service
1727
OPENAI_API_KEY=
1828

apps/backend/src/__tests__/config.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ const validEnv = {
77
JWT_SECRET: 'test-secret',
88
PORT: '3001',
99
TOKEN_TRANSFER_CONTRACT_ID: 'CONTRACT123',
10+
OBJECT_STORE_ENDPOINT: 'http://localhost:9000',
11+
OBJECT_STORE_BUCKET: 'clicked',
12+
OBJECT_STORE_ACCESS_KEY: 'clicked',
13+
OBJECT_STORE_SECRET_KEY: 'clickedsecret',
14+
OBJECT_STORE_REGION: 'us-east-1',
15+
OBJECT_STORE_FORCE_PATH_STYLE: 'true',
1016
};
1117

1218
describe('loadEnv', () => {
@@ -26,6 +32,12 @@ describe('loadEnv', () => {
2632
JWT_SECRET: 'test-secret',
2733
PORT: 3001,
2834
TOKEN_TRANSFER_CONTRACT_ID: 'CONTRACT123',
35+
OBJECT_STORE_ENDPOINT: 'http://localhost:9000',
36+
OBJECT_STORE_BUCKET: 'clicked',
37+
OBJECT_STORE_ACCESS_KEY: 'clicked',
38+
OBJECT_STORE_SECRET_KEY: 'clickedsecret',
39+
OBJECT_STORE_REGION: 'us-east-1',
40+
OBJECT_STORE_FORCE_PATH_STYLE: true,
2941
});
3042
expect(errorSpy).not.toHaveBeenCalled();
3143
expect(logSpy).not.toHaveBeenCalled();
@@ -78,4 +90,15 @@ describe('loadEnv', () => {
7890
const parsed = EnvSchema.parse({ ...validEnv, PORT: '8080' });
7991
expect(parsed.PORT).toBe(8080);
8092
});
93+
94+
it('coerces OBJECT_STORE_FORCE_PATH_STYLE from string to boolean', () => {
95+
expect(
96+
EnvSchema.parse({ ...validEnv, OBJECT_STORE_FORCE_PATH_STYLE: 'false' })
97+
.OBJECT_STORE_FORCE_PATH_STYLE,
98+
).toBe(false);
99+
expect(
100+
EnvSchema.parse({ ...validEnv, OBJECT_STORE_FORCE_PATH_STYLE: 'true' })
101+
.OBJECT_STORE_FORCE_PATH_STYLE,
102+
).toBe(true);
103+
});
81104
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import {
3+
DeleteObjectCommand,
4+
GetObjectCommand,
5+
HeadBucketCommand,
6+
S3Client,
7+
} from '@aws-sdk/client-s3';
8+
import { createObjectStore, createObjectStoreClient } from '../lib/objectStore.js';
9+
10+
const config = {
11+
OBJECT_STORE_ENDPOINT: 'http://localhost:9000',
12+
OBJECT_STORE_BUCKET: 'clicked',
13+
OBJECT_STORE_ACCESS_KEY: 'clicked',
14+
OBJECT_STORE_SECRET_KEY: 'clickedsecret',
15+
OBJECT_STORE_REGION: 'us-east-1',
16+
OBJECT_STORE_FORCE_PATH_STYLE: true,
17+
};
18+
19+
describe('createObjectStoreClient', () => {
20+
it('configures the S3 client for path-style MinIO endpoints', () => {
21+
const client = createObjectStoreClient(config);
22+
expect(client).toBeInstanceOf(S3Client);
23+
expect(client.config.endpoint).toBeDefined();
24+
});
25+
26+
it('supports virtual-hosted AWS/R2 style endpoints when path style is disabled', () => {
27+
const client = createObjectStoreClient({
28+
...config,
29+
OBJECT_STORE_ENDPOINT: 'https://s3.amazonaws.com',
30+
OBJECT_STORE_FORCE_PATH_STYLE: false,
31+
});
32+
expect(client).toBeInstanceOf(S3Client);
33+
});
34+
});
35+
36+
describe('ObjectStore', () => {
37+
const send = vi.fn();
38+
39+
beforeEach(() => {
40+
send.mockReset();
41+
vi.spyOn(S3Client.prototype, 'send').mockImplementation(send);
42+
});
43+
44+
it('checks bucket reachability with HeadBucket', async () => {
45+
send.mockResolvedValue({});
46+
const store = createObjectStore(config);
47+
48+
await store.ensureBucketReachable();
49+
50+
expect(send).toHaveBeenCalledWith(expect.any(HeadBucketCommand));
51+
});
52+
53+
it('uploads, reads, and deletes objects in the configured bucket', async () => {
54+
send.mockResolvedValue({});
55+
const store = createObjectStore(config);
56+
57+
await store.putObject('avatars/user.png', Buffer.from('png'), 'image/png');
58+
await store.getObject('avatars/user.png');
59+
await store.deleteObject('avatars/user.png');
60+
61+
expect(send).toHaveBeenNthCalledWith(
62+
1,
63+
expect.objectContaining({
64+
input: expect.objectContaining({
65+
Bucket: 'clicked',
66+
Key: 'avatars/user.png',
67+
ContentType: 'image/png',
68+
}),
69+
}),
70+
);
71+
expect(send).toHaveBeenNthCalledWith(2, expect.any(GetObjectCommand));
72+
expect(send).toHaveBeenNthCalledWith(3, expect.any(DeleteObjectCommand));
73+
});
74+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
process.env['JWT_SECRET'] = 'test-secret-for-ci-only';
22
process.env['DATABASE_URL'] = 'postgres://localhost/test';
3+
process.env['OBJECT_STORE_ENDPOINT'] = 'http://localhost:9000';
4+
process.env['OBJECT_STORE_BUCKET'] = 'clicked';
5+
process.env['OBJECT_STORE_ACCESS_KEY'] = 'clicked';
6+
process.env['OBJECT_STORE_SECRET_KEY'] = 'clickedsecret';
7+
process.env['OBJECT_STORE_REGION'] = 'us-east-1';
8+
process.env['OBJECT_STORE_FORCE_PATH_STYLE'] = 'true';

apps/backend/src/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { z } from 'zod';
22

3+
const booleanEnv = z
4+
.enum(['true', 'false', '1', '0'])
5+
.transform((value) => value === 'true' || value === '1');
6+
37
/**
48
* Startup environment schema. Every variable here is required for the
59
* backend to boot; `loadEnv` validates `process.env` against it and exits
@@ -11,6 +15,12 @@ export const EnvSchema = z.object({
1115
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
1216
PORT: z.coerce.number().int('PORT must be an integer').positive('PORT must be positive'),
1317
TOKEN_TRANSFER_CONTRACT_ID: z.string().min(1, 'TOKEN_TRANSFER_CONTRACT_ID is required'),
18+
OBJECT_STORE_ENDPOINT: z.string().min(1, 'OBJECT_STORE_ENDPOINT is required'),
19+
OBJECT_STORE_BUCKET: z.string().min(1, 'OBJECT_STORE_BUCKET is required'),
20+
OBJECT_STORE_ACCESS_KEY: z.string().min(1, 'OBJECT_STORE_ACCESS_KEY is required'),
21+
OBJECT_STORE_SECRET_KEY: z.string().min(1, 'OBJECT_STORE_SECRET_KEY is required'),
22+
OBJECT_STORE_REGION: z.string().min(1, 'OBJECT_STORE_REGION is required'),
23+
OBJECT_STORE_FORCE_PATH_STYLE: booleanEnv,
1424
});
1525

1626
export type Env = z.infer<typeof EnvSchema>;

apps/backend/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,14 @@ import {
4747
} from './services/stellarListener.js';
4848
import { startFileCleanupJob } from './services/fileCleanup.js';
4949
import { loadEnv } from './config.js';
50+
import { createObjectStore } from './lib/objectStore.js';
5051

5152
dotenv.config();
5253

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

5759
const httpServer = createServer(app);
5860
const io = new Server(httpServer, {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
DeleteObjectCommand,
3+
GetObjectCommand,
4+
HeadBucketCommand,
5+
PutObjectCommand,
6+
S3Client,
7+
type PutObjectCommandInput,
8+
} from '@aws-sdk/client-s3';
9+
import type { Env } from '../config.js';
10+
11+
export type ObjectStoreConfig = Pick<
12+
Env,
13+
| 'OBJECT_STORE_ENDPOINT'
14+
| 'OBJECT_STORE_BUCKET'
15+
| 'OBJECT_STORE_ACCESS_KEY'
16+
| 'OBJECT_STORE_SECRET_KEY'
17+
| 'OBJECT_STORE_REGION'
18+
| 'OBJECT_STORE_FORCE_PATH_STYLE'
19+
>;
20+
21+
/**
22+
* Build an S3-compatible client from env. The same configuration works against
23+
* local MinIO (path-style + custom endpoint), AWS S3, and Cloudflare R2 —
24+
* only the env values change.
25+
*/
26+
export function createObjectStoreClient(config: ObjectStoreConfig): S3Client {
27+
return new S3Client({
28+
endpoint: config.OBJECT_STORE_ENDPOINT,
29+
region: config.OBJECT_STORE_REGION,
30+
credentials: {
31+
accessKeyId: config.OBJECT_STORE_ACCESS_KEY,
32+
secretAccessKey: config.OBJECT_STORE_SECRET_KEY,
33+
},
34+
forcePathStyle: config.OBJECT_STORE_FORCE_PATH_STYLE,
35+
});
36+
}
37+
38+
export class ObjectStore {
39+
constructor(
40+
private readonly client: S3Client,
41+
private readonly bucket: string,
42+
) {}
43+
44+
async ensureBucketReachable(): Promise<void> {
45+
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
46+
}
47+
48+
async putObject(key: string, body: NonNullable<PutObjectCommandInput['Body']>, contentType?: string) {
49+
await this.client.send(
50+
new PutObjectCommand({
51+
Bucket: this.bucket,
52+
Key: key,
53+
Body: body,
54+
...(contentType ? { ContentType: contentType } : {}),
55+
}),
56+
);
57+
}
58+
59+
async getObject(key: string) {
60+
return this.client.send(
61+
new GetObjectCommand({
62+
Bucket: this.bucket,
63+
Key: key,
64+
}),
65+
);
66+
}
67+
68+
async deleteObject(key: string) {
69+
await this.client.send(
70+
new DeleteObjectCommand({
71+
Bucket: this.bucket,
72+
Key: key,
73+
}),
74+
);
75+
}
76+
}
77+
78+
export function createObjectStore(config: ObjectStoreConfig): ObjectStore {
79+
return new ObjectStore(createObjectStoreClient(config), config.OBJECT_STORE_BUCKET);
80+
}

infra/docker-compose.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,43 @@ services:
3737
retries: 5
3838
start_period: 2s
3939

40+
# #223 — S3-compatible object storage (MinIO). Swap env vars to target AWS S3
41+
# or Cloudflare R2 in production; the backend uses the same S3 client path.
42+
minio:
43+
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
44+
command: server /data --console-address ":9001"
45+
environment:
46+
MINIO_ROOT_USER: clicked
47+
MINIO_ROOT_PASSWORD: clickedsecret
48+
ports:
49+
- "9000:9000"
50+
- "9001:9001"
51+
volumes:
52+
- minio_data:/data
53+
restart: unless-stopped
54+
healthcheck:
55+
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
56+
interval: 10s
57+
timeout: 5s
58+
retries: 5
59+
start_period: 5s
60+
61+
# Create the application bucket on first boot with a private ACL (no anonymous
62+
# read). Re-runs are idempotent via --ignore-existing.
63+
minio-init:
64+
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
65+
depends_on:
66+
minio:
67+
condition: service_healthy
68+
entrypoint: >
69+
/bin/sh -c "
70+
mc alias set local http://minio:9000 clicked clickedsecret &&
71+
mc mb --ignore-existing local/clicked &&
72+
mc anonymous set none local/clicked
73+
"
74+
restart: "no"
75+
4076
volumes:
4177
postgres_data:
4278
redis_data:
79+
minio_data:

0 commit comments

Comments
 (0)