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
43 changes: 31 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,22 +1,41 @@
# Server Environment Variables
# ==============================================================================
# TechNexusOrg Platform Environment Configuration Example
# Copy this file to .env or .env.local for local development.
# In production, all secrets must be strong, randomly generated, and unique.
# ==============================================================================

# Application Runtime
NODE_ENV=development
APP_URL=http://localhost:3000

# Authentication & Session Security (AES-256 secret, min 32 chars)
AUTH_SECRET=changeme_in_production_min_32_characters_long_secret_key_123
# Authentication & Session Security (Min 32 characters, randomly generated)
# Generate with: openssl rand -base64 32
AUTH_SECRET=development_secret_key_strictly_for_local_testing_min_32_chars

# Internal Automation & Service-to-Service Secret (Min 32 characters)
# Used for backend cron sync jobs and service-level operations
# Generate with: openssl rand -base64 32
INTERNAL_SERVICE_SECRET=dev_internal_service_key_strictly_for_local_testing_min_32_chars

# GitHub OAuth App Credentials
# GitHub OAuth Application Credentials
# Register at: https://github.com/settings/developers
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
# Callback URL: <APP_URL>/api/auth/callback
GITHUB_CLIENT_ID=your_github_oauth_client_id
GITHUB_CLIENT_SECRET=your_github_oauth_client_secret

# Optional: GitHub Personal Access Token or App Token for elevated rate limits when querying TechNexusOrg
# Optional: GitHub Access Token for elevated API rate limits on repository sync
GITHUB_ACCESS_TOKEN=

# Database Connection (PostgreSQL)
# e.g., postgresql://user:password@localhost:5432/technexus
# If left empty in development/testing, platform falls back to embedded PGlite
# GitHub Webhook HMAC Secret
# Configured in TechNexusOrg organization webhook settings
# Payload URL: <APP_URL>/api/webhooks/github
GITHUB_WEBHOOK_SECRET=your_github_webhook_secret_key

# PostgreSQL Database Connection URL
# Mandatory in production. Format: postgresql://username:password@hostname:5432/database_name
# If left unset in development/test, platform uses embedded PGlite
DATABASE_URL=

# GitHub Webhook Secret (for verifying incoming webhooks from TechNexusOrg repositories)
GITHUB_WEBHOOK_SECRET=your_github_webhook_secret_key
# Organization Trust Boundary (Defaults to official TechNexusOrg GitHub Org)
TECH_NEXUS_ORG_ID=333899669
TECH_NEXUS_ORG_NAME=TechNexusOrg
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
validate:
name: Lint, Test & Build
Expand Down Expand Up @@ -37,5 +40,7 @@ jobs:
run: npm run build
env:
NODE_ENV: production
CI: "true"
AUTH_SECRET: "ci_build_dummy_secret_key_strictly_for_testing_32_chars"
INTERNAL_SERVICE_SECRET: "ci_service_dummy_secret_key_strictly_for_testing_32_chars"
APP_URL: http://localhost:3000
7 changes: 7 additions & 0 deletions drizzle/0005_project_trust_boundary.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "github_repo_id" integer;
--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "github_owner_id" integer;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "projects_github_repo_id_idx" ON "projects" ("github_repo_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "projects_github_owner_id_idx" ON "projects" ("github_owner_id");
37 changes: 36 additions & 1 deletion drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,41 @@
"when": 1790358237482,
"tag": "0000_demonic_owl",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1791000000000,
"tag": "0001_sprint_lifecycle_tables",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1792000000000,
"tag": "0002_issue_body",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1792500000000,
"tag": "0003_pr_nullable_user",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1793000000000,
"tag": "0004_mvp_hardening",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1793325000000,
"tag": "0005_project_trust_boundary",
"breakpoints": true
}
]
}
}
58 changes: 46 additions & 12 deletions src/app/api/sync/issues/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session";
import { getFreshAuthenticatedUser, hasRole } from "@/lib/auth/authorization";
import { syncRepositoryIssues } from "@/lib/github/issues";
import { env, safeCompare } from "@/lib/env";

export async function POST(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAME)?.value;
const user = token ? await verifySessionToken(token) : null;

// Verify privileged role or internal bearer secret
// 1. Verify service bearer token using constant-time comparison
const authHeader = request.headers.get("authorization");
const isAuthorizedSecret =
authHeader && process.env.AUTH_SECRET && authHeader === `Bearer ${process.env.AUTH_SECRET}`;
const expectedBearer = `Bearer ${env.INTERNAL_SERVICE_SECRET}`;
const isAuthorizedService = safeCompare(authHeader, expectedBearer);

let isAuthorizedUser = false;
if (!isAuthorizedService) {
// 2. Fall back to fresh server-side DB session verification
const freshUser = await getFreshAuthenticatedUser(request);
if (freshUser && hasRole(freshUser, ["admin", "maintainer"])) {
// CSRF / Origin validation for session cookie requests
const origin = request.headers.get("origin");
const host = request.headers.get("host");
if (origin && host) {
try {
const originHost = new URL(origin).host;
if (originHost !== host) {
return NextResponse.json({ error: "CSRF protection: Invalid origin." }, { status: 403 });
}
} catch {
return NextResponse.json({ error: "CSRF protection: Malformed origin." }, { status: 403 });
}
}
isAuthorizedUser = true;
}
}

if (!isAuthorizedSecret && (!user || (user.role !== "admin" && user.role !== "maintainer"))) {
return NextResponse.json({ error: "Unauthorized. Admin or maintainer privileges required." }, { status: 403 });
if (!isAuthorizedService && !isAuthorizedUser) {
return NextResponse.json(
{ error: "Unauthorized. Admin or maintainer privileges required." },
{ status: 403 }
);
}

try {
const body = await request.json().catch(() => ({}));
const owner = body.owner || "TechNexusOrg";
const repo = body.repo || "platform";
const owner = (body.owner || env.TECH_NEXUS_ORG_NAME).trim();
const repo = (body.repo || "platform").trim();

// Trust boundary: enforce official organization
if (owner.toLowerCase() !== env.TECH_NEXUS_ORG_NAME.toLowerCase()) {
return NextResponse.json(
{ error: `Forbidden: Only repositories under ${env.TECH_NEXUS_ORG_NAME} may be synced.` },
{ status: 400 }
);
}

const result = await syncRepositoryIssues(owner, repo);

Expand All @@ -30,6 +61,9 @@ export async function POST(request: NextRequest) {
});
} catch (error: any) {
console.error("Issue sync error:", error);
return NextResponse.json({ error: error.message || "Failed to sync issues" }, { status: 500 });
return NextResponse.json(
{ error: error.message || "Failed to sync issues" },
{ status: 500 }
);
}
}
8 changes: 8 additions & 0 deletions src/app/api/webhooks/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

// Delivery identifier is mandatory for audit & idempotency
if (!deliveryId) {
return NextResponse.json(
{ error: "Missing required X-GitHub-Delivery header" },
{ status: 400 }
);
}

let repoName: string | undefined;
try {
const parsed = JSON.parse(rawBody);
Expand Down
40 changes: 29 additions & 11 deletions src/lib/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,42 @@ export async function getDb() {

const databaseUrl = process.env.DATABASE_URL;

if (databaseUrl && databaseUrl.startsWith("postgres")) {
const { drizzle } = await import("drizzle-orm/postgres-js");
const postgres = (await import("postgres")).default;
const client = postgres(databaseUrl, {
max: process.env.NODE_ENV === "production" ? 10 : 1,
});
dbInstance = drizzle(client, { schema });
return dbInstance;
if (
databaseUrl &&
(databaseUrl.startsWith("postgres://") ||
databaseUrl.startsWith("postgresql://") ||
databaseUrl.startsWith("postgres:"))
) {
try {
const { drizzle } = await import("drizzle-orm/postgres-js");
const postgres = (await import("postgres")).default;
const client = postgres(databaseUrl, {
max: process.env.NODE_ENV === "production" ? 10 : 1,
});
dbInstance = drizzle(client, { schema });
return dbInstance;
} catch (err: any) {
console.error("Failed to connect to PostgreSQL database:", err);
throw new Error(`Production database connection failed: ${err.message}`);
}
}

// Fail-closed in production: Never silently fall back to local file or memory
if (process.env.NODE_ENV === "production") {
throw new Error(
"Fatal: Production environment requires a valid PostgreSQL DATABASE_URL. PGlite local database fallback is strictly disabled in production."
);
}

// Embedded PGlite fallback for local development & testing
const { drizzle } = await import("drizzle-orm/pglite");
const { PGlite } = await import("@electric-sql/pglite");

// In test environment, use pure in-memory instance
const pglite = process.env.NODE_ENV === "test"
? new PGlite()
: new PGlite(process.env.PGLITE_DATA_DIR || "./.data/pglite");
const pglite =
process.env.NODE_ENV === "test"
? new PGlite()
: new PGlite(process.env.PGLITE_DATA_DIR || "./.data/pglite");

dbInstance = drizzle(pglite, { schema });
return dbInstance;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export const projects = pgTable(
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
githubRepo: text("github_repo").notNull().unique(), // e.g. "TechNexusOrg/platform"
githubRepoId: integer("github_repo_id"),
githubOwnerId: integer("github_owner_id"),
description: text("description").notNull(),
primaryLanguage: text("primary_language").notNull(),
languages: jsonb("languages").$type<string[]>().default([]).notNull(),
Expand All @@ -133,6 +135,8 @@ export const projects = pgTable(
(table) => [
uniqueIndex("projects_slug_idx").on(table.slug),
uniqueIndex("projects_repo_idx").on(table.githubRepo),
index("projects_github_repo_id_idx").on(table.githubRepoId),
index("projects_github_owner_id_idx").on(table.githubOwnerId),
]
);

Expand Down
85 changes: 84 additions & 1 deletion src/lib/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { describe, it, expect } from "vitest";
import { validateEnv } from "./env";
import { validateEnv, safeCompare } from "./env";

describe("Environment Validation", () => {
it("validates default development environment safely", () => {
const valid = validateEnv({
NODE_ENV: "development",
APP_URL: "http://localhost:3000",
AUTH_SECRET: "12345678901234567890123456789012",
INTERNAL_SERVICE_SECRET: "12345678901234567890123456789012",
GITHUB_CLIENT_ID: "mock_id",
GITHUB_CLIENT_SECRET: "mock_secret",
GITHUB_WEBHOOK_SECRET: "test_secret",
Expand All @@ -15,6 +16,8 @@ describe("Environment Validation", () => {
expect(valid.NODE_ENV).toBe("development");
expect(valid.APP_URL).toBe("http://localhost:3000");
expect(valid.AUTH_SECRET).toHaveLength(32);
expect(valid.TECH_NEXUS_ORG_ID).toBe(333899669);
expect(valid.TECH_NEXUS_ORG_NAME).toBe("TechNexusOrg");
});

it("fails if AUTH_SECRET is shorter than 32 characters", () => {
Expand All @@ -35,4 +38,84 @@ describe("Environment Validation", () => {
})
).toThrow();
});

describe("Production Hardening & Fail-Closed Guardrails", () => {
it("fails in production if known dev placeholder AUTH_SECRET is used", () => {
expect(() =>
validateEnv({
NODE_ENV: "production",
APP_URL: "https://platform.technexus.org",
AUTH_SECRET: "development_secret_key_strictly_for_local_testing_min_32_chars",
INTERNAL_SERVICE_SECRET: "strong_production_service_secret_999999999999",
GITHUB_CLIENT_ID: "valid_client_id_12345",
GITHUB_CLIENT_SECRET: "valid_client_secret_9876543210",
GITHUB_WEBHOOK_SECRET: "strong_webhook_secret_123456",
DATABASE_URL: "postgresql://postgres:secret@db.host.internal:5432/technexus",
})
).toThrow(/Production requires a strong, random AUTH_SECRET/);
});

it("fails in production if GITHUB_CLIENT_ID is a mock placeholder", () => {
expect(() =>
validateEnv({
NODE_ENV: "production",
APP_URL: "https://platform.technexus.org",
AUTH_SECRET: "strong_production_auth_secret_111111111111111",
INTERNAL_SERVICE_SECRET: "strong_production_service_secret_999999999999",
GITHUB_CLIENT_ID: "mock_client_id_for_dev",
GITHUB_CLIENT_SECRET: "valid_client_secret_9876543210",
GITHUB_WEBHOOK_SECRET: "strong_webhook_secret_123456",
DATABASE_URL: "postgresql://postgres:secret@db.host.internal:5432/technexus",
})
).toThrow(/Production requires a valid GITHUB_CLIENT_ID/);
});

it("fails in production if DATABASE_URL is missing or invalid", () => {
expect(() =>
validateEnv({
NODE_ENV: "production",
APP_URL: "https://platform.technexus.org",
AUTH_SECRET: "strong_production_auth_secret_111111111111111",
INTERNAL_SERVICE_SECRET: "strong_production_service_secret_999999999999",
GITHUB_CLIENT_ID: "valid_client_id_12345",
GITHUB_CLIENT_SECRET: "valid_client_secret_9876543210",
GITHUB_WEBHOOK_SECRET: "strong_webhook_secret_123456",
DATABASE_URL: "",
})
).toThrow(/Production requires a valid PostgreSQL DATABASE_URL/);
});

it("passes in production when all security requirements are satisfied", () => {
const valid = validateEnv({
NODE_ENV: "production",
APP_URL: "https://platform.technexus.org",
AUTH_SECRET: "strong_production_auth_secret_111111111111111",
INTERNAL_SERVICE_SECRET: "strong_production_service_secret_999999999999",
GITHUB_CLIENT_ID: "valid_client_id_12345",
GITHUB_CLIENT_SECRET: "valid_client_secret_9876543210",
GITHUB_WEBHOOK_SECRET: "strong_webhook_secret_123456",
DATABASE_URL: "postgresql://postgres:secret@db.host.internal:5432/technexus",
});

expect(valid.NODE_ENV).toBe("production");
expect(valid.DATABASE_URL).toContain("postgresql://");
});
});

describe("safeCompare", () => {
it("returns true for matching strings in constant time", () => {
expect(safeCompare("super_secret_token_123", "super_secret_token_123")).toBe(true);
});

it("returns false for non-matching strings", () => {
expect(safeCompare("super_secret_token_123", "wrong_token_456")).toBe(false);
expect(safeCompare("super_secret_token_123", "super_secret_token_124")).toBe(false);
});

it("returns false safely when strings differ in length or are missing", () => {
expect(safeCompare("short", "longer_string")).toBe(false);
expect(safeCompare(null, "some_token")).toBe(false);
expect(safeCompare("some_token", undefined)).toBe(false);
});
});
});
Loading
Loading