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
3 changes: 2 additions & 1 deletion src/protect/install/adapters/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)] };
Expand Down
38 changes: 2 additions & 36 deletions src/protect/install/adapters/tanstack-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/protect/install/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/protect/install/seam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)] };
Expand Down
45 changes: 44 additions & 1 deletion src/protect/install/util.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion src/protect/templates/astro-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createProtection>> | 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
Expand Down
4 changes: 3 additions & 1 deletion src/protect/templates/fastify-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createProtection>> | 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
Expand Down
5 changes: 4 additions & 1 deletion src/protect/templates/generic-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createProtection>> | undefined;

/** One memoized protection policy. Rules come from the Patchstack API per-site (cached); the
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/protect/templates/next-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createProtection>> | 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
Expand Down
4 changes: 3 additions & 1 deletion src/protect/templates/sveltekit-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createProtection>> | 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
Expand Down
90 changes: 90 additions & 0 deletions tests/protect/site-uuid-install.test.ts
Original file line number Diff line number Diff line change
@@ -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, string> = {}): 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<string, string>;
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__');
});
});
Loading