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
16 changes: 11 additions & 5 deletions src/protect/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

// Guard templates ship next to the built CLI (dist/protect/templates).
const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), 'protect', 'templates');
// Guard templates ship next to the built CLI (dist/protect/templates). Resolve for both the
// built layout (install.ts is bundled into dist/cli.js at the dist root → protect/templates) and
// the source layout (install.ts lives in src/protect/ → templates is a sibling).
const HERE = dirname(fileURLToPath(import.meta.url));
const TEMPLATES =
[join(HERE, 'protect', 'templates'), join(HERE, 'templates')].find((p) => existsSync(p)) ??
join(HERE, 'protect', 'templates');
const APP = process.cwd();
const PS_DIR = join(APP, 'src/integrations/patchstack');

Expand All @@ -36,19 +41,20 @@ const CLIENT_TUNNEL = [

const START_IMPORTS = [
'import { getRequest } from "@tanstack/react-start/server";',
'import { GUARD_PATH, handleGuardRequest, inspectServerFn } from "@/integrations/patchstack/guard";',
'import { GUARD_PATH, handleGuardRequest, inspectServerFn, screenResponse } from "@/integrations/patchstack/guard";',
].join('\n');

const REQUEST_MIDDLEWARE_DEF = [
'',
'// Patchstack guard (browser tunnel): intercept tunneled Supabase traffic before anything else.',
'// Patchstack guard (browser tunnel): intercept tunneled Supabase traffic before anything else,',
'// then screen the outgoing response (SSR HTML / data) for leaked secrets & PII.',
'const patchstackGuard = createMiddleware().server(async ({ next }) => {',
' const request = getRequest();',
' if (request) {',
' const { pathname } = new URL(request.url);',
' if (pathname === GUARD_PATH) return handleGuardRequest(request);',
' }',
' return next();',
' return screenResponse(await next());',
'});',
'',
].join('\n');
Expand Down
15 changes: 15 additions & 0 deletions src/protect/templates/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,18 @@ export async function inspectServerFn(data: unknown): Promise<{ rule?: string; m
}
return _inspect(data);
}

// Response phase: redact leaked secrets / PII (private keys, cloud keys, JWTs, DB URLs, …) from an
// outgoing response before it leaves the server. Applied to the SSR / non-tunnel response in
// src/start.ts (the browser→Supabase tunnel screens its own forwarded response). Only acts on a
// web Response (text/JSON/HTML) — anything else, or any error, passes through untouched (fail-open,
// never breaks a response).
export async function screenResponse(response: unknown): Promise<unknown> {
try {
if (!(response instanceof Response)) return response;
const protection = await getProtection();
return protection.screenResponse ? await protection.screenResponse(response) : response;
} catch {
return response; // fail open
}
}
98 changes: 98 additions & 0 deletions tests/protect/install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { runProtect } from '../../src/protect/install.js';

// Scaffolder coverage for `patchstack-connect protect` (runProtect): it must detect a TanStack
// Start + Supabase app, scaffold the guard, and patch client.ts + start.ts at the right anchors —
// including wiring the response-screening middleware — without duplicating on re-run.

// Minimal fixture mirroring the anchors a real Lovable app exposes.
const START_TS = `import { createStart, createMiddleware } from "@tanstack/react-start";
import { attachSupabaseAuth } from "@/integrations/supabase/auth-attacher";

const errorMiddleware = createMiddleware().server(async ({ next }) => next());

export const startInstance = createStart(() => ({
functionMiddleware: [attachSupabaseAuth],
requestMiddleware: [errorMiddleware],
}));
`;

const CLIENT_TS = `import { createClient } from '@supabase/supabase-js';
function createSupabaseFetch(supabaseKey: string): typeof fetch {
return (input, init) => {
const headers = new Headers();
headers.set('apikey', supabaseKey);
return fetch(input, { ...init, headers });
};
}
`;

function makeApp(): string {
const dir = mkdtempSync(path.join(tmpdir(), 'ps-protect-'));
writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'app', dependencies: { '@tanstack/react-start': '^1.0.0' } }));
mkdirSync(path.join(dir, 'src/integrations/supabase'), { recursive: true });
writeFileSync(path.join(dir, 'src/start.ts'), START_TS);
writeFileSync(path.join(dir, 'src/integrations/supabase/client.ts'), CLIENT_TS);
return dir;
}

const read = (dir: string, rel: string) => readFileSync(path.join(dir, rel), 'utf8');
const count = (hay: string, needle: string) => hay.split(needle).length - 1;

describe('runProtect scaffolder', () => {
let dir: string;
beforeEach(() => {
dir = makeApp();
return () => rmSync(dir, { recursive: true, force: true });
});

it('scaffolds guard.ts + rules.json', () => {
runProtect(dir);
expect(existsSync(path.join(dir, 'src/integrations/patchstack/guard.ts'))).toBe(true);
const rules = JSON.parse(read(dir, 'src/integrations/patchstack/rules.json'));
expect(Array.isArray(rules.firewall)).toBe(true);
});

it('patches client.ts with the browser tunnel', () => {
runProtect(dir);
const client = read(dir, 'src/integrations/supabase/client.ts');
expect(client).toContain('x-ps-target');
expect(client).toContain("headers.set('apikey', supabaseKey);"); // anchor preserved
});

it('wires both guards + response screening into start.ts, preserving existing middleware', () => {
runProtect(dir);
const start = read(dir, 'src/start.ts');
expect(start).toContain('inspectServerFn');
expect(start).toContain('screenResponse');
expect(start).toContain('const patchstackGuard =');
expect(start).toContain('const patchstackFunctionGuard =');
// response screening is wired on the non-tunnel path
expect(start).toContain('return screenResponse(await next());');
// guards registered FIRST, existing middleware kept
expect(start).toContain('requestMiddleware: [patchstackGuard, errorMiddleware]');
expect(start).toContain('functionMiddleware: [patchstackFunctionGuard, attachSupabaseAuth]');
});

it('is idempotent — re-running does not duplicate wiring', () => {
runProtect(dir);
runProtect(dir);
const start = read(dir, 'src/start.ts');
const client = read(dir, 'src/integrations/supabase/client.ts');
expect(count(start, 'const patchstackGuard =')).toBe(1);
expect(count(start, 'const patchstackFunctionGuard =')).toBe(1);
expect(count(start, 'patchstackGuard, errorMiddleware')).toBe(1);
expect(count(client, 'x-ps-target')).toBe(1);
});

it('skips an unsupported stack (no @tanstack/react-start)', () => {
const plain = mkdtempSync(path.join(tmpdir(), 'ps-plain-'));
writeFileSync(path.join(plain, 'package.json'), JSON.stringify({ name: 'x', dependencies: {} }));
runProtect(plain);
expect(existsSync(path.join(plain, 'src/integrations/patchstack/guard.ts'))).toBe(false);
rmSync(plain, { recursive: true, force: true });
});
});
Loading