From acd502e5c33beb5247b09b18fce6f09bba39a99a Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 15:42:00 +0200 Subject: [PATCH] feat(protect): scaffold response screening + first scaffolder tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broaden the installed guard beyond the Supabase-tunnel path so SSR / non-tunnel responses are also screened for leaked secrets & PII: - guard.ts template: export screenResponse(response) — screens a web Response via the protection's response rules; passthrough for non-Response values and on any error (fail-open, never breaks a response). - install.ts: wire `return screenResponse(await next())` into the request middleware and add screenResponse to the guard import (fresh installs). - install.ts: resolve the templates dir for BOTH the built layout (dist/cli.js → protect/templates) and the source layout (src/protect/ → templates sibling), so runProtect is exercisable from source. This was previously dist-only. - tests/protect/install.test.ts (NEW, +5): first coverage for the scaffolder — scaffolds guard+rules, patches client.ts tunnel, wires both guards + response screening into start.ts preserving existing middleware, is idempotent, and skips an unsupported stack. Validated end-to-end by running the real shipped CLI against a genuine Lovable app copy (anchors match, idempotent, seeded demo rules enforce 11/11). 390 tests. Co-Authored-By: Claude Opus 4.8 --- src/protect/install.ts | 16 ++++-- src/protect/templates/guard.ts | 15 ++++++ tests/protect/install.test.ts | 98 ++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/protect/install.test.ts diff --git a/src/protect/install.ts b/src/protect/install.ts index 6b4755b..08253a5 100644 --- a/src/protect/install.ts +++ b/src/protect/install.ts @@ -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'); @@ -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'); diff --git a/src/protect/templates/guard.ts b/src/protect/templates/guard.ts index dd35819..46bb0cf 100644 --- a/src/protect/templates/guard.ts +++ b/src/protect/templates/guard.ts @@ -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 { + try { + if (!(response instanceof Response)) return response; + const protection = await getProtection(); + return protection.screenResponse ? await protection.screenResponse(response) : response; + } catch { + return response; // fail open + } +} diff --git a/tests/protect/install.test.ts b/tests/protect/install.test.ts new file mode 100644 index 0000000..2d44015 --- /dev/null +++ b/tests/protect/install.test.ts @@ -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 }); + }); +});