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
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 5 additions & 4 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -15,16 +15,17 @@
"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",
"test:watch": "bun test --watch ./src",
"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"
},
Expand Down
83 changes: 83 additions & 0 deletions apps/api/scripts/smoke-localstack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Copyright (c) 2026 Xiro The Dev <lethanhtrung.trungle@gmail.com>
*
* 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.
*/

/// <reference types="node" />

/**
* 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<void> {
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);
});
42 changes: 42 additions & 0 deletions apps/api/scripts/write-dist-tsconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2026 Xiro The Dev <lethanhtrung.trungle@gmail.com>
*
* 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.
*/

/// <reference types="node" />

// 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`);
13 changes: 13 additions & 0 deletions apps/api/src/auth/infrastructure/token.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/auth/infrastructure/token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,27 @@ 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<string>(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,
): string {
// 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<StringValue>('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<StringValue>('JWT_REFRESH_TOKEN_EXPIRES_IN', '604800'),
expiresIn: this.expiresIn('JWT_REFRESH_TOKEN_EXPIRES_IN', 604800),
});
}

Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/storage/storage.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('R2_FORCE_PATH_STYLE') === 'true';
}
}
1 change: 1 addition & 0 deletions apps/api/src/storage/storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion scripts/localstack/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 2 additions & 11 deletions scripts/localstack/init/01-seed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading