@patchstack/connect 0.3.23 → 0.3.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-INSTALL.md +16 -7
- package/README.md +22 -2
- package/dist/chunk-MJOTFUDE.js +87 -0
- package/dist/chunk-MJOTFUDE.js.map +1 -0
- package/dist/cli.js +2857 -132
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +93 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +92 -1
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +941 -221
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +7 -0
- package/dist/protect.edge.js +3530 -0
- package/dist/protect.edge.js.map +7 -0
- package/dist/protect.js +826 -195
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-ZIFRZF25.js → refresh-manifest-MUWVIPFR.js} +9 -2
- package/dist/refresh-manifest-MUWVIPFR.js.map +1 -0
- package/package.json +11 -3
- package/dist/refresh-manifest-ZIFRZF25.js.map +0 -1
package/AGENT-INSTALL.md
CHANGED
|
@@ -5,11 +5,14 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
|
|
|
5
5
|
## Package and command behavior
|
|
6
6
|
|
|
7
7
|
- Package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. `npm view @patchstack/connect` shows the live registry metadata.
|
|
8
|
-
-
|
|
8
|
+
- **What is sent to Patchstack is the dependency list only** — read from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — package names + versions, for vulnerability matching. No source code, no env var values, no file paths, no git history is ever transmitted. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.)
|
|
9
|
+
- **One command reads source files:** `map` (see below) parses your server source to report your app's attack surface. It runs only when you invoke it and prints to stdout. It transmits nothing unless you explicitly pass `--upload`, which sends that description of your app's structure to your own site's Patchstack endpoint — never source code, and never without that flag. No other command reads source (`protect` writes guard files but does not analyze your code).
|
|
9
10
|
- **`scan` makes one source edit, and only after a successful post:** it adds (or updates) the disclosure widget's `<script>` tag in the project's root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists. It touches no other file, never edits on `--dry-run` or after a failed post, leaves any pre-existing manual widget tag untouched, and is disabled entirely by `"widget": false` in `.patchstackrc.json`. `mark-build` writes to build output only (`dist/`, `build/`, `out/`, `.output/public`), never to source. `guide`, `status`, and `init` write nothing except `init`'s own `.patchstackrc.json`.
|
|
10
11
|
- **`setup` runs `scan`, then `protect`, then edits `package.json` scripts:** provisioning happens first so the runtime guard can bake the real site UUID. It verifies the resulting framework seam, preserves existing commands, adds `scan` after dependency installs and before builds, adds `mark-build` after builds, and uses a direct build chain for Bun. It never runs the project build. If the widget or runtime guard needs a framework-specific manual merge, it prints the exact remaining step instead of overwriting user code.
|
|
11
12
|
- The package also exposes **`protect`** directly (runtime exploit guard; its templates live under `dist/protect/`). `setup` invokes it automatically; `scan`, `guide`, `status`, and `mark-build` do not. It writes only local files and auto-wires known stacks — **TanStack Start + Supabase** (patches the Supabase client + `src/start.ts`), **Next.js** (scaffolds `middleware.ts`), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **Nuxt** (`server/middleware/`), **NestJS** (`app.use(patchstackMiddleware)` in the bootstrap), **Fastify** (`app.register(patchstackFastify)`), and **Express** (`app.use(patchstackMiddleware)`). On **any other stack** it scaffolds a framework-agnostic guard under `src/patchstack/` and prints a wiring plan — then you finish the install by importing that guard into your server entry (`protectFetch(handler)` for a Web-Fetch server, or `app.use(patchstackMiddleware)` for Node/Express) and running `patchstack-connect protect --check` to confirm it is wired (exit 1 until it is). Passing `--demo` seeds a broad sample rule set (for demonstrations, not production).
|
|
12
13
|
- **`demo node-serialize` is an explicit production-backed walkthrough.** It requires `node-serialize@0.0.4` to already be present in the lockfile; it does not install the vulnerable dependency. It runs the same production `scan`, polls the configured site's public Pulse rules endpoint until rule `18843` is served, runs `protect`, verifies the generated guard, and prints exploit/benign test requests. It writes the same manifest/widget and guard files as those underlying commands. It does not start/restart the app and does not send the printed requests.
|
|
14
|
+
- **`map` is a local, read-only analysis command.** It walks the project's server source (skipping `node_modules`, build output and dot-directories; it does not follow symlinks out of the project unless you pass `--follow-symlinks`), parses it with the project's **own** `typescript`, and prints JSON describing the attack surface: entry points, the inputs each reads, the sinks they can reach (database / file system / process / outbound HTTP) with the npm package behind each, and evidence-backed input→sink flows, each labelled with how the link was established — from an exact read at the sink's own call site, through a transformed or cross-module link, down to the two being present together with no proven link. Static analysis is best-effort, so the output reports the *detected* surface with coverage counters — not a completeness guarantee. It writes nothing (except the file you name with `--out`) and is never invoked by `scan`, `setup`, `guide`, `protect`, or `mark-build`.
|
|
15
|
+
- **`map --upload` is the one opt-in that sends anything derived from your source.** It POSTs the same JSON document to `monitor/pulse/input-map/<your site uuid>` so Patchstack can pin protection rules to your app's own parameter names instead of guessing them. What is sent is exactly what `map` prints — a structural description: route paths, parameter/field names, the dependency behind each sink, and file paths with line numbers. **No source code, no file contents, no environment variable values.** It never runs without the flag, it is skipped when no entry points are detected, and a failure to reach Patchstack is reported and ignored rather than failing your build. Omit the flag and the command stays entirely local.
|
|
13
16
|
- **`demo-guide node-serialize` is the read-only companion.** It checks the Host-created site configuration and vulnerable lockfile entry, explains the complete local prepare/run/restart/prove/cleanup sequence, and prints the next exact command. It does not require a deployment and does not change files or contact Patchstack.
|
|
14
17
|
- Patchstack is not WordPress-only. This connector monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile.
|
|
15
18
|
|
|
@@ -73,7 +76,12 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
|
|
|
73
76
|
<script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="<SITE_UUID>" defer></script>
|
|
74
77
|
```
|
|
75
78
|
|
|
76
|
-
Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The site UUID is public by design — it ships in client-side HTML and is not a secret. The
|
|
79
|
+
Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The site UUID is public by design — it ships in client-side HTML and is not a secret. The credentials are the opposite, and `scan` writes both of them for you — **there is no manual step, and you should never invent or ask the user for these values**:
|
|
80
|
+
|
|
81
|
+
- `apiKey` (also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) — authenticates block-log reporting through the connector `POST /api/logs/log`, so "Threats blocked" fills in the dashboard.
|
|
82
|
+
- `pulseAuth` (also `PATCHSTACK_PULSE_AUTH`) — authenticates Pulse ingest: the manifest, the attack-surface map and package removal. Exchanged for a short-lived token rather than sent directly. Falls back to `apiKey` when absent, so older projects keep working.
|
|
83
|
+
|
|
84
|
+
Both are server-only. Never put either in the widget tag, client bundles, or public env vars (`NEXT_PUBLIC_*`, etc.). Prefer `PATCHSTACK_API_KEY` / `PATCHSTACK_PULSE_AUTH` in production; `.patchstackrc.json` is fine for local DX. If a credential is lost, `npx @patchstack/connect login` recovers it via dashboard approval — do not delete the file and re-provision, which would create a second site. Opt out of reporting with `PATCHSTACK_TELEMETRY=off`. If the project must not carry the widget, persist `"widget": false` in `.patchstackrc.json`; otherwise the next scan re-adds it.
|
|
77
85
|
|
|
78
86
|
4. **Install and verify runtime protection:**
|
|
79
87
|
|
|
@@ -94,7 +102,7 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
|
|
|
94
102
|
- The CLI never opens the dashboard link and never asks for Patchstack credentials.
|
|
95
103
|
- Label hosted workspace scans with `PATCHSTACK_ENVIRONMENT=sandbox` in that process only. Leave production builds unset (the default is `production`) and never commit a sandbox label into files shared with production.
|
|
96
104
|
- If a step fails, stop and report it. Don't proceed with placeholders.
|
|
97
|
-
- In CI where `.patchstackrc.json` can't be committed, set `PATCHSTACK_SITE_UUID` as
|
|
105
|
+
- In CI where `.patchstackrc.json` can't be committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_PULSE_AUTH` as env vars instead. Precedence: CLI flag → env var → `.patchstackrc.json`. `login` is interactive and refuses to run in CI, so CI always takes its credential from the environment.
|
|
98
106
|
|
|
99
107
|
## Verifying the install
|
|
100
108
|
|
|
@@ -119,10 +127,11 @@ Remove only the pieces that are actually present — check for each first. If no
|
|
|
119
127
|
2. **Remove the widget snippets** from the layout/template: the `<script src="https://cdn.patchstack.com/patchstack-widget.js">` tag and any `PatchstackWidget.init(...)` call (which may live in a separate client component/plugin/effect). Afterwards, grep the repo for `patchstack-widget` and `PatchstackWidget` to confirm nothing remains.
|
|
120
128
|
3. **Remove runtime protection before uninstalling the package.** Delete the connector-managed guard/rules files and remove only their managed imports, middleware registrations, tunnel code, and `#region patchstack…` blocks from the framework/server files. Preserve unrelated middleware and application code. Run `rg "patchstack|x-ps-target"` (or the available equivalent) afterwards and inspect every remaining source hit.
|
|
121
129
|
4. **Remove the hooks from `package.json` scripts.** If a hook was chained (e.g. `"postbuild": "existing-command && patchstack-connect mark-build"`), remove only the `patchstack-connect …` part and keep the rest; if removal leaves a script empty, delete the key.
|
|
122
|
-
5. **
|
|
123
|
-
6. **
|
|
124
|
-
7. **
|
|
130
|
+
5. **Signal Patchstack that the package is being removed**: run `npx @patchstack/connect uninstall` (while the package is still installed and `.patchstackrc.json` still exists). If the site was never claimed, this deletes its anonymous record on Patchstack; if the site is claimed, it is only flagged — the record stays until its owner removes it in the dashboard. A failed signal must not stop the uninstall; continue with the remaining steps.
|
|
131
|
+
6. **Uninstall the package** with the manager matching the lockfile: `npm uninstall` / `pnpm remove` / `yarn remove` / `bun remove` `@patchstack/connect`. Don't hand-edit `node_modules` or the lockfile.
|
|
132
|
+
7. **Delete `.patchstackrc.json`** and remove `PATCHSTACK_SITE_UUID`, `PATCHSTACK_API_KEY` (and public-prefixed variants like `NEXT_PUBLIC_PATCHSTACK_SITE_UUID`) from env files and CI variables.
|
|
133
|
+
8. **Commit** the changes. Reporting stops immediately. The `window.__PATCHSTACK_PROD__` flag that `mark-build` injected lives only in build output, never in source — the next build simply won't contain it (rebuild if build output is committed).
|
|
125
134
|
|
|
126
|
-
|
|
135
|
+
The `uninstall` signal is the only account-side effect local removal can have: it deletes an *unclaimed* (anonymous) record and merely flags a *claimed* one. A claimed site keeps using a site slot until its owner removes it in the dashboard at https://app.patchstack.com — end your report by telling the user this, alongside the site UUID from step 1. Never attempt to authenticate or remove a claimed site on the user's behalf.
|
|
127
136
|
|
|
128
137
|
The reverse also holds: removing the site on Patchstack's side (dashboard delete or the widget's uninstall flow) does not touch these local files — they must still be removed with the steps above. `npx @patchstack/connect status` shows `Site status: removed from Patchstack` in that state.
|
package/README.md
CHANGED
|
@@ -69,6 +69,16 @@ patchstack-connect protect Install/reconcile the always-
|
|
|
69
69
|
guard. Auto-wires supported server stacks;
|
|
70
70
|
use --check to verify or --demo for local rules.
|
|
71
71
|
Also run by setup; never run by scan/guide/mark-build.
|
|
72
|
+
patchstack-connect map [--dir p] [--out f] [--upload]
|
|
73
|
+
Print a JSON map of this project's attack
|
|
74
|
+
surface: server entry points, the inputs each
|
|
75
|
+
reads, the sinks they can reach (database, file
|
|
76
|
+
system, process, outbound HTTP) and the npm
|
|
77
|
+
package behind each sink. READS YOUR SOURCE
|
|
78
|
+
FILES locally and parses them with the
|
|
79
|
+
project's own TypeScript; writes nothing except
|
|
80
|
+
--out, and posts nothing. Never run by
|
|
81
|
+
scan/setup/guide/protect — run it yourself.
|
|
72
82
|
patchstack-connect demo node-serialize Production-backed walkthrough: require
|
|
73
83
|
node-serialize@0.0.4, scan it, wait for live
|
|
74
84
|
rule 18843, install + verify the runtime guard,
|
|
@@ -108,13 +118,23 @@ Environment variables:
|
|
|
108
118
|
```json
|
|
109
119
|
{
|
|
110
120
|
"siteUuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
121
|
+
"apiKey": "…",
|
|
122
|
+
"pulseAuth": "…",
|
|
111
123
|
"widget": true
|
|
112
124
|
}
|
|
113
125
|
```
|
|
114
126
|
|
|
115
127
|
`"widget"` is optional and defaults to `true`; set it to `false` to stop the connector from managing the disclosure-widget tag (see *The disclosure widget*).
|
|
116
128
|
|
|
117
|
-
|
|
129
|
+
**You do not write `apiKey` or `pulseAuth` yourself.** The first `scan` provisions the site and the connector saves both, so setup needs no manual step. They hold the same value today and exist as separate fields so Pulse ingest and block-log reporting can diverge later.
|
|
130
|
+
|
|
131
|
+
The site UUID identifies the site and is **not** a secret — the disclosure widget ships the same UUID in client-side HTML.
|
|
132
|
+
|
|
133
|
+
`apiKey` and `pulseAuth` **are** secrets. `apiKey` authenticates block-log reporting; `pulseAuth` authenticates Pulse ingest (manifest, attack-surface map, package removal) and is exchanged for a short-lived token rather than sent directly. Keep both out of the widget tag, client bundles and public env vars (`NEXT_PUBLIC_*`). For deploys, prefer `PATCHSTACK_API_KEY` and `PATCHSTACK_PULSE_AUTH` in the platform's secret store over the committed file.
|
|
134
|
+
|
|
135
|
+
If a credential is ever lost, `npx @patchstack/connect login` recovers it — approval happens in the dashboard and rotates the credential.
|
|
136
|
+
|
|
137
|
+
In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_PULSE_AUTH`. Precedence is CLI flag → env var → `.patchstackrc.json`.
|
|
118
138
|
|
|
119
139
|
### Sandbox and production manifests
|
|
120
140
|
|
|
@@ -189,7 +209,7 @@ Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postM
|
|
|
189
209
|
}
|
|
190
210
|
```
|
|
191
211
|
|
|
192
|
-
That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile. Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. (`mark-build` separately stamps built HTML with a stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL` — never their values.)
|
|
212
|
+
That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile. (The `map` command reads source files locally to report your attack surface; it transmits nothing unless you pass `--upload`, which sends that structural description — route paths, parameter names, the dependency behind each sink, and file/line locations, never file contents — to your own site's endpoint so rules can be pinned to your real parameter names.) Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. (`mark-build` separately stamps built HTML with a stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL` — never their values.)
|
|
193
213
|
|
|
194
214
|
## Supported lockfiles
|
|
195
215
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/pulse-token.ts
|
|
2
|
+
var TOKEN_SKEW_MS = 6e4;
|
|
3
|
+
function buildTokenUrl(manifestEndpoint) {
|
|
4
|
+
const url = new URL(manifestEndpoint);
|
|
5
|
+
const path = url.pathname.replace(/\/$/, "");
|
|
6
|
+
url.pathname = path.endsWith("/manifest") ? `${path.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
|
|
7
|
+
url.search = "";
|
|
8
|
+
url.hash = "";
|
|
9
|
+
return url.toString();
|
|
10
|
+
}
|
|
11
|
+
function parsePulseAuth(credential) {
|
|
12
|
+
const index = credential.lastIndexOf("-");
|
|
13
|
+
if (index <= 0 || index === credential.length - 1) return null;
|
|
14
|
+
const clientId = credential.slice(index + 1);
|
|
15
|
+
if (!/^\d+$/.test(clientId)) return null;
|
|
16
|
+
return { clientId, clientSecret: credential.slice(0, index) };
|
|
17
|
+
}
|
|
18
|
+
var cached = null;
|
|
19
|
+
var inflight = null;
|
|
20
|
+
function clearPulseToken() {
|
|
21
|
+
cached = null;
|
|
22
|
+
}
|
|
23
|
+
async function getPulseToken(config, fetchImpl = fetch) {
|
|
24
|
+
if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
|
|
25
|
+
if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
|
|
26
|
+
return cached.token;
|
|
27
|
+
}
|
|
28
|
+
if (inflight !== null) return inflight;
|
|
29
|
+
const credentials = parsePulseAuth(config.pulseAuth);
|
|
30
|
+
if (credentials === null) return null;
|
|
31
|
+
inflight = (async () => {
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetchImpl(buildTokenUrl(config.endpoint), {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: {
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
Accept: "application/json",
|
|
38
|
+
"User-Agent": "@patchstack/connect"
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
grant_type: "client_credentials",
|
|
42
|
+
client_id: credentials.clientId,
|
|
43
|
+
client_secret: credentials.clientSecret
|
|
44
|
+
}),
|
|
45
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok) return null;
|
|
48
|
+
const body = await response.json();
|
|
49
|
+
if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
|
|
50
|
+
const expiresIn = Number(body.expires_in);
|
|
51
|
+
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
52
|
+
cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
|
|
53
|
+
return body.access_token;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
} finally {
|
|
57
|
+
inflight = null;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
return inflight;
|
|
61
|
+
}
|
|
62
|
+
async function pulseAuthHeader(config, fetchImpl = fetch) {
|
|
63
|
+
const token = await getPulseToken(config, fetchImpl);
|
|
64
|
+
return token === null ? {} : { Authorization: `Bearer ${token}` };
|
|
65
|
+
}
|
|
66
|
+
async function pulseFetch(config, url, init, fetchImpl = fetch) {
|
|
67
|
+
const send = async () => {
|
|
68
|
+
const auth = await pulseAuthHeader(config, fetchImpl);
|
|
69
|
+
const response = await fetchImpl(url, {
|
|
70
|
+
...init,
|
|
71
|
+
headers: { ...init.headers, ...auth }
|
|
72
|
+
});
|
|
73
|
+
return { response, authenticated: auth.Authorization !== void 0 };
|
|
74
|
+
};
|
|
75
|
+
const first = await send();
|
|
76
|
+
if (first.response.status === 401 && first.authenticated) {
|
|
77
|
+
clearPulseToken();
|
|
78
|
+
return (await send()).response;
|
|
79
|
+
}
|
|
80
|
+
return first.response;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
pulseAuthHeader,
|
|
85
|
+
pulseFetch
|
|
86
|
+
};
|
|
87
|
+
//# sourceMappingURL=chunk-MJOTFUDE.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/pulse-token.ts"],"sourcesContent":["import type { Config } from './types.js';\n\n/**\n * Bearer tokens for the authenticated Pulse endpoints (ADR-0018).\n *\n * Deliberately separate from the block-log token flow in\n * `src/protect/firewall-log.js`: that path talks to the auth/ Lambda's\n * /oauth/token and must keep working exactly as it does today.\n */\n\nconst TOKEN_SKEW_MS = 60_000;\n\n/** Build the Pulse token URL corresponding to a manifest endpoint override. */\nexport function buildTokenUrl(manifestEndpoint: string): string {\n const url = new URL(manifestEndpoint);\n const path = url.pathname.replace(/\\/$/, '');\n url.pathname = path.endsWith('/manifest')\n ? `${path.slice(0, -'/manifest'.length)}/token`\n : '/monitor/pulse/token';\n url.search = '';\n url.hash = '';\n return url.toString();\n}\n\n/** Split the WP-format `{secret}-{oauth.id}` credential on its last hyphen. */\nexport function parsePulseAuth(credential: string): { clientId: string; clientSecret: string } | null {\n const index = credential.lastIndexOf('-');\n if (index <= 0 || index === credential.length - 1) return null;\n\n const clientId = credential.slice(index + 1);\n if (!/^\\d+$/.test(clientId)) return null;\n\n return { clientId, clientSecret: credential.slice(0, index) };\n}\n\nlet cached: { token: string; expiresAt: number } | null = null;\nlet inflight: Promise<string | null> | null = null;\n\n/** Drops the cached token. Exported for tests and for 401 handling. */\nexport function clearPulseToken(): void {\n cached = null;\n}\n\n/**\n * Resolve a bearer token for `config.pulseAuth`, exchanging one if needed.\n *\n * Returns null whenever a token cannot be obtained — no credential, a rejected\n * exchange, a network failure. Callers then send the request unauthenticated,\n * which the server still accepts while it runs dual-accept.\n */\nexport async function getPulseToken(\n config: Config,\n fetchImpl: typeof fetch = fetch,\n): Promise<string | null> {\n // Not `=== null`: Config is public, so callers can hand us an object that\n // predates this field, and an unusable credential must never throw here.\n if (typeof config.pulseAuth !== 'string' || config.pulseAuth.length === 0) return null;\n\n if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {\n return cached.token;\n }\n if (inflight !== null) return inflight;\n\n const credentials = parsePulseAuth(config.pulseAuth);\n if (credentials === null) return null;\n\n inflight = (async () => {\n try {\n const response = await fetchImpl(buildTokenUrl(config.endpoint), {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n 'User-Agent': '@patchstack/connect',\n },\n body: JSON.stringify({\n grant_type: 'client_credentials',\n client_id: credentials.clientId,\n client_secret: credentials.clientSecret,\n }),\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n\n if (!response.ok) return null;\n\n const body = (await response.json()) as { access_token?: unknown; expires_in?: unknown };\n if (typeof body.access_token !== 'string' || body.access_token.length === 0) return null;\n\n const expiresIn = Number(body.expires_in);\n const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : 3600_000;\n cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };\n\n return body.access_token;\n } catch {\n return null;\n } finally {\n inflight = null;\n }\n })();\n\n return inflight;\n}\n\n/**\n * `Authorization` header for a Pulse request, or `{}` when unauthenticated.\n * Spread into an existing header object so call sites stay one line.\n */\nexport async function pulseAuthHeader(\n config: Config,\n fetchImpl: typeof fetch = fetch,\n): Promise<Record<string, string>> {\n const token = await getPulseToken(config, fetchImpl);\n return token === null ? {} : { Authorization: `Bearer ${token}` };\n}\n\n/**\n * Send a Pulse request, attaching the bearer token and retrying once if the\n * server rejects it.\n *\n * A cached token can stop being valid before it expires — the credential may\n * have been rotated or revoked meanwhile — so the server's 401 is authoritative\n * over our own clock. Without this a long-running process would keep presenting\n * a dead token until its local expiry.\n *\n * Only 401 retries: a 403 is a scope or site mismatch, which a fresh token\n * would not fix.\n */\nexport async function pulseFetch(\n config: Config,\n url: string,\n init: RequestInit,\n fetchImpl: typeof fetch = fetch,\n): Promise<Response> {\n const send = async () => {\n const auth = await pulseAuthHeader(config, fetchImpl);\n const response = await fetchImpl(url, {\n ...init,\n headers: { ...(init.headers as Record<string, string> | undefined), ...auth },\n });\n\n return { response, authenticated: auth.Authorization !== undefined };\n };\n\n const first = await send();\n\n // Retrying an unauthenticated request would just repeat it: the 401 was\n // about something other than our token.\n if (first.response.status === 401 && first.authenticated) {\n clearPulseToken();\n\n return (await send()).response;\n }\n\n return first.response;\n}\n"],"mappings":"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","names":[]}
|