diff --git a/.gitignore b/.gitignore
index 68b097df..d9bf8f57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ node_modules/
# Environment files
.env
.env.local
+.env.development
.env.production
.env.test
diff --git a/apps/bff/scripts/docker-entrypoint.sh b/apps/bff/scripts/docker-entrypoint.sh
index 17900f41..68032a5a 100755
--- a/apps/bff/scripts/docker-entrypoint.sh
+++ b/apps/bff/scripts/docker-entrypoint.sh
@@ -88,8 +88,14 @@ fi
# =============================================================================
if [ "$RUN_MIGRATIONS" = "true" ] && [ -n "$DATABASE_URL" ]; then
echo "🗄️ Running database migrations..."
-
- if pnpm dlx prisma@"${PRISMA_VERSION}" migrate deploy --schema=/app/prisma/schema.prisma; then
+
+ # Set Prisma schema path for migration
+ export PRISMA_SCHEMA_PATH="/app/prisma/schema.prisma"
+
+ # Change to app directory where schema is located
+ cd /app
+
+ if pnpm dlx prisma@"${PRISMA_VERSION}" migrate deploy; then
echo "✅ Migrations complete"
else
echo "⚠️ Migration failed - check database connectivity"
diff --git a/apps/bff/src/core/config/app.config.ts b/apps/bff/src/core/config/app.config.ts
index 4d27af57..bbd571a3 100644
--- a/apps/bff/src/core/config/app.config.ts
+++ b/apps/bff/src/core/config/app.config.ts
@@ -2,12 +2,23 @@ import { resolve } from "node:path";
import type { ConfigModuleOptions } from "@nestjs/config";
import { validate } from "./env.validation.js";
+const nodeEnv = process.env.NODE_ENV || "development";
+
+// Load env files in order of priority (first found wins)
+// 1. .env.{NODE_ENV}.local - local overrides for specific environment
+// 2. .env.local - local overrides (not committed)
+// 3. .env.{NODE_ENV} - environment-specific
+// 4. .env - default
+// Also check monorepo root as fallback
export const appConfig: ConfigModuleOptions = {
isGlobal: true,
expandVariables: true,
validate,
envFilePath: [
+ resolve(process.cwd(), `.env.${nodeEnv}.local`),
+ resolve(process.cwd(), ".env.local"),
+ resolve(process.cwd(), `.env.${nodeEnv}`),
resolve(process.cwd(), ".env"),
- resolve(process.cwd(), "../../.env"), // Monorepo root
+ resolve(process.cwd(), "../../.env"), // Monorepo root fallback
],
};
diff --git a/apps/bff/src/core/logging/logging.module.ts b/apps/bff/src/core/logging/logging.module.ts
index 754b999e..a46dbe47 100644
--- a/apps/bff/src/core/logging/logging.module.ts
+++ b/apps/bff/src/core/logging/logging.module.ts
@@ -1,6 +1,8 @@
import { Global, Module } from "@nestjs/common";
import { LoggerModule } from "nestjs-pino";
+const prettyLogsEnabled = process.env.PRETTY_LOGS === "true" || process.env.NODE_ENV !== "production";
+
@Global()
@Module({
imports: [
@@ -31,18 +33,18 @@ import { LoggerModule } from "nestjs-pino";
statusCode: res.statusCode,
}),
},
- transport:
- process.env.NODE_ENV === "development"
- ? {
- target: "pino-pretty",
- options: {
- colorize: true,
- translateTime: "yyyy-mm-dd HH:MM:ss",
- ignore: "pid,hostname,req,res",
- messageFormat: "{msg}",
- },
- }
- : undefined,
+ transport: prettyLogsEnabled
+ ? {
+ target: "pino-pretty",
+ options: {
+ colorize: true,
+ translateTime: false,
+ singleLine: true,
+ // keep level for coloring but drop other noisy metadata
+ ignore: "pid,req,res,context,name,time",
+ },
+ }
+ : undefined,
redact: {
paths: [
"req.headers.authorization",
diff --git a/apps/bff/src/modules/auth/presentation/strategies/local.strategy.ts b/apps/bff/src/modules/auth/presentation/strategies/local.strategy.ts
index cd40ea59..330f8a99 100644
--- a/apps/bff/src/modules/auth/presentation/strategies/local.strategy.ts
+++ b/apps/bff/src/modules/auth/presentation/strategies/local.strategy.ts
@@ -3,6 +3,7 @@ import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import type { Request } from "express";
import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js";
+import { ErrorCode, ErrorMessages } from "@customer-portal/domain/common";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
@@ -17,7 +18,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
): Promise<{ id: string; email: string; role: string }> {
const user = await this.authFacade.validateUser(email, password, req);
if (!user) {
- throw new UnauthorizedException("Invalid credentials");
+ throw new UnauthorizedException(ErrorMessages[ErrorCode.INVALID_CREDENTIALS]);
}
return user;
}
diff --git a/apps/bff/test/catalog-contract.spec.ts b/apps/bff/test/catalog-contract.spec.ts
deleted file mode 100644
index e840a4ba..00000000
--- a/apps/bff/test/catalog-contract.spec.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-///
-import request from "supertest";
-import type { INestApplication } from "@nestjs/common";
-import { Test } from "@nestjs/testing";
-import type { Server } from "node:http";
-
-import { AppModule } from "../src/app.module.js";
-import {
- parseInternetCatalog,
- internetCatalogResponseSchema,
-} from "@customer-portal/domain/catalog";
-import { apiSuccessResponseSchema } from "@customer-portal/domain/common/schema";
-
-const internetCatalogApiResponseSchema = apiSuccessResponseSchema(internetCatalogResponseSchema);
-
-const isRecord = (value: unknown): value is Record =>
- typeof value === "object" && value !== null;
-
-const isHttpServer = (value: unknown): value is Server =>
- isRecord(value) && typeof value.listen === "function" && typeof value.close === "function";
-
-describe("Catalog contract", () => {
- let app: INestApplication;
-
- beforeAll(async () => {
- const moduleRef = await Test.createTestingModule({
- imports: [AppModule],
- }).compile();
-
- app = moduleRef.createNestApplication();
- await app.init();
- });
-
- afterAll(async () => {
- await app.close();
- });
-
- it("should return internet catalog matching domain schema", async () => {
- const serverCandidate: unknown = app.getHttpServer();
- if (!isHttpServer(serverCandidate)) {
- throw new Error("Expected Nest application to expose an HTTP server");
- }
-
- const response = await request(serverCandidate).get("/api/catalog/internet/plans");
-
- expect(response.status).toBe(200);
- const payload = internetCatalogApiResponseSchema.parse(response.body);
- expect(() => parseInternetCatalog(payload.data)).not.toThrow();
- });
-});
diff --git a/apps/bff/test/jest-e2e.json b/apps/bff/test/jest-e2e.json
deleted file mode 100644
index dda3baf1..00000000
--- a/apps/bff/test/jest-e2e.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "moduleFileExtensions": ["js", "json", "ts"],
- "rootDir": ".",
- "testEnvironment": "node",
- "testRegex": ".spec.ts$",
- "transform": {
- "^.+\\.(t|j)s$": [
- "ts-jest",
- {
- "tsconfig": {
- "types": ["jest", "node"]
- }
- }
- ]
- },
- "moduleNameMapper": {
- "^@bff/(.*)$": "/../src/$1"
- },
- "globals": {
- "ts-jest": {
- "tsconfig": {
- "types": ["jest", "node"]
- }
- }
- }
-}
-
diff --git a/apps/bff/test/tsconfig.json b/apps/bff/test/tsconfig.json
deleted file mode 100644
index 1f8f422f..00000000
--- a/apps/bff/test/tsconfig.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "extends": "../tsconfig.json",
- "compilerOptions": {
- "types": ["jest", "node", "supertest"]
- },
- "include": ["./**/*.ts", "../src/**/*"]
-}
-
diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs
index dd14c603..bb07c9b6 100644
--- a/apps/portal/next.config.mjs
+++ b/apps/portal/next.config.mjs
@@ -1,15 +1,10 @@
/* eslint-env node */
-import bundleAnalyzer from "@next/bundle-analyzer";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(__dirname, "..", "..");
-const withBundleAnalyzer = bundleAnalyzer({
- enabled: process.env.ANALYZE === "true",
-});
-
/** @type {import('next').NextConfig} */
const nextConfig = {
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
@@ -38,71 +33,6 @@ const nextConfig = {
remotePatterns: [{ protocol: "https", hostname: "**" }],
},
- async rewrites() {
- if (process.env.NODE_ENV !== "production") {
- return [{ source: "/api/:path*", destination: "http://localhost:4000/api/:path*" }];
- }
- return [];
- },
-
- async headers() {
- const isDev = process.env.NODE_ENV === "development";
- if (isDev) {
- const devConnectSources = ["'self'", "https:", "http://localhost:*", "ws://localhost:*"];
- const devScriptSrc = ["'self'", "'unsafe-inline'", "'unsafe-eval'", "blob:"].join(" ");
-
- return [
- {
- source: "/(.*)",
- headers: [
- { key: "X-Frame-Options", value: "DENY" },
- { key: "X-Content-Type-Options", value: "nosniff" },
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
- { key: "X-XSS-Protection", value: "1; mode=block" },
- {
- key: "Content-Security-Policy",
- value: [
- "default-src 'self'",
- `script-src ${devScriptSrc}`,
- "style-src 'self' 'unsafe-inline'",
- "img-src 'self' data: https:",
- "font-src 'self' data:",
- `connect-src ${devConnectSources.join(" ")}`,
- "frame-ancestors 'none'",
- ].join("; "),
- },
- ],
- },
- ];
- }
-
- const connectSources = ["'self'", "https:"];
-
- return [
- {
- source: "/(.*)",
- headers: [
- { key: "X-Frame-Options", value: "DENY" },
- { key: "X-Content-Type-Options", value: "nosniff" },
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
- { key: "X-XSS-Protection", value: "1; mode=block" },
- {
- key: "Content-Security-Policy",
- value: [
- "default-src 'self'",
- "script-src 'self'",
- "style-src 'self' 'unsafe-inline'",
- "img-src 'self' data: https:",
- "font-src 'self' data:",
- `connect-src ${connectSources.join(" ")}`,
- "frame-ancestors 'none'",
- ].join("; "),
- },
- ],
- },
- ];
- },
-
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
@@ -114,4 +44,12 @@ const nextConfig = {
typescript: { ignoreBuildErrors: false },
};
-export default withBundleAnalyzer(nextConfig);
+// Only load bundle analyzer when explicitly requested
+let exportedConfig = nextConfig;
+
+if (process.env.ANALYZE === "true") {
+ const bundleAnalyzer = (await import("@next/bundle-analyzer")).default;
+ exportedConfig = bundleAnalyzer({ enabled: true })(nextConfig);
+}
+
+export default exportedConfig;
diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx
index d3541111..50df9a42 100644
--- a/apps/portal/src/app/layout.tsx
+++ b/apps/portal/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
+import { headers } from "next/headers";
import "./globals.css";
import { QueryProvider } from "@/lib/providers";
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
@@ -23,15 +24,19 @@ export const metadata: Metadata = {
// This is the recommended approach for apps with heavy useSearchParams usage
export const dynamic = 'force-dynamic';
-export default function RootLayout({
+export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
+ // Get nonce from proxy.ts for CSP-compliant inline scripts
+ const headersList = await headers();
+ const nonce = headersList.get("x-nonce") || undefined;
+
return (
-
+
{children}
diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts
index dc41300f..aefda618 100644
--- a/apps/portal/src/lib/api/runtime/client.ts
+++ b/apps/portal/src/lib/api/runtime/client.ts
@@ -56,8 +56,9 @@ export interface ApiClient {
/**
* Resolve API base URL:
- * - Browser: Use same origin (Next.js rewrites in dev, nginx proxy in prod)
- * - SSR: Use NEXT_PUBLIC_API_BASE env var or fallback to localhost:4000
+ * - If NEXT_PUBLIC_API_BASE is set, use it (enables direct BFF calls in dev via CORS)
+ * - Browser fallback: Use same origin (nginx proxy in prod)
+ * - SSR fallback: Use localhost:4000
*/
export const resolveBaseUrl = (explicitBase?: string): string => {
// 1. Explicit base URL provided (for testing/overrides)
@@ -65,20 +66,20 @@ export const resolveBaseUrl = (explicitBase?: string): string => {
return explicitBase.replace(/\/+$/, "");
}
- // 2. Browser: use same origin
- // - Development: Next.js rewrites proxy /api/* to localhost:4000
- // - Production: nginx proxies /api/* to backend
- if (typeof window !== "undefined" && window.location?.origin) {
- return window.location.origin;
- }
-
- // 3. Server-side: check env var for SSR requests
+ // 2. Check NEXT_PUBLIC_API_BASE env var (works in both browser and SSR)
+ // In development: set to http://localhost:4000 for direct CORS calls
+ // In production: typically not set, falls through to same-origin
const envBase = process.env.NEXT_PUBLIC_API_BASE;
if (envBase?.trim() && envBase.startsWith("http")) {
return envBase.replace(/\/+$/, "");
}
- // 4. Fallback for server-side in development
+ // 3. Browser fallback: use same origin (production nginx proxy)
+ if (typeof window !== "undefined" && window.location?.origin) {
+ return window.location.origin;
+ }
+
+ // 4. SSR fallback for development
return "http://localhost:4000";
};
diff --git a/apps/portal/src/lib/providers.tsx b/apps/portal/src/lib/providers.tsx
index 40e32a2d..8cde2984 100644
--- a/apps/portal/src/lib/providers.tsx
+++ b/apps/portal/src/lib/providers.tsx
@@ -1,6 +1,6 @@
/**
* React Query Provider
- * Simple provider setup for TanStack Query
+ * Simple provider setup for TanStack Query with CSP nonce support
*/
"use client";
@@ -10,7 +10,12 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
import { isApiError } from "@/lib/api/runtime/client";
-export function QueryProvider({ children }: { children: React.ReactNode }) {
+interface QueryProviderProps {
+ children: React.ReactNode;
+ nonce?: string;
+}
+
+export function QueryProvider({ children, nonce }: QueryProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
@@ -46,7 +51,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
{children}
- {process.env.NODE_ENV === "development" && }
+ {process.env.NODE_ENV === "development" && }
);
}
diff --git a/apps/portal/src/proxy.ts b/apps/portal/src/proxy.ts
new file mode 100644
index 00000000..4b48a901
--- /dev/null
+++ b/apps/portal/src/proxy.ts
@@ -0,0 +1,98 @@
+import { NextRequest, NextResponse } from "next/server";
+
+/**
+ * Next.js 16 Proxy (formerly Middleware)
+ *
+ * Generates a cryptographic nonce for each request to allow inline scripts
+ * while maintaining Content Security Policy protection.
+ *
+ * @see https://nextjs.org/docs/app/guides/content-security-policy
+ * @see https://nextjs.org/docs/messages/middleware-to-proxy
+ */
+
+export function proxy(request: NextRequest) {
+ // Generate a random nonce for this request
+ const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
+
+ // Determine environment
+ const isDev = process.env.NODE_ENV === "development";
+
+ // Build CSP header value
+ const cspHeader = buildCSP(nonce, isDev);
+
+ // Clone the request headers
+ const requestHeaders = new Headers(request.headers);
+ requestHeaders.set("x-nonce", nonce);
+
+ // Create response with updated headers
+ const response = NextResponse.next({
+ request: {
+ headers: requestHeaders,
+ },
+ });
+
+ // Set CSP header on response
+ response.headers.set("Content-Security-Policy", cspHeader);
+
+ // Add additional security headers
+ response.headers.set("X-Frame-Options", "DENY");
+ response.headers.set("X-Content-Type-Options", "nosniff");
+ response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
+ response.headers.set("X-XSS-Protection", "1; mode=block");
+ response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
+
+ return response;
+}
+
+function buildCSP(nonce: string, isDev: boolean): string {
+ if (isDev) {
+ // Development: More permissive for HMR and dev tools
+ return [
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-eval' 'unsafe-inline'", // HMR needs eval
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: https:",
+ "font-src 'self' data:",
+ "connect-src 'self' https: http://localhost:* ws://localhost:*",
+ "frame-ancestors 'none'",
+ ].join("; ");
+ }
+
+ // Production: Strict CSP with nonce
+ // 'strict-dynamic' allows scripts loaded by nonced scripts to execute.
+ // Next 16 applies the nonce to its own inline scripts, so 'unsafe-inline'
+ // is not required in script-src when the nonce is present.
+ return [
+ "default-src 'self'",
+ `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
+ "style-src 'self' 'unsafe-inline'", // Next.js requires this for styled-jsx
+ "img-src 'self' data: https:",
+ "font-src 'self' data:",
+ "connect-src 'self' https:",
+ "frame-ancestors 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "object-src 'none'",
+ "upgrade-insecure-requests",
+ ].join("; ");
+}
+
+// Apply proxy to all routes except static assets
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico, sitemap.xml, robots.txt (metadata files)
+ * - api/health (health check endpoint)
+ */
+ {
+ source: "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|api/health).*)",
+ missing: [
+ { type: "header", key: "next-router-prefetch" },
+ { type: "header", key: "purpose", value: "prefetch" },
+ ],
+ },
+ ],
+};
diff --git a/docker/portainer/PORTAINER-GUIDE.md b/docker/Prod - Portainer/PORTAINER-GUIDE.md
similarity index 100%
rename from docker/portainer/PORTAINER-GUIDE.md
rename to docker/Prod - Portainer/PORTAINER-GUIDE.md
diff --git a/docker/portainer/README.md b/docker/Prod - Portainer/README.md
similarity index 100%
rename from docker/portainer/README.md
rename to docker/Prod - Portainer/README.md
diff --git a/docker/portainer/docker-compose.yml b/docker/Prod - Portainer/docker-compose.yml
similarity index 98%
rename from docker/portainer/docker-compose.yml
rename to docker/Prod - Portainer/docker-compose.yml
index ca5439f3..a1e1016f 100644
--- a/docker/portainer/docker-compose.yml
+++ b/docker/Prod - Portainer/docker-compose.yml
@@ -151,7 +151,7 @@ services:
cache:
image: redis:7-alpine
container_name: portal-cache
- command: ["redis-server", "--save", "60", "1", "--loglevel", "warning", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
+ command: ["redis-server", "--save", "60", "1", "--loglevel", "warning", "--maxmemory", "256mb", "--maxmemory-policy", "noeviction"]
volumes:
- redis_data:/data
restart: unless-stopped
diff --git a/docker/Prod - Portainer/stack.env b/docker/Prod - Portainer/stack.env
new file mode 100644
index 00000000..e3634cfc
--- /dev/null
+++ b/docker/Prod - Portainer/stack.env
@@ -0,0 +1,112 @@
+# =============================================================================
+# Customer Portal - Portainer Environment Variables
+# =============================================================================
+# Copy these into Portainer UI when creating/updating the stack
+# Replace all placeholder values with your actual secrets
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# Images & Ports
+# -----------------------------------------------------------------------------
+FRONTEND_IMAGE=portal-frontend
+BACKEND_IMAGE=portal-backend
+IMAGE_TAG=latest
+FRONTEND_PORT=3000
+BACKEND_PORT=4000
+
+# -----------------------------------------------------------------------------
+# Application
+# -----------------------------------------------------------------------------
+APP_NAME=customer-portal-bff
+APP_BASE_URL=https://asolutions.jp
+CORS_ORIGIN=https://asolutions.jp
+
+# -----------------------------------------------------------------------------
+# Database (PostgreSQL)
+# -----------------------------------------------------------------------------
+POSTGRES_DB=portal_prod
+POSTGRES_USER=portal
+POSTGRES_PASSWORD=wf8vVNxaGXqJbE4AMwBg8olJtUptLNcH
+
+# -----------------------------------------------------------------------------
+# Security & Auth
+# -----------------------------------------------------------------------------
+# Generate with: openssl rand -base64 32
+JWT_SECRET=N+HXXwJBM93omVC8mCbrrWKNR/deCmSe5q4TTwMFur8=
+JWT_EXPIRES_IN=7d
+BCRYPT_ROUNDS=12
+CSRF_SECRET_KEY=/W6PPJ0DeduasE4GqeLIxfdSNg9TDNwyuVNz0IWz0Bs=
+
+# Auth Settings
+AUTH_ALLOW_REDIS_TOKEN_FAILOPEN=false
+AUTH_REQUIRE_REDIS_FOR_TOKENS=false
+AUTH_MAINTENANCE_MODE=false
+
+# Rate Limiting
+RATE_LIMIT_TTL=60
+RATE_LIMIT_LIMIT=100
+
+# -----------------------------------------------------------------------------
+# WHMCS Integration
+# -----------------------------------------------------------------------------
+WHMCS_BASE_URL=https://dev-wh.asolutions.co.jp
+WHMCS_API_IDENTIFIER=WZckHGfzAQEum3v5SAcSfzgvVkPJEF2M
+WHMCS_API_SECRET=YlqKyynJ6I1088DV6jufFj6cJiW0N0y4
+# -----------------------------------------------------------------------------
+# Salesforce Integration
+# -----------------------------------------------------------------------------
+SF_LOGIN_URL=https://asolutions.my.salesforce.com
+SF_CLIENT_ID=3MVG9n_HvETGhr3Af33utEHAR_KbKEQh_.KRzVBBA6u3tSIMraIlY9pqNqKJgUILstAPS4JASzExj3OpCRbLz
+SF_USERNAME=portal.integration@asolutions.co.jp
+SF_EVENTS_ENABLED=true
+
+# Salesforce Private Key (Base64 encoded)
+# To encode: base64 -w 0 < sf-private.key
+SF_PRIVATE_KEY_BASE64=MIIDvzCCAqegAwIBAgIUWD/Nx/Tem+FbPzsowuIYP6eioWwwDQYJKoZIhvcNAQEL
+BQAwbzELMAkGA1UEBhMCSlAxDjAMBgNVBAgMBVRva3lvMQ4wDAYDVQQHDAVUb2t5
+bzEZMBcGA1UECgwQQXNzaXN0IFNvbHV0aW9uczELMAkGA1UECwwCSVQxGDAWBgNV
+BAMMD0N1c3RvbWVyIFBvcnRhbDAeFw0yNTA4MTUwNTAxMDNaFw0yNjA4MTUwNTAx
+MDNaMG8xCzAJBgNVBAYTAkpQMQ4wDAYDVQQIDAVUb2t5bzEOMAwGA1UEBwwFVG9r
+eW8xGTAXBgNVBAoMEEFzc2lzdCBTb2x1dGlvbnMxCzAJBgNVBAsMAklUMRgwFgYD
+VQQDDA9DdXN0b21lciBQb3J0YWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
+AoIBAQCtBK38ZXzz3LA8dKqHbwz+ucWLPqz8sjoXY4V0W+fMhifn5Oi4u3k2mlqg
+J2bGFPn8DH5cRafM0+CCwO9TV6PbYxolsO7NKjFxSERJPqj5tZ0bpZljul4J0wiJ
+ZyT8NWK+WV9aga+zrHOThgvUSPJAb3I1FbRSSha9k2UsaZ5Ubo43EFMRAoAU1DqV
+tRvG9UW+Ditrlr/8hhDT8WREwzwdGc4GVtM2AsiNNbKM5kcjhu8sgKZ2j+ZCM+0l
+yk0JUcciSYUWgY79XEvCVAAiUGiL3qtxurEe02f9/ISWawbJne1SQIhaXZycsehm
+VHN4ySW5uj2waOu4IzDXOqW75e+1AgMBAAGjUzBRMB0GA1UdDgQWBBTOBfxQ/VS+
+MtjqnY2ielB6n4qHVDAfBgNVHSMEGDAWgBTOBfxQ/VS+MtjqnY2ielB6n4qHVDAP
+BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAcpPfUF4kPuiB91Igq
+gwpRZmUJjq0+fIv2Llyn3Q+srk/IOcnkjJgaksGTXPwsMErtcD60z1tIzp39aDl2
+wUmTxssXg41X7jBpy9Q7wXduvZwpSHbwrz8GShGKnwBCITTVHg0PRCIMn9DvJg3H
+So/A2TQyazhWSh1yz4P6hAn7UKAG4rzMPjyzq+RYFOSKKCtdJ5ImqIkrNnHhQj/4
+py/E6K6/ZkroKWr6z1gFU2E8xQQ+u1YNAEjL8U+vd0ftLmYTHCciaZdy4emo5BRg
+V8oZp81Kw1Da+nVuBCMtZ4ICYLBI8LVtfkzdFDr3MShRWcPe+k6/lbDfT98qy01O
+26sJ
+
+
+# -----------------------------------------------------------------------------
+# Freebit SIM API
+# -----------------------------------------------------------------------------
+FREEBIT_BASE_URL=https://i1-q.mvno.net/emptool/api
+FREEBIT_OEM_ID=PASI
+FREEBIT_OEM_KEY=6Au3o7wrQNR07JxFHPmf0YfFqN9a31t5
+
+# -----------------------------------------------------------------------------
+# Email (SendGrid)
+# -----------------------------------------------------------------------------
+EMAIL_ENABLED=true
+EMAIL_FROM=no-reply@asolutions.jp
+EMAIL_FROM_NAME=Assist Solutions
+SENDGRID_API_KEY=
+
+# -----------------------------------------------------------------------------
+# Salesforce Portal Config
+# -----------------------------------------------------------------------------
+PORTAL_PRICEBOOK_ID=01sTL000008eLVlYAM
+PORTAL_PRICEBOOK_NAME=Portal
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+LOG_LEVEL=info
diff --git a/docker/portainer/stack.env.example b/docker/Prod - Portainer/stack.env.example
similarity index 100%
rename from docker/portainer/stack.env.example
rename to docker/Prod - Portainer/stack.env.example
diff --git a/docker/portainer/update-stack.sh b/docker/Prod - Portainer/update-stack.sh
similarity index 100%
rename from docker/portainer/update-stack.sh
rename to docker/Prod - Portainer/update-stack.sh
diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml
deleted file mode 100644
index fc51f65d..00000000
--- a/docker/prod/docker-compose.yml
+++ /dev/null
@@ -1,188 +0,0 @@
-# =============================================================================
-# Customer Portal - Production Docker Compose
-# =============================================================================
-# Full stack for standalone production deployments (non-Portainer)
-# For Portainer/Plesk, use docker/portainer/docker-compose.yml instead
-# =============================================================================
-
-services:
- # ---------------------------------------------------------------------------
- # Frontend (Next.js)
- # ---------------------------------------------------------------------------
- frontend:
- build:
- context: ../..
- dockerfile: apps/portal/Dockerfile
- args:
- - NODE_VERSION=22
- - PNPM_VERSION=${PNPM_VERSION:-10.25.0}
- - NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE:-/api}
- - NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-Customer Portal}
- - NEXT_PUBLIC_APP_VERSION=${NEXT_PUBLIC_APP_VERSION:-1.0.0}
- image: portal-frontend:${IMAGE_TAG:-latest}
- container_name: portal-frontend
- ports:
- - "${FRONTEND_PORT:-3000}:3000"
- environment:
- - NODE_ENV=production
- - PORT=3000
- - HOSTNAME=0.0.0.0
- restart: unless-stopped
- depends_on:
- backend:
- condition: service_healthy
- networks:
- - portal-network
- healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>r.ok||process.exit(1)).catch(()=>process.exit(1))"]
- interval: 30s
- timeout: 10s
- start_period: 40s
- retries: 3
-
- # ---------------------------------------------------------------------------
- # Backend (NestJS BFF)
- # ---------------------------------------------------------------------------
- backend:
- build:
- context: ../..
- dockerfile: apps/bff/Dockerfile
- args:
- - NODE_VERSION=22
- - PNPM_VERSION=${PNPM_VERSION:-10.25.0}
- - PRISMA_VERSION=7.1.0
- image: portal-backend:${IMAGE_TAG:-latest}
- container_name: portal-backend
- ports:
- - "${BACKEND_PORT:-4000}:4000"
- environment:
- # Core
- - NODE_ENV=production
- - APP_NAME=${APP_NAME:-customer-portal-bff}
- - APP_BASE_URL=${APP_BASE_URL}
- - BFF_PORT=4000
- - PORT=4000
-
- # Database
- - DATABASE_URL=postgresql://${POSTGRES_USER:-portal}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB:-portal_prod}?schema=public
-
- # Redis
- - REDIS_URL=redis://cache:6379/0
-
- # Security
- - JWT_SECRET=${JWT_SECRET}
- - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
- - BCRYPT_ROUNDS=${BCRYPT_ROUNDS:-12}
- - CORS_ORIGIN=${CORS_ORIGIN}
- - TRUST_PROXY=true
- - CSRF_SECRET_KEY=${CSRF_SECRET_KEY}
-
- # Auth
- - AUTH_ALLOW_REDIS_TOKEN_FAILOPEN=${AUTH_ALLOW_REDIS_TOKEN_FAILOPEN:-false}
- - AUTH_REQUIRE_REDIS_FOR_TOKENS=${AUTH_REQUIRE_REDIS_FOR_TOKENS:-false}
- - AUTH_MAINTENANCE_MODE=${AUTH_MAINTENANCE_MODE:-false}
-
- # Rate Limiting
- - RATE_LIMIT_TTL=${RATE_LIMIT_TTL:-60}
- - RATE_LIMIT_LIMIT=${RATE_LIMIT_LIMIT:-100}
- - EXPOSE_VALIDATION_ERRORS=false
-
- # WHMCS
- - WHMCS_BASE_URL=${WHMCS_BASE_URL}
- - WHMCS_API_IDENTIFIER=${WHMCS_API_IDENTIFIER}
- - WHMCS_API_SECRET=${WHMCS_API_SECRET}
-
- # Salesforce
- - SF_LOGIN_URL=${SF_LOGIN_URL}
- - SF_CLIENT_ID=${SF_CLIENT_ID}
- - SF_USERNAME=${SF_USERNAME}
- - SF_EVENTS_ENABLED=${SF_EVENTS_ENABLED:-true}
- - SF_PRIVATE_KEY_BASE64=${SF_PRIVATE_KEY_BASE64}
- - SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key
-
- # Freebit
- - FREEBIT_BASE_URL=${FREEBIT_BASE_URL:-https://i1.mvno.net/emptool/api}
- - FREEBIT_OEM_ID=${FREEBIT_OEM_ID:-PASI}
- - FREEBIT_OEM_KEY=${FREEBIT_OEM_KEY}
-
- # Email
- - EMAIL_ENABLED=${EMAIL_ENABLED:-true}
- - EMAIL_FROM=${EMAIL_FROM:-no-reply@asolutions.jp}
- - EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-Assist Solutions}
- - SENDGRID_API_KEY=${SENDGRID_API_KEY}
-
- # Portal
- - PORTAL_PRICEBOOK_ID=${PORTAL_PRICEBOOK_ID}
- - PORTAL_PRICEBOOK_NAME=${PORTAL_PRICEBOOK_NAME:-Portal}
-
- # Logging
- - LOG_LEVEL=${LOG_LEVEL:-info}
-
- # Migrations
- - RUN_MIGRATIONS=${RUN_MIGRATIONS:-true}
- restart: unless-stopped
- depends_on:
- database:
- condition: service_healthy
- cache:
- condition: service_healthy
- networks:
- - portal-network
- healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r=>r.ok||process.exit(1)).catch(()=>process.exit(1))"]
- interval: 30s
- timeout: 10s
- start_period: 60s
- retries: 3
-
- # ---------------------------------------------------------------------------
- # PostgreSQL Database
- # ---------------------------------------------------------------------------
- database:
- image: postgres:17-alpine
- container_name: portal-database
- environment:
- - POSTGRES_DB=${POSTGRES_DB:-portal_prod}
- - POSTGRES_USER=${POSTGRES_USER:-portal}
- - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
- volumes:
- - postgres_data:/var/lib/postgresql/data
- restart: unless-stopped
- networks:
- - portal-network
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-portal} -d ${POSTGRES_DB:-portal_prod}"]
- interval: 10s
- timeout: 5s
- start_period: 30s
- retries: 5
-
- # ---------------------------------------------------------------------------
- # Redis Cache
- # ---------------------------------------------------------------------------
- cache:
- image: redis:7-alpine
- container_name: portal-cache
- command: ["redis-server", "--save", "60", "1", "--loglevel", "warning", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
- volumes:
- - redis_data:/data
- restart: unless-stopped
- networks:
- - portal-network
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 10s
- timeout: 5s
- retries: 5
-
-volumes:
- postgres_data:
- driver: local
- redis_data:
- driver: local
-
-networks:
- portal-network:
- driver: bridge
-
diff --git a/env/dev.env.sample b/env/dev.env.sample
index 4d830232..6f42f45d 100644
--- a/env/dev.env.sample
+++ b/env/dev.env.sample
@@ -1,21 +1,21 @@
# =============================================================================
# Customer Portal - Development Environment
# =============================================================================
-# Copy to .env in project root for local development
-# This file configures both frontend and backend for dev mode
+# Copy to .env in project root OR to apps/portal/.env.development and
+# apps/bff/.env.development for per-app configuration.
+#
+# Most settings have sensible defaults - only required values and
+# dev-specific overrides are listed here.
# =============================================================================
# -----------------------------------------------------------------------------
-# Core
+# REQUIRED - No defaults, must be set
# -----------------------------------------------------------------------------
-NODE_ENV=development
-APP_NAME=customer-portal-bff
-APP_BASE_URL=http://localhost:3000
+DATABASE_URL=postgresql://dev:dev@localhost:5432/portal_dev?schema=public
-# Ports
-BFF_PORT=4000
-NEXT_PORT=3000
+# Generate with: openssl rand -base64 32
+JWT_SECRET=HjHsUyTE3WhPn5N07iSvurdV4hk2VEkIuN+lIflHhVQ=
# -----------------------------------------------------------------------------
# Frontend (Next.js) - Browser-exposed variables
@@ -27,112 +27,82 @@ NEXT_PUBLIC_API_BASE=http://localhost:4000
NEXT_PUBLIC_ENABLE_DEVTOOLS=true
# -----------------------------------------------------------------------------
-# Database & Cache
+# CORS - Required for direct browser calls to BFF
# -----------------------------------------------------------------------------
-DATABASE_URL=postgresql://dev:dev@localhost:5432/portal_dev?schema=public
-REDIS_URL=redis://localhost:6379
-
-# -----------------------------------------------------------------------------
-# Security
-# -----------------------------------------------------------------------------
-
-# Generate with: openssl rand -base64 32
-JWT_SECRET=HjHsUyTE3WhPn5N07iSvurdV4hk2VEkIuN+lIflHhVQ=
-JWT_EXPIRES_IN=7d
-BCRYPT_ROUNDS=12
-
CORS_ORIGIN=http://localhost:3000
-TRUST_PROXY=false
-
-# Redis token handling (relaxed for dev)
-AUTH_ALLOW_REDIS_TOKEN_FAILOPEN=false
-AUTH_REQUIRE_REDIS_FOR_TOKENS=false
-AUTH_MAINTENANCE_MODE=false
# -----------------------------------------------------------------------------
-# Rate Limiting (relaxed for dev)
-# -----------------------------------------------------------------------------
-
-RATE_LIMIT_TTL=60
-RATE_LIMIT_LIMIT=1000
-AUTH_RATE_LIMIT_TTL=900
-AUTH_RATE_LIMIT_LIMIT=10
-
-# Show detailed validation errors in dev
-EXPOSE_VALIDATION_ERRORS=true
-
-# -----------------------------------------------------------------------------
-# WHMCS Integration (dev credentials)
-# -----------------------------------------------------------------------------
-
-WHMCS_DEV_BASE_URL=
-WHMCS_DEV_API_IDENTIFIER=
-WHMCS_DEV_API_SECRET=
-WHMCS_QUEUE_CONCURRENCY=15
-WHMCS_QUEUE_TIMEOUT_MS=30000
-
-# -----------------------------------------------------------------------------
-# Salesforce Integration
-# -----------------------------------------------------------------------------
-
-SF_LOGIN_URL=
-SF_CLIENT_ID=
-SF_PRIVATE_KEY_PATH=./secrets/sf-private.key
-SF_USERNAME=
-
-# Timeouts
-SF_AUTH_TIMEOUT_MS=30000
-SF_TOKEN_TTL_MS=720000
-SF_TOKEN_REFRESH_BUFFER_MS=60000
-
-# Queue throttling
-SF_QUEUE_CONCURRENCY=15
-SF_QUEUE_TIMEOUT_MS=30000
-SF_QUEUE_LONG_RUNNING_TIMEOUT_MS=600000
-
-# Platform Events
-SF_EVENTS_ENABLED=true
-SF_EVENTS_REPLAY=LATEST
-SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443
-
-# -----------------------------------------------------------------------------
-# Freebit SIM Management
-# -----------------------------------------------------------------------------
-
-FREEBIT_BASE_URL=
-FREEBIT_OEM_ID=
-FREEBIT_OEM_KEY=
-FREEBIT_TIMEOUT=30000
-
-# -----------------------------------------------------------------------------
-# Email
-# -----------------------------------------------------------------------------
-
-EMAIL_ENABLED=true
-EMAIL_USE_QUEUE=true
-EMAIL_FROM=no-reply@asolutions.co.jp
-EMAIL_FROM_NAME=Assist Solutions
-
-# -----------------------------------------------------------------------------
-# Portal Configuration
-# -----------------------------------------------------------------------------
-
-PORTAL_PRICEBOOK_ID=01sTL000008eLVlYAM
-PORTAL_PRICEBOOK_NAME=Portal
-
-# -----------------------------------------------------------------------------
-# Logging
+# Dev Overrides (relaxed limits, verbose logging)
# -----------------------------------------------------------------------------
LOG_LEVEL=debug
-DISABLE_HTTP_LOGGING=false
+RATE_LIMIT_LIMIT=1000
+AUTH_RATE_LIMIT_LIMIT=10
# -----------------------------------------------------------------------------
-# Local Dev Bypasses (NEVER enable in production!)
+# External Services - Fill in when testing integrations
# -----------------------------------------------------------------------------
-DISABLE_CSRF=false
-DISABLE_RATE_LIMIT=false
-DISABLE_ACCOUNT_LOCKING=false
+# WHMCS (Development/Sandbox)
+WHMCS_DEV_BASE_URL=
+WHMCS_DEV_API_IDENTIFIER=
+WHMCS_DEV_API_SECRET=
+# Salesforce
+SF_LOGIN_URL=
+SF_CLIENT_ID=
+SF_USERNAME=
+SF_PRIVATE_KEY_PATH=./secrets/sf-private.key
+
+# Freebit SIM Management
+FREEBIT_BASE_URL=
+FREEBIT_OEM_KEY=
+
+# SendGrid Email
+SENDGRID_API_KEY=
+
+# -----------------------------------------------------------------------------
+# DEFAULTS - Uncomment only if you need to override
+# -----------------------------------------------------------------------------
+
+# Core (defaults shown)
+# NODE_ENV=development
+# APP_NAME=customer-portal-bff
+# APP_BASE_URL=http://localhost:3000
+# BFF_PORT=4000
+# REDIS_URL=redis://localhost:6379
+
+# Security (defaults shown)
+# JWT_EXPIRES_IN=7d
+# BCRYPT_ROUNDS=14
+# TRUST_PROXY=false
+
+# Rate Limiting (production defaults - dev overrides above)
+# RATE_LIMIT_TTL=60
+# AUTH_RATE_LIMIT_TTL=900
+
+# Freebit (defaults shown)
+# FREEBIT_OEM_ID=PASI
+# FREEBIT_TIMEOUT=30000
+# FREEBIT_RETRY_ATTEMPTS=3
+
+# Email (defaults shown)
+# EMAIL_ENABLED=true
+# EMAIL_USE_QUEUE=true
+# EMAIL_FROM=no-reply@example.com
+# EMAIL_FROM_NAME=Assist Solutions
+
+# Portal (defaults shown)
+# PORTAL_PRICEBOOK_ID=01sTL000008eLVlYAM
+# PORTAL_PRICEBOOK_NAME=Portal
+
+# Salesforce Events (defaults shown)
+# SF_EVENTS_ENABLED=false
+# SF_EVENTS_REPLAY=LATEST
+# SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443
+
+# Dev Bypasses - NEVER enable in production!
+# DISABLE_CSRF=false
+# DISABLE_RATE_LIMIT=false
+# DISABLE_ACCOUNT_LOCKING=false
diff --git a/env/portal-backend.env.sample b/env/portal-backend.env.sample
index 24f6f9b9..71af8521 100644
--- a/env/portal-backend.env.sample
+++ b/env/portal-backend.env.sample
@@ -6,7 +6,7 @@
# =============================================================================
# -----------------------------------------------------------------------------
-# REQUIRED - Must be configured
+# REQUIRED - Must be configured (no defaults)
# -----------------------------------------------------------------------------
NODE_ENV=production
@@ -17,117 +17,118 @@ JWT_SECRET=CHANGE_ME_GENERATE_WITH_openssl_rand_base64_32
# Core Application
# -----------------------------------------------------------------------------
-APP_NAME=customer-portal-bff
+# APP_NAME=customer-portal-bff # default
APP_BASE_URL=https://your-domain.com
-BFF_PORT=4000
+# BFF_PORT=4000 # default
+
+# -----------------------------------------------------------------------------
+# Cache & Session
+# -----------------------------------------------------------------------------
+
+REDIS_URL=redis://cache:6379/0
# -----------------------------------------------------------------------------
# Security
# -----------------------------------------------------------------------------
-# Redis cache (required for production token management)
-REDIS_URL=redis://cache:6379/0
-
-# JWT configuration
-JWT_EXPIRES_IN=7d
-BCRYPT_ROUNDS=12
-
-# CORS - set to your frontend domain
CORS_ORIGIN=https://your-domain.com
TRUST_PROXY=true
-# CSRF Protection (generate secret: openssl rand -base64 32)
+# CSRF Protection (generate: openssl rand -base64 32)
CSRF_SECRET_KEY=CHANGE_ME_GENERATE_WITH_openssl_rand_base64_32
+# JWT (defaults shown)
+# JWT_EXPIRES_IN=7d
+# BCRYPT_ROUNDS=14
+
# Redis token handling
-AUTH_ALLOW_REDIS_TOKEN_FAILOPEN=false
-AUTH_REQUIRE_REDIS_FOR_TOKENS=false
-
-# Maintenance mode (enable during deployments)
-AUTH_MAINTENANCE_MODE=false
+# AUTH_ALLOW_REDIS_TOKEN_FAILOPEN=false # default
+# AUTH_REQUIRE_REDIS_FOR_TOKENS=false # default
+# AUTH_MAINTENANCE_MODE=false # default
# -----------------------------------------------------------------------------
-# Rate Limiting
+# Rate Limiting (defaults shown - uncomment to override)
# -----------------------------------------------------------------------------
-RATE_LIMIT_TTL=60
-RATE_LIMIT_LIMIT=100
-AUTH_RATE_LIMIT_TTL=900
-AUTH_RATE_LIMIT_LIMIT=3
-LOGIN_RATE_LIMIT_TTL=900
-LOGIN_RATE_LIMIT_LIMIT=5
+# RATE_LIMIT_TTL=60
+# RATE_LIMIT_LIMIT=100
+# AUTH_RATE_LIMIT_TTL=900
+# AUTH_RATE_LIMIT_LIMIT=3
+# LOGIN_RATE_LIMIT_TTL=900
+# LOGIN_RATE_LIMIT_LIMIT=5
-# CAPTCHA (optional - set provider to 'turnstile' or 'hcaptcha' to enable)
-AUTH_CAPTCHA_PROVIDER=none
-AUTH_CAPTCHA_SECRET=
-
-# Hide validation errors from clients in production
-EXPOSE_VALIDATION_ERRORS=false
+# CAPTCHA (optional)
+# AUTH_CAPTCHA_PROVIDER=none # 'none', 'turnstile', or 'hcaptcha'
+# AUTH_CAPTCHA_SECRET=
# -----------------------------------------------------------------------------
-# WHMCS Integration
+# EXTERNAL SERVICES - Configure all that apply
# -----------------------------------------------------------------------------
+# --- WHMCS (Billing) ---
WHMCS_BASE_URL=https://accounts.asolutions.co.jp
-WHMCS_API_IDENTIFIER=
-WHMCS_API_SECRET=
-WHMCS_WEBHOOK_SECRET=
+WHMCS_API_IDENTIFIER=CHANGE_ME
+WHMCS_API_SECRET=CHANGE_ME
-# Queue throttling
-WHMCS_QUEUE_CONCURRENCY=15
-WHMCS_QUEUE_TIMEOUT_MS=30000
-
-# -----------------------------------------------------------------------------
-# Salesforce Integration
-# -----------------------------------------------------------------------------
+# Queue settings (defaults shown)
+# WHMCS_QUEUE_CONCURRENCY=15
+# WHMCS_QUEUE_INTERVAL_CAP=300
+# WHMCS_QUEUE_TIMEOUT_MS=30000
+# --- Salesforce (CRM) ---
SF_LOGIN_URL=https://asolutions.my.salesforce.com
-SF_CLIENT_ID=
-SF_USERNAME=
+SF_CLIENT_ID=CHANGE_ME
+SF_USERNAME=CHANGE_ME
SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key
-SF_WEBHOOK_SECRET=
-# Queue throttling
-SF_QUEUE_CONCURRENCY=15
-SF_QUEUE_TIMEOUT_MS=30000
-SF_QUEUE_LONG_RUNNING_TIMEOUT_MS=600000
+# Queue settings (defaults shown)
+# SF_QUEUE_CONCURRENCY=15
+# SF_QUEUE_LONG_RUNNING_CONCURRENCY=22
+# SF_QUEUE_INTERVAL_CAP=600
+# SF_QUEUE_TIMEOUT_MS=30000
+# SF_QUEUE_LONG_RUNNING_TIMEOUT_MS=600000
+# SF_DAILY_API_LIMIT=100000
+
+# Token management (defaults shown)
+# SF_AUTH_TIMEOUT_MS=30000
+# SF_TOKEN_TTL_MS=720000
+# SF_TOKEN_REFRESH_BUFFER_MS=60000
# Platform Events
SF_EVENTS_ENABLED=true
-SF_EVENTS_REPLAY=LATEST
-SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443
-
-# -----------------------------------------------------------------------------
-# Freebit SIM Management
-# -----------------------------------------------------------------------------
+# SF_EVENTS_REPLAY=LATEST # default
+# SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443 # default
+# SF_PUBSUB_NUM_REQUESTED=25 # default
+# SF_PUBSUB_QUEUE_MAX=100 # default
+# --- Freebit (SIM Management) ---
FREEBIT_BASE_URL=https://i1.mvno.net/emptool/api
-FREEBIT_OEM_ID=PASI
-FREEBIT_OEM_KEY=
-FREEBIT_TIMEOUT=30000
+FREEBIT_OEM_KEY=CHANGE_ME
+# FREEBIT_OEM_ID=PASI # default
+# FREEBIT_TIMEOUT=30000 # default
+# FREEBIT_RETRY_ATTEMPTS=3 # default
-# -----------------------------------------------------------------------------
-# Email (SendGrid)
-# -----------------------------------------------------------------------------
-
-EMAIL_ENABLED=true
+# --- SendGrid (Email) ---
+SENDGRID_API_KEY=CHANGE_ME
+# EMAIL_ENABLED=true # default
+# EMAIL_USE_QUEUE=true # default
EMAIL_FROM=no-reply@asolutions.jp
-EMAIL_FROM_NAME=Assist Solutions
-SENDGRID_API_KEY=
-SENDGRID_SANDBOX=false
+# EMAIL_FROM_NAME=Assist Solutions # default
+# SENDGRID_SANDBOX=false # default
# -----------------------------------------------------------------------------
# Portal Configuration
# -----------------------------------------------------------------------------
-PORTAL_PRICEBOOK_ID=
-PORTAL_PRICEBOOK_NAME=Portal
+PORTAL_PRICEBOOK_ID=CHANGE_ME
+# PORTAL_PRICEBOOK_NAME=Portal # default
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
LOG_LEVEL=info
+# PRETTY_LOGS=false # auto-disabled in production
# -----------------------------------------------------------------------------
# Node Runtime
@@ -137,21 +138,20 @@ NODE_OPTIONS=--max-old-space-size=512
# =============================================================================
-# ADVANCED CONFIGURATION (rarely need to change)
+# SALESFORCE FIELD MAPPINGS (Advanced)
# =============================================================================
-# The following variables have sensible defaults and only need to be set
-# if your Salesforce org uses non-standard field API names.
-# Uncomment and modify only if needed.
+# All fields have sensible defaults matching standard SF field API names.
+# Only uncomment if your Salesforce org uses non-standard field names.
# =============================================================================
-# --- Salesforce Field Mappings - Account ---
+# --- Account Fields ---
# ACCOUNT_INTERNET_ELIGIBILITY_FIELD=Internet_Eligibility__c
# ACCOUNT_CUSTOMER_NUMBER_FIELD=SF_Account_No__c
# ACCOUNT_PORTAL_STATUS_FIELD=Portal_Status__c
# ACCOUNT_PORTAL_STATUS_SOURCE_FIELD=Portal_Registration_Source__c
# ACCOUNT_PORTAL_LAST_SIGNED_IN_FIELD=Portal_Last_SignIn__c
-# --- Salesforce Field Mappings - Product ---
+# --- Product Fields ---
# PRODUCT_SKU_FIELD=StockKeepingUnit
# PRODUCT_PORTAL_CATEGORY_FIELD=Product2Categories1__c
# PRODUCT_PORTAL_CATALOG_FIELD=Portal_Catalog__c
@@ -163,14 +163,12 @@ NODE_OPTIONS=--max-old-space-size=512
# PRODUCT_INTERNET_PLAN_TIER_FIELD=Internet_Plan_Tier__c
# PRODUCT_INTERNET_OFFERING_TYPE_FIELD=Internet_Offering_Type__c
# PRODUCT_DISPLAY_ORDER_FIELD=Catalog_Order__c
-# PRODUCT_BUNDLED_ADDON_FIELD=Bundled_Addon__c
-# PRODUCT_IS_BUNDLED_ADDON_FIELD=Is_Bundled_Addon__c
# PRODUCT_SIM_DATA_SIZE_FIELD=SIM_Data_Size__c
# PRODUCT_SIM_PLAN_TYPE_FIELD=SIM_Plan_Type__c
# PRODUCT_SIM_HAS_FAMILY_DISCOUNT_FIELD=SIM_Has_Family_Discount__c
# PRODUCT_VPN_REGION_FIELD=VPN_Region__c
-# --- Salesforce Field Mappings - Order ---
+# --- Order Fields ---
# ORDER_TYPE_FIELD=Type
# ORDER_ACTIVATION_TYPE_FIELD=Activation_Type__c
# ORDER_ACTIVATION_SCHEDULED_AT_FIELD=Activation_Scheduled_At__c
@@ -183,7 +181,7 @@ NODE_OPTIONS=--max-old-space-size=512
# ORDER_MNP_PHONE_FIELD=MNP_Phone_Number__c
# ORDER_WHMCS_ORDER_ID_FIELD=WHMCS_Order_ID__c
-# --- Salesforce CDC Channels (Change Data Capture) ---
+# --- CDC Channels ---
# SF_CATALOG_PRODUCT_CDC_CHANNEL=/data/Product2ChangeEvent
# SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL=/data/PricebookEntryChangeEvent
# SF_ORDER_CDC_CHANNEL=/data/OrderChangeEvent
diff --git a/env/portal-frontend.env.sample b/env/portal-frontend.env.sample
index 8cbe014c..6bf42691 100644
--- a/env/portal-frontend.env.sample
+++ b/env/portal-frontend.env.sample
@@ -2,16 +2,17 @@
# Customer Portal Frontend (Next.js) - Production Environment
# =============================================================================
# Copy to portal-frontend.env
-# Note: NEXT_PUBLIC_* variables are embedded at build time
+# Note: NEXT_PUBLIC_* variables are embedded at BUILD time
# =============================================================================
NODE_ENV=production
-# Application name shown in UI
+# Application identity shown in UI
NEXT_PUBLIC_APP_NAME=Assist Solutions Portal
NEXT_PUBLIC_APP_VERSION=1.0.0
-# API endpoint - use /api for same-origin requests behind reverse proxy
+# API endpoint - use /api for same-origin requests behind nginx proxy
+# In production, nginx proxies /api/* to the BFF container
NEXT_PUBLIC_API_BASE=/api
# Disable React Query devtools in production
diff --git a/package.json b/package.json
index 0a7763b1..8fd983f4 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"scripts": {
"dev": "./scripts/dev/manage.sh apps",
"dev:all": "pnpm --filter @customer-portal/domain build && pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev",
+ "dev:apps": "pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev",
"build": "pnpm --filter @customer-portal/domain build && pnpm --recursive --filter=!@customer-portal/domain run build",
"start": "pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run start",
"test": "pnpm --recursive run test",
diff --git a/portal-backend.latest.tar.gz.sha256 b/portal-backend.latest.tar.gz.sha256
index 49e2b1b3..1a4fba12 100644
--- a/portal-backend.latest.tar.gz.sha256
+++ b/portal-backend.latest.tar.gz.sha256
@@ -1 +1 @@
-b809f14715623b94d3a38d058ab09ef4cb3f9aa655031c4613c2feeedacce14b /home/barsa/projects/customer_portal/customer-portal/portal-backend.latest.tar.gz
+2b5865668763cce0781a0e6448ce2106f1736c27ade328b2da4920d6946ecce7 /home/barsa/projects/customer_portal/customer-portal/portal-backend.latest.tar.gz
diff --git a/portal-frontend.latest.tar.gz.sha256 b/portal-frontend.latest.tar.gz.sha256
index 0d861f06..04a59da0 100644
--- a/portal-frontend.latest.tar.gz.sha256
+++ b/portal-frontend.latest.tar.gz.sha256
@@ -1 +1 @@
-447e6f2bebb4670bab788977187704c10a5b8cf0e318f950d16bc15d1e459ce2 /home/barsa/projects/customer_portal/customer-portal/portal-frontend.latest.tar.gz
+e37256f0aa756d9838bcc00d1fd2433db1d199e72b38595d0c986a7d85eb534e /home/barsa/projects/customer_portal/customer-portal/portal-frontend.latest.tar.gz