Refactor GitHub Workflows to Consolidate Node and pnpm Setup

- Unified Node.js and pnpm setup across deploy, pr-checks, and security workflows by introducing a custom action for streamlined configuration.
- Removed redundant setup steps to enhance workflow clarity and maintainability.
- Updated security workflow to include concurrency control for better job management.
This commit is contained in:
barsa 2025-12-25 19:01:00 +09:00
parent 84a11f7efc
commit b1ff1e8fd3
24 changed files with 147 additions and 160 deletions

View File

@ -0,0 +1,26 @@
name: Setup Node & pnpm
description: Setup Node.js and pnpm with pnpm store caching
inputs:
node-version:
description: Node.js version to use
required: false
default: "22"
pnpm-version:
description: pnpm version to use
required: false
default: "10.25.0"
runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ inputs.pnpm-version }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: pnpm

View File

@ -21,14 +21,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile

View File

@ -24,16 +24,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.25.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile

View File

@ -14,6 +14,10 @@ on:
# Allow manual trigger
workflow_dispatch:
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
security-audit:
name: Security Vulnerability Audit
@ -23,47 +27,18 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- 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: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run security audit
id: audit
run: |
# Run audit and capture exit code
pnpm audit --audit-level=high || echo "AUDIT_FAILED=true" >> $GITHUB_OUTPUT
run: pnpm security:check
# Generate detailed report
pnpm audit --json > audit-report.json || true
- name: Parse audit results
if: steps.audit.outputs.AUDIT_FAILED == 'true'
run: |
echo "⚠️ Security vulnerabilities detected!"
echo "Please review the audit report and update vulnerable packages."
pnpm audit
exit 1
- name: Generate audit report (JSON)
if: always()
run: pnpm audit --json > audit-report.json || true
- name: Upload audit report
if: always()
@ -107,6 +82,15 @@ jobs:
languages: javascript-typescript
queries: security-and-quality
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build (for better CodeQL extraction)
run: pnpm build
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
@ -122,13 +106,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Check for outdated dependencies
run: |

View File

@ -0,0 +1,18 @@
import { ForbiddenException, Injectable } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
type RequestWithUserRole = {
user?: { role?: string };
};
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<RequestWithUserRole>();
const role = request?.user?.role;
if (role !== "ADMIN") {
throw new ForbiddenException("Admin access required");
}
return true;
}
}

View File

@ -5,12 +5,13 @@ import { SecureErrorMapperService } from "./services/secure-error-mapper.service
import { CsrfService } from "./services/csrf.service.js";
import { CsrfMiddleware } from "./middleware/csrf.middleware.js";
import { CsrfController } from "./controllers/csrf.controller.js";
import { AdminGuard } from "./guards/admin.guard.js";
@Module({
imports: [ConfigModule],
controllers: [CsrfController],
providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware],
exports: [SecureErrorMapperService, CsrfService],
providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware, AdminGuard],
exports: [SecureErrorMapperService, CsrfService, AdminGuard],
})
export class SecurityModule implements NestModule {
configure(consumer: MiddlewareConsumer) {

View File

@ -413,7 +413,8 @@ export class SimCallHistoryService {
): Promise<DomesticCallHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Use production phone number for call history (test number has no call data)
// Dev/testing mode: call history data is currently sourced from a fixed account.
// TODO: Replace with the validated subscription account once call history data is available per user.
const account = "08077052946";
// Default to available month if not specified
@ -477,7 +478,8 @@ export class SimCallHistoryService {
): Promise<InternationalCallHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Use production phone number for call history (test number has no call data)
// Dev/testing mode: call history data is currently sourced from a fixed account.
// TODO: Replace with the validated subscription account once call history data is available per user.
const account = "08077052946";
// Default to available month if not specified
@ -543,7 +545,8 @@ export class SimCallHistoryService {
): Promise<SmsHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Use production phone number for SMS history (test number has no SMS data)
// Dev/testing mode: call history data is currently sourced from a fixed account.
// TODO: Replace with the validated subscription account once call history data is available per user.
const account = "08077052946";
// Default to available month if not specified

View File

@ -10,6 +10,7 @@ import {
BadRequestException,
UsePipes,
Header,
UseGuards,
} from "@nestjs/common";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { SubscriptionsService } from "./subscriptions.service.js";
@ -46,6 +47,7 @@ import { ZodValidationPipe } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SimPlanService } from "./sim-management/services/sim-plan.service.js";
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service.js";
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
import {
EsimManagementService,
type ReissueSimRequest,
@ -117,18 +119,20 @@ export class SubscriptionsController {
/**
* List available files on SFTP for debugging
*/
@Public()
@UseGuards(AdminGuard)
@Get("sim/call-history/sftp-files")
async listSftpFiles(@Query("path") path: string = "/home/PASI") {
if (!path.startsWith("/home/PASI")) {
throw new BadRequestException("Invalid path");
}
const files = await this.simCallHistoryService.listSftpFiles(path);
return { success: true, data: files, path };
}
/**
* Trigger manual import of call history (admin only)
* TODO: Add proper admin authentication before production
*/
@Public()
@UseGuards(AdminGuard)
@Post("sim/call-history/import")
async importCallHistory(@Query("month") yearMonth: string) {
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) {
@ -163,6 +167,7 @@ export class SubscriptionsController {
}
@Get("debug/sim-details/:account")
@UseGuards(AdminGuard)
async debugSimDetails(@Param("account") account: string) {
return await this.simManagementService.getSimDetailsDebug(account);
}
@ -191,6 +196,7 @@ export class SubscriptionsController {
// ==================== SIM Management Endpoints (subscription-specific) ====================
@Get(":id/sim/debug")
@UseGuards(AdminGuard)
async debugSimSubscription(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number

View File

@ -5,6 +5,7 @@ import { SimManagementService } from "./sim-management.service.js";
import { SimUsageStoreService } from "./sim-usage-store.service.js";
import { SimOrdersController } from "./sim-orders.controller.js";
import { SimOrderActivationService } from "./sim-order-activation.service.js";
import { SecurityModule } from "@bff/core/security/security.module.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
@ -14,6 +15,7 @@ import { InternetManagementModule } from "./internet-management/internet-managem
@Module({
imports: [
SecurityModule,
WhmcsModule,
MappingsModule,
FreebitModule,

View File

@ -14,34 +14,19 @@ import { SupportService } from "./support.service.js";
import { ZodValidationPipe } from "nestjs-zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { z } from "zod";
import { createHash } from "node:crypto";
import {
supportCaseFilterSchema,
createCaseRequestSchema,
publicContactRequestSchema,
type SupportCaseFilter,
type SupportCaseList,
type SupportCase,
type CreateCaseRequest,
type CreateCaseResponse,
type PublicContactRequest,
} from "@customer-portal/domain/support";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
// Public contact form schema
const publicContactSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Valid email required"),
phone: z.string().optional(),
subject: z.string().min(1, "Subject is required"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
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);
};
import { hashEmailForLogs } from "./support.logging.js";
@Controller("support")
export class SupportController {
@ -87,7 +72,7 @@ export class SupportController {
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes
async publicContact(
@Body(new ZodValidationPipe(publicContactSchema))
@Body(new ZodValidationPipe(publicContactRequestSchema))
body: PublicContactRequest
): Promise<{ success: boolean; message: string }> {
this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) });

View File

@ -0,0 +1,10 @@
import { createHash } from "node:crypto";
/**
* Hash an email for logs (PII minimization).
* Use a short, stable prefix to correlate events without storing the email itself.
*/
export const hashEmailForLogs = (email: string): string => {
const normalized = email.trim().toLowerCase();
return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
};

View File

@ -8,10 +8,12 @@ import {
type SupportCaseList,
type CreateCaseRequest,
type CreateCaseResponse,
type PublicContactRequest,
} from "@customer-portal/domain/support";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { hashEmailForLogs } from "./support.logging.js";
/**
* Status values that indicate an open/active case
@ -133,14 +135,9 @@ export class SupportService {
* Create a contact request from public form (no authentication required)
* Creates a Web-to-Case in Salesforce or sends an email notification
*/
async createPublicContactRequest(request: {
name: string;
email: string;
phone?: string;
subject: string;
message: string;
}): Promise<void> {
this.logger.log("Creating public contact request", { email: request.email });
async createPublicContactRequest(request: PublicContactRequest): Promise<void> {
const emailHash = hashEmailForLogs(request.email);
this.logger.log("Creating public contact request", { emailHash });
try {
// Create a case without account association (Web-to-Case style)
@ -155,12 +152,12 @@ export class SupportService {
});
this.logger.log("Public contact request created successfully", {
email: request.email,
emailHash,
});
} catch (error) {
this.logger.error("Failed to create public contact request", {
error: getErrorMessage(error),
email: request.email,
emailHash,
});
// Don't throw - we don't want to expose internal errors to public users
// In production, this should send a fallback email notification

View File

@ -337,7 +337,13 @@ export const useCatalogStore = create<CatalogStore>()(
// Only persist configuration state, not transient UI state
partialize: state => ({
internet: state.internet,
sim: state.sim,
sim: {
...state.sim,
// Do NOT persist sensitive identifiers or MNP/identity fields in localStorage.
eid: "",
wantsMnp: false,
mnpData: { ...initialSimState.mnpData },
},
}),
}
)

View File

@ -1,23 +1,17 @@
"use client";
import { useState, useCallback } from "react";
import { z } from "zod";
import Link from "next/link";
import { Button, Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useZodForm } from "@/hooks/useZodForm";
import { Mail, CheckCircle, MapPin } from "lucide-react";
const contactFormSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Please enter a valid email address"),
phone: z.string().optional(),
subject: z.string().min(1, "Subject is required"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
type ContactFormData = z.infer<typeof contactFormSchema>;
import {
publicContactRequestSchema,
type PublicContactRequest,
} from "@customer-portal/domain/support";
import { apiClient, ApiError, isApiError } from "@/lib/api";
/**
* PublicContactView - Contact page with form, phone, chat, and location info
@ -26,29 +20,28 @@ export function PublicContactView() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const handleSubmit = useCallback(async (data: ContactFormData) => {
const handleSubmit = useCallback(async (data: PublicContactRequest) => {
setSubmitError(null);
try {
const response = await fetch("/api/support/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || "Failed to send message");
}
await apiClient.POST("/api/support/contact", { body: data });
setIsSubmitted(true);
} catch (error) {
if (isApiError(error)) {
setSubmitError(error.message || "Failed to send message");
return;
}
if (error instanceof ApiError) {
setSubmitError(error.message || "Failed to send message");
return;
}
setSubmitError(error instanceof Error ? error.message : "Failed to send message");
}
}, []);
const form = useZodForm<ContactFormData>({
schema: contactFormSchema,
const form = useZodForm<PublicContactRequest>({
schema: publicContactRequestSchema,
initialValues: {
name: "",
email: "",

View File

@ -11,7 +11,6 @@ import {
whmcsInvoiceRawSchema,
type WhmcsInvoiceListItem,
whmcsInvoiceListItemSchema,
type WhmcsInvoiceItemsRaw,
whmcsInvoiceItemsRawSchema,
} from "./raw.types.js";
import { parseAmount, formatDate } from "../../../providers/whmcs/utils.js";

View File

@ -5,8 +5,6 @@
* Minimal transformation - validates and normalizes only address structure.
*/
import { z } from "zod";
import type { WhmcsClient, Address } from "../../schema.js";
import { whmcsClientSchema, addressSchema } from "../../schema.js";
import {

View File

@ -235,8 +235,6 @@ const nullableProfileFields = [
"lastlogin",
] as const;
type NullableProfileKey = (typeof nullableProfileFields)[number];
const nullableProfileOverrides = nullableProfileFields.reduce<Record<string, z.ZodTypeAny>>(
(acc, field) => {
acc[field] = z.string().nullable().optional();

View File

@ -5,12 +5,6 @@
* These functions contain no infrastructure dependencies (no DB, no HTTP, no logging).
*/
import { z } from "zod";
import {
createMappingRequestSchema,
updateMappingRequestSchema,
userIdMappingSchema,
} from "./schema.js";
import type {
CreateMappingRequest,
UpdateMappingRequest,

View File

@ -220,7 +220,6 @@ export function generateCancellationMonthOptions(
export function getCancellationEligibility(today: Date = new Date()): CancellationEligibility {
const availableMonths = generateCancellationMonthOptions(today);
const earliestMonth = getEarliestCancellationMonth(today);
const currentYearMonth = formatYearMonth(today);
const day = today.getDate();
// Check if current month is still available

View File

@ -5,8 +5,6 @@
* Validated types are derived from schemas (see schema.ts).
*/
import type { SalesforceProductFieldMap } from "../services/contract.js";
import type { SalesforceAccountFieldMap } from "../customer/index.js";
import type { UserIdMapping } from "../mappings/contract.js";
import type { SalesforceOrderRecord } from "./providers/salesforce/raw.types.js";
import type { OrderConfigurations } from "./schema.js";

View File

@ -4,12 +4,7 @@
import type { PaymentMethod, PaymentGateway } from "../../contract.js";
import { paymentMethodSchema, paymentGatewaySchema } from "../../schema.js";
import {
type WhmcsPaymentMethodRaw,
type WhmcsPaymentGatewayRaw,
whmcsPaymentMethodRawSchema,
whmcsPaymentGatewayRawSchema,
} from "./raw.types.js";
import { whmcsPaymentMethodRawSchema, whmcsPaymentGatewayRawSchema } from "./raw.types.js";
const PAYMENT_TYPE_MAP: Record<string, PaymentMethod["type"]> = {
creditcard: "CreditCard",

View File

@ -40,13 +40,6 @@ function coerceNumber(value: unknown): number | undefined {
return undefined;
}
function inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" {
const normalized = sku.toLowerCase();
if (normalized.includes("24")) return "24-Month";
if (normalized.includes("12")) return "12-Month";
return "One-time";
}
// ============================================================================
// Base Product Mapper
// ============================================================================

View File

@ -5,17 +5,7 @@
import type { SimDetails, SimUsage, SimTopUpHistory, SimType, SimStatus } from "../../contract.js";
import { simDetailsSchema, simUsageSchema, simTopUpHistorySchema } from "../../schema.js";
import {
type FreebitAccountDetailsRaw,
type FreebitTrafficInfoRaw,
type FreebitQuotaHistoryRaw,
type FreebitAuthResponseRaw,
type FreebitTopUpRaw,
type FreebitAddSpecRaw,
type FreebitPlanChangeRaw,
type FreebitCancelPlanRaw,
type FreebitCancelAccountRaw,
type FreebitEsimReissueRaw,
type FreebitEsimAddAccountRaw,
freebitAccountDetailsRawSchema,
freebitTrafficInfoRawSchema,
freebitQuotaHistoryRawSchema,
@ -28,7 +18,6 @@ import {
freebitEsimReissueRawSchema,
freebitEsimAddAccountRawSchema,
} from "./raw.types.js";
import { normalizeAccount } from "./utils.js";
function asString(value: unknown): string {
if (typeof value === "string") return value;

View File

@ -90,6 +90,17 @@ export const createCaseResponseSchema = z.object({
caseNumber: z.string(),
});
/**
* Public contact form schema (unauthenticated)
*/
export const publicContactRequestSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Valid email required"),
phone: z.string().optional(),
subject: z.string().min(1, "Subject is required"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>;
export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>;
@ -99,3 +110,4 @@ export type SupportCaseList = z.infer<typeof supportCaseListSchema>;
export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>;
export type CreateCaseRequest = z.infer<typeof createCaseRequestSchema>;
export type CreateCaseResponse = z.infer<typeof createCaseResponseSchema>;
export type PublicContactRequest = z.infer<typeof publicContactRequestSchema>;