Skip to content
Closed
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
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: CI

on:
push:
branches: [master, main]
pull_request:
branches: [master, main]

jobs:
lint-typecheck:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run typecheck

test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- run: npm run test -- --run

build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- run: npm run build

e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- run: npm run e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
82 changes: 77 additions & 5 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
import type { NextConfig } from "next";

const isProduction = process.env.NODE_ENV === "production";

/**
* Generate a random nonce for CSP
*/
const generateNonce = (): string => {
return Buffer.from(crypto.randomUUID()).toString("base64");
};

/**
* Build CSP header value with nonce
*/
const buildCspHeader = (nonce: string): string => {
const directives = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"font-src 'self'",
"connect-src 'self'",
"media-src 'self'",
"object-src 'none'",
"frame-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
];
return directives.join("; ");
};

const nextConfig: NextConfig = {
typescript: {
// TODO: Set to false and fix all type errors before a major release.
// Left as true temporarily to unblock development while type coverage
// catches up to the runtime behavior. Type errors will still fail CI
// via `npm run typecheck` (tsc --noEmit).
ignoreBuildErrors: true,
ignoreBuildErrors: false,
},
serverExternalPackages: ["ws", "better-sqlite3", "systeminformation"],
// Explicitly use webpack mode

webpack: (config, { isServer }) => {

Check warning on line 41 in next.config.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

'isServer' is defined but never used
// Suppress warnings about optional macOS temperature-sensor dependencies
// that are attempted to be loaded on Linux/Windows builds. These packages
// are optional peer dependencies of `systeminformation` and do not affect
Expand All @@ -20,6 +49,49 @@
];
return config;
},
async headers() {
const nonce = generateNonce();

return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: buildCspHeader(nonce),
},
{
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: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()",
},
...(isProduction
? [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
]
: []),
],
},
];
},
};

export default nextConfig;
46 changes: 28 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"remark-gfm": "^4.0.1",
"systeminformation": "^5.31.5",
"tailwind-merge": "^3.4.0",
"ws": "^8.18.3"
"ws": "^8.18.3",
"zod": "3.22.4"
},
"devDependencies": {
"@playwright/test": "^1.58.0",
Expand Down
56 changes: 54 additions & 2 deletions server/access-gate.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { URL } = require("node:url");
const crypto = require("crypto");

const parseCookies = (header) => {
const raw = typeof header === "string" ? header : "";
Expand All @@ -24,10 +25,50 @@ const buildRedirectUrl = (req, nextPathWithQuery) => {
return `${proto}://${host}${nextPathWithQuery}`;
};

/**
* Generate a CSRF token for state-changing request protection
* @returns {string} A random CSRF token
*/
const generateCsrfToken = () => {
return crypto.randomBytes(32).toString("hex");
};

/**
* Hash a CSRF token for cookie storage
* @param {string} token - The raw token
* @returns {string} SHA-256 hash of the token
*/
const hashCsrfToken = (token) => {
return crypto.createHash("sha256").update(token).digest("hex");
};

/**
* Get CSRF cookie settings
* @param {string} token - The hashed token value
* @param {boolean} isProduction - Whether in production mode
* @returns {string} Cookie header value
*/
const getCsrfCookieSettings = (token, isProduction) => {
const secure = isProduction ? "; Secure" : "";
return `__Host-csrf_token=${token}${secure}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400`;
};

/**
* Get readable CSRF token cookie settings
* @param {string} token - The raw token value (not hashed)
* @param {boolean} isProduction - Whether in production mode
* @returns {string} Cookie header value
*/
const getReadableCsrfCookieSettings = (token, isProduction) => {
const secure = isProduction ? "; Secure" : "";
return `csrf_token_readable=${token}${secure}; Path=/; SameSite=Strict; Max-Age=86400`;
};

function createAccessGate(options) {
const token = String(options?.token ?? "").trim();
const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access";
const queryParam = String(options?.queryParam ?? "access_token").trim() || "access_token";
const isProduction = process.env.NODE_ENV === "production";

const enabled = Boolean(token);

Expand All @@ -53,9 +94,20 @@ function createAccessGate(options) {
}

url.searchParams.delete(queryParam);
const cookieValue = `${cookieName}=${token}; HttpOnly; Path=/; SameSite=Lax`;

// Generate CSRF token for security
const csrfToken = generateCsrfToken();
const csrfHashed = hashCsrfToken(csrfToken);

// Set multiple cookies: access token, CSRF token, and readable CSRF
const cookies = [
`${cookieName}=${token}; HttpOnly; Path=/; SameSite=Strict${isProduction ? "; Secure" : ""}`,
getCsrfCookieSettings(csrfHashed, isProduction),
getReadableCsrfCookieSettings(csrfToken, isProduction),
];

res.statusCode = 302;
res.setHeader("Set-Cookie", cookieValue);
res.setHeader("Set-Cookie", cookies);
res.setHeader("Location", buildRedirectUrl(req, url.pathname + url.search));
res.end();
return true;
Expand Down
17 changes: 14 additions & 3 deletions src/app/api/intents/agent-create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { NextResponse } from "next/server";
import { ensureDomainIntentRuntime, parseIntentBody } from "@/lib/controlplane/intent-route";
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
import { slugifyAgentName } from "@/lib/gateway/agentConfig";
import {
agentCreateSchema,
validateInput,
createValidationErrorResponse,
} from "@/lib/validation/schemas";

export const runtime = "nodejs";

Expand Down Expand Up @@ -30,11 +35,17 @@ export async function POST(request: Request) {
return bodyOrError as NextResponse;
}

const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : "";
if (!name) {
return NextResponse.json({ error: "name is required." }, { status: 400 });
// Validate input with Zod
const validation = validateInput(agentCreateSchema, bodyOrError);
if (!validation.success) {
return NextResponse.json(
createValidationErrorResponse(validation.error, validation.issues),
{ status: 400 }
);
}

const { name } = validation.data;

const runtimeOrError = await ensureDomainIntentRuntime();
if (runtimeOrError instanceof Response) {
return runtimeOrError as NextResponse;
Expand Down
18 changes: 15 additions & 3 deletions src/app/api/intents/agent-delete/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { NextResponse } from "next/server";

import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
import {
agentDeleteSchema,
validateInput,
createValidationErrorResponse,
} from "@/lib/validation/schemas";

export const runtime = "nodejs";

Expand All @@ -9,9 +14,16 @@ export async function POST(request: Request) {
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
if (!agentId) {
return NextResponse.json({ error: "agentId is required." }, { status: 400 });

// Validate input with Zod
const validation = validateInput(agentDeleteSchema, bodyOrError);
if (!validation.success) {
return NextResponse.json(
createValidationErrorResponse(validation.error, validation.issues),
{ status: 400 }
);
}

const { agentId } = validation.data;
return await executeGatewayIntent("agents.delete", { agentId });
}
Loading
Loading