Enhance GitHub Workflows and Refactor BFF Code

- Added support for push and workflow_dispatch events in pr-checks.yml to improve CI flexibility.
- Implemented concurrency control in workflows to manage job execution more effectively.
- Updated pnpm setup to include caching and specified version for consistency.
- Removed redundant logging code from BFF application to streamline signal handling and improve readability.
- Introduced CSRF_SECRET_KEY validation in environment configuration for production hardening.
- Refactored logo component to use a fallback mechanism for image loading, enhancing user experience.
- Added linting scripts to package.json for improved code quality checks.
This commit is contained in:
barsa 2025-12-25 18:34:45 +09:00
parent 9b2ce83229
commit 84a11f7efc
11 changed files with 74 additions and 206 deletions

View File

@ -2,9 +2,18 @@ name: Pull Request Checks
on: on:
pull_request: pull_request:
push:
branches: branches:
- main - main
- master - master
workflow_dispatch:
concurrency:
group: pr-checks-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
quality-checks: quality-checks:
@ -15,42 +24,34 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.25.0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "22"
cache: "pnpm"
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Check formatting
run: pnpm format:check
- name: Run linter - name: Run linter
run: pnpm lint run: pnpm lint
- name: Run type check - name: Run type check
run: pnpm type-check run: pnpm type-check
- name: Run security audit
run: pnpm security:check
- name: Run tests - name: Run tests
run: pnpm test run: pnpm test
- name: Check formatting - name: Build
run: pnpm format:check run: pnpm build
- name: Verify domain dist is up-to-date
run: pnpm domain:check-dist

View File

@ -97,11 +97,6 @@ jobs:
contents: read contents: read
security-events: write security-events: write
strategy:
fail-fast: false
matrix:
language: ["javascript", "typescript"]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -109,16 +104,13 @@ jobs:
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: javascript-typescript
queries: security-and-quality queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:${{matrix.language}}" category: "/language:javascript-typescript"
outdated-dependencies: outdated-dependencies:
name: Check Outdated Dependencies name: Check Outdated Dependencies

View File

@ -120,21 +120,6 @@ export async function bootstrap(): Promise<INestApplication> {
// Rely on Nest's built-in shutdown hooks. External orchestrator will send signals. // Rely on Nest's built-in shutdown hooks. External orchestrator will send signals.
app.enableShutdownHooks(); app.enableShutdownHooks();
// #region agent log (hypothesis S1)
fetch("http://127.0.0.1:7242/ingest/a683e422-cfe7-4556-a583-809fbfbeeb4a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "run1",
hypothesisId: "S1",
location: "apps/bff/src/app/bootstrap.ts:enableShutdownHooks",
message: "Nest shutdown hooks enabled",
data: { pid: process.pid },
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
// API routing prefix is applied via RouterModule in AppModule for clarity and modern routing. // API routing prefix is applied via RouterModule in AppModule for clarity and modern routing.

View File

@ -243,5 +243,12 @@ export function validate(config: Record<string, unknown>): Record<string, unknow
]; ];
throw new Error(`Invalid environment configuration: ${messages.join("; ")}`); throw new Error(`Invalid environment configuration: ${messages.join("; ")}`);
} }
// Production hardening: CSRF must be backed by a stable secret key.
// (If not set, CsrfService generates an ephemeral key which is not suitable for production.)
if (result.data.NODE_ENV === "production" && !result.data.CSRF_SECRET_KEY) {
throw new Error(
"Invalid environment configuration: CSRF_SECRET_KEY must be set when NODE_ENV=production"
);
}
return result.data; return result.data;
} }

View File

@ -10,6 +10,7 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { randomUUID } from "node:crypto";
const LOCK_PREFIX = "lock:"; const LOCK_PREFIX = "lock:";
const DEFAULT_TTL_MS = 30_000; // 30 seconds const DEFAULT_TTL_MS = 30_000; // 30 seconds
@ -176,7 +177,7 @@ export class DistributedLockService {
* Generate a unique token for lock ownership * Generate a unique token for lock ownership
*/ */
private generateToken(): string { private generateToken(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; return randomUUID();
} }
/** /**

View File

@ -44,69 +44,16 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
this.pool = pool; this.pool = pool;
this.instanceTag = `${process.pid}:${Date.now()}`; this.instanceTag = `${process.pid}:${Date.now()}`;
// #region agent log (hypothesis S3)
fetch("http://127.0.0.1:7242/ingest/a683e422-cfe7-4556-a583-809fbfbeeb4a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "run1",
hypothesisId: "S3",
location: "apps/bff/src/infra/database/prisma.service.ts:constructor",
message: "PrismaService constructed",
data: { instanceTag: this.instanceTag, pid: process.pid },
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
} }
async onModuleInit() { async onModuleInit() {
await this.$connect(); await this.$connect();
this.logger.log("Database connection established"); this.logger.log("Database connection established");
// #region agent log (hypothesis S3)
fetch("http://127.0.0.1:7242/ingest/a683e422-cfe7-4556-a583-809fbfbeeb4a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "run1",
hypothesisId: "S3",
location: "apps/bff/src/infra/database/prisma.service.ts:onModuleInit",
message: "PrismaService connected",
data: { instanceTag: this.instanceTag },
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
} }
async onModuleDestroy() { async onModuleDestroy() {
this.destroyCalls += 1; this.destroyCalls += 1;
// #region agent log (hypothesis S1)
fetch("http://127.0.0.1:7242/ingest/a683e422-cfe7-4556-a583-809fbfbeeb4a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "run1",
hypothesisId: "S1",
location: "apps/bff/src/infra/database/prisma.service.ts:onModuleDestroy(entry)",
message: "PrismaService destroy called",
data: {
instanceTag: this.instanceTag,
destroyCalls: this.destroyCalls,
poolEnded: this.poolEnded,
pid: process.pid,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
// Make shutdown idempotent: Nest can call destroy hooks more than once during restarts. // Make shutdown idempotent: Nest can call destroy hooks more than once during restarts.
if (this.poolEnded) { if (this.poolEnded) {
this.logger.warn("Database pool already closed; skipping duplicate shutdown"); this.logger.warn("Database pool already closed; skipping duplicate shutdown");
@ -117,25 +64,5 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
await this.$disconnect(); await this.$disconnect();
await this.pool.end(); await this.pool.end();
this.logger.log("Database connection closed"); this.logger.log("Database connection closed");
// #region agent log (hypothesis S1)
fetch("http://127.0.0.1:7242/ingest/a683e422-cfe7-4556-a583-809fbfbeeb4a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "run1",
hypothesisId: "S1",
location: "apps/bff/src/infra/database/prisma.service.ts:onModuleDestroy(exit)",
message: "PrismaService destroy completed",
data: {
instanceTag: this.instanceTag,
destroyCalls: this.destroyCalls,
poolEnded: this.poolEnded,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
} }
} }

View File

@ -11,21 +11,6 @@ for (const signal of signals) {
process.once(signal, () => { process.once(signal, () => {
void (async () => { void (async () => {
logger.log(`Received ${signal}. Closing Nest application...`); logger.log(`Received ${signal}. Closing Nest application...`);
// #region agent log (hypothesis S1)
fetch("http://127.0.0.1:7242/ingest/a683e422-cfe7-4556-a583-809fbfbeeb4a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "run1",
hypothesisId: "S1",
location: "apps/bff/src/main.ts:signalHandler(entry)",
message: "Process signal handler invoked",
data: { signal, pid: process.pid, appInitialized: !!app },
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
if (!app) { if (!app) {
logger.warn("Nest application not initialized. Exiting immediately."); logger.warn("Nest application not initialized. Exiting immediately.");
@ -34,21 +19,6 @@ for (const signal of signals) {
} }
try { try {
// #region agent log (hypothesis S1)
fetch("http://127.0.0.1:7242/ingest/a683e422-cfe7-4556-a583-809fbfbeeb4a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "run1",
hypothesisId: "S1",
location: "apps/bff/src/main.ts:signalHandler(beforeClose)",
message: "Calling app.close()",
data: { signal, pid: process.pid },
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
await app.close(); await app.close();
logger.log("Nest application closed gracefully."); logger.log("Nest application closed gracefully.");
} catch (error) { } catch (error) {
@ -58,21 +28,6 @@ for (const signal of signals) {
resolvedError.stack resolvedError.stack
); );
} finally { } finally {
// #region agent log (hypothesis S1)
fetch("http://127.0.0.1:7242/ingest/a683e422-cfe7-4556-a583-809fbfbeeb4a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "run1",
hypothesisId: "S1",
location: "apps/bff/src/main.ts:signalHandler(exit)",
message: "Exiting process after shutdown handler",
data: { signal, pid: process.pid },
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
process.exit(0); process.exit(0);
} }
})(); })();

View File

@ -15,6 +15,7 @@ import { ZodValidationPipe } from "nestjs-zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { z } from "zod"; import { z } from "zod";
import { createHash } from "node:crypto";
import { import {
supportCaseFilterSchema, supportCaseFilterSchema,
createCaseRequestSchema, createCaseRequestSchema,
@ -37,6 +38,11 @@ const publicContactSchema = z.object({
type PublicContactRequest = z.infer<typeof publicContactSchema>; type PublicContactRequest = z.infer<typeof publicContactSchema>;
const hashEmailForLogs = (email: string): string => {
const normalized = email.trim().toLowerCase();
return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
};
@Controller("support") @Controller("support")
export class SupportController { export class SupportController {
constructor( constructor(
@ -84,7 +90,7 @@ export class SupportController {
@Body(new ZodValidationPipe(publicContactSchema)) @Body(new ZodValidationPipe(publicContactSchema))
body: PublicContactRequest body: PublicContactRequest
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
this.logger.log("Public contact form submission", { email: body.email }); this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) });
try { try {
await this.supportService.createPublicContactRequest(body); await this.supportService.createPublicContactRequest(body);
@ -96,7 +102,7 @@ export class SupportController {
} catch (error) { } catch (error) {
this.logger.error("Failed to process public contact form", { this.logger.error("Failed to process public contact form", {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
email: body.email, emailHash: hashEmailForLogs(body.email),
}); });
throw error; throw error;
} }

View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -9,6 +9,33 @@ interface LogoProps {
} }
export function Logo({ className = "", size = 32 }: LogoProps) { export function Logo({ className = "", size = 32 }: LogoProps) {
const [fallback, setFallback] = React.useState(false);
if (fallback) {
return (
<div className={className} style={{ width: size, height: size }}>
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-full h-full"
role="img"
aria-label="Assist Solutions Logo"
>
{/* Top section - Light blue curved arrows */}
<path d="M8 8 C12 4, 20 4, 24 8 L20 12 C18 10, 14 10, 12 12 Z" fill="#60A5FA" />
<path d="M24 8 C28 12, 28 20, 24 24 L20 20 C22 18, 22 14, 20 12 Z" fill="#60A5FA" />
{/* Bottom section - Dark blue curved arrows */}
<path d="M8 24 C12 28, 20 28, 24 24 L20 20 C18 22, 14 22, 12 20 Z" fill="#1E40AF" />
<path d="M8 24 C4 20, 4 12, 8 8 L12 12 C10 14, 10 18, 12 20 Z" fill="#1E40AF" />
</svg>
</div>
);
}
return ( return (
<div className={className} style={{ width: size, height: size }}> <div className={className} style={{ width: size, height: size }}>
<Image <Image
@ -17,43 +44,7 @@ export function Logo({ className = "", size = 32 }: LogoProps) {
width={size} width={size}
height={size} height={size}
className="w-full h-full object-contain" className="w-full h-full object-contain"
onError={e => { onError={() => setFallback(true)}
// Fallback to SVG if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = "none";
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `
<svg
width="${size}"
height="${size}"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Top section - Light blue curved arrows -->
<path
d="M8 8 C12 4, 20 4, 24 8 L20 12 C18 10, 14 10, 12 12 Z"
fill="#60A5FA"
/>
<path
d="M24 8 C28 12, 28 20, 24 24 L20 20 C22 18, 22 14, 20 12 Z"
fill="#60A5FA"
/>
<!-- Bottom section - Dark blue curved arrows -->
<path
d="M8 24 C12 28, 20 28, 24 24 L20 20 C18 22, 14 22, 12 20 Z"
fill="#1E40AF"
/>
<path
d="M8 24 C4 20, 4 12, 8 8 L12 12 C10 14, 10 18, 12 20 Z"
fill="#1E40AF"
/>
</svg>
`;
}
}}
/> />
</div> </div>
); );

View File

@ -169,6 +169,9 @@
"build": "tsc", "build": "tsc",
"dev": "tsc -w --preserveWatchOutput", "dev": "tsc -w --preserveWatchOutput",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "echo 'No tests yet'",
"type-check": "NODE_OPTIONS=\"--max-old-space-size=2048 --max-semi-space-size=128\" tsc --project tsconfig.json --noEmit", "type-check": "NODE_OPTIONS=\"--max-old-space-size=2048 --max-semi-space-size=128\" tsc --project tsconfig.json --noEmit",
"typecheck": "pnpm run type-check" "typecheck": "pnpm run type-check"
}, },