Skip to content

[ENG-3568] Pulse ingest authentication — 2/3 connect bearer token - #149

Merged
mariojgt merged 7 commits into
mainfrom
mariot/eng-3568-pulse-auth
Aug 19, 2026
Merged

[ENG-3568] Pulse ingest authentication — 2/3 connect bearer token#149
mariojgt merged 7 commits into
mainfrom
mariot/eng-3568-pulse-auth

Conversation

@mariojgt

Copy link
Copy Markdown
Contributor

🥞 Merge order: 2 of 3 — needs patchstack/saas#1326 (1/3) deployed first, since it adds the monitor/pulse/token endpoint. Safe to merge before then: without the endpoint the exchange simply fails and requests stay unauthenticated.

Implements the connect half 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.

pulseAuth is a separate field

New in .patchstackrc.json and as PATCHSTACK_PULSE_AUTH, deliberately not reusing apiKey:

{
  "siteUuid": "",
  "apiKey": "",     // block-logs — untouched
  "pulseAuth": ""   // Pulse ingest — new
}

Same value today, but the two paths can diverge later without disturbing each other. pulseAuth falls back to apiKey, so sites provisioned before this change authenticate with no re-provision.

What sends a token

Call Sends bearer
pulse/manifest (with UUID) yes
pulse/manifest (bootstrap) no — no credential exists until this request issues one
pulse/input-map yes
pulse/package-removed yes
api/logs/log (block-logs) unchanged — still the Lambda's JWT

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/rules therefore can't be enforced until that lands.

Note for review

src/pulse-token.ts duplicates roughly 30 lines of cache-and-exchange logic that already exists in protect/firewall-log.js. That was deliberate: the existing one is a closure inside createFirewallLogReporter, 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

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>
@coderbuds

coderbuds Bot commented Aug 19, 2026

Copy link
Copy Markdown

Introduces Pulse authentication with clear patterns and thorough tests.

🎯 Quality: 87% Excellent · 📦 Size: Medium

📈 This month: Your 54th PR — above team average · Averaging Good

See how your team is trending →

mariojgt and others added 5 commits August 19, 2026 11:49
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>
@mariojgt

Copy link
Copy Markdown
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>
@mariojgt
mariojgt merged commit 0fabc83 into main Aug 19, 2026
6 checks passed
@mariojgt
mariojgt deleted the mariot/eng-3568-pulse-auth branch August 19, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants