[ENG-3568] Pulse ingest authentication — 2/3 connect bearer token - #149
Merged
Conversation
Implements the connect half of ADR-0018. Build-time Pulse calls now carry a bearer token when a credential is available, and behave exactly as before when one is not. - `pulseAuth` is a new field in .patchstackrc.json and PATCHSTACK_PULSE_AUTH, kept separate from `apiKey` so the block-log path is untouched. It falls back to `apiKey`, so sites provisioned before this change authenticate without re-provisioning. - src/pulse-token.ts exchanges that credential at monitor/pulse/token and caches the token on expires_in with skew and single-flight. Deliberately separate from the token flow in protect/firewall-log.js, which keeps talking to the auth/ Lambda unchanged. - manifest, input-map and package-removed send Authorization when a token is available. Failure to obtain one yields no header rather than an error, so the request falls through to the server's legacy UUID path. The guard's runtime rules fetch is not included: it runs in edge runtimes without node:fs and reads env directly, so it needs its own change on the protection path. Full suite passes: 1172 tests. Co-Authored-By: Claude <noreply@anthropic.com>
|
Introduces Pulse authentication with clear patterns and thorough tests. 🎯 Quality: 87% Excellent · 📦 Size: Medium 📈 This month: Your 54th PR — above team average · Averaging Good |
Losing .patchstackrc.json currently means a site can never authenticate
again, since the credential is issued once and never re-revealed. This adds
the RFC 8628 device flow that recovers it.
$ npx @patchstack/connect login
Your code: WDJB-MJHT
Approve at: https://app.patchstack.com/activate
The device code stays in the process; the short user code is what the human
carries to the browser, and it is useless without the device code. Approval
happens in the dashboard and requires an existing owner, so the CLI proves
nothing on its own — starting a flow is deliberately unauthenticated.
Approving rotates the credential, so recovery and rotation are one operation
and a leaked old credential stops working.
Refuses to run in CI: it is interactive, and build logs are exactly the
disclosure channel this work exists to close. Deploys use
PATCHSTACK_PULSE_AUTH from the platform's secret store.
Named `login` rather than `refresh` because the bearer token already
refreshes itself unattended; this is for the durable credential.
Full suite passes: 1178 tests.
Co-Authored-By: Claude <noreply@anthropic.com>
The install docs described apiKey as block-log-only and never mentioned pulseAuth, which scan now also writes. An agent following them would have had no idea the second field exists or what it is for. - states plainly that scan writes both credentials and there is no manual step, so an agent never invents or asks for these values - separates what each credential authenticates, and that pulseAuth falls back to apiKey so older projects keep working - adds PATCHSTACK_PULSE_AUTH to the CI guidance, and notes that login is interactive and refuses to run there - points at `connect login` for a lost credential rather than deleting the file and re-provisioning, which would create a second site Behaviour unchanged; this is documentation only. Where the durable credential should ultimately live is still the open decision in ADR-0018, so the existing commit guidance is left as it stands. Co-Authored-By: Claude <noreply@anthropic.com>
Approving a login rotates oauth_clients.secret, and block-log reporting authenticates with that same secret. login persisted only pulseAuth, so apiKey was left holding a value the server had just invalidated — block-log delivery would have stopped silently the first time anyone recovered a credential. Persists both fields, and asserts it. Co-Authored-By: Claude <noreply@anthropic.com>
A cached token can stop being valid before it expires — the credential may have been rotated or revoked meanwhile — so the server's 401 is authoritative over our local clock. Without this a long-running process kept presenting a dead token until its own expiry, and rotating a credential could not reach an already-running guard. Adds pulseFetch, which attaches the bearer, and on 401 drops the cached token, re-exchanges and retries the request once. The four Pulse calls in client.ts now go through it instead of setting the header themselves, so the behaviour is in one place rather than four. Only 401 retries: a 403 is a scope or site mismatch that a fresh token would not fix. A request that was unauthenticated to begin with is not retried either — its 401 was about something other than the token. Full suite passes: 1183 tests. Co-Authored-By: Claude <noreply@anthropic.com>
Contributor
Author
|
/review |
The last Pulse call still going out on the UUID alone. With this, every endpoint in ADR-0018 can be enforced; pulse/rules could not be before. resolvePulseAuth mirrors the existing resolveApiKey — options, then PATCHSTACK_PULSE_AUTH, then .patchstackrc.json — including its lazy node:fs import, so runtimes without a filesystem degrade instead of failing to load. It falls back to apiKey, so guards installed before pulseAuth existed keep authenticating. Resolved once at boot and threaded through ctx rather than re-read on every refresh. Protection never hinges on getting a token: no credential, a rejected exchange or an offline token endpoint all yield no header, and the request goes out as it does today. Tested for all three. Also widens the edge-safe invariant. It scanned src/protect/ only, and pulse-client now imports src/pulse-token.ts from outside that directory, so a Node builtin added there would have gone unnoticed. The check now follows that import. Full suite passes: 1213 tests. Co-Authored-By: Claude <noreply@anthropic.com>
daniloradovic
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🥞 Merge order: 2 of 3 — needs patchstack/saas#1326 (1/3) deployed first, since it adds the
monitor/pulse/tokenendpoint. Safe to merge before then: without the endpoint the exchange simply fails and requests stay unauthenticated.Implements the
connecthalf of ADR-0018.Nothing breaks if anything goes wrong
Failure to obtain a token yields no header, not an error — the request falls through to the server's legacy UUID path, which stays dual-accept. No credential, a rejected exchange, an offline network: all degrade to today's behaviour.
Full suite passes: 1172 tests, 96 files.
pulseAuthis a separate fieldNew in
.patchstackrc.jsonand asPATCHSTACK_PULSE_AUTH, deliberately not reusingapiKey:{ "siteUuid": "…", "apiKey": "…", // block-logs — untouched "pulseAuth": "…" // Pulse ingest — new }Same value today, but the two paths can diverge later without disturbing each other.
pulseAuthfalls back toapiKey, so sites provisioned before this change authenticate with no re-provision.What sends a token
pulse/manifest(with UUID)pulse/manifest(bootstrap)pulse/input-mappulse/package-removedapi/logs/log(block-logs)Deliberately not included
The guard's runtime rules fetch. It runs in edge runtimes without
node:fs, reads env directly, and sits on the protection path — it needs its own change with its own care.pulse/rulestherefore can't be enforced until that lands.Note for review
src/pulse-token.tsduplicates roughly 30 lines of cache-and-exchange logic that already exists inprotect/firewall-log.js. That was deliberate: the existing one is a closure insidecreateFirewallLogReporter, so sharing it would mean editing the block-log path. Happy to extract and share instead if you'd rather pay that risk once.Ref ENG-3568
🤖 Generated with Claude Code