Skip to content

Commit 0363312

Browse files
mariojgtclaude
andauthored
Let the deployed guard name the build it runs (#456)
The guard's live report sends `_patchstack.manifest_checksum` from its rules file, but nothing wrote that field, so a live report could only say that some build was serving. The `prebuild` scan now records the checksum of the package list being built there, beside the map stamp, before the bundler reads the file. A server-rendered deployment with no marker on its pages can then say which build it is. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent b446292 commit 0363312

7 files changed

Lines changed: 152 additions & 20 deletions

File tree

‎AGENT-INSTALL.md‎

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ PATCHSTACK_ENVIRONMENT=sandbox npx @patchstack/connect setup
464464

465465
The generated `prebuild` scan deliberately carries no hard-coded environment. A production build with no override reports `production` only because the platform's own variables — its tier, or its branch name — say it is one; a preview or pull request on those platforms reports `sandbox` by itself, and a hosted builder's *workspace* reports `sandbox` for anything but a build lifecycle script, and must receive `PATCHSTACK_ENVIRONMENT=sandbox` from its host only if it runs builds (the Replit workspace reports `sandbox` on its own) — its published build needs no override, because a build in such a project is the publish step. Runtime protection itself is not environment-specific: `PATCHSTACK_ENVIRONMENT` labels manifests only. Use `PATCHSTACK_MODE=dry-run` when protection should observe rather than block.
466466

467-
During a build, the `prebuild` scan removes any previous map stamp. A later `map --upload` in the same pre-bundle lifecycle hashes the map's policy content (excluding analyser timing and memory observations) and records that identity in the guard's existing rules file. The guard presents it on the rules request it already makes; only an explicit Patchstack confirmation naming the same map lets a rule scoped to one of your app's parameter names block. A build with no confirmed identity still enforces every ordinary rule — only scoped rules drop to detect-only, with the reason reported. Outside a pre-bundle hook, `map --upload` changes no file and sends no identity. In any project whose guard has a rules file, unless `.patchstackrc.json` sets `"bundleRules": false`, a `scan` that holds the key also uploads the map, writes its identity and the site's current rules into the guard's rules file (keeping the starter signatures, only when something changed), so a keyless build of the same source — a hosted builder's publish, or any deploy without the key — ships those rules and is recognised. The same runs store a read-only rules credential in that file (`_patchstack.rules_reader`, from `monitor/pulse/rules-reader/<uuid>`), with which the published app fetches live rules and reports detections — it can read this site's rules and report detections for this site, and nothing else. Detections reported on it are stored **unverified** (set `PATCHSTACK_API_KEY` in the deployment to report as verified). See "Which build a rule belongs to" in `AGENT-INSTALL.md`.
467+
During a build, the `prebuild` scan removes any previous map stamp and records the checksum of the package list being built as `_patchstack.manifest_checksum` in the guard's rules file; the deployed guard sends it when it reports the deployment live, so Patchstack knows which build is serving. A later `map --upload` in the same pre-bundle lifecycle hashes the map's policy content (excluding analyser timing and memory observations) and records that identity in the guard's existing rules file. The guard presents it on the rules request it already makes; only an explicit Patchstack confirmation naming the same map lets a rule scoped to one of your app's parameter names block. A build with no confirmed identity still enforces every ordinary rule — only scoped rules drop to detect-only, with the reason reported. Outside a pre-bundle hook, `map --upload` changes no file and sends no identity. In any project whose guard has a rules file, unless `.patchstackrc.json` sets `"bundleRules": false`, a `scan` that holds the key also uploads the map, writes its identity and the site's current rules into the guard's rules file (keeping the starter signatures, only when something changed), so a keyless build of the same source — a hosted builder's publish, or any deploy without the key — ships those rules and is recognised. The same runs store a read-only rules credential in that file (`_patchstack.rules_reader`, from `monitor/pulse/rules-reader/<uuid>`), with which the published app fetches live rules and reports detections — it can read this site's rules and report detections for this site, and nothing else. Detections reported on it are stored **unverified** (set `PATCHSTACK_API_KEY` in the deployment to report as verified). See "Which build a rule belongs to" in `AGENT-INSTALL.md`.
468468

469469
### `scan` as a build hook
470470

‎src/build-stamp.ts‎

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -299,17 +299,23 @@ function detectIndent(text: string): string | number {
299299
return whitespace.includes('\t') ? '\t' : whitespace.length;
300300
}
301301

302+
interface LoadedRulesFile {
303+
kind: 'loaded';
304+
path: string;
305+
file: string;
306+
text: string;
307+
parsed: unknown;
308+
bundle: Record<string, unknown>;
309+
}
310+
302311
/**
303-
* Write `id` into the guard's rules file, or remove any stamp when `id` is null.
312+
* The guard's rules file, read and parsed, or why nothing may be written into it.
304313
*
305314
* Corrupt JSON is left exactly as it is. Replacing it with a fresh bundle would discard whatever the
306-
* app had — possibly rules it is relying on — to fix a field that only affects whether pinned rules may
307-
* enforce. Reporting that stamping was unavailable is the proportionate answer.
308-
*
309-
* Idempotent: a build whose identity has not changed rewrites nothing, so a committed file does not
310-
* churn on every local build.
315+
* app had — possibly rules it is relying on — to fix metadata the rules themselves do not depend on.
316+
* Reporting that stamping was unavailable is the proportionate answer.
311317
*/
312-
export function applyBuildStamp(cwd: string, id: string | null): StampOutcome {
318+
function loadRulesFile(cwd: string): LoadedRulesFile | { kind: 'skipped'; reason: string } {
313319
const location = findRulesFile(cwd);
314320
if (location.kind === 'none') return { kind: 'skipped', reason: 'no guard rules file in this project' };
315321
if (location.kind === 'unreadable') {
@@ -338,6 +344,33 @@ export function applyBuildStamp(cwd: string, id: string | null): StampOutcome {
338344
}
339345
const bundle = ruleBundleOf(parsed);
340346
if (bundle === null) return { kind: 'skipped', reason: `${file} is not a guard rules bundle` };
347+
348+
return { kind: 'loaded', path: location.path, file, text, parsed, bundle };
349+
}
350+
351+
/** Write the bundle back in the file's own indentation, or say why it could not be. */
352+
function saveRulesFile(cwd: string, loaded: LoadedRulesFile): { kind: 'skipped'; reason: string } | null {
353+
const { text, bundle } = loaded;
354+
const serialised = JSON.stringify(bundle, null, detectIndent(text)) + (text.endsWith('\n') ? '\n' : '');
355+
try {
356+
writeProjectFileSync(cwd, loaded.path, serialised, { encoding: 'utf8' });
357+
} catch (err) {
358+
return { kind: 'skipped', reason: `${loaded.file} could not be written (${(err as Error).message})` };
359+
}
360+
361+
return null;
362+
}
363+
364+
/**
365+
* Write `id` into the guard's rules file, or remove any stamp when `id` is null.
366+
*
367+
* Idempotent: a build whose identity has not changed rewrites nothing, so a committed file does not
368+
* churn on every local build.
369+
*/
370+
export function applyBuildStamp(cwd: string, id: string | null): StampOutcome {
371+
const loaded = loadRulesFile(cwd);
372+
if (loaded.kind === 'skipped') return loaded;
373+
const { file, parsed, bundle } = loaded;
341374
const current = readBuildStamp(bundle);
342375
// Clearing keys off the RAW property, not the canonical reading of it. A malformed `build_id` reads as
343376
// null, so comparing canonical values would call a file that still carries `"deadbee"` unchanged and
@@ -351,12 +384,35 @@ export function applyBuildStamp(cwd: string, id: string | null): StampOutcome {
351384
if (Object.keys(namespace).length === 0) delete bundle[BUILD_STAMP_KEY];
352385
else bundle[BUILD_STAMP_KEY] = namespace;
353386

354-
const serialised = JSON.stringify(bundle, null, detectIndent(text)) + (text.endsWith('\n') ? '\n' : '');
355-
try {
356-
writeProjectFileSync(cwd, location.path, serialised, { encoding: 'utf8' });
357-
} catch (err) {
358-
return { kind: 'skipped', reason: `${file} could not be written (${(err as Error).message})` };
359-
}
387+
const failed = saveRulesFile(cwd, loaded);
388+
if (failed !== null) return failed;
360389

361390
return id === null ? { kind: 'cleared', file } : { kind: 'stamped', file, id };
362391
}
392+
393+
/** A manifest checksum as `scan` computes it: twelve lowercase hex characters. */
394+
const MANIFEST_CHECKSUM = /^[0-9a-f]{12}$/;
395+
396+
/**
397+
* Write the checksum of the build about to be compiled into the guard's rules file.
398+
*
399+
* The guard sends it when it reports the deployment live, which names the build that deployment runs.
400+
* Without it, a live report says only that some build is serving. Idempotent like the build stamp.
401+
*/
402+
export function applyManifestChecksum(cwd: string, checksum: string): StampOutcome {
403+
if (!MANIFEST_CHECKSUM.test(checksum)) return { kind: 'skipped', reason: 'not a manifest checksum' };
404+
405+
const loaded = loadRulesFile(cwd);
406+
if (loaded.kind === 'skipped') return loaded;
407+
const { file, parsed, bundle } = loaded;
408+
const namespace = { ...((bundle[BUILD_STAMP_KEY] ?? {}) as Record<string, unknown>) };
409+
if (bundle === parsed && namespace.manifest_checksum === checksum) return { kind: 'unchanged', file };
410+
411+
namespace.manifest_checksum = checksum;
412+
bundle[BUILD_STAMP_KEY] = namespace;
413+
414+
const failed = saveRulesFile(cwd, loaded);
415+
if (failed !== null) return failed;
416+
417+
return { kind: 'stamped', file, id: checksum };
418+
}

‎src/cli.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ import { runDevelopment } from './dev-command.js';
121121
import { checkActivation } from './activation-check.js';
122122
import { syncSetupProtection } from './setup-sync.js';
123123
import { isInstallOrBuildHook, isPreBundleBuildHook, undeliveredReportLines } from './build-hook.js';
124-
import { applyBuildStamp } from './build-stamp.js';
124+
import { applyBuildStamp, applyManifestChecksum } from './build-stamp.js';
125125
import { detectStack, type StackDescriptor } from './stack.js';
126126
import { PatchstackError, type StoreManifestResponse } from './types.js';
127127
import { buildWidgetTag, ensureSourceWidget, ensureWidgetInHtml, removeSourceWidget } from './widget.js';
@@ -873,7 +873,15 @@ async function runScan(
873873
// Independent of the widget: the marker is how Patchstack recognises the live build, with or without
874874
// the widget on the page. The checksum is the manifest this run is posting — on a pre-build hook the
875875
// build about to be compiled — so the page says which build is live, the question the dashboard grades on.
876-
reportSourceMarker(shellFramework, computeManifestChecksum(payload.packages), report);
876+
const buildChecksum = computeManifestChecksum(payload.packages);
877+
reportSourceMarker(shellFramework, buildChecksum, report);
878+
879+
// The same checksum goes into the guard's rules file before the bundler reads it, so the deployed
880+
// guard can name its build when it reports the deployment live, page or no page.
881+
if (isPreBundleBuildHook()) {
882+
const named = applyManifestChecksum(process.cwd(), buildChecksum);
883+
if (named.kind === 'stamped') say(`Named this build (${buildChecksum}) in ${named.file} for the guard's live report.`);
884+
}
877885

878886
const provisioning = config.siteUuid === null;
879887
if (provisioning) {

‎tests/bin-invocation.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,28 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => {
838838
}
839839
});
840840

841+
it('names the build in the guard rules file on a pre-bundle build, and only then', async () => {
842+
const server = await acceptingServer();
843+
const dir = freshProject();
844+
try {
845+
const rules = path.join(dir, 'patchstack.rules.json');
846+
writeFileSync(rules, `${JSON.stringify({ firewall: [], whitelists: [], whitelist_keys: {} }, null, 2)}\n`);
847+
writeFileSync(
848+
path.join(dir, 'guard.ts'),
849+
'import { createProtection } from "@patchstack/connect/protect";\nimport rules from "./patchstack.rules.json";\nvoid createProtection({ rules });\n',
850+
);
851+
852+
await scan(dir, server.endpoint, { npm_lifecycle_event: 'postinstall', npm_lifecycle_script: 'patchstack-connect scan' });
853+
expect(JSON.parse(readFileSync(rules, 'utf8'))._patchstack?.manifest_checksum).toBeUndefined();
854+
855+
await scan(dir, server.endpoint, { npm_lifecycle_event: 'prebuild', npm_lifecycle_script: 'patchstack-connect scan' });
856+
expect(JSON.parse(readFileSync(rules, 'utf8'))._patchstack?.manifest_checksum).toMatch(/^[0-9a-f]{12}$/);
857+
} finally {
858+
await server.close();
859+
rmSync(dir, { recursive: true, force: true });
860+
}
861+
});
862+
841863
it('says a network failure plainly, with the code for support', async () => {
842864
const dir = freshProject();
843865
try {

‎tests/build-stamp.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { basename, dirname, join } from 'node:path';
1111
import { afterEach, describe, expect, it } from 'vitest';
1212

1313
import { hasRawBuildStamp, readBuildStamp } from '../src/build-id.js';
14-
import { applyBuildStamp, findRulesFile } from '../src/build-stamp.js';
14+
import { applyBuildStamp, applyManifestChecksum, findRulesFile } from '../src/build-stamp.js';
1515
import { isPreBundleBuildHook } from '../src/build-hook.js';
1616

1717
const A = 'a'.repeat(64);
@@ -295,6 +295,42 @@ describe('writing the stamp', () => {
295295
});
296296
});
297297

298+
describe('naming the build for the guard’s live report', () => {
299+
const CHECKSUM = 'a1b2c3d4e5f6';
300+
301+
it('writes the checksum where the guard reads it, beside the other reserved fields', () => {
302+
const dir = protectedProject('patchstack.rules.json', bundleText({ _patchstack: { build_id: A, rules_reader: 'reader' } }));
303+
expect(applyManifestChecksum(dir, CHECKSUM)).toEqual({ kind: 'stamped', file: 'patchstack.rules.json', id: CHECKSUM });
304+
305+
const after = JSON.parse(read(dir, 'patchstack.rules.json'));
306+
expect(after._patchstack).toEqual({ build_id: A, rules_reader: 'reader', manifest_checksum: CHECKSUM });
307+
expect(after.firewall).toEqual(BUNDLE.firewall);
308+
});
309+
310+
it('rewrites nothing for the same build, and replaces a previous one', () => {
311+
const dir = protectedProject('patchstack.rules.json', bundleText());
312+
applyManifestChecksum(dir, CHECKSUM);
313+
const first = read(dir, 'patchstack.rules.json');
314+
315+
expect(applyManifestChecksum(dir, CHECKSUM)).toEqual({ kind: 'unchanged', file: 'patchstack.rules.json' });
316+
expect(read(dir, 'patchstack.rules.json')).toBe(first);
317+
318+
expect(applyManifestChecksum(dir, 'ffffffffffff')).toMatchObject({ kind: 'stamped', id: 'ffffffffffff' });
319+
expect(JSON.parse(read(dir, 'patchstack.rules.json'))._patchstack.manifest_checksum).toBe('ffffffffffff');
320+
});
321+
322+
it('writes nothing that is not a checksum, and nothing into a project with no guard', () => {
323+
const dir = protectedProject('patchstack.rules.json', bundleText());
324+
const before = read(dir, 'patchstack.rules.json');
325+
expect(applyManifestChecksum(dir, 'not-a-checksum').kind).toBe('skipped');
326+
expect(read(dir, 'patchstack.rules.json')).toBe(before);
327+
328+
const bare = project({ 'rules.json': bundleText() });
329+
expect(applyManifestChecksum(bare, CHECKSUM).kind).toBe('skipped');
330+
expect(read(bare, 'rules.json')).toBe(bundleText());
331+
});
332+
});
333+
298334
describe('when a build may write to the project', () => {
299335
it('is a pre-bundle build lifecycle, and nothing else', () => {
300336
const before = project({'package.json':JSON.stringify({scripts:{build:'patchstack-connect scan && vite build && patchstack-connect mark-build'}})});

‎tests/protect/rules-reader.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@ describe('a guard without the site credential, shipped with a rules reader', ()
172172
await protection.stop();
173173
});
174174

175+
it('names the build it runs in the live report when its rules file carries the checksum', async () => {
176+
const { protection, calls } = await guard({ rules_reader: READER, manifest_checksum: 'a1b2c3d4e5f6' });
177+
178+
await protection.fetchGuard()(new Request('https://my-live-app.example.com/'));
179+
180+
const live = calls.find((c) => c.url.includes('/live/'));
181+
expect(JSON.parse(String(live?.init.body))).toEqual({ origin: 'https://my-live-app.example.com', checksum: 'a1b2c3d4e5f6' });
182+
await protection.stop();
183+
});
184+
175185
it('reports the forwarded public host, not the internal one, behind a reverse proxy', async () => {
176186
const { protection, calls } = await guard({ rules_reader: READER });
177187

0 commit comments

Comments
 (0)