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
7 changes: 6 additions & 1 deletion src/protect/install/adapters/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { bakeSiteUuid, hasDependency, read, log, templatesDir } from '../util.js
import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js';
import { copyProjectFileSync, ensureProjectDirectorySync, writeProjectFileSync } from '../../../safe-file.js';
import { composeNextMiddleware, composeNextRoute, nextCompiler, nextSourceWired, standardNextRouting, NEXT_MARKER, ROUTE_MARKER } from './next-source.js';
import { installTemplate } from '../template-upgrade.js';

function middlewareInfo(cwd: string) {
const candidates = ['middleware.ts', 'middleware.js', 'src/middleware.ts', 'src/middleware.js'];
Expand Down Expand Up @@ -85,7 +86,10 @@ function wire(cwd: string, opts: WireOptions): WireResult {
const guardPath = join(cwd, mw.guard);
const guardConflict = existsSync(guardPath) && !sharedGuardPresent(guardPath);
const ensureGuard = () => {
if (existsSync(guardPath)) return;
if (existsSync(guardPath)) {
if (mw.guard.endsWith('.ts') && installTemplate(cwd, mw.guard, 'next-guard.ts')) changed.push(mw.guard);
return;
}
const source = read(join(templates, 'next-guard.ts'));
writeProjectFileSync(cwd, guardPath, mw.guard.endsWith('.js')
? ts!.transpileModule(source, { compilerOptions: { target: ts!.ScriptTarget.ES2022, module: ts!.ModuleKind.ESNext } }).outputText
Expand All @@ -101,6 +105,7 @@ function wire(cwd: string, opts: WireOptions): WireResult {
changed.push(mw.relFile);
log(`scaffolded ${mw.relFile} (request-phase guard)`);
} else if (existing.includes(NEXT_MARKER) || existing.includes('#region patchstack-next (')) {
if (existing.includes('#region patchstack-next (') && installTemplate(cwd, mw.relFile, 'next-middleware.ts')) changed.push(mw.relFile);
log(`${mw.relFile} already has a Patchstack guard — left as-is`);
if (ts && existing.includes(NEXT_MARKER)) ensureGuard();
} else {
Expand Down
6 changes: 2 additions & 4 deletions src/protect/install/adapters/tanstack-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js
import { copyProjectFileSync, ensureProjectDirectorySync, writeProjectFileSync } from '../../../safe-file.js';
import { parsedSource, sourceCompiler, type Compiler } from '../syntax.js';
import { matchesGuardTemplate } from '../template-match.js';
import { installTemplate } from '../template-upgrade.js';

const CLIENT_TUNNEL = [
'',
Expand Down Expand Up @@ -120,10 +121,7 @@ function scaffold(cwd: string, opts: WireOptions): string[] {
const dst = join(cwd, 'src/integrations/patchstack');
ensureProjectDirectorySync(cwd, dst);
const changed: string[] = [];
if (!existsSync(join(dst, 'guard.ts'))) {
copyProjectFileSync(cwd, join(templates, 'guard.ts'), join(dst, 'guard.ts'));
changed.push(GUARD_FILE);
}
if (installTemplate(cwd, GUARD_FILE, 'guard.ts')) changed.push(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.
Expand Down
6 changes: 2 additions & 4 deletions src/protect/install/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { WireOptions, VerifyResult } from './types.js';
import type { GuardModuleQuery } from './source-scope.js';
import { copyProjectFileSync, ensureProjectDirectorySync } from '../../safe-file.js';
import { matchesGuardTemplate } from './template-match.js';
import { installTemplate } from './template-upgrade.js';
import {
stripComments,
maskStringContents,
Expand Down Expand Up @@ -107,10 +108,7 @@ export function scaffoldGeneric(
ensureProjectDirectorySync(cwd, dst);
const guardRel = `${dir}/${guardFile}`;
const changed: string[] = [];
if (!existsSync(join(dst, guardFile))) {
copyProjectFileSync(cwd, join(templates, guardTemplate), join(dst, guardFile));
changed.push(guardRel);
}
if (installTemplate(cwd, guardRel, guardTemplate)) changed.push(guardRel);
if (!opts.demo && matchesGuardTemplate(cwd, guardRel, guardTemplate)) bakeSiteUuid(cwd, guardRel);
const rulesDst = join(dst, 'rules.json');
if (opts.demo || !existsSync(rulesDst)) {
Expand Down
28 changes: 23 additions & 5 deletions src/protect/install/seam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { join, dirname } from 'node:path';
import { bakeSiteUuid, read, log, templatesDir } from './util.js';
import type { WireOptions, WireResult, VerifyResult } from './types.js';
import { copyProjectFileSync, ensureProjectDirectorySync } from '../../safe-file.js';
import { parsedSource, sourceCompiler } from './syntax.js';
import { installTemplate } from './template-upgrade.js';

export interface SeamSpec {
templateName: string; // template copied to the seam target when none exists
Expand Down Expand Up @@ -39,9 +41,8 @@ export function wireSeam(cwd: string, opts: WireOptions, spec: SeamSpec): WireRe

const current = existing ? read(join(cwd, existing)) : '';
if (existing && current.includes(spec.marker)) {
// Already ours — do NOT re-copy the template over it: the whole seam file is user-editable
// (unlike the tanstack region-marked blocks), so overwriting would discard any edits.
log(`${existing} already has the Patchstack guard — left as-is`);
// Only an unchanged generated file can be upgraded; customized hooks remain user-owned.
if (installTemplate(cwd, seamRel, spec.templateName)) changed.push(seamRel);
return { ok: true, changed };
}
if (existing) {
Expand All @@ -59,13 +60,30 @@ export function wireSeam(cwd: string, opts: WireOptions, spec: SeamSpec): WireRe
export function verifySeam(cwd: string, spec: SeamSpec): VerifyResult {
const existing = spec.candidates.find((c) => existsSync(join(cwd, c)));
const seamRel = existing ?? spec.target;
const present = existing ? read(join(cwd, existing)).includes(spec.marker) : false;
const present = existing ? templateWiringPresent(cwd, existing, spec.templateName) : false;
const rulesPresent = existsSync(join(cwd, rulesRel(seamRel)));
return {
wired: present && rulesPresent,
checks: [
{ label: `${spec.seamLabel} present`, ok: present, hint: `run \`patchstack-connect protect\` (writes ${spec.target})` },
{ label: `${spec.seamLabel} wiring verified`, ok: present, hint: `run \`patchstack-connect protect\`; customized hooks need manual verification (${spec.target})` },
{ label: 'rules co-located with the guard', ok: rulesPresent, hint: 'run `patchstack-connect protect`' },
],
};
}

/** Compare executable hook statements, not comments or strings that merely name a guard. */
export function templateWiringPresent(cwd: string, file: string, template: string): boolean {
const ts = sourceCompiler(cwd);
if (!ts) return false;
const actual = parsedSource(ts, file, read(join(cwd, file)));
const expected = parsedSource(ts, template, read(join(templatesDir(), template)));
if (!actual || !expected) return false;
const printer = ts.createPrinter({ removeComments: true });
const emit = (node: import('typescript').Node, tree: import('typescript').SourceFile) => printer.printNode(ts.EmitHint.Unspecified, node, tree);
const required = expected.statements.filter(node =>
ts.isExportAssignment(node) || (ts.canHaveModifiers(node) && ts.getModifiers(node)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword))
|| (ts.isFunctionDeclaration(node) && node.name?.text === 'getProtection')
|| (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@patchstack/connect/protect'));
const code = actual.statements.map(node => emit(node, actual));
return required.length >= 3 && required.every(node => code.filter(text => text === emit(node, expected)).length === 1);
}
46 changes: 46 additions & 0 deletions src/protect/install/template-upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { read, log, templatesDir } from './util.js';
import { writeProjectFileSync } from '../../safe-file.js';

// Fingerprints of public generated helpers. Only the baked site identity is excluded.
const PREVIOUS: Record<string, string> = {
'generic-guard.ts': '2c6bb8324b909e646049c36725704b474a8bfb1b6849c392eb3462eed83727e9',
'generic-guard.js': '42357bcf0cb0541e584821d03e06ced28f98d4ef91a76a50e39008ef18c63709',
'generic-guard.cjs': '8de21893474a3139eff9e8fc48a0c13e976e64421281db9e8e93b0c0549361b8',
'express-guard.ts': '6c30cc82b7cbd3fee62d423fcb20b27eba1d6cc1ed054efcf9d64eb360bc7b17',
'express-guard.js': 'bce7c76a061297621791d4ebf838449e3880d05f333588fed7bf88431d1e712e',
'express-guard.cjs': '3a6430f00fc159c28a897e700cc492c306f39b556f0f4cb4b1e718993b6c40b4',
'fastify-plugin.ts': '5d606cb8f8f2e6fe35b9d377c5568c5a2090a81af9f0acb219ebc15a713792e9',
'fastify-plugin.js': '03327d437513571d53554ca34c84ba170ebeebb5e89c2602ae340fb82178ab48',
'fastify-plugin.cjs': 'b3754bf023ebb5f2a22e0a95cfe74bb783034ec3db9ce61a3e7202237e09e1dd',
'guard.ts': '270e29769f5a7b3ab70ee39970be5a4e150e1b3949877687dca1ed83596afae7',
'sveltekit-hooks.ts': '0a68389ac9018dc0ab5805356ad78301f039cb4c701bf642e7f986cc959da990',
'astro-middleware.ts': 'f217e302fb7019de451c7538e9266a5ce38aa6a6651072b6291d4593b31f60ce',
'nuxt-middleware.ts': 'e377c48cc8c08d20c61c6119bb5f4c8991f0eed3ebd31310b8a0fc6eb5505e4e',
'next-middleware.ts': '65c163cdb2ddda4fe53c1a3cd20aa6ab63ad7595a66985d9141d595374ecfab3',
'next-guard.ts': 'e37c4d67619df9d850a7d94b567bd76841b6ac8e774a5b442689482f5374c451',
};
const identity = /const PS_SITE_UUID = "([^"]*)";/;
const normalized = (source: string) => source.replace(identity, 'const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";');

/** Install a missing helper or upgrade a known unmodified one, retaining its baked site UUID. */
export function installTemplate(cwd: string, file: string, template: string): boolean {
const next = read(join(templatesDir(), template));
const target = join(cwd, file);
if (!existsSync(target)) {
writeProjectFileSync(cwd, target, next);
return true;
}
const previous = read(target);
if (normalized(previous) === normalized(next)) return false;
if (createHash('sha256').update(normalized(previous)).digest('hex') !== PREVIOUS[template]) {
log(`${file} is customized or unrecognized — preserved; review its request/response wiring and rule-refresh settings manually.`);
return false;
}
const site = identity.exec(previous)?.[1];
writeProjectFileSync(cwd, target, site ? next.replace(identity, () => `const PS_SITE_UUID = ${JSON.stringify(site)};`) : next);
log(`updated unchanged generated helper ${file}`);
return true;
}
4 changes: 2 additions & 2 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -1681,14 +1681,14 @@ export async function createProtection(options = {}) {

// Wrap a fetch handler: screens the request, then the response (redact/block).
fetch(handler) {
return async (request, ...rest) => {
return async function (request, ...rest) {
// The request phase's own resolution is carried into the response phase rather than the response
// screening making a second one. Two resolutions for one request can disagree, and a response
// detection naming a different address than the request detection describes two clients that do
// not exist.
const { blocked, client } = await screenFetchRequest(request, rest);
if (blocked) return blocked;
const response = await handler(request, ...rest);
const response = await handler.call(this, request, ...rest);

return screenResp(response, reqContextFromFetch(request, client));
};
Expand Down
9 changes: 5 additions & 4 deletions src/protect/templates/astro-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ async function getProtection() {
}

async function buildProtection() {
const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
const token = process.env.PATCHSTACK_WAF_TOKEN;
const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
const common = { mode, egress: true } as const;
const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block";
const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN);
const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID;
const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000;
const common = { mode, egress: true, refreshMs } as const;
return createProtection(
siteUuid
? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
Expand Down
13 changes: 5 additions & 8 deletions src/protect/templates/express-guard.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,10 @@ async function getProtection() {
}

async function buildProtection() {
const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
const token = process.env.PATCHSTACK_WAF_TOKEN;
const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
// The sandbox dev server is long-lived and isn't restarted on change, so refresh the live
// rules periodically — a dependency flagged after boot is then enforced without a restart.
// Production relies on a redeploy (which re-fetches at boot), so refresh stays off there.
const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0;
const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block";
const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN);
const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID;
const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000;
const common = { mode, egress: true, refreshMs };
return createProtection(
siteUuid
Expand Down Expand Up @@ -85,7 +82,7 @@ function patchstackMiddleware(req, res, next) {
// there is nothing to answer and the request is screened as normal.
const screen = () => {
getProtection().then(
(active) => active.express()(req, res, carryOn),
(active) => active.express({ screenResponses: true })(req, res, carryOn),
(err) => {
psStepAside(err);
carryOn();
Expand Down
13 changes: 5 additions & 8 deletions src/protect/templates/express-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,10 @@ async function getProtection() {
}

async function buildProtection() {
const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
const token = process.env.PATCHSTACK_WAF_TOKEN;
const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
// The sandbox dev server is long-lived and isn't restarted on change, so refresh the live
// rules periodically — a dependency flagged after boot is then enforced without a restart.
// Production relies on a redeploy (which re-fetches at boot), so refresh stays off there.
const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0;
const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block";
const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN);
const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID;
const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000;
const common = { mode, egress: true, refreshMs };
return createProtection(
siteUuid
Expand Down Expand Up @@ -86,7 +83,7 @@ export function patchstackMiddleware(req, res, next) {
// there is nothing to answer and the request is screened as normal.
const screen = () => {
getProtection().then(
(active) => active.express()(req, res, carryOn),
(active) => active.express({ screenResponses: true })(req, res, carryOn),
(err) => {
psStepAside(err);
carryOn();
Expand Down
13 changes: 5 additions & 8 deletions src/protect/templates/express-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,10 @@ async function getProtection() {
}

async function buildProtection() {
const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
const token = process.env.PATCHSTACK_WAF_TOKEN;
const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
// The sandbox dev server is long-lived and isn't restarted on change, so refresh the live
// rules periodically — a dependency flagged after boot is then enforced without a restart.
// Production relies on a redeploy (which re-fetches at boot), so refresh stays off there.
const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0;
const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block";
const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN);
const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID;
const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000;
const common = { mode, egress: true, refreshMs } as const;
return createProtection(
siteUuid
Expand Down Expand Up @@ -82,7 +79,7 @@ export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: un
// there is nothing to answer and the request is screened as normal.
const screen = () => {
getProtection().then(
(protection) => (protection.express() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
(protection) => (protection.express({ screenResponses: true }) as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
(err) => {
psStepAside(err);
carryOn();
Expand Down
Loading
Loading