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
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
installCommand,
renderGuideChecklist,
} from './guide.js';
import { runProtect } from './protect/install.js';
import { runProtect } from './protect/install/index.js';
import { wireBuildScripts } from './setup.js';
import { detectStack, type StackDescriptor } from './stack.js';
import { PatchstackError } from './types.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
// `patchstack-connect protect` — installs the runtime guard into a TanStack Start + Supabase app.
// Adapter: TanStack Start + Supabase (the shape Lovable emits).
//
// "Add Patchstack" already installs the connector; this wires the always-on guard so exploit
// requests against known-vulnerable packages are blocked, with zero changes to the user's own
// code. Idempotent (safe to re-run).
//
// The engine ships inside @patchstack/connect (exported as @patchstack/connect/protect), so the
// scaffolded guard just imports it — no extra dependency, no local manifest. Rules come from the
// Patchstack API at runtime (cached), with a bundled fallback until a token is configured.

import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

// 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 read = (p: string) => readFileSync(p, 'utf8');
const log = (msg: string) => console.log(`patchstack protect: ${msg}`);
// Wires the always-on guard with zero changes to the user's own code — patches the generated
// Supabase client (browser→Supabase tunnel) and src/start.ts (request + function middleware),
// scaffolds src/integrations/patchstack/{guard.ts,rules.json}, and bakes the site UUID.
// Idempotent + upgrades in place via the managed `#region` blocks.

import { writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
import { read, log, templatesDir } from '../util.js';
import type { Adapter, WireOptions, WireResult } from '../types.js';

const CLIENT_TUNNEL = [
'',
Expand Down Expand Up @@ -103,10 +91,15 @@ function reconcileBlock(s: string, region: string, block: string, legacyConst: s
return s.replace(insertBefore, block + '\n\n' + insertBefore);
}

export function detectSupportedStack(cwd: string): boolean {
function detect(cwd: string): boolean {
const pkgPath = join(cwd, 'package.json');
if (!existsSync(pkgPath)) return false;
const pkg = JSON.parse(read(pkgPath));
let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
try {
pkg = JSON.parse(read(pkgPath));
} catch {
return false;
}
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
return (
Boolean(deps['@tanstack/react-start']) &&
Expand All @@ -115,68 +108,91 @@ export function detectSupportedStack(cwd: string): boolean {
);
}

function scaffold(cwd: string, opts: { demo?: boolean } = {}): void {
const GUARD_FILE = 'src/integrations/patchstack/guard.ts';

function scaffold(cwd: string, opts: WireOptions): string[] {
const templates = templatesDir();
const dst = join(cwd, 'src/integrations/patchstack');
mkdirSync(dst, { recursive: true });
copyFileSync(join(TEMPLATES, 'guard.ts'), join(dst, 'guard.ts')); // guard.ts is managed — always refreshed
copyFileSync(join(templates, 'guard.ts'), join(dst, 'guard.ts')); // guard.ts is managed — always refreshed
const changed = [GUARD_FILE];
const rulesDst = join(dst, 'rules.json');
// Default: the high-precision starter, written only if absent (don't clobber the user's rules on
// re-run). --demo: (re)seed the broad multi-class sample bundle for a self-contained demonstration.
if (opts.demo) {
copyFileSync(join(TEMPLATES, 'demo-rules.json'), rulesDst);
copyFileSync(join(templates, 'demo-rules.json'), rulesDst);
changed.push('src/integrations/patchstack/rules.json');
log('scaffolded guard.ts + rules.json (demo sample rule set)');
} else if (!existsSync(rulesDst)) {
copyFileSync(join(TEMPLATES, 'rules.json'), rulesDst);
copyFileSync(join(templates, 'rules.json'), rulesDst);
changed.push('src/integrations/patchstack/rules.json');
log('scaffolded guard.ts + rules.json (starter rules)');
} else {
log('scaffolded guard.ts (kept existing rules.json)');
}
return changed;
}

// Bake the site UUID from .patchstackrc.json (written by `patchstack-connect scan`) into the
// scaffolded guard, so the deployed Worker calls the live Pulse rules API with zero user config.
// Left as the inert placeholder (guard falls back to PATCHSTACK_SITE_UUID env / bundled rules)
// when the app hasn't been scanned yet or the file can't be read.
function bakeSiteUuid(cwd: string): void {
// Bake the site UUID from .patchstackrc.json (written by `scan`) into the scaffolded guard, so the
// deployed Worker calls the live Pulse rules API with zero user config. Left as the inert
// placeholder when the app hasn't been scanned yet or the file can't be read. Returns whether it baked.
function bakeSiteUuid(cwd: string): boolean {
const rc = join(cwd, '.patchstackrc.json');
if (!existsSync(rc)) return log('no .patchstackrc.json — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback');
if (!existsSync(rc)) {
log('no .patchstackrc.json — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback');
return false;
}
let uuid: string | undefined;
try {
uuid = JSON.parse(read(rc)).siteUuid;
} catch {
return log('.patchstackrc.json unreadable — skipping site-UUID bake');
log('.patchstackrc.json unreadable — skipping site-UUID bake');
return false;
}
// Guard on UUID format so a malformed value falls through to the inert placeholder rather
// than baking junk into a TS string literal (broken build / replace-token hazards).
// Guard on UUID format so a malformed value falls through to the inert placeholder rather than
// baking junk into a TS string literal (broken build / replace-token hazards).
if (!uuid || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) {
return log('.patchstackrc.json siteUuid missing or malformed — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback');
log('.patchstackrc.json siteUuid missing or malformed — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback');
return false;
}
const p = join(cwd, 'src/integrations/patchstack/guard.ts');
if (!existsSync(p)) return;
const p = join(cwd, GUARD_FILE);
if (!existsSync(p)) return false;
const s = read(p);
if (!s.includes('__PATCHSTACK_SITE_UUID__')) return log('guard.ts site UUID already baked');
if (!s.includes('__PATCHSTACK_SITE_UUID__')) {
log('guard.ts site UUID already baked');
return false;
}
writeFileSync(p, s.replace('__PATCHSTACK_SITE_UUID__', uuid));
log('baked site UUID into guard.ts — live rules from the Patchstack API');
return true;
}

function patchClient(cwd: string): void {
function patchClient(cwd: string): boolean {
const p = join(cwd, 'src/integrations/supabase/client.ts');
let s = read(p);
if (s.includes('x-ps-target')) return log('client.ts already wired');
const s = read(p);
if (s.includes('x-ps-target')) {
log('client.ts already wired');
return false;
}
const anchor = "headers.set('apikey', supabaseKey);";
if (!s.includes(anchor)) return log('client.ts anchor not found — skipping (template changed?)');
if (!s.includes(anchor)) {
log('client.ts anchor not found — skipping (template changed?)');
return false;
}
writeFileSync(p, s.replace(anchor, anchor + '\n' + CLIENT_TUNNEL));
log('patched client.ts (tunnel Supabase through the guard)');
return true;
}

function patchStart(cwd: string): void {
function patchStart(cwd: string): boolean {
const p = join(cwd, 'src/start.ts');
let s = read(p);
const importAnchor = 'import { createStart, createMiddleware } from "@tanstack/react-start";';
const exportAnchor = 'export const startInstance';
const rmAnchor = 'requestMiddleware: [';
if (!s.includes(importAnchor) || !s.includes(exportAnchor)) {
return log('start.ts anchors not found — skipping (template changed?)');
log('start.ts anchors not found — skipping (template changed?)');
return false;
}

// Each step reconciles idempotently: a re-run (including after a connect upgrade) refreshes the
Expand Down Expand Up @@ -209,26 +225,33 @@ function patchStart(cwd: string): void {
}
}

if (s === original) return log('start.ts already wired');
if (s === original) {
log('start.ts already wired');
return false;
}
writeFileSync(p, s);
log('patched start.ts (guard registered as request + function middleware)');
return true;
}

/** Scaffold + wire the runtime guard into the app. */
export function runProtect(cwd: string, opts: { demo?: boolean } = {}): void {
if (!detectSupportedStack(cwd)) {
log('runtime protection currently supports TanStack Start + Supabase apps; stack not detected — skipping.');
return;
}
scaffold(cwd, opts);
// In demo mode, keep the local sample rules active — don't bake a site UUID (which would make the
// guard fetch live Pulse rules instead of the bundled demo set).
if (!opts.demo) bakeSiteUuid(cwd);
patchClient(cwd);
patchStart(cwd);
function wire(cwd: string, opts: WireOptions): WireResult {
const changed = scaffold(cwd, opts);
// In demo mode, keep the local sample rules active — don't bake a site UUID (which would make
// the guard fetch live Pulse rules instead of the bundled demo set).
if (!opts.demo && bakeSiteUuid(cwd)) changed.push(GUARD_FILE);
if (patchClient(cwd)) changed.push('src/integrations/supabase/client.ts');
if (patchStart(cwd)) changed.push('src/start.ts');
log(
opts.demo
? 'done — guard wired with the demo sample rules (blocks by default). Set PATCHSTACK_MODE=dry-run for log-only.'
: 'done — guard wired and always-on (blocks by default). Set PATCHSTACK_MODE=dry-run for log-only.',
);
return { ok: true, changed: [...new Set(changed)] };
}

export const tanstackSupabaseAdapter: Adapter = {
name: 'tanstack-supabase',
label: 'TanStack Start + Supabase',
detect,
wire,
};
33 changes: 33 additions & 0 deletions src/protect/install/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// `patchstack-connect protect` — scaffold the always-on runtime guard into an app.
//
// Stack-specific wiring lives in one adapter per builder/framework shape (see ./adapters/). This
// orchestrator picks the first adapter that matches the app and delegates. If none matches it
// ESCALATES (never silently skips) — the guard engine itself is stack-agnostic, so an unmatched
// app is a gap in auto-wiring coverage to be closed by a new adapter or an agent-assisted install,
// not a reason to leave the app unprotected quietly.

import { log } from './util.js';
import { tanstackSupabaseAdapter } from './adapters/tanstack-supabase.js';
import type { Adapter, WireOptions, ProtectResult } from './types.js';

// Registry — order = match priority. Add new stacks here.
const ADAPTERS: Adapter[] = [tanstackSupabaseAdapter];

/** Scaffold + wire the runtime guard into the app at `cwd`. Best-effort — never throws. */
export function runProtect(cwd: string, opts: WireOptions = {}): ProtectResult {
const adapter = ADAPTERS.find((a) => a.detect(cwd));

if (!adapter) {
log(
`no built-in adapter matched this app's stack. Auto-wire currently supports: ` +
`${ADAPTERS.map((a) => a.label).join(', ')}. The guard engine is stack-agnostic; ` +
`wiring for this stack needs a new adapter or an agent-assisted install (see AGENT-INSTALL.md) — not skipped silently.`,
);
return { status: 'unsupported', supported: ADAPTERS.map((a) => a.name) };
}

const result = adapter.wire(cwd, opts);
return { status: 'wired', adapter: adapter.name, changed: result.changed };
}

export type { Adapter, WireOptions, WireResult, ProtectResult } from './types.js';
32 changes: 32 additions & 0 deletions src/protect/install/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Stack-adapter contract for `patchstack-connect protect`.
//
// Each adapter knows one builder/framework shape: whether it applies (`detect`) and how to
// scaffold + wire the guard into it (`wire`). The orchestrator (index.ts) picks the first
// matching adapter; if none matches it escalates (never silently skips) so the wiring can be
// handed to the builder's own agent instead of a human.

export interface WireOptions {
/** Seed the broad demo sample rule set instead of the high-precision starter. */
demo?: boolean;
}

export interface WireResult {
ok: boolean;
/** Repo-relative files scaffolded or patched. */
changed: string[];
}

export interface Adapter {
/** Stable id, e.g. "tanstack-supabase". */
name: string;
/** Human label for messages, e.g. "TanStack Start + Supabase". */
label: string;
/** Does this adapter handle the app in `cwd`? */
detect(cwd: string): boolean;
/** Scaffold + wire the guard. Only called when `detect()` returned true. */
wire(cwd: string, opts: WireOptions): WireResult;
}

export type ProtectResult =
| { status: 'wired'; adapter: string; changed: string[] }
| { status: 'unsupported'; supported: string[] };
22 changes: 22 additions & 0 deletions src/protect/install/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Shared helpers for the `patchstack-connect protect` scaffolder (adapters + orchestrator).

import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

export const read = (p: string): string => readFileSync(p, 'utf8');
export const log = (msg: string): void => console.log(`patchstack protect: ${msg}`);

// Guard templates ship next to the built CLI (dist/protect/templates). Resolve for the built
// layout (this code is bundled into dist/cli.js at the dist root → protect/templates) and the
// source layout (this file lives in src/protect/install/ → ../templates).
const HERE = dirname(fileURLToPath(import.meta.url));
export function templatesDir(): string {
const builtLayout = join(HERE, 'protect', 'templates'); // built: dist/cli.js → dist/protect/templates
const candidates = [
builtLayout,
join(HERE, '..', 'templates'), // source: src/protect/install/ → src/protect/templates
join(HERE, 'templates'),
];
return candidates.find((p) => existsSync(p)) ?? builtLayout;
}
19 changes: 18 additions & 1 deletion tests/protect/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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';
import { runProtect } from '../../src/protect/install/index.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 —
Expand Down Expand Up @@ -195,4 +195,21 @@ export const startInstance = createStart(() => ({
expect(existsSync(path.join(plain, 'src/integrations/patchstack/guard.ts'))).toBe(false);
rmSync(plain, { recursive: true, force: true });
});

it('returns a wired result naming the matched adapter + changed files', () => {
const res: any = runProtect(dir);
expect(res.status).toBe('wired');
expect(res.adapter).toBe('tanstack-supabase');
expect(res.changed).toContain('src/integrations/patchstack/guard.ts');
expect(res.changed).toContain('src/start.ts');
});

it('escalates (does not silently skip) on an unmatched stack', () => {
const plain = mkdtempSync(path.join(tmpdir(), 'ps-plain-'));
writeFileSync(path.join(plain, 'package.json'), JSON.stringify({ name: 'x', dependencies: {} }));
const res: any = runProtect(plain);
expect(res.status).toBe('unsupported');
expect(res.supported).toContain('tanstack-supabase');
rmSync(plain, { recursive: true, force: true });
});
});
2 changes: 1 addition & 1 deletion tests/protect/protect-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runProtect } from '../../src/protect/install.js';
import { runProtect } from '../../src/protect/install/index.js';

let dir: string;
beforeEach(() => {
Expand Down
Loading