@shelbyb/daywalker-sdk 0.5.0 → 0.7.0

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/README.md CHANGED
@@ -15,11 +15,13 @@ Runs on **Node.js 20.19+**, **Bun**, **Deno**, **Cloudflare Workers**, **Next.js
15
15
  - **Web Standards Core**: standard `crypto.subtle`, `Uint8Array`, `fetch`, `Request`, and `Response`. The one runtime dependency is [`re2js`](https://www.npmjs.com/package/re2js), so flag regex rules run in linear time.
16
16
  - **RFC 9421 (HTTP Message Signatures)**: Cryptographically binds `@method`, `@path`, `@query` (when the URL has one), `@authority`, and `content-digest` (POST/PUT/PATCH and any request with a body) using Ed25519 signatures (`Signature-Input`, `Signature`).
17
17
  - **RFC 9530 (Digest Fields)**: Computes standard `Content-Digest: sha-256=:<base64>:` headers for payload integrity.
18
+ - **OAuth 2.0 Client Credentials**: Calls to Daywalker Auth carry a short-lived, DPoP-bound (RFC 9449) access token from `POST /api/v1/oauth/token`, refreshed automatically with rotating refresh tokens. The API key is only the client credential for that grant and is never sent anywhere else.
18
19
  - **Class-Based Client Architecture**: Instantiable `Daywalker` client supporting dependency injection, with a lazy default singleton for plug-and-play simplicity.
19
- - **Resilience & Rate Limiting**: 5s per-attempt timeout, exponential backoff with jitter, `Retry-After` honored up to 60s. Network errors are retried only for idempotent requests (or with an `idempotencyKey`), and redirects are never followed, so the API key can't leak to another host.
20
+ - **Resilience & Rate Limiting**: 5s per-attempt timeout, exponential backoff with jitter, `Retry-After` honored up to 60s. Network errors are retried only for idempotent requests (or with an `idempotencyKey`), and redirects are never followed, so credentials and access tokens can't leak to another host.
20
21
  - **Deterministic Type-Safe Results**: Discriminated union returns (`Result<T, E>`) for clean handling without try/catch boilerplate.
21
22
  - **Security-Hardened Next.js Middleware**: Built-in Server Action probe mitigation (CVE-2025-55182 defense), normalized paths (no `%2e%2e` / `//` bypasses), open-redirect-safe error routing, and fail-closed validation (503 when Daywalker is unreachable).
22
- - **Zero-Egress Inbound Verification**: In-memory caching (60–300s TTL) and offline JWT token lease validation using Daywalker's asymmetric public key.
23
+ - **Zero-Egress Inbound Verification**: In-memory caching (60s by default, never more than 300s) and offline verification of Daywalker access tokens, including the DPoP proof of sender-constrained tokens.
24
+ - **Tamper-Resistant by Design**: client state lives in true private fields, config and prototypes are frozen, WebCrypto is captured at load, and every input is bounded and validated. See [Security model](#security-model).
23
25
  - **Decoupled Push Architecture CLI**: `npx daywalker init --json` registers keys directly via `POST /api/v1/keys/register` with zero runtime reverse-HTTP calls.
24
26
 
25
27
  ---
@@ -55,21 +57,97 @@ DAYWALKER_API_KEY=srv_live_... npx daywalker init --service my-service-slug --do
55
57
 
56
58
  The private key is never printed unless you pass `--print-key`.
57
59
 
60
+ ### Or: ephemeral keys (nothing stored anywhere)
61
+
62
+ With `ephemeralKey`, each process generates its signing key in memory as a **non-extractable** key. It registers that key with a lifetime and replaces it in place before it expires. There's no private key in env vars, files or a store, and nothing to rotate by hand:
63
+
64
+ ```ts
65
+ const client = new Daywalker({
66
+ ephemeralKey: { ttlSeconds: 21_600, instanceId: process.env.HOSTNAME }, // or DAYWALKER_EPHEMERAL_KEY=true
67
+ });
68
+ process.on("SIGTERM", async () => {
69
+ await client.close(); // revokes the key (and the OAuth sessions it started)
70
+ process.exit(0);
71
+ });
72
+ ```
73
+
74
+ - **Lifetime** (`ttlSeconds`, env `DAYWALKER_KEY_TTL_SECONDS`): 1 hour to 30 days, default 6 hours. When a quarter of it is left, a new key is registered in the same slot. Requests keep using the old key until the new one is ready, and Daywalker keeps the old key valid for 10 more minutes. The next OAuth session starts with the new key.
75
+ - **Slot** (`instanceId`, env `DAYWALKER_INSTANCE_ID`): one key per (domain, slot). Registering in an occupied slot retires the previous key. Default: a random slot per process.
76
+ - **Limit:** Daywalker allows 20 live keys per domain. Give long-lived instances a stable `instanceId` so a restart replaces its own key, call `close()` on shutdown, and use a shared `keyStore` instead on serverless platforms with many short-lived instances. When the domain is full, the error says so: list keys with `npx daywalker keys` and revoke unused ones with `npx daywalker revoke <keyId>`.
77
+
78
+ ### Or: provision the key automatically
79
+
80
+ Instead of running `init` and copying `DAYWALKER_SIGNING_KEY` into every environment, give the client a **key store**. On first use it loads the key from the store, or generates one, registers it for the service's domain (`POST /api/v1/keys/register`, with `DAYWALKER_API_KEY`) and saves it:
81
+
82
+ ```ts
83
+ import { Daywalker } from "@shelbyb/daywalker-sdk";
84
+ import { fileKeyStore } from "@shelbyb/daywalker-sdk/keystore/file";
85
+
86
+ const client = new Daywalker({
87
+ keyStore: fileKeyStore(), // .daywalker/signing-key.json (0600, in a 0700 directory)
88
+ });
89
+ await client.provisionSigningKey(); // optional: provision at startup and fail fast, instead of on the first request
90
+ ```
91
+
92
+ All you need is `DAYWALKER_API_KEY` and a domain. The domain is detected from `VERCEL_PROJECT_PRODUCTION_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `APP_URL` and similar; set `DAYWALKER_DOMAIN` elsewhere.
93
+
94
+ | Store | Import | Use it for |
95
+ |---|---|---|
96
+ | `fileKeyStore({ path? })` | `@shelbyb/daywalker-sdk/keystore/file` | VMs and containers with a persistent volume (Node.js). Atomic create-if-absent; refuses symlinks and files others can read |
97
+ | `memoryKeyStore()` | `@shelbyb/daywalker-sdk/keystore` | Tests and single long-lived processes: every new process registers a new key |
98
+ | `envKeyStore(env?)` | `@shelbyb/daywalker-sdk/keystore` | Read-only: the key `daywalker init` wrote to the environment |
99
+ | your own | — | Serverless and several instances: any shared store with an atomic create-if-absent (Redis `SET key value NX`, Vercel / Cloudflare KV, a secrets manager) |
100
+
101
+ A store is `{ load(), saveIfAbsent?(record) }`. `saveIfAbsent` must be atomic: when several instances start at once, exactly one save wins and the others load the winner's key. Each of them may already have registered its own key; the losers revoke theirs right away (`DELETE /api/v1/keys/{keyId}`). Stored keys don't expire; every key the SDK registers gets its own slot (`dw-<keyId>`), so registering one never retires a key another deployment is still using.
102
+
103
+ ```ts
104
+ const redisKeyStore = (redis) => ({
105
+ load: async () => JSON.parse((await redis.get("daywalker:signing-key")) ?? "null"),
106
+ saveIfAbsent: async (record) => (await redis.set("daywalker:signing-key", JSON.stringify(record), { NX: true })) === "OK",
107
+ });
108
+ ```
109
+
110
+ Safeguards:
111
+ - Only keys this process generated **and registered** are ever saved, so a key planted in the store is never registered with your API key.
112
+ - A stored key is checked against its `keyId` (RFC 7638 thumbprint) and domain on every load, and imported non-extractable.
113
+ - `selfCheck()` / `npx daywalker check --offline` only read the store; they never provision.
114
+
115
+ Treat the store like any secret: it holds the private key. `DAYWALKER_SIGNING_KEY` and `signer` take precedence over `keyStore`.
116
+
117
+ ### Or: keep the key in a KMS (no private key in the app at all)
118
+
119
+ Pass a `signer` and the SDK never sees the private key. Every RFC 9421 signature is made by your KMS or HSM. `sign` receives the signature base and must return the raw 64-byte Ed25519 signature:
120
+
121
+ ```ts
122
+ const client = new Daywalker({
123
+ signer: {
124
+ keyId: "kms-ed25519-2026-10", // the id you registered the key under
125
+ publicJwk, // optional but recommended: the SDK checks the signer against it once before any request
126
+ sign: async (data) => yourKms.signEd25519(KMS_KEY_ID, data), // must resolve to the raw 64-byte signature (Uint8Array)
127
+ },
128
+ });
129
+ ```
130
+
131
+ Your KMS must support Ed25519 (pure EdDSA over the message, not a pre-hashed variant). Register the KMS public key once with `registerPublicKey({ apiKey, domain, publicJwk })`. The signer is captured at construction, so changing the object later has no effect.
132
+
58
133
  ---
59
134
 
60
135
  ## Environment
61
136
 
62
137
  | Variable | Required | Purpose |
63
138
  |---|---|---|
64
- | `DAYWALKER_API_KEY` | ✅ | The service's Daywalker API token (`srv_live_…`) or JWT lease |
139
+ | `DAYWALKER_API_KEY` | ✅ | The service's OAuth client credential (`srv_live_…`). It is sent only to `POST /api/v1/oauth/token` (client credentials grant, together with the signing key) and to `POST /api/v1/keys/register` by `daywalker init` / `rotate`. Every other call uses an access token. See [Authentication](#authentication-oauth-20-client-credentials) |
65
140
  | `DAYWALKER_SERVICE_SLUG` | ✅ | The service slug to validate against (e.g. `sonalyze`). One API key can cover several services: a comma-separated list (`gigpulse,devpulse`) is accepted and the first is the default. See [One API key, several services](#one-api-key-several-services) |
66
- | `DAYWALKER_SIGNING_KEY` | for signed requests | Ed25519 private key from `daywalker init` (PKCS#8 Base64 DER or PEM) |
141
+ | `DAYWALKER_SIGNING_KEY` | for signed requests | Ed25519 private key from `daywalker init` (PKCS#8 Base64 DER or PEM). Not needed with a `keyStore` (auto-provisioning) or a `signer` (KMS); see [Setup](#setup-once-per-service) |
67
142
  | `DAYWALKER_KEY_ID` | optional | RFC 7638 JWK thumbprint derived during key generation |
68
143
  | `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Previous private key, maintained during deployment transitions |
69
- | `DAYWALKER_DOMAIN` | optional | Optional caller identifier or host domain name |
144
+ | `DAYWALKER_DOMAIN` | when not detected | The service's public domain: the key is registered for it. Detected from your platform's URL variables when unset |
145
+ | `DAYWALKER_EPHEMERAL_KEY` | optional | `true` for [ephemeral signing keys](#or-ephemeral-keys-nothing-stored-anywhere), with `DAYWALKER_KEY_TTL_SECONDS` and `DAYWALKER_INSTANCE_ID` |
70
146
  | `DAYWALKER_PUBLIC_KEY` | optional | Daywalker Auth public key for zero-egress offline JWT lease verification |
147
+ | `DAYWALKER_JWKS` | optional | `auto` verifies access tokens offline against Daywalker's JWKS, fetched from `${DAYWALKER_AUTH_URL}/.well-known/jwks.json` and cached, so signing-key rotation needs no redeploy. Default `off`. See [Zero-Egress Offline Verification](#zero-egress-offline-verification) |
71
148
  | `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev`; must be `https://` (except localhost) |
72
- | `DAYWALKER_LEASE_ISSUER` | optional | Expected `iss` of offline-verified JWT leases |
149
+ | `DAYWALKER_LEASE_ISSUER` | optional | Expected `iss` of offline-verified JWTs (default `https://auth.daywalker.dev`) |
150
+ | `DAYWALKER_PUBLIC_ORIGIN` | behind a proxy | The origin clients use to reach this service (e.g. `https://api.example.com`), for checking DPoP proofs. Defaults to the request URL's origin |
73
151
 
74
152
  ---
75
153
 
@@ -90,7 +168,7 @@ const client = new Daywalker({
90
168
 
91
169
  // Option 2: Default lazy singleton (reads process.env automatically)
92
170
  const result = await daywalker.validateToken(incomingToken);
93
- // Service health check of this service's own DAYWALKER_API_KEY: daywalker.validateServiceKey()
171
+ // Service health check of this service's own OAuth client credentials: daywalker.validateServiceCredentials()
94
172
 
95
173
  if (result.ok) {
96
174
  console.log("Validated tenant:", result.data.tenantId);
@@ -101,6 +179,28 @@ if (result.ok) {
101
179
  }
102
180
  ```
103
181
 
182
+ ### Authentication (OAuth 2.0 Client Credentials)
183
+
184
+ The SDK authenticates to Daywalker Auth with the OAuth 2.0 client credentials grant ([RFC 6749 §4.4](https://www.rfc-editor.org/rfc/rfc6749#section-4.4)) described in the [Daywalker API docs](https://auth.daywalker.dev/docs). You don't call it yourself: the first request that needs it runs the flow.
185
+
186
+ 1. **Client credentials grant.** `POST /api/v1/oauth/token` with `grant_type=client_credentials`, authenticated by the API key (`Authorization: Bearer srv_live_…`) **and** an RFC 9421 signature from the registered Ed25519 signing key, from an allowlisted domain or IP. This is the only request that carries the API key.
187
+ 2. **DPoP-bound tokens.** Every token request carries a DPoP proof ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) from a key the client generates in memory. Daywalker answers with `token_type: "DPoP"`: a 5 to 15 minute EdDSA access token bound to that key (`cnf.jkt`), plus a single-use refresh token (30 day session).
188
+ 3. **Calls use the access token.** `validateToken()` and `client.fetch()` send `Authorization: DPoP <access_token>` plus a fresh DPoP proof with `ath` (bearer or unbound tokens are refused with `401 DPOP_REQUIRED`).
189
+ 4. **Refresh.** Within 30 seconds of expiry, or on `401 ACCESS_TOKEN_EXPIRED` (retried once), the client trades the refresh token for a new pair (`grant_type=refresh_token`, no API key, same DPoP key). When the session has ended (`invalid_grant`), it runs the client credentials grant again.
190
+
191
+ ```ts
192
+ const client = new Daywalker({
193
+ apiKey: process.env.DAYWALKER_API_KEY, // client credential, used only at the token endpoint
194
+ signingKey: process.env.DAYWALKER_SIGNING_KEY, // signs the grant (RFC 9421)
195
+ });
196
+
197
+ await client.validateToken(incomingToken); // Authorization: DPoP <access token>, obtained on demand
198
+ await client.accessToken(); // the current access token, refreshed as needed
199
+ await client.validateServiceCredentials(); // health check: can this service still get a token for its service?
200
+ ```
201
+
202
+ If the grant itself fails (revoked API key, unregistered signing key, allowlist mismatch), `validateToken()` returns `503 SERVICE_UNAVAILABLE` with the token endpoint's code in the message, never a `401` for the caller's token; a missing `DAYWALKER_API_KEY` is `500 CONFIGURATION_ERROR`.
203
+
104
204
  ### Type-Safe Verification & Error Handling
105
205
 
106
206
  ```ts
@@ -120,11 +220,11 @@ try {
120
220
  ### RFC 9421 Signed Requests with Automatic Backoff
121
221
 
122
222
  ```ts
123
- // Outbound request automatically computes Content-Digest (RFC 9530)
124
- // and attaches RFC 9421 signature headers. Retries 429 rate limits automatically.
223
+ // Outbound request automatically adds `Authorization: DPoP <access token>` with a DPoP proof,
224
+ // computes Content-Digest (RFC 9530) and attaches RFC 9421 signature headers. Retries 429 rate limits automatically.
125
225
  const res = await client.fetch("/api/v1/tokens/validate", {
126
226
  method: "POST",
127
- body: { token: "user_token", serviceSlug: "other-service" },
227
+ body: { token: "user_token" },
128
228
  idempotencyKey: crypto.randomUUID(), // optional: lets a POST be retried after a network error
129
229
  });
130
230
  ```
@@ -149,7 +249,7 @@ export const config = {
149
249
  };
150
250
  ```
151
251
 
152
- Each request must carry `Authorization: Bearer <token>` or `x-api-token`; without one, API routes get `401` and pages redirect to `errorPath` (without an error message in the URL). Paths are decoded and normalized before any check, so `/_next/static/../admin` is authenticated. Only `/_next/static/`, `/_next/image`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml` and the health paths skip auth by default. File extensions say nothing about whether a route is public, so they no longer skip auth; opt in with `staticFileExtensions: STATIC_FILE_EXTENSIONS` if you rely on that. When Daywalker is unreachable the middleware answers `503`, and `500` for a configuration error. To gate the app on this service's own API key instead (the self-healing error page), use `createDaywalkerServiceHealthMiddleware` with the same options.
252
+ Each request must carry `Authorization: Bearer <token>`, `Authorization: DPoP <token>` with its `DPoP` proof header, or `x-api-token`. An `Authorization` header with any other scheme, or a credentials header sent twice, is refused rather than skipped. Without credentials, API routes get `401` and pages redirect to `errorPath` (without an error message in the URL). Paths are decoded and normalized before any check, so `/_next/static/../admin` is authenticated. Only `/_next/static/`, `/_next/image`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml` and the health paths skip auth by default. File extensions say nothing about whether a route is public, so they no longer skip auth; opt in with `staticFileExtensions: STATIC_FILE_EXTENSIONS` if you rely on that. When Daywalker is unreachable the middleware answers `503`, and `500` for a configuration error. To gate the app on this service's own OAuth client credentials instead (the self-healing error page), use `createDaywalkerServiceHealthMiddleware` with the same options.
153
253
 
154
254
  ### Route Handler Wrapper
155
255
 
@@ -185,16 +285,15 @@ app.get("/api/me", (c) => {
185
285
 
186
286
  ```ts
187
287
  import express from "express";
188
- import { daywalkerAuth, daywalkerKeys } from "@shelbyb/daywalker-sdk/express";
288
+ import { daywalkerAuth } from "@shelbyb/daywalker-sdk/express";
189
289
 
190
290
  const app = express();
191
- app.use(daywalkerKeys()); // serves GET /.well-known/daywalker-keys.json
192
291
  app.use("/api", daywalkerAuth({ serviceSlug: "sonalyze" }));
193
292
 
194
293
  app.get("/api/me", (req, res) => res.json({ auth: req.auth }));
195
294
  ```
196
295
 
197
- `daywalkerAuth` reads `Authorization: Bearer <token>` or `x-api-token`, answers `401` when neither is present, and otherwise returns the same status codes as the other middleware (`429` with `Retry-After`, `503` when Daywalker is unreachable). On success the validated payload is on `req.auth`. Both functions also work with plain `http.createServer` handlers.
296
+ `daywalkerAuth` reads credentials the same way as the Next middleware, answers `401` when there are none (or they are malformed), and otherwise returns the same status codes as the other middleware (`429` with `Retry-After`, `503` when Daywalker is unreachable). On success the validated payload is on `req.auth`. It also works with plain `http.createServer` handlers.
198
297
 
199
298
  ---
200
299
 
@@ -211,26 +310,51 @@ DAYWALKER_API_KEY=srv_live_… npx daywalker init --service gigpulse --domain gi
211
310
  DAYWALKER_API_KEY=srv_live_… npx daywalker init --service devpulse --domain devpulse.daywalker.dev
212
311
  ```
213
312
 
214
- `init` and `rotate` fail right away with `service_not_covered` if the API key doesn't cover the slug you name. An app that serves several services lists them all, `--service gigpulse,devpulse` (`DAYWALKER_SERVICE_SLUG="gigpulse,devpulse"`); the first is the default for `validateToken()`, and `client.config.serviceSlugs` holds the list.
313
+ `init` and `rotate` take no service: the API key decides which services your credentials cover (`--service` is optional and only sets the audience for offline verification, `DAYWALKER_SERVICE_SLUG`).
314
+
315
+ **Client credentials flow (the JWT lists every service).** Your backend authenticates with its client credentials (API key + registered signing key, see [Authentication](#authentication-oauth-20-client-credentials)) and gets a short-lived access token (5 to 15 minutes) plus a rotating refresh token. No request names a service; the token lists them:
316
+
317
+ ```ts
318
+ const tokens = await client.getTokens();
319
+ tokens.services; // ["gigpulse", "devpulse"]; also the JWT's `services` and `aud` claims
320
+
321
+ // Least privilege: only what this process needs (403 SERVICE_NOT_GRANTED for a service the key doesn't cover)
322
+ await client.getTokens({ scope: ["devpulse"], expiresIn: 600 });
323
+
324
+ // Always-fresh access token: refreshes (and re-authenticates if the session ended) automatically
325
+ const accessToken = await client.accessToken();
326
+ ```
327
+
328
+ **Refresh token rotation.** `client.refreshTokens()` swaps the refresh token for a *new* access token and a *new* refresh token; the old one stops working. Daywalker keeps each session's refresh tokens as a family: presenting an old one again is treated as theft and revokes the whole family, so your client has to start over with its credentials. `accessToken()` serializes refreshes so concurrent requests never present the same token twice. Don't refresh from several processes with one session.
215
329
 
216
- **The JWT lists every service.** Once your backend has authenticated with its signed request, ask for a lease. It lists all the service slugs the token covers (and may use right now):
330
+ **The access token** is an EdDSA JWT (RFC 9068, `typ: at+jwt`) with `iss`, `aud`, `jti`, `iat`, `nbf`, `exp`, `services`, and a `user` object: the user's public UUID v7 `id`, `name` and `email`. Read it with `accessTokenUser(payload)` **after** verifying:
217
331
 
218
332
  ```ts
219
- const lease = await client.issueLease();
220
- lease.services; // ["gigpulse", "devpulse"]; also the JWT's `services` and `aud` claims
333
+ import { verifyJwtLease, accessTokenUser } from "@shelbyb/daywalker-sdk";
334
+ const res = await verifyJwtLease({ token, publicKey: jwks, expectedServiceSlug: "devpulse", expectedIssuer: "https://auth.daywalker.dev" });
335
+ if (res.valid) accessTokenUser(res.payload!); // { id: "018f…", name: "Ada", email: "ada@example.com" }
336
+ ```
337
+
338
+ Verification is strict: only `EdDSA` (never `none` or HMAC, so no algorithm confusion), canonical base64url, `typ` `at+jwt`, issuer, your audience, required `jti` and `exp`, and at most 30 seconds of clock skew. Never decode a token without verifying it.
221
339
 
222
- // Least privilege: only what this process needs. A service the key doesn't cover is refused (403 SERVICE_NOT_GRANTED).
223
- const narrow = await client.issueLease({ services: ["devpulse"], ttlSeconds: 3600 });
340
+ **Web apps: HttpOnly cookies.** Keep tokens on your server. If the browser must hold them, send them from your backend in cookies scripts can't read:
341
+
342
+ ```ts
343
+ import { daywalkerCookies, clearTokenCookie } from "@shelbyb/daywalker-sdk";
344
+ const tokens = await client.getTokens();
345
+ const res = new Response("ok");
346
+ for (const c of daywalkerCookies(tokens, { refreshPath: "/api/auth/refresh" })) res.headers.append("Set-Cookie", c);
347
+ // HttpOnly; Secure; SameSite=Strict. The refresh token cookie is only sent to /api/auth/refresh.
224
348
  ```
225
349
 
226
- Services that are inactive, in `DRAFT`, `TESTING` (unless an admin owns the token), or no longer entitled to the token's owner are left out. Check `lease.services` rather than assuming.
350
+ Services that are inactive, in `DRAFT`, `TESTING` (unless an admin owns the token), or no longer entitled to the token's owner are left out. Check `tokens.services` rather than assuming.
227
351
 
228
- Whoever receives the lease verifies it for **their own** service. It only passes when that slug is in the lease's `services` and `aud`:
352
+ Whoever receives the access token verifies it for **their own** service. It only passes when that slug is in the token's `services` and `aud`:
229
353
 
230
354
  ```ts
231
355
  const verifier = new Daywalker({ serviceSlug: "devpulse", daywalkerPublicKey: process.env.DAYWALKER_PUBLIC_KEY });
232
356
  const result = await verifier.validateToken(incomingJwt);
233
- if (result.ok) result.data.services; // ["gigpulse", "devpulse"]: everything this lease covers
357
+ if (result.ok) result.data.services; // ["gigpulse", "devpulse"]: everything this token covers
234
358
  ```
235
359
 
236
360
  Online validation of an `srv_live_…` token reports the same list in `result.data.services` (a server that predates multi-service tokens yields just the validated slug).
@@ -253,7 +377,23 @@ const client = new Daywalker({
253
377
  const result = await client.validateToken(incomingJwt);
254
378
  ```
255
379
 
256
- Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) containing your service slug, so `serviceSlug` must be configured. A lease that lists `services` must list yours too. `result.data.services` is every service the lease covers (see [One API key, several services](#one-api-key-several-services)). Set `leaseIssuer` (or `DAYWALKER_LEASE_ISSUER`) to also check `iss`.
380
+ ### Surviving Daywalker key rotation (`jwks: "auto"`)
381
+
382
+ A static `DAYWALKER_PUBLIC_KEY` stops verifying the moment Daywalker rotates its signing key. Set `DAYWALKER_JWKS=auto` (or `jwks: "auto"`) and the client verifies against Daywalker's published key set instead:
383
+
384
+ ```ts
385
+ const client = new Daywalker({ serviceSlug: "myservice", jwks: "auto" });
386
+ ```
387
+
388
+ - The set is fetched from `${authUrl}/.well-known/jwks.json` and nowhere else: a `jwks_uri` in a token response or in the set itself is never followed. The fetch sends no credentials, follows no redirects, bypasses HTTP caches, is capped at 1 MiB and times out after `timeoutMs`.
389
+ - Only Ed25519 signing keys for EdDSA with a `kid` are kept (`kty: OKP`, `crv: Ed25519`, `alg` absent or `EdDSA`, `use` absent or `sig`, no private part), at most 16. Everything else in the set is ignored.
390
+ - The set is cached in the client for 10 minutes (`JWKS_CACHE_TTL_MS`). A token naming a `kid` the set doesn't have (a rotation) triggers a refetch, but fetches are never closer than 30 seconds apart (`JWKS_REFETCH_COOLDOWN_MS`), whatever triggers them, so tokens with made-up `kid`s can't make the client hammer Daywalker. Concurrent callers share one fetch.
391
+ - A failed refresh (outage, redirect, non-JSON, no usable key) keeps the last good set.
392
+ - Fail closed: while no set has ever been obtained, the static `daywalkerPublicKey` is used if one is configured; otherwise `offlineOnly` validation returns `503 SERVICE_UNAVAILABLE` and normal validation goes online (`POST /api/v1/tokens/validate`).
393
+
394
+ Without `jwks: "auto"`, offline verification uses `daywalkerPublicKey` exactly as before.
395
+
396
+ Tokens must be RFC 9068 access tokens (`typ: at+jwt`, `alg: EdDSA`) with a numeric `exp`, a `jti`, and an `aud` containing your service slug, so `serviceSlug` must be configured. A token that lists `services` must list yours too. A DPoP-bound token (`cnf.jkt`) is only accepted with `Authorization: DPoP` and a valid proof: right method and URL, signed by the bound key, fresh, carrying the token's hash, and never used before. A copied token alone is useless. With several instances, pass a shared `dpopReplayStore` (like `replayStore` for signatures). `result.data.services` is every service the lease covers (see [One API key, several services](#one-api-key-several-services)). Set `leaseIssuer` (or `DAYWALKER_LEASE_ISSUER`) to also check `iss`.
257
397
 
258
398
  ---
259
399
 
@@ -262,8 +402,10 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) containing your
262
402
  | Command | Description |
263
403
  |---|---|
264
404
  | `npx daywalker init --service <slug[,slug…]> [--domain <host>] [--api-key-stdin] [--skip-register] [--force] [--json]` | Generate Ed25519 key, register with Daywalker Auth, and write `.env.local` or output JSON |
265
- | `npx daywalker check` | Validate configuration, signature readiness, and connection |
266
- | `npx daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
405
+ | `npx daywalker check [--offline]` | Validate configuration and signature readiness, then run the OAuth client credentials grant to confirm the credentials work and cover the configured service(s) (`--offline` skips that) |
406
+ | `npx daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local`. The previous key stays registered until you revoke it, so running deployments keep working |
407
+ | `npx daywalker keys [--domain <host>] [--json]` | List the domain's signing keys: status, slot, expiry, last use, and which one is current or previous |
408
+ | `npx daywalker revoke <keyId> [--force]` | Revoke a key now; Daywalker also ends the OAuth sessions it started. Refuses the current `DAYWALKER_KEY_ID` unless `--force` |
267
409
 
268
410
  ---
269
411
 
@@ -271,12 +413,14 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) containing your
271
413
 
272
414
  | Endpoint | SDK usage | Notes |
273
415
  |---|---|---|
274
- | `POST /api/v1/tokens/validate` | `client.validateToken()` | Body `{ token, serviceSlug }`; signed with `Signature-Input`, `Signature`, `Content-Digest`. Errors: `400` (`DIGEST_MISMATCH`/missing fields), `401` (`INVALID_SIGNATURE`/invalid token), `403`, `429`. Response includes `valid`, `tokenId`, `tokenName`, `userId`, `stage`, `rateLimit`, `rateLimitHourly` and `X-RateLimit-*` / `X-RateLimit-Hourly-*` headers |
275
- | `POST /api/v1/keys/register` | `npx daywalker init` / `rotate` | Bearer auth; body `{ keyId, domain, publicKey, serviceSlug? }`; `403 service_not_covered` if the key doesn't cover `serviceSlug` |
276
- | `POST /api/v1/leases/issue` | `client.issueLease(ttlSeconds? \| { ttlSeconds?, services? })` | Signed request returns `{ lease, tokenType, expiresAt, jwksUri, services }` (EdDSA JWT listing every service the key covers; `services` narrows it) |
416
+ | `POST /api/v1/tokens/validate` | `client.validateToken()` | `Authorization: DPoP <access_token>` + `DPoP` proof; body `{ token }`; signed with `Signature-Input`, `Signature`, `Content-Digest`. Errors: `400` (`DIGEST_MISMATCH`/missing fields), `401` (`INVALID_SIGNATURE`, `DPOP_REQUIRED`, `INVALID_DPOP_PROOF`, `DPOP_REPLAY`, `ACCESS_TOKEN_EXPIRED`, invalid token), `403` (`SERVICE_NOT_GRANTED`, allowlist), `429`. Response includes `valid`, `tokenId`, `tokenName`, `userId`, `stage`, `rateLimit`, `rateLimitHourly` and `X-RateLimit-*` / `X-RateLimit-Hourly-*` headers |
417
+ | `POST /api/v1/keys/register` | `registerPublicKey()`, `npx daywalker init` / `rotate`, `keyStore` / `ephemeralKey` provisioning | `Authorization: Bearer <API key>` (bootstraps the signing key the client credentials grant needs); body `{ keyId, domain, publicKey, serviceSlug?, instanceId?, ttlSeconds?, expiresAt? }`. `200` re-registration (idempotent), `201` new; `409 KEY_LIMIT_REACHED` (20 live keys per domain) or `KEY_ID_CONFLICT`; `403 domain_not_allowed`. The SDK always passes an `instanceId`, because without one Daywalker retires every other slot-less key of the domain after 10 minutes |
418
+ | `GET /api/v1/keys?domain=` | `listSigningKeys()`, `npx daywalker keys` | The domain's keys with `status`, `instanceId`, `expiresAt`, `lastUsedAt` |
419
+ | `DELETE /api/v1/keys/{keyId}` | `revokeSigningKey()`, `client.close()`, `npx daywalker revoke` | Revokes now and ends the OAuth sessions started with the key |
420
+ | `POST /api/v1/oauth/token` | `client.getTokens({ scope?, expiresIn? })`, `client.refreshTokens()`, `client.accessToken()` (called for you by `validateToken()` / `fetch()`) | `client_credentials` (API key as Bearer + signed request, mandatory DPoP) and `refresh_token` grants return `{ accessToken, refreshToken, expiresAt, services, … }`; tokens are sender-constrained via DPoP (RFC 9449), refresh tokens rotate, and reuse revokes the session |
277
421
  | `GET /.well-known/jwks.json` | `client.fetchJwks()` | Daywalker public JWKS for offline lease verification |
278
422
 
279
- Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization: Bearer <JWT or API key>`.
423
+ Inbound tokens may be supplied as `x-api-token: srv_live_...`, `Authorization: Bearer <token>`, or `Authorization: DPoP <access token>` with a `DPoP` proof. An online answer must name your service: a token validated for a sibling service your credentials also cover is refused with `403 SERVICE_NOT_GRANTED`.
280
424
 
281
425
  ---
282
426
 
@@ -286,7 +430,7 @@ Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
286
430
  - `Content-Digest: sha-256=:<base64-encoded-hash>:` (POST/PUT/PATCH, and any request with a body)
287
431
  - `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`. `"@query"` is added after `"@path"` when the URL has a query string; `"content-digest"` is left out only for bodyless GET/HEAD/DELETE/OPTIONS requests.
288
432
  - `Signature: sig1=:<base64-encoded-signature>:`
289
- - `Authorization: Bearer <DAYWALKER_API_KEY>`
433
+ - `Authorization: DPoP <access_token>` and `DPoP: <proof JWT>` (`htm`, `htu`, `iat`, `jti`, `ath`). Only the client credentials grant on `/api/v1/oauth/token` sends `Authorization: Bearer <DAYWALKER_API_KEY>` instead.
290
434
 
291
435
  A fresh random `nonce` is generated for every attempt (including retries), so identical requests in the same second still produce unique signatures and never trip replay protection. `validateToken()` also shares one in-flight request between concurrent callers for the same token, and briefly caches failures (`negativeCacheTtlMs`, default 3000ms; 429s and network errors are never cached).
292
436
 
@@ -294,6 +438,22 @@ Verification reads the covered components from `Signature-Input`, requires `cont
294
438
 
295
439
  ---
296
440
 
441
+ ## Security Model
442
+
443
+ This SDK checks that callers hold a valid Daywalker credential, and that the service it runs in holds valid OAuth client credentials. It is built to resist tampering by **other code in the same process** (a compromised dependency, an injected script, a confused AI agent editing at runtime):
444
+
445
+ - **No reachable internals.** Caches, tokens, the DPoP key and the config are ES `#private` fields, so `client._cache` doesn't exist to be poisoned. `Daywalker.prototype`, the class and each instance are frozen, so `validateToken` can't be swapped by assignment, including on the shared `daywalker` client.
446
+ - **Frozen, secret-free config.** `client.config` is frozen and shows the API key and private keys only as `"[configured]"`. The options object is read once, so changing it later (say, `authUrl` or `fetch`) has no effect.
447
+ - **Captured primitives.** WebCrypto (`subtle.verify`, `sign`, `digest`, …) is captured when the SDK loads, so replacing `crypto.subtle.verify` later can't make forged tokens verify. The Next adapter builds its own responses and ignores any `NextResponse` planted on `globalThis`.
448
+ - **Sender-constrained tokens.** The SDK's own access tokens are DPoP-bound to a non-extractable key generated in memory, and only the token endpoint ever sees the API key. Incoming DPoP-bound tokens need a valid proof.
449
+ - **Bounded, validated input.** Tokens (≤ 8 KiB, token characters only), headers, DPoP proofs, JWTs and response bodies (≤ 1 MiB) are size-checked and validated before use. Nothing is followed through a redirect, cached by an HTTP cache (`cache: "no-store"`), or trusted from an answer of the wrong shape (fail closed).
450
+
451
+ **What no client-side SDK can do:** stop the operator of the server it runs on. Whoever controls a deployment can edit `node_modules`, patch the code, or delete the middleware, and obfuscation only slows that down. So treat this package as the *enforcement point*, and put the *decision* on Daywalker's side, where it can't be removed:
452
+
453
+ 1. Make the service depend on what only a valid grant provides. Read limits and features from the verified access token (`service_limits`, `service_features`) rather than from local config, so code with the SDK ripped out has nothing to run on.
454
+ 2. Revoke on Daywalker. Revoking the API key, signing key or allowlist entry ends the client credentials session (and every refresh) at the next token request, within 15 minutes at most.
455
+ 3. Keep the signing key and API key in a secret manager, run `npx daywalker check` in CI and at startup, and watch Daywalker's usage analytics for clients that stop calling in.
456
+
297
457
  ## DevPulse SDKs (one package)
298
458
 
299
459
  The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable; `react` and the OpenFeature SDKs are optional peer dependencies used only by their subpaths.