From 0c554c644596204cd77eddfb4907fab585e8657d Mon Sep 17 00:00:00 2001 From: Elliot Taylor Date: Wed, 15 Jul 2026 19:33:55 +0200 Subject: [PATCH] fix: bake site UUID into generated guards --- src/protect/install/adapters/next.ts | 3 +- .../install/adapters/tanstack-supabase.ts | 38 +------- src/protect/install/generic.ts | 3 +- src/protect/install/seam.ts | 3 +- src/protect/install/util.ts | 45 +++++++++- src/protect/templates/astro-middleware.ts | 4 +- src/protect/templates/fastify-plugin.ts | 4 +- src/protect/templates/generic-guard.ts | 5 +- src/protect/templates/next-middleware.ts | 4 +- src/protect/templates/sveltekit-hooks.ts | 4 +- tests/protect/site-uuid-install.test.ts | 90 +++++++++++++++++++ 11 files changed, 158 insertions(+), 45 deletions(-) create mode 100644 tests/protect/site-uuid-install.test.ts diff --git a/src/protect/install/adapters/next.ts b/src/protect/install/adapters/next.ts index 14b75bd..2c5bc89 100644 --- a/src/protect/install/adapters/next.ts +++ b/src/protect/install/adapters/next.ts @@ -5,7 +5,7 @@ import { writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; import { join } from 'node:path'; -import { read, log, templatesDir } from '../util.js'; +import { bakeSiteUuid, read, log, templatesDir } from '../util.js'; import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js'; function hasNextDep(cwd: string): boolean { @@ -64,6 +64,7 @@ function wire(cwd: string, opts: WireOptions): WireResult { // Fresh (or already-ours) → write the managed middleware. copyFileSync(join(templates, 'next-middleware.ts'), mwPath); + if (!opts.demo) bakeSiteUuid(cwd, mw.relFile); changed.push(mw.relFile); log(mw.exists ? `refreshed ${mw.relFile} (Patchstack middleware)` : `scaffolded ${mw.relFile} (Patchstack middleware)`); return { ok: true, changed: [...new Set(changed)] }; diff --git a/src/protect/install/adapters/tanstack-supabase.ts b/src/protect/install/adapters/tanstack-supabase.ts index 6da6a0d..722e1c2 100644 --- a/src/protect/install/adapters/tanstack-supabase.ts +++ b/src/protect/install/adapters/tanstack-supabase.ts @@ -7,7 +7,7 @@ import { writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; import { join } from 'node:path'; -import { read, log, templatesDir } from '../util.js'; +import { bakeSiteUuid, read, log, templatesDir } from '../util.js'; import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js'; const CLIENT_TUNNEL = [ @@ -133,40 +133,6 @@ function scaffold(cwd: string, opts: WireOptions): string[] { return changed; } -// 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)) { - 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 { - 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). - 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)) { - log('.patchstackrc.json siteUuid missing or malformed — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback'); - return false; - } - const p = join(cwd, GUARD_FILE); - if (!existsSync(p)) return false; - const s = read(p); - 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): boolean { const p = join(cwd, 'src/integrations/supabase/client.ts'); const s = read(p); @@ -238,7 +204,7 @@ 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 (!opts.demo && bakeSiteUuid(cwd, GUARD_FILE)) changed.push(GUARD_FILE); if (patchClient(cwd)) changed.push('src/integrations/supabase/client.ts'); if (patchStart(cwd)) changed.push('src/start.ts'); log( diff --git a/src/protect/install/generic.ts b/src/protect/install/generic.ts index 685a7fd..bdb3165 100644 --- a/src/protect/install/generic.ts +++ b/src/protect/install/generic.ts @@ -5,7 +5,7 @@ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; -import { read, templatesDir } from './util.js'; +import { bakeSiteUuid, read, templatesDir } from './util.js'; import type { WireOptions, VerifyResult } from './types.js'; const GUARD_MARKER = 'patchstack/guard'; @@ -21,6 +21,7 @@ export function scaffoldGeneric(cwd: string, opts: WireOptions, guardTemplate = mkdirSync(dst, { recursive: true }); copyFileSync(join(templates, guardTemplate), join(dst, 'guard.ts')); const changed = [`${dir}/guard.ts`]; + if (!opts.demo) bakeSiteUuid(cwd, `${dir}/guard.ts`); const rulesDst = join(dst, 'rules.json'); if (opts.demo || !existsSync(rulesDst)) { copyFileSync(join(templates, opts.demo ? 'demo-rules.json' : 'rules.json'), rulesDst); diff --git a/src/protect/install/seam.ts b/src/protect/install/seam.ts index 67fa152..bd5fb4e 100644 --- a/src/protect/install/seam.ts +++ b/src/protect/install/seam.ts @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import { read, log, templatesDir } from './util.js'; +import { bakeSiteUuid, read, log, templatesDir } from './util.js'; import type { WireOptions, WireResult, VerifyResult } from './types.js'; export interface SeamSpec { @@ -43,6 +43,7 @@ export function wireSeam(cwd: string, opts: WireOptions, spec: SeamSpec): WireRe } copyFileSync(join(templates, spec.templateName), join(cwd, seamRel)); + if (!opts.demo) bakeSiteUuid(cwd, seamRel); changed.push(seamRel); log(existing ? `refreshed ${seamRel}` : `scaffolded ${seamRel}`); return { ok: true, changed: [...new Set(changed)] }; diff --git a/src/protect/install/util.ts b/src/protect/install/util.ts index 82121e7..5d1de4b 100644 --- a/src/protect/install/util.ts +++ b/src/protect/install/util.ts @@ -1,6 +1,6 @@ // Shared helpers for the `patchstack-connect protect` scaffolder (adapters + orchestrator). -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -17,6 +17,49 @@ export function hasDependency(cwd: string, name: string): boolean { } } +const SITE_UUID_PLACEHOLDER = '__PATCHSTACK_SITE_UUID__'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Bake the site UUID written by `scan` into a managed runtime-guard template. + * + * The UUID is public project configuration (the disclosure widget exposes the + * same value). Keeping the environment-variable fallback in the template lets + * unscanned projects remain inert and lets deployments override it explicitly. + */ +export function bakeSiteUuid(cwd: string, guardRelPath: string): boolean { + const rc = join(cwd, '.patchstackrc.json'); + if (!existsSync(rc)) { + log('no .patchstackrc.json — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback'); + return false; + } + + let uuid: unknown; + try { + uuid = JSON.parse(read(rc)).siteUuid; + } catch { + log('.patchstackrc.json unreadable — skipping site-UUID bake'); + return false; + } + + if (typeof uuid !== 'string' || !UUID_RE.test(uuid)) { + log('.patchstackrc.json siteUuid missing or malformed — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback'); + return false; + } + + const guardPath = join(cwd, guardRelPath); + if (!existsSync(guardPath)) return false; + const source = read(guardPath); + if (!source.includes(SITE_UUID_PLACEHOLDER)) { + log(`${guardRelPath} site UUID already baked`); + return false; + } + + writeFileSync(guardPath, source.replace(SITE_UUID_PLACEHOLDER, uuid)); + log(`baked site UUID into ${guardRelPath} — live rules from the Patchstack API`); + return true; +} + // 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). diff --git a/src/protect/templates/astro-middleware.ts b/src/protect/templates/astro-middleware.ts index a03f784..c07b33c 100644 --- a/src/protect/templates/astro-middleware.ts +++ b/src/protect/templates/astro-middleware.ts @@ -5,12 +5,14 @@ import type { MiddlewareHandler } from "astro"; import { createProtection } from "@patchstack/connect/protect"; import fallbackRules from "./patchstack.rules.json"; +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; + let _protection: Awaited> | undefined; async function getProtection() { if (!_protection) { const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = process.env.PATCHSTACK_SITE_UUID; + const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; const common = { mode, egress: true } as const; _protection = await createProtection( siteUuid diff --git a/src/protect/templates/fastify-plugin.ts b/src/protect/templates/fastify-plugin.ts index d8d949a..7c1d5a9 100644 --- a/src/protect/templates/fastify-plugin.ts +++ b/src/protect/templates/fastify-plugin.ts @@ -5,12 +5,14 @@ import { createProtection } from "@patchstack/connect/protect"; import fallbackRules from "./rules.json"; +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; + let _protection: Awaited> | undefined; async function getProtection() { if (!_protection) { const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = process.env.PATCHSTACK_SITE_UUID; + const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; const common = { mode, egress: true } as const; _protection = await createProtection( siteUuid diff --git a/src/protect/templates/generic-guard.ts b/src/protect/templates/generic-guard.ts index 14e523f..aadfe3b 100644 --- a/src/protect/templates/generic-guard.ts +++ b/src/protect/templates/generic-guard.ts @@ -7,6 +7,9 @@ import { createProtection } from "@patchstack/connect/protect"; import fallbackRules from "./rules.json"; +// Baked by `patchstack-connect protect` from .patchstackrc.json when available. +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; + let _protection: Awaited> | undefined; /** One memoized protection policy. Rules come from the Patchstack API per-site (cached); the @@ -15,7 +18,7 @@ export async function getProtection() { if (!_protection) { const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = process.env.PATCHSTACK_SITE_UUID; + const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; const common = { mode, egress: true } as const; _protection = await createProtection( siteUuid diff --git a/src/protect/templates/next-middleware.ts b/src/protect/templates/next-middleware.ts index f3f7758..7ba5d19 100644 --- a/src/protect/templates/next-middleware.ts +++ b/src/protect/templates/next-middleware.ts @@ -5,12 +5,14 @@ import { createProtection } from "@patchstack/connect/protect"; import fallbackRules from "./patchstack.rules.json"; +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; + let _protection: Awaited> | undefined; async function getProtection() { if (!_protection) { const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = process.env.PATCHSTACK_SITE_UUID; + const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; const common = { mode, egress: true } as const; _protection = await createProtection( siteUuid diff --git a/src/protect/templates/sveltekit-hooks.ts b/src/protect/templates/sveltekit-hooks.ts index 7fcfeac..2c1bfdc 100644 --- a/src/protect/templates/sveltekit-hooks.ts +++ b/src/protect/templates/sveltekit-hooks.ts @@ -5,12 +5,14 @@ import type { Handle } from "@sveltejs/kit"; import { createProtection } from "@patchstack/connect/protect"; import fallbackRules from "./patchstack.rules.json"; +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; + let _protection: Awaited> | undefined; async function getProtection() { if (!_protection) { const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = process.env.PATCHSTACK_SITE_UUID; + const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; const common = { mode, egress: true } as const; _protection = await createProtection( siteUuid diff --git a/tests/protect/site-uuid-install.test.ts b/tests/protect/site-uuid-install.test.ts new file mode 100644 index 0000000..08d6b79 --- /dev/null +++ b/tests/protect/site-uuid-install.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runProtect } from '../../src/protect/install/index.js'; + +const UUID = '3f1a9c2e-1b4d-4c8a-9e2f-7a6b5c4d3e2f'; +const dirs: string[] = []; + +function fixture(pkg: object, files: Record = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'ps-site-uuid-')); + dirs.push(dir); + writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg)); + writeFileSync(join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: UUID })); + for (const [rel, contents] of Object.entries(files)) { + mkdirSync(join(dir, rel, '..'), { recursive: true }); + writeFileSync(join(dir, rel), contents); + } + return dir; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('runtime guard site UUID propagation', () => { + const cases: Array<{ + name: string; + pkg: object; + files?: Record; + guard: string; + }> = [ + { + name: 'generic fallback', + pkg: { name: 'generic-app' }, + guard: 'patchstack/guard.ts', + }, + { + name: 'Express', + pkg: { dependencies: { express: '^4.21.2' } }, + files: { 'src/server.ts': "import express from 'express';\nconst app = express();\n" }, + guard: 'src/patchstack/guard.ts', + }, + { + name: 'Fastify', + pkg: { dependencies: { fastify: '^4.26.0' } }, + files: { 'src/server.ts': "import Fastify from 'fastify';\nconst app = Fastify();\n" }, + guard: 'src/patchstack/guard.ts', + }, + { + name: 'NestJS', + pkg: { dependencies: { '@nestjs/core': '^10.0.0' } }, + files: { 'src/main.ts': 'const app = await NestFactory.create(AppModule);\n' }, + guard: 'src/patchstack/guard.ts', + }, + { + name: 'Next.js', + pkg: { dependencies: { next: '^14.0.0' } }, + guard: 'middleware.ts', + }, + { + name: 'SvelteKit', + pkg: { devDependencies: { '@sveltejs/kit': '^2.0.0' } }, + guard: 'src/hooks.server.ts', + }, + { + name: 'Astro', + pkg: { dependencies: { astro: '^4.0.0' } }, + guard: 'src/middleware.ts', + }, + ]; + + for (const testCase of cases) { + it(`bakes .patchstackrc.json into the ${testCase.name} guard`, () => { + const dir = fixture(testCase.pkg, testCase.files); + runProtect(dir); + + const guard = readFileSync(join(dir, testCase.guard), 'utf8'); + expect(guard).toContain(UUID); + expect(guard).not.toContain('__PATCHSTACK_SITE_UUID__'); + }); + } + + it('keeps the placeholder in demo mode so local demo rules stay active', () => { + const dir = fixture({ dependencies: { next: '^14.0.0' } }); + runProtect(dir, { demo: true }); + + expect(readFileSync(join(dir, 'middleware.ts'), 'utf8')).toContain('__PATCHSTACK_SITE_UUID__'); + }); +});