diff --git a/package-lock.json b/package-lock.json index 2e35a5f..4b6575f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@patchstack/connect", "version": "0.5.24", "license": "MIT", + "dependencies": { + "typescript": "^5.4.0" + }, "bin": { "patchstack-connect": "dist/cli.js" }, @@ -15,7 +18,6 @@ "@types/node": "^26.5.0", "fastify": "^5.12.1", "tsup": "^8.0.0", - "typescript": "^5.4.0", "vitest": "^4.1.11", "yaml": "^2.9.0" }, @@ -3032,7 +3034,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 0247dfe..847428b 100644 --- a/package.json +++ b/package.json @@ -96,11 +96,13 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, + "dependencies": { + "typescript": "^5.4.0" + }, "devDependencies": { "@types/node": "^26.5.0", "fastify": "^5.12.1", "tsup": "^8.0.0", - "typescript": "^5.4.0", "vitest": "^4.1.11", "yaml": "^2.9.0" }, diff --git a/src/protect/install/adapters/tanstack-supabase.ts b/src/protect/install/adapters/tanstack-supabase.ts index 3a754b5..1b878fa 100644 --- a/src/protect/install/adapters/tanstack-supabase.ts +++ b/src/protect/install/adapters/tanstack-supabase.ts @@ -10,17 +10,20 @@ import { join } from 'node:path'; import { bakeSiteUuid, read, log, templatesDir } from '../util.js'; 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'; const CLIENT_TUNNEL = [ '', " // PATCHSTACK: in the browser, tunnel Supabase traffic through the app's own server guard", ' // (same-origin) so payloads are inspected before they reach Supabase.', " if (typeof window !== 'undefined') {", - " const target = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);", - " const method = init?.method ?? (input instanceof Request ? input.method : 'GET');", - " headers.set('x-ps-target', target);", + ' const original = new Request(input, init);', " const guardUrl = new URL('/_patchstack/guard', window.location.origin).toString();", - ' return fetch(guardUrl, { ...init, method, headers });', + ' const forwarded = new Request(guardUrl, original);', + ' headers.forEach((value, key) => forwarded.headers.set(key, value));', + " forwarded.headers.set('x-ps-target', original.url);", + ' return fetch(forwarded);', ' }', '', ].join('\n'); @@ -116,8 +119,11 @@ function scaffold(cwd: string, opts: WireOptions): string[] { const templates = templatesDir(); const dst = join(cwd, 'src/integrations/patchstack'); ensureProjectDirectorySync(cwd, dst); - copyProjectFileSync(cwd, join(templates, 'guard.ts'), join(dst, 'guard.ts')); // guard.ts is managed — always refreshed - const changed = [GUARD_FILE]; + const changed: string[] = []; + if (!existsSync(join(dst, 'guard.ts'))) { + copyProjectFileSync(cwd, join(templates, 'guard.ts'), join(dst, '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. @@ -135,43 +141,92 @@ function scaffold(cwd: string, opts: WireOptions): string[] { return changed; } -function patchClient(cwd: string): boolean { - const p = join(cwd, 'src/integrations/supabase/client.ts'); - const s = read(p); +function patchClient(ts: Compiler, s: string): string | null { if (s.includes('x-ps-target')) { - log('client.ts already wired'); - return false; + return s; } - const anchor = "headers.set('apikey', supabaseKey);"; - if (!s.includes(anchor)) { - log('client.ts anchor not found — skipping (template changed?)'); - return false; - } - writeProjectFileSync(cwd, p, s.replace(anchor, anchor + '\n' + CLIENT_TUNNEL), { encoding: 'utf8' }); - log('patched client.ts (tunnel Supabase through the guard)'); - return true; + const tree = parsedSource(ts, 'client.ts', s); + if (!tree) return null; + const anchors: number[] = []; + const visit = (node: import('typescript').Node) => { + if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) { + const call = node.expression; + if (call.expression.getText(tree) === 'headers.set' + && call.arguments[0] && ts.isStringLiteral(call.arguments[0]) && call.arguments[0].text === 'apikey' + && call.arguments[1]?.getText(tree) === 'supabaseKey') anchors.push(node.end); + } + ts.forEachChild(node, visit); + }; + visit(tree); + if (anchors.length !== 1) return null; + return s.slice(0, anchors[0]) + '\n' + CLIENT_TUNNEL + s.slice(anchors[0]); } -function patchStart(cwd: string): boolean { - const p = join(cwd, 'src/start.ts'); - let s = read(p); +function patchStart(ts: Compiler, original: string): string | null { + let s = original; const importAnchor = 'import { createStart, createMiddleware } from "@tanstack/react-start";'; const exportAnchor = 'export const startInstance'; const rmAnchor = 'requestMiddleware: ['; + const tree = parsedSource(ts, 'start.ts', s); + if (!tree) return null; + const startImport = tree.statements.find(node => ts.isImportDeclaration(node) + && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@tanstack/react-start' + && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) + && node.importClause.namedBindings.elements.length === 2 + && node.importClause.namedBindings.elements.every(e => !e.propertyName && !e.isTypeOnly + && ['createStart', 'createMiddleware'].includes(e.name.text))); + if (!startImport) return null; + const declaration = tree.statements.filter(ts.isVariableStatement) + .filter(node => node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) + .flatMap(node => [...node.declarationList.declarations]) + .find(node => ts.isIdentifier(node.name) && node.name.text === 'startInstance'); + const call = declaration?.initializer; + if (!call || !ts.isCallExpression(call) || call.expression.getText(tree) !== 'createStart' || call.arguments.length !== 1) return null; + const factory = call.arguments[0]!; + if (!ts.isArrowFunction(factory) || factory.parameters.length) return null; + let config = factory.body; + while (ts.isParenthesizedExpression(config)) config = config.expression; + if (!ts.isObjectLiteralExpression(config)) return null; + if (config.properties.some(p => !ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name))) return null; + const edits = [{ start: startImport.getStart(tree), end: startImport.end, text: importAnchor }]; + for (const name of ['requestMiddleware', 'functionMiddleware']) { + const properties = config.properties.filter(p => p.name?.getText(tree) === name); + if (properties.length > 1 || (name === 'requestMiddleware' && !properties.length)) return null; + const property = properties[0]; + if (!property || !ts.isPropertyAssignment(property)) continue; + if (!ts.isArrayLiteralExpression(property.initializer)) return null; + edits.push({ start: property.getStart(tree), end: property.initializer.getStart(tree) + 1, text: `${name}: [` }); + } + const guardImports = tree.statements.filter(node => ts.isImportDeclaration(node) + && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@/integrations/patchstack/guard'); + if (guardImports.length > 1) return null; + const guardImport = guardImports[0]; + if (guardImport) { + if (!ts.isImportDeclaration(guardImport) || !guardImport.importClause?.namedBindings + || !ts.isNamedImports(guardImport.importClause.namedBindings) + || guardImport.importClause.namedBindings.elements.some(e => e.propertyName || e.isTypeOnly + || !['GUARD_PATH', 'handleGuardRequest', 'inspectServerFn', 'screenResponse', 'guardRequest'].includes(e.name.text))) return null; + edits.push({ start: guardImport.getStart(tree), end: guardImport.end, text: GUARD_IMPORT }); + } + for (const edit of edits.sort((a, b) => b.start - a.start)) s = s.slice(0, edit.start) + edit.text + s.slice(edit.end); if (!s.includes(importAnchor) || !s.includes(exportAnchor)) { - log('start.ts anchors not found — skipping (template changed?)'); - return false; + return null; } + if (!s.includes(rmAnchor)) return null; // Each step reconciles idempotently: a re-run (including after a connect upgrade) refreshes the // managed blocks in place — never duplicates, never leaves a stale version behind. - const original = s; // Imports — refresh the managed guard import line wholesale (upgrade), else insert both imports. if (GUARD_IMPORT_RE.test(s)) { s = s.replace(GUARD_IMPORT_RE, GUARD_IMPORT); } else { - s = s.replace(importAnchor, importAnchor + '\n' + START_IMPORTS); + const requestImport = tree.statements.some(node => ts.isImportDeclaration(node) + && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@tanstack/react-start/server' + && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) + && node.importClause.namedBindings.elements.some(e => !e.propertyName && e.name.text === 'getRequest')); + if (!requestImport && /\bgetRequest\b/.test(original)) return null; + s = s.replace(importAnchor, importAnchor + '\n' + (requestImport ? GUARD_IMPORT : START_IMPORTS)); } // Middleware blocks — upgrade a marked region / migrate a legacy block / insert fresh. @@ -193,22 +248,42 @@ function patchStart(cwd: string): boolean { } } - if (s === original) { - log('start.ts already wired'); - return false; - } - writeProjectFileSync(cwd, p, s, { encoding: 'utf8' }); - log('patched start.ts (guard registered as request + function middleware)'); - return true; + return parsedSource(ts, 'start.ts', s) ? s : null; } function wire(cwd: string, opts: WireOptions): WireResult { + const ts = sourceCompiler(cwd); + const clientPath = join(cwd, 'src/integrations/supabase/client.ts'); + const startPath = join(cwd, 'src/start.ts'); + const oldClient = read(clientPath); + const oldStart = read(startPath); + const client = ts && patchClient(ts, oldClient); + const start = ts && patchStart(ts, oldStart); + if (!ts || !client || !start || !parsedSource(ts, 'client.ts', client)) { + log('TanStack wiring needs manual integration: client and server left untouched; no browser traffic was redirected.'); + return { ok: false, changed: [] }; + } const changed = scaffold(cwd, opts); + if (!matchesGuardTemplate(cwd, GUARD_FILE, 'guard.ts')) { + log('Custom guard helper needs manual review; client and server entries were left untouched.'); + return { ok: false, changed }; + } // 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, GUARD_FILE)) changed.push(GUARD_FILE); - if (patchClient(cwd)) changed.push('src/integrations/supabase/client.ts'); - if (patchStart(cwd)) changed.push('src/start.ts'); + try { + if (start !== oldStart) { + writeProjectFileSync(cwd, startPath, start); + changed.push('src/start.ts'); + } + if (client !== oldClient) { + writeProjectFileSync(cwd, clientPath, client); + changed.push('src/integrations/supabase/client.ts'); + } + } catch (error) { + if (start !== oldStart) writeProjectFileSync(cwd, startPath, oldStart); + throw error; + } log( opts.demo ? 'done — guard wired with the demo sample rules (blocks by default). Set PATCHSTACK_MODE=dry-run for log-only.' @@ -227,6 +302,7 @@ function verify(cwd: string): VerifyResult { const checks = [ { label: 'guard.ts scaffolded', ok: guard.length > 0, hint: 'run `patchstack-connect protect`' }, + { label: 'guard helper implementation verified', ok: matchesGuardTemplate(cwd, GUARD_FILE, 'guard.ts'), hint: 'preserved custom helpers require manual review before redirecting browser traffic' }, { label: 'Supabase client tunnels through the guard', ok: client.includes('x-ps-target'), hint: 'run `patchstack-connect protect` to re-patch src/integrations/supabase/client.ts' }, { label: 'request middleware defined + registered', ok: start.includes('const patchstackGuard =') && start.includes('requestMiddleware: [patchstackGuard'), hint: 'run `patchstack-connect protect` to re-patch src/start.ts' }, { label: 'server-function middleware defined + registered', ok: start.includes('const patchstackFunctionGuard =') && start.includes('functionMiddleware: [patchstackFunctionGuard'), hint: 'run `patchstack-connect protect` to re-patch src/start.ts' }, diff --git a/src/protect/install/generic.ts b/src/protect/install/generic.ts index 5774494..bbcad72 100644 --- a/src/protect/install/generic.ts +++ b/src/protect/install/generic.ts @@ -9,6 +9,7 @@ import { bakeSiteUuid, hasDependency, read, templatesDir } from './util.js'; 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 { stripComments, maskStringContents, @@ -104,10 +105,13 @@ export function scaffoldGeneric( const dir = genericDir(cwd); const dst = join(cwd, dir); ensureProjectDirectorySync(cwd, dst); - copyProjectFileSync(cwd, join(templates, guardTemplate), join(dst, guardFile)); const guardRel = `${dir}/${guardFile}`; - const changed = [guardRel]; - if (!opts.demo) bakeSiteUuid(cwd, guardRel); + const changed: string[] = []; + if (!existsSync(join(dst, guardFile))) { + copyProjectFileSync(cwd, join(templates, guardTemplate), join(dst, guardFile)); + changed.push(guardRel); + } + if (!opts.demo && matchesGuardTemplate(cwd, guardRel, guardTemplate)) bakeSiteUuid(cwd, guardRel); const rulesDst = join(dst, 'rules.json'); if (opts.demo || !existsSync(rulesDst)) { copyProjectFileSync(cwd, join(templates, opts.demo ? 'demo-rules.json' : 'rules.json'), rulesDst); @@ -318,11 +322,14 @@ export function genericVerify(cwd: string): VerifyResult { // `tsconfig.json` after setup ran still has the guard it was scaffolded. const present = GUARD_FILENAMES.find((name) => existsSync(join(cwd, dir, name))); const scaffolded = present !== undefined; + const template = present?.endsWith('.ts') ? 'generic-guard.ts' : present?.endsWith('.cjs') ? 'generic-guard.cjs' : 'generic-guard.js'; + const helperVerified = !!present && matchesGuardTemplate(cwd, `${dir}/${present}`, template); const imported = scaffolded && guardIsImported(cwd, join(cwd, dir, present)); return { - wired: scaffolded && imported, + wired: scaffolded && helperVerified && imported, checks: [ { label: 'generic guard scaffolded', ok: scaffolded, hint: 'run `patchstack-connect protect`' }, + { label: 'guard helper implementation verified', ok: helperVerified, hint: 'preserved custom helpers require manual review of their exports and request/response screening' }, { label: 'guard imported and called in a server entry', ok: imported, diff --git a/src/protect/install/index.ts b/src/protect/install/index.ts index 8e80a4c..108ad99 100644 --- a/src/protect/install/index.ts +++ b/src/protect/install/index.ts @@ -56,7 +56,10 @@ export function runProtect(cwd: string, opts: WireOptions = {}): ProtectResult { adapter = ADAPTERS.find((a) => a.detect(cwd)); if (adapter) { const result = adapter.wire(cwd, opts); - return { status: 'wired', adapter: adapter.name, changed: result.changed }; + const verification = adapter.verify(cwd); + if (result.ok && verification.wired) return { status: 'wired', adapter: adapter.name, changed: result.changed }; + const plan = verification.checks.filter(check => !check.ok).map(check => check.hint ?? check.label).join('\n'); + return { status: 'scaffolded', adapter: adapter.name, changed: result.changed, plan }; } } catch (err) { // A wire/detect failure (read-only FS, EACCES, a bad source file) must not crash the CLI — diff --git a/src/protect/install/register.ts b/src/protect/install/register.ts index 17ea6dd..b357e18 100644 --- a/src/protect/install/register.ts +++ b/src/protect/install/register.ts @@ -1,7 +1,6 @@ // Shared "register-into-app" wiring for adapters whose framework exposes a mutable app instance you // hook a guard onto (Express `app.use`, Fastify `app.register`, NestJS `app.use` on main.ts). Scaffold -// the guard, then insert the registration call right after the app instance is created — dependency-free -// anchor + #region-marker patching, idempotent. +// the guard, then register it after a parsed app-initialization or body-parser statement. import { existsSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; @@ -20,6 +19,8 @@ import { } from './source-scope.js'; import type { WireOptions, WireResult, VerifyResult } from './types.js'; import { writeProjectFileSync } from '../../safe-file.js'; +import { parsedSource, sourceCompiler, statementEndLine, statementPosition } from './syntax.js'; +import { matchesGuardTemplate } from './template-match.js'; /** * A route or router registration — `app.get(...)`, `app.post(...)`, `app.use('/path', router)`. @@ -31,15 +32,15 @@ import { writeProjectFileSync } from '../../safe-file.js'; const routeRegistration = (appVar: string) => new RegExp( `^\\s*${appVar}\\.(?:get|post|put|patch|delete|head|options|all|route)\\(|` + - `^\\s*${appVar}\\.use\\(\\s*['"\`]`, + `^\\s*${appVar}\\.use\\(\\s*(?:['"\`]|[\\w$]+(?:\\.[\\w$]+)*\\s*[,\\)])`, 'm', ); /** * Route lines that come before `guardIndex`, if any. * - * `app.use('/path', router)` counts: mounting a router registers everything in it. A bare `app.use(fn)` does - * not — that is middleware, and middleware ordering is what the parser anchor already handles. + * Both prefixed and bare router mounts count. An identifier passed to `app.use` can be a router, so it + * cannot safely be treated as a non-terminating middleware merely because the prefix is absent. */ export function routesBefore(source: string, appVar: string, guardIndex: number): number[] { if (guardIndex < 0) return []; @@ -160,7 +161,7 @@ function wiringState( ); const appIndex = maskedLines.findIndex((line) => spec.appRe.test(line)); const anchor = spec.callAfter ? spec.callAfter(appVar) : null; - const anchorIndex = anchor ? maskedLines.findIndex((line) => anchor.test(line)) : -1; + const anchorIndex = anchor ? maskedLines.reduce((last, line, index) => anchor.test(line) ? index : last, -1) : -1; return { importAtTopLevel: importIndex !== -1 && isTopLevelLine(code, importIndex), @@ -245,8 +246,18 @@ export function wireRegister(cwd: string, opts: WireOptions, spec: RegisterSpec) const target = guardTarget(cwd, entry.relPath, spec); const { changed, dir } = scaffoldGeneric(cwd, opts, target.template, target.file); + if (!matchesGuardTemplate(cwd, `${dir}/${target.file}`, target.template)) { + log(`${dir}/${target.file} needs manual review; the existing helper and ${entry.relPath} were left untouched.`); + return { ok: false, changed }; + } + const p = join(cwd, entry.relPath); const s = read(p); + const compiler = sourceCompiler(cwd); + if (!compiler || !parsedSource(compiler, entry.relPath, s)) { + log(`${entry.relPath} left untouched: a working TypeScript parser is required for source edits; ${spec.manualHint}`); + return { ok: false, changed }; + } const state = wiringState(s, spec, entry.appVar, entry.relPath, { cwd, guardDir: dir }); if (state.importAtTopLevel && state.callInAppScope) { // Wired, but not necessarily in the right place. The guard reads a parsed body, so a registration above @@ -291,7 +302,9 @@ export function wireRegister(cwd: string, opts: WireOptions, spec: RegisterSpec) // registration statement below stays top level and refers to a name that is not there. if (!state.importAtTopLevel) { const lastImport = lastTopLevelImportLine(s); - const importIdx = lastImport === -1 ? firstStatementLine(lines) : lastImport + 1; + const importEnd = lastImport === -1 ? -1 : statementEndLine(compiler, entry.relPath, s, lastImport); + if (importEnd === null) return { ok: false, changed }; + const importIdx = lastImport === -1 ? firstStatementLine(lines) : importEnd + 1; lines.splice(importIdx, 0, importLine); } @@ -300,13 +313,32 @@ export function wireRegister(cwd: string, opts: WireOptions, spec: RegisterSpec) if (appIdx !== -1 && !state.callInAppScope) { const preferred = spec.callAfter?.(entry.appVar); const preferredIdx = preferred - ? lines.findIndex((line, index) => index > appIdx && preferred.test(line)) + ? lines.reduce((last, line, index) => index > appIdx && preferred.test(line) + && inSameBlockAfter(lines.join('\n'), appIdx, index) ? index : last, -1) : -1; const callIdx = preferredIdx === -1 ? appIdx : preferredIdx; - lines.splice(callIdx + 1, 0, REGION, spec.call(entry.appVar), '// #endregion patchstack'); + if (spec.requireCallAfter && preferredIdx === -1) return { ok: false, changed }; + const current = lines.join('\n'); + const statement = statementPosition(compiler, entry.relPath, current, callIdx, preferredIdx === -1 ? spec.appRe : preferred!); + if (statement === null) return { ok: false, changed }; + const { start, end } = statement; + const tail = current.slice(end); + const trailingComment = /^[ \t]*\/\//.test(tail); + const insertion = trailingComment ? current.indexOf('\n', end) : end; + const position = insertion === -1 ? current.length : insertion; + // End the statement explicitly before inserting; a following `(` must not trigger ASI continuation. + const separator = current.slice(0, end).trimEnd().endsWith(';') ? '' : ';'; + const prefix = current.slice(current.lastIndexOf('\n', start - 1) + 1, start).trim(); + const updated = current.slice(0, start) + (prefix ? '\n' : '') + current.slice(start, end) + separator + current.slice(end, position) + + '\n' + REGION + '\n' + spec.call(entry.appVar) + '\n// #endregion patchstack\n' + current.slice(position); + lines.splice(0, lines.length, ...updated.split('\n')); } const patched = lines.join('\n'); + if (!parsedSource(compiler, entry.relPath, patched)) { + log(`${entry.relPath} would not parse after patching — left untouched; ${spec.manualHint}`); + return { ok: false, changed }; + } writeProjectFileSync(cwd, p, patched, { encoding: 'utf8' }); // Said at install time, because this is the moment somebody is looking. The guard goes after the body @@ -367,6 +399,8 @@ function serverIsGuarded( server: { relPath: string; appVar: string }, guardDir: string, ): boolean { + const target = guardTarget(cwd, server.relPath, spec); + if (!matchesGuardTemplate(cwd, `${guardDir}/${target.file}`, target.template)) return false; const source = read(join(cwd, server.relPath)); const state = wiringState(source, spec, server.appVar, server.relPath, { cwd, guardDir }); if (!state.importAtTopLevel || !state.callInAppScope || !state.ordered) return false; @@ -379,6 +413,7 @@ export function verifyRegister(cwd: string, spec: RegisterSpec): VerifyResult { const entry = findAppInstance(cwd, spec.appRe); const target = entry ? guardTarget(cwd, entry.relPath, spec) : null; const scaffolded = target ? existsSync(join(cwd, dir, target.file)) : false; + const helperVerified = !!target && matchesGuardTemplate(cwd, `${dir}/${target.file}`, target.template); const entrySource = entry ? read(join(cwd, entry.relPath)) : ''; const state = entry ? wiringState(entrySource, spec, entry.appVar, entry.relPath, { cwd, guardDir: dir }) @@ -398,9 +433,10 @@ export function verifyRegister(cwd: string, spec: RegisterSpec): VerifyResult { .map((other) => other.relPath); return { - wired: scaffolded && wired && noEarlyRoutes && unguarded.length === 0, + wired: scaffolded && helperVerified && wired && noEarlyRoutes && unguarded.length === 0, checks: [ { label: `${spec.label} guard scaffolded`, ok: scaffolded, hint: 'run `patchstack-connect protect`' }, + { label: 'guard helper implementation verified', ok: helperVerified, hint: 'preserved custom helpers require manual review of their exports and request/response screening' }, { label: `guard registered on the ${spec.label}`, ok: wired, hint: spec.manualHint }, ...(entry && entry.others.length > 0 ? [ diff --git a/src/protect/install/syntax.ts b/src/protect/install/syntax.ts new file mode 100644 index 0000000..f986665 --- /dev/null +++ b/src/protect/install/syntax.ts @@ -0,0 +1,52 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import type tsType from 'typescript'; + +export type Compiler = typeof tsType; + +/** Resolve a parser without evaluating application source or configuration. */ +export function sourceCompiler(cwd: string): Compiler | null { + for (const base of [join(cwd, 'package.json'), import.meta.url]) { + try { return createRequire(base)('typescript') as Compiler; } catch { /* optional compiler */ } + } + return null; +} + +export function parsedSource(ts: Compiler, file: string, source: string) { + const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + return (tree as typeof tree & { parseDiagnostics: unknown[] }).parseDiagnostics.length ? null : tree; +} + +/** A complete statement, not the first line of an import, call, or declaration. */ +export function statementEndLine(ts: Compiler, file: string, source: string, line: number): number | null { + const tree = parsedSource(ts, file, source); + if (!tree) return null; + let end: number | null = null; + const visit = (node: tsType.Node) => { + if ((ts.isImportDeclaration(node) || ts.isVariableStatement(node) || ts.isExpressionStatement(node)) + && tree.getLineAndCharacterOfPosition(node.getStart(tree)).line === line) { + const tail = source.slice(node.end).split('\n', 1)[0]!.trim(); + if (tail !== '' && !tail.startsWith('//')) return; + end = tree.getLineAndCharacterOfPosition(node.end).line; + return; + } + ts.forEachChild(node, visit); + }; + visit(tree); + return end; +} + +/** Locate a single matching statement, including inside a compact bootstrap function. */ +export function statementPosition(ts: Compiler, file: string, source: string, line: number, matches: RegExp): {start: number; end: number} | null { + const tree = parsedSource(ts, file, source); + if (!tree) return null; + const statements: {start: number; end: number}[] = []; + const visit = (node: tsType.Node) => { + if ((ts.isVariableStatement(node) || ts.isExpressionStatement(node)) + && tree.getLineAndCharacterOfPosition(node.getStart(tree)).line === line + && matches.test(node.getText(tree))) statements.push({start: node.getStart(tree), end: node.end}); + ts.forEachChild(node, visit); + }; + visit(tree); + return statements.length === 1 ? statements[0]! : null; +} diff --git a/src/protect/install/template-match.ts b/src/protect/install/template-match.ts new file mode 100644 index 0000000..9ffce37 --- /dev/null +++ b/src/protect/install/template-match.ts @@ -0,0 +1,33 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { parsedSource, sourceCompiler, type Compiler } from './syntax.js'; +import { read, templatesDir } from './util.js'; + +/** Unknown executable helper code requires manual integration, not an automatic import. */ +export function matchesGuardTemplate(cwd: string, file: string, template: string): boolean { + if (!existsSync(join(cwd, file))) return false; + const ts = sourceCompiler(cwd); + if (!ts) return false; + const actual = canonical(ts, file, read(join(cwd, file))); + const expected = canonical(ts, template, read(join(templatesDir(), template))); + return actual !== null && expected !== null && actual === expected; +} + +function canonical(ts: Compiler, file: string, source: string): string | null { + const tree = parsedSource(ts, file, source); + if (!tree) return null; + const normalized = ts.transform(tree, [context => root => { + const visit: import('typescript').Visitor = node => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === 'PS_SITE_UUID' + && node.initializer && ts.isStringLiteral(node.initializer)) { + return ts.factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, + ts.factory.createStringLiteral('__PATCHSTACK_SITE_UUID__')); + } + if (ts.isStringLiteral(node)) return ts.factory.createStringLiteral(node.text); + return ts.visitEachChild(node, visit, context); + }; + return ts.visitEachChild(root, visit, context); + }]); + try { return ts.createPrinter({ removeComments: true }).printFile(normalized.transformed[0]!); } + finally { normalized.dispose(); } +} diff --git a/tests/guide.test.ts b/tests/guide.test.ts index f7a572d..fbe6ca3 100644 --- a/tests/guide.test.ts +++ b/tests/guide.test.ts @@ -44,7 +44,7 @@ describe('guide', () => { const writeGenericProtection = (underSrc = false): void => { const root = underSrc ? path.join(cwd, 'src') : cwd; mkdirSync(path.join(root, 'patchstack'), { recursive: true }); - writeFileSync(path.join(root, 'patchstack', 'guard.ts'), 'export const protectFetch = () => {};'); + writeFileSync(path.join(root, 'patchstack', 'guard.ts'), readFileSync(new URL('../src/protect/templates/generic-guard.ts', import.meta.url))); // Imported AND called. An import on its own wraps no request, so a fixture that stopped at the import // would be describing a project the checklist should not call done. writeFileSync( diff --git a/tests/protect/injection-safety.test.ts b/tests/protect/injection-safety.test.ts new file mode 100644 index 0000000..b1d497b --- /dev/null +++ b/tests/protect/injection-safety.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import ts from 'typescript'; +import { runProtect, runVerify } from '../../src/protect/install/index.js'; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); +function project(dependencies: Record, files: Record) { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const cwd = mkdtempSync(join(tmpdir(), 'ps-injection-')); + dirs.push(cwd); + for (const [file, source] of Object.entries({ 'package.json': JSON.stringify({ type: 'module', dependencies }), ...files })) { + mkdirSync(dirname(join(cwd, file)), { recursive: true }); + writeFileSync(join(cwd, file), source); + } + return cwd; +} +const read = (cwd: string, file: string) => readFileSync(join(cwd, file), 'utf8'); +const syntaxErrors = (source: string) => (ts.createSourceFile('test.ts', source, ts.ScriptTarget.Latest, true) as ts.SourceFile & {parseDiagnostics: unknown[]}).parseDiagnostics; + +describe('complete-statement registration', () => { + it.each(['ts', 'js'])('preserves multiline imports and parsers in %s', ext => { + const file = `src/server.${ext}`; + const cwd = project({ express: '^5' }, { [file]: `import express, { + json, +} from 'express'; +const app = express(); +app.use(json({ + limit: '1mb', +})); +app.use(express.urlencoded({ extended: false })); +app.post('/submit', handler); +` }); + runProtect(cwd); + const source = read(cwd, file); + expect(syntaxErrors(source)).toEqual([]); + expect(source.indexOf('app.use(patchstackMiddleware)')).toBeGreaterThan(source.indexOf('extended: false')); + expect(runVerify(cwd).wired).toBe(true); + runProtect(cwd); + expect(read(cwd, file)).toBe(source); + }); + + it.each([ + ['fastify', "import fastify from 'fastify';\nconst app = fastify({\n logger: true,\n});\napp.listen({port:3000});\n"], + ['@nestjs/core', "import { NestFactory } from '@nestjs/core';\nasync function bootstrap() {\nconst app = await NestFactory.create(\n AppModule,\n {rawBody: true},\n);\nawait app.listen(3000);\n}\nbootstrap();\n"], + ['@nestjs/core', "import { NestFactory } from '@nestjs/core';\nasync function main(){ const app = await NestFactory.create(AppModule); app.use(express.json()); await app.listen(3000); }\nmain();\n"], + ])('preserves multiline %s initialization', (dependency, source) => { + const cwd = project({ [dependency]: '*' }, { 'src/server.ts': source }); + runProtect(cwd); + expect(syntaxErrors(read(cwd, 'src/server.ts'))).toEqual([]); + expect(runVerify(cwd).wired).toBe(true); + }); + + it('reports an unprefixed router above the guard', () => { + const cwd = project({ express: '^5' }, { 'server.ts': "import express from 'express';\nconst app = express();\napp.use(router);\napp.use(express.json());\n" }); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(runVerify(cwd).wired).toBe(false); + }); + + it('does not overwrite a customized helper on a rerun', () => { + const cwd = project({ express: '^5' }, { 'server.ts': "import express from 'express';\nconst app = express();\napp.use(express.json());\n" }); + runProtect(cwd); + const file = 'patchstack/guard.ts'; + const customized = read(cwd, file) + '\n// Application-specific configuration.\n'; + writeFileSync(join(cwd, file), customized); + runProtect(cwd); + expect(read(cwd, file)).toBe(customized); + expect(runVerify(cwd).wired).toBe(true); + }); + + it.each([ + ['js', 'export function applicationGuard() {}'], + ['js', 'export function patchstackMiddleware(req, res, next) { next(); }'], + ['ts', 'export function patchstackMiddleware(req: unknown, res: unknown, next: () => void) { next(); }'], + ['cjs', 'module.exports = { applicationGuard() {} };'], + ['cjs', 'module.exports = { patchstackMiddleware(req, res, next) { next(); } };'], + ])('does not wire an unverified %s helper: %s', (ext, helper) => { + const source = ext === 'cjs' + ? "const express = require('express');\nconst app = express();\napp.use(express.json());\n" + : "import express from 'express';\nconst app = express();\napp.use(express.json());\n"; + const file = `src/server.${ext}`; + const guard = `src/patchstack/guard.${ext}`; + const cwd = project({ express: '^5' }, { [file]: source, [guard]: helper }); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd, file)).toBe(source); + expect(read(cwd, guard)).toBe(helper); + expect(runVerify(cwd).wired).toBe(false); + }); + + it('reports a helper changed after installation instead of claiming protection', () => { + const cwd = project({ express: '^5' }, { 'server.ts': "import express from 'express';\nconst app = express();\napp.use(express.json());\n" }); + runProtect(cwd); + const entry = read(cwd, 'server.ts'); + const helper = 'export function patchstackMiddleware(req: unknown, res: unknown, next: () => void) { next(); }'; + writeFileSync(join(cwd, 'patchstack/guard.ts'), helper); + expect(runVerify(cwd).wired).toBe(false); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd, 'server.ts')).toBe(entry); + expect(read(cwd, 'patchstack/guard.ts')).toBe(helper); + }); +}); + +const client = `export function createSupabaseFetch(supabaseKey: string): typeof fetch { + return (input, init) => { + const headers = new Headers(init?.headers); + headers.set("apikey", supabaseKey); + return fetch(input, { ...init, headers }); + }; +}`; +const start = `import { createMiddleware, createStart } from '@tanstack/react-start'; +import { getRequest } from '@tanstack/react-start/server'; +export const startInstance = createStart(() => ({ + requestMiddleware : [], +}));`; +const clientFile = 'src/integrations/supabase/client.ts'; +function tanstack(startSource = start, clientSource = client) { + return project({ '@tanstack/react-start': '^1' }, { 'src/start.ts': startSource, [clientFile]: clientSource }); +} + +describe('TanStack paired edits', () => { + it('leaves both entries alone when a custom helper cannot be verified', () => { + const cwd = tanstack(); + const file = 'src/integrations/patchstack/guard.ts'; + mkdirSync(dirname(join(cwd, file)), { recursive: true }); + const helper = 'export const GUARD_PATH = "/custom";'; + writeFileSync(join(cwd, file), helper); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd, clientFile)).toBe(client); + expect(read(cwd, 'src/start.ts')).toBe(start); + expect(read(cwd, file)).toBe(helper); + expect(runVerify(cwd).wired).toBe(false); + }); + it('accepts formatting variations and reuses an existing request import', () => { + const cwd = tanstack(); + expect(runProtect(cwd).status).toBe('wired'); + const source = read(cwd, 'src/start.ts'); + expect(source.match(/import \{ getRequest \}/g)).toHaveLength(1); + expect(syntaxErrors(source)).toEqual([]); + runProtect(cwd); + expect(read(cwd, 'src/start.ts')).toBe(source); + }); + + it.each([ + start.replace('requestMiddleware : []', 'requestMiddleware: middlewareFromConfig'), + start.replace('requestMiddleware : []', '...externalConfig, requestMiddleware: []'), + start.replace('createStart(() => ({', 'createStart(dynamicConfig, () => ({'), + ])('does not redirect the client when the server cannot be composed', source => { + const cwd = tanstack(source); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd, clientFile)).toBe(client); + expect(read(cwd, 'src/start.ts')).toBe(source); + expect(runVerify(cwd).wired).toBe(false); + }); + + it('does not change the server when the client is unsupported', () => { + const cwd = tanstack(start, 'export const client = createClient(url, key);'); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd, 'src/start.ts')).toBe(start); + }); + + it('preserves Request bodies, headers, overrides and cancellation when tunneling', async () => { + const cwd = tanstack(); + runProtect(cwd); + const source = ts.transpileModule(read(cwd, clientFile), {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText.replace('export ', ''); + const fetched: Request[] = []; + const fetcher = new Function('window', 'fetch', source + '; return createSupabaseFetch("public-test-key");')( + { location:{origin:'https://app.example'} }, async (request: Request) => { fetched.push(request); return new Response('ok'); }, + ) as typeof fetch; + const controller = new AbortController(); + const input = new Request('https://backend.example/rest/v1/items', {method:'POST',body:'original',headers:{authorization:'Bearer synthetic'},signal:controller.signal}); + await fetcher(input, {body:'overridden'}); + const forwarded = fetched[0]!; + expect(forwarded.url).toBe('https://app.example/_patchstack/guard'); + expect(forwarded.method).toBe('POST'); + expect(await forwarded.text()).toBe('overridden'); + expect(forwarded.headers.get('authorization')).toBe('Bearer synthetic'); + expect(forwarded.headers.get('x-ps-target')).toBe(input.url); + controller.abort(); + expect(forwarded.signal.aborted).toBe(true); + await fetcher('https://backend.example/rest/v1/items'); + expect(fetched[1]!.method).toBe('GET'); + expect(fetched[1]!.body).toBeNull(); + }); +}); diff --git a/tests/protect/install-scope.test.ts b/tests/protect/install-scope.test.ts index a4d9731..c6890ad 100644 --- a/tests/protect/install-scope.test.ts +++ b/tests/protect/install-scope.test.ts @@ -256,7 +256,7 @@ describe('wiring an Express entry', () => { }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(false); }); @@ -443,7 +443,7 @@ describe('a name in a comment is not wiring', () => { }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(false); }); @@ -467,7 +467,7 @@ describe('a name in a comment is not wiring', () => { }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(false); }); @@ -537,7 +537,7 @@ describe('a name in a comment is not wiring', () => { }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(false); }); @@ -614,7 +614,7 @@ describe('the module a guard binding comes from', () => { }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(false); }); @@ -662,7 +662,7 @@ describe('the module a guard binding comes from', () => { }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(false); }); @@ -682,7 +682,7 @@ describe('the module a guard binding comes from', () => { }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(false); }); @@ -782,7 +782,7 @@ describe('a guard already registered in the wrong place', () => { }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); const said: string[] = []; const original = console.log; @@ -812,7 +812,7 @@ describe('what counts as the guard module itself', () => { function scaffoldedGuard(cwd: string): void { mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), 'module.exports = { patchstackMiddleware: () => {} };\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.cjs'), readFileSync(new URL('../../src/protect/templates/express-guard.cjs', import.meta.url))); writeFileSync(join(cwd, 'src/patchstack/rules.json'), '{"firewall":[],"whitelists":[]}\n'); } @@ -881,7 +881,7 @@ describe('what counts as the guard module itself', () => { ].join('\n'), }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.ts'), 'export const patchstackMiddleware = () => {};\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.ts'), readFileSync(new URL('../../src/protect/templates/express-guard.ts', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(true); }); @@ -902,7 +902,7 @@ describe('what counts as the guard module itself', () => { ].join('\n'), }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.js'), 'export const patchstackMiddleware = () => {};\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.js'), readFileSync(new URL('../../src/protect/templates/express-guard.js', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(false); }); @@ -926,7 +926,7 @@ describe('what counts as the guard module itself', () => { ].join('\n'), }); mkdirSync(join(cwd, 'src/patchstack'), { recursive: true }); - writeFileSync(join(cwd, 'src/patchstack/guard.ts'), 'export const patchstackMiddleware = () => {};\n'); + writeFileSync(join(cwd, 'src/patchstack/guard.ts'), readFileSync(new URL('../../src/protect/templates/express-guard.ts', import.meta.url))); expect(expressAdapter.verify(cwd).wired).toBe(true); }); diff --git a/tsup.config.ts b/tsup.config.ts index eefb58e..aa7cf2b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -34,8 +34,8 @@ export default defineConfig([ target: 'node20', banner: { js: '#!/usr/bin/env node' }, // `map` parses the target app's source with a TypeScript compiler resolved at RUNTIME (the app's - // own `typescript`, or the environment's). Never bundle the compiler into the CLI — it's a heavy - // devDependency and the runtime guard never needs it. + // own `typescript`, or Connect's CLI dependency). Keep it external to the bundles: the runtime + // guard does not import the compiler. external: ['typescript'], }, {