From 6e8b5269b66f15ab50799549ab07b21e7f2a68d7 Mon Sep 17 00:00:00 2001 From: Xiro The Dev Date: Fri, 4 Sep 2026 19:22:02 +0700 Subject: [PATCH 1/2] feat(api): R2_FORCE_PATH_STYLE for LocalStack S3, seeded bucket, smoke script --- CONTEXT.md | 4 ++ apps/api/.env.example | 2 + apps/api/package.json | 1 + apps/api/scripts/smoke-localstack.ts | 83 +++++++++++++++++++++++++ apps/api/src/storage/storage.config.ts | 5 ++ apps/api/src/storage/storage.service.ts | 1 + scripts/localstack/docker-compose.yml | 2 +- scripts/localstack/init/01-seed.sh | 13 +--- 8 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 apps/api/scripts/smoke-localstack.ts diff --git a/CONTEXT.md b/CONTEXT.md index 08b315e8..dd8fd618 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -64,6 +64,10 @@ _Avoid_: feedback, rating An append-only audit record of security-relevant actions (auth successes, authorization failures, rate-limit hits). _Avoid_: log entry, audit entry +**Media store**: +The object store holding stored media (avatars, images, blog content), reached through one S3-compatible endpoint per environment: Cloudflare R2 serves the Prod stack; LocalStack stands in for it under development. +_Avoid_: R2 (the config names are `R2_*`, not the concept), file storage, bucket + **Service**: One deployable application — each backend and each frontend is its own Service, deployed and scaled on its own. _Avoid_: app, microservice, container diff --git a/apps/api/.env.example b/apps/api/.env.example index 9cc236da..6f3585c0 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -66,6 +66,8 @@ R2_SECRET_ACCESS_KEY=your_secret_key R2_BUCKET_NAME=webdevstudios-storage R2_PUBLIC_URL=https://pub-xxxxx.r2.dev R2_ENDPOINT=https://xxxxx.r2.cloudflarestorage.com +# LocalStack dev: R2_ENDPOINT=http://127.0.0.1:4566, R2_FORCE_PATH_STYLE=true, any dummy keys +R2_FORCE_PATH_STYLE=false # Redis REDIS_HOST="localhost" diff --git a/apps/api/package.json b/apps/api/package.json index f1bd6eba..336fec7d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,6 +25,7 @@ "test:cov": "bun test --coverage ./src ./test", "test:debug": "bun --inspect-brk test ./src", "test:e2e": "bun test ./test", + "smoke:localstack": "bun scripts/smoke-localstack.ts", "confirm-webhook": "./scripts/confirm-webhook.sh", "test-webhook": "./scripts/test-webhook.sh" }, diff --git a/apps/api/scripts/smoke-localstack.ts b/apps/api/scripts/smoke-localstack.ts new file mode 100644 index 00000000..7ae4d219 --- /dev/null +++ b/apps/api/scripts/smoke-localstack.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2026 Xiro The Dev + * + * Source Available License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to: + * - View and study the Software for educational purposes + * - Fork this repository on GitHub for personal reference + * - Share links to this repository + * + * THE FOLLOWING ARE PROHIBITED: + * - Using the Software in production or commercial applications + * - Copying substantial portions of the Software into other projects + * - Distributing modified versions of the Software + * - Removing or altering copyright notices + * + * For commercial licensing or usage permissions, contact: lethanhtrung.trungle@gmail.com + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +/// + +/** + * Smoke check: apps/api StorageService against a LocalStack S3 endpoint. + * Run from apps/api with the stack up: + * bun run scripts/smoke-localstack.ts + * Env overrides supported; defaults match a local LocalStack (port 4566). + */ +import { ConfigService } from '@nestjs/config'; + +import { StorageConfig } from '../src/storage/storage.config'; +import { StorageService } from '../src/storage/storage.service'; + +Object.assign(process.env, { + R2_ACCOUNT_ID: process.env.R2_ACCOUNT_ID ?? 'smoke', + R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? 'test', + R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY ?? 'test', + R2_BUCKET_NAME: process.env.R2_BUCKET_NAME ?? 'webdevstudios-storage', + R2_PUBLIC_URL: process.env.R2_PUBLIC_URL ?? 'http://127.0.0.1:4566/webdevstudios-storage', + R2_ENDPOINT: process.env.R2_ENDPOINT ?? 'http://127.0.0.1:4566', + R2_FORCE_PATH_STYLE: 'true', +}); + +async function main(): Promise { + const service = new StorageService(new StorageConfig(new ConfigService({ ...process.env }))); + + const key = `smoke/${Date.now()}.md`; + const content = `# localstack smoke ${new Date().toISOString()}`; + + const uploaded = await service.uploadFile({ + key, + file: Buffer.from(content, 'utf-8'), + contentType: 'text/markdown', + }); + if (uploaded.url !== `${process.env.R2_PUBLIC_URL}/${key}`) { + throw new Error(`unexpected url: ${uploaded.url}`); + } + + const fetched = await service.getBlogContent(key); + if (fetched !== content) { + throw new Error(`round-trip mismatch: ${fetched.slice(0, 80)}`); + } + + await service.deleteFile(key); + let deleted = false; + try { + await service.getBlogContent(key); + } catch { + deleted = true; + } + if (!deleted) { + throw new Error('object still readable after deleteFile'); + } + + console.log(`localstack storage smoke OK (${process.env.R2_ENDPOINT})`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/apps/api/src/storage/storage.config.ts b/apps/api/src/storage/storage.config.ts index 217a2044..ddeb91a4 100644 --- a/apps/api/src/storage/storage.config.ts +++ b/apps/api/src/storage/storage.config.ts @@ -53,4 +53,9 @@ export class StorageConfig { `https://${this.accountId}.r2.cloudflarestorage.com` ); } + + // LocalStack/S3 dev servers only resolve bucket via path; R2 uses virtual-host style. + get forcePathStyle(): boolean { + return this.configService.get('R2_FORCE_PATH_STYLE') === 'true'; + } } diff --git a/apps/api/src/storage/storage.service.ts b/apps/api/src/storage/storage.service.ts index d0440a7f..926df191 100644 --- a/apps/api/src/storage/storage.service.ts +++ b/apps/api/src/storage/storage.service.ts @@ -80,6 +80,7 @@ export class StorageService { this.s3Client = new S3Client({ region: 'auto', endpoint: this.config.endpoint, + forcePathStyle: this.config.forcePathStyle, credentials: { accessKeyId: this.config.accessKeyId, secretAccessKey: this.config.secretAccessKey, diff --git a/scripts/localstack/docker-compose.yml b/scripts/localstack/docker-compose.yml index 2262c6e2..8e1f4bd7 100644 --- a/scripts/localstack/docker-compose.yml +++ b/scripts/localstack/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - '4566:4566' # single edge port cho mọi service environment: - - SERVICES=s3,sqs,sns,dynamodb,secretsmanager,iam,lambda + - SERVICES=s3 - DEBUG=0 volumes: - './init:/etc/localstack/init/ready.d' # chạy sau khi edge sẵn sàng diff --git a/scripts/localstack/init/01-seed.sh b/scripts/localstack/init/01-seed.sh index ee304352..8ab9ad3f 100755 --- a/scripts/localstack/init/01-seed.sh +++ b/scripts/localstack/init/01-seed.sh @@ -2,16 +2,7 @@ # Chạy tự động bởi LocalStack khi edge sẵn sàng (init/ready.d). set -euo pipefail -awslocal s3 mb "s3://webdevstudios" || true -awslocal sqs create-queue --queue-name order-events -awslocal sns create-topic --name order-notifications -awslocal dynamodb create-table \ - --table-name idempotency \ - --attribute-definitions AttributeName=key,AttributeType=S \ - --key-schema AttributeName=key,KeyType=HASH \ - --billing-mode PAY_PER_REQUEST -awslocal secretsmanager create-secret \ - --name webdev/local/app \ - --secret-string '{"MAIL_PASS":"fake-resend-key","JWT_SECRET_KEY":"local-dev-only"}' +# Only S3 is consumed by the apps (storage modules read R2_*); see CONTEXT-MAP. +awslocal s3 mb "s3://webdevstudios-storage" || true echo "localstack initialised" From 4e6478ed716185ce9651c9c788a5e1ff4588d32a Mon Sep 17 00:00:00 2001 From: Xiro The Dev Date: Fri, 4 Sep 2026 23:41:01 +0700 Subject: [PATCH 2/2] fix(api): run compiled dist with bun without dual-identity DI crash, treat numeric JWT TTLs as seconds --- apps/api/package.json | 8 ++-- apps/api/scripts/write-dist-tsconfig.ts | 42 +++++++++++++++++++ .../auth/infrastructure/token.service.spec.ts | 13 ++++++ .../src/auth/infrastructure/token.service.ts | 11 ++++- 4 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 apps/api/scripts/write-dist-tsconfig.ts diff --git a/apps/api/package.json b/apps/api/package.json index 336fec7d..9b6fb0c6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,7 @@ "private": true, "license": "SEE LICENSE IN LICENSE.md", "scripts": { - "build": "bunx --bun --no-install nest build", + "build": "bunx --bun --no-install nest build && bun scripts/write-dist-tsconfig.ts", "postinstall": "bunx --bun --no-install prisma generate", "prisma:generate": "bunx --bun --no-install prisma generate", "prisma:migrate": "bunx --bun --no-install prisma migrate dev", @@ -15,9 +15,9 @@ "prisma:seed": "bunx --bun --no-install prisma db seed", "prisma:seed:user": "bun prisma/user.seed.ts", "format": "bunx --bun --no-install prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "bunx --bun --no-install nest start", - "dev": "bunx --bun --no-install nest start --watch", - "start:debug": "bunx --bun --no-install nest start --debug --watch", + "start": "bun run build && bun dist/main.js", + "dev": "bun --watch src/main.ts", + "start:debug": "bun --watch --inspect-brk src/main.ts", "start:prod": "bun dist/main.js", "lint": "bunx --bun --no-install oxlint --fix .", "test": "bun test ./src", diff --git a/apps/api/scripts/write-dist-tsconfig.ts b/apps/api/scripts/write-dist-tsconfig.ts new file mode 100644 index 00000000..8cd920e4 --- /dev/null +++ b/apps/api/scripts/write-dist-tsconfig.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2026 Xiro The Dev + * + * Source Available License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to: + * - View and study the Software for educational purposes + * - Fork this repository on GitHub for personal reference + * - Share links to this repository + * + * THE FOLLOWING ARE PROHIBITED: + * - Using the Software in production or commercial applications + * - Copying substantial portions of the Software into other projects + * - Distributing modified versions of the Software + * - Removing or altering copyright notices + * + * For commercial licensing or usage permissions, contact: lethanhtrung.trungle@gmail.com + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +/// + +// Bun resolves tsconfig paths from the nearest tsconfig.json; inside dist the dev +// mapping @/* -> ./src/* loads classes twice (dist provider vs src token) and Nest DI +// crashes. This runtime tsconfig points aliases into dist so bun loads one copy — +// the same fix the Dockerfile applies at image build time. +import { writeFileSync } from 'fs'; + +const runtime = { + compilerOptions: { + baseUrl: '.', + paths: { + '@/*': ['./*'], + '@prisma/client': ['../src/generated/prisma'], + '@prisma/client/*': ['../src/generated/prisma/*'], + }, + }, +}; + +writeFileSync('dist/tsconfig.json', `${JSON.stringify(runtime, null, 2)}\n`); diff --git a/apps/api/src/auth/infrastructure/token.service.spec.ts b/apps/api/src/auth/infrastructure/token.service.spec.ts index 3bacac1d..6363612e 100644 --- a/apps/api/src/auth/infrastructure/token.service.spec.ts +++ b/apps/api/src/auth/infrastructure/token.service.spec.ts @@ -45,6 +45,19 @@ describe('TokenService', () => { expect(refreshExp - Date.now()).toBeGreaterThan(accessExp - Date.now()); }); + test('numeric-string JWT TTLs mean seconds, not milliseconds', () => { + const service = makeService({ + JWT_ACCESS_TOKEN_EXPIRES_IN: '3600', + JWT_REFRESH_TOKEN_EXPIRES_IN: '7200', + }); + + const access = service.generateAccessToken({ sub: 'user-1', email: 'u@example.com' }); + const refresh = service.verifyToken(service.generateRefreshToken({ sub: 'user-1' })); + + expect(service.verifyToken(access).exp! - Math.floor(Date.now() / 1000)).toBeGreaterThan(3000); + expect(refresh.exp! - Math.floor(Date.now() / 1000)).toBeGreaterThan(6000); + }); + test('access tokens carry the session id as jti when given', () => { const service = makeService(); diff --git a/apps/api/src/auth/infrastructure/token.service.ts b/apps/api/src/auth/infrastructure/token.service.ts index 2d1d2dc1..64d8f50f 100644 --- a/apps/api/src/auth/infrastructure/token.service.ts +++ b/apps/api/src/auth/infrastructure/token.service.ts @@ -34,6 +34,13 @@ export class TokenService { private readonly configService: ConfigService, ) {} + // jsonwebtoken parses a bare numeric string as milliseconds; JWT_*_EXPIRES_IN config is in seconds. + private expiresIn(key: string, fallbackSeconds: number): number | StringValue { + const raw = this.configService.get(key, String(fallbackSeconds)); + const seconds = Number(raw); + return Number.isNaN(seconds) ? (raw as StringValue) : seconds; + } + generateAccessToken( payload: { sub: string; email: string; role?: string }, sessionId?: string, @@ -41,13 +48,13 @@ export class TokenService { // jti carries the Session id so identity survives refresh rotation const claims = sessionId ? { ...payload, jti: sessionId } : payload; return this.jwtService.sign(claims, { - expiresIn: this.configService.get('JWT_ACCESS_TOKEN_EXPIRES_IN', '3600'), + expiresIn: this.expiresIn('JWT_ACCESS_TOKEN_EXPIRES_IN', 3600), }); } generateRefreshToken(payload: { sub: string }): string { return this.jwtService.sign(payload, { - expiresIn: this.configService.get('JWT_REFRESH_TOKEN_EXPIRES_IN', '604800'), + expiresIn: this.expiresIn('JWT_REFRESH_TOKEN_EXPIRES_IN', 604800), }); }