@shelbyb/daywalker-sdk 0.6.0 → 0.8.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 access token |
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
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
 
@@ -213,7 +312,7 @@ DAYWALKER_API_KEY=srv_live_… npx daywalker init --service devpulse --domain de
213
312
 
214
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`).
215
314
 
216
- **Client credentials flow (the JWT lists every service).** Your backend authenticates with its client credentials (API key + registered signing key) 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:
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:
217
316
 
218
317
  ```ts
219
318
  const tokens = await client.getTokens();
@@ -278,17 +377,46 @@ const client = new Daywalker({
278
377
  const result = await client.validateToken(incomingJwt);
279
378
  ```
280
379
 
281
- Tokens must have a numeric `exp`, a `jti`, 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`.
282
397
 
283
398
  ---
284
399
 
400
+ ## Service License & Config Bundle
401
+
402
+ Daywalker issues each service a license key with the client credentials grant: derived per service, signing key and one-hour window, never returned for a revoked or suspended service.
403
+
404
+ ```ts
405
+ const { licenseKey, expiresAt } = await client.getLicense(); // cached until a minute before expiry
406
+ const bundle = await client.fetchBundle(); // { dataKeySalt, features, issuedAt, expiresAt, ... }
407
+ ```
408
+
409
+ `fetchBundle()` downloads a bundle sealed (AES-256-GCM) under the license key and opens it locally, so only a client that got the license from Daywalker can read it. Derive app secrets from `licenseKey` + `bundle.dataKeySalt` (HKDF) so removing the SDK breaks the app. Errors are `DaywalkerAuthError`s: `SERVICE_REVOKED`, `SERVICE_SUSPENDED`, `DOMAIN_REVOKED`, `DOMAIN_MISMATCH`, `LICENSE_UNAVAILABLE`, `BUNDLE_UNREADABLE`. Never log the key.
410
+
285
411
  ## CLI Reference
286
412
 
287
413
  | Command | Description |
288
414
  |---|---|
289
415
  | `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 |
290
- | `npx daywalker check` | Validate configuration, signature readiness, and connection |
291
- | `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` |
416
+ | `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) |
417
+ | `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 |
418
+ | `npx daywalker keys [--domain <host>] [--json]` | List the domain's signing keys: status, slot, expiry, last use, and which one is current or previous |
419
+ | `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` |
292
420
 
293
421
  ---
294
422
 
@@ -296,12 +424,14 @@ Tokens must have a numeric `exp`, a `jti`, and an `aud` (or `serviceSlug`) conta
296
424
 
297
425
  | Endpoint | SDK usage | Notes |
298
426
  |---|---|---|
299
- | `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 |
300
- | `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` |
301
- | `POST /api/v1/oauth/token` | `client.getTokens({ scope?, expiresIn? })`, `client.refreshTokens()`, `client.accessToken()` | `client_credentials` (signed request) and `refresh_token` grants return `{ accessToken, refreshToken, expiresAt, services, … }`; refresh tokens rotate and reuse revokes the session |
427
+ | `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 |
428
+ | `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 |
429
+ | `GET /api/v1/keys?domain=` | `listSigningKeys()`, `npx daywalker keys` | The domain's keys with `status`, `instanceId`, `expiresAt`, `lastUsedAt` |
430
+ | `DELETE /api/v1/keys/{keyId}` | `revokeSigningKey()`, `client.close()`, `npx daywalker revoke` | Revokes now and ends the OAuth sessions started with the key |
431
+ | `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 |
302
432
  | `GET /.well-known/jwks.json` | `client.fetchJwks()` | Daywalker public JWKS for offline lease verification |
303
433
 
304
- Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization: Bearer <JWT or API key>`.
434
+ 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`.
305
435
 
306
436
  ---
307
437
 
@@ -311,7 +441,7 @@ Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
311
441
  - `Content-Digest: sha-256=:<base64-encoded-hash>:` (POST/PUT/PATCH, and any request with a body)
312
442
  - `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.
313
443
  - `Signature: sig1=:<base64-encoded-signature>:`
314
- - `Authorization: Bearer <DAYWALKER_API_KEY>`
444
+ - `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.
315
445
 
316
446
  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).
317
447
 
@@ -319,6 +449,22 @@ Verification reads the covered components from `Signature-Input`, requires `cont
319
449
 
320
450
  ---
321
451
 
452
+ ## Security Model
453
+
454
+ 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):
455
+
456
+ - **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.
457
+ - **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.
458
+ - **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`.
459
+ - **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.
460
+ - **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).
461
+
462
+ **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:
463
+
464
+ 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.
465
+ 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.
466
+ 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.
467
+
322
468
  ## DevPulse SDKs (one package)
323
469
 
324
470
  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.
package/dist/cli.js CHANGED
@@ -3,27 +3,35 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createDaywalker } from "./client.js";
5
5
  import { parseServiceSlugs, publicHostname } from "./config.js";
6
- import { generateSigningKey, loadSigningKey, registerPublicKey, redactSecrets } from "./crypto/index.js";
6
+ import { generateSigningKey, keySlotFor, listSigningKeys, loadSigningKey, registerPublicKey, redactSecrets, revokeSigningKey } from "./crypto/index.js";
7
7
  const HELP = `daywalker: connect a service to Daywalker Auth (RFC 9421 / Push Architecture)
8
8
 
9
9
  Usage:
10
10
  daywalker init [--service <slug[,slug…]>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--force] [--skip-register] [--json|--export] [--print-key]
11
11
  daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--skip-register] [--json|--export] [--print-key]
12
- daywalker check
12
+ daywalker check [--offline]
13
+ daywalker keys [--domain <host>] [--auth-url <url>] [--api-key-stdin] [--json]
14
+ daywalker revoke <keyId> [--domain <host>] [--auth-url <url>] [--api-key-stdin] [--force]
13
15
 
14
16
  init Generates an Ed25519 keypair, registers the public key directly with Daywalker Auth
15
17
  (POST /api/v1/keys/register), and writes DAYWALKER_* settings to .env.local (or outputs JSON with --json).
16
18
  Nothing is written if registration fails. --force replaces an existing key (kept as
17
19
  DAYWALKER_SIGNING_KEY_PREVIOUS for rollback). No public ingress or /.well-known route handlers are required.
18
20
  rotate Generates a new Ed25519 key, registers it with Daywalker Auth, and keeps the old one
19
- in .env.local as DAYWALKER_SIGNING_KEY_PREVIOUS during deployment transition.
21
+ in .env.local as DAYWALKER_SIGNING_KEY_PREVIOUS during deployment transition. The old key stays
22
+ registered until you revoke it (\`daywalker revoke <keyId>\`) once the new key is deployed everywhere.
20
23
  Nothing is written, and the exit code is non-zero, unless registration succeeds.
21
- check Validates configuration and confirms Daywalker Auth connection and signature readiness.
24
+ check Validates configuration and signature readiness, then runs the OAuth client credentials grant
25
+ against Daywalker Auth to confirm the credentials work and cover the configured service(s).
26
+ keys Lists the signing keys registered for the domain (status, slot, expiry, last use).
27
+ revoke Revokes a signing key now; Daywalker also ends the OAuth sessions it started. Refuses the key in
28
+ DAYWALKER_KEY_ID (the one this service signs with) unless --force.
22
29
 
23
30
  Options:
24
31
  --api-key-stdin Read the Daywalker API key from stdin (e.g. \`op read ... | daywalker init --api-key-stdin\`).
25
32
  The key is otherwise taken from DAYWALKER_API_KEY in the environment or .env.local.
26
- --api-key <key> also works but exposes the key in shell history and process listings.
33
+ (--api-key <key> is refused: it would expose the key in shell history and process listings.)
34
+ --offline check only: skip the live OAuth client credentials check against Daywalker Auth.
27
35
  --skip-register Do not register the public key (alias: --no-register); register it yourself before deploying.
28
36
  --json, --export Output configuration as JSON for piping into Vercel, DigitalOcean, Fly.io, AWS SSM, etc.
29
37
  The JSON includes the private key (DAYWALKER_SIGNING_KEY); nothing is written to .env.local.
@@ -59,8 +67,6 @@ async function readStdin() {
59
67
  return Buffer.concat(chunks).toString("utf8").trim();
60
68
  }
61
69
  async function resolveApiKey(opts, existing) {
62
- if (typeof opts["api-key"] === "string")
63
- return opts["api-key"].trim() || null;
64
70
  if (opts["api-key-stdin"]) {
65
71
  const fromStdin = await readStdin();
66
72
  if (!fromStdin)
@@ -69,6 +75,13 @@ async function resolveApiKey(opts, existing) {
69
75
  }
70
76
  return String(existing.get("DAYWALKER_API_KEY") ?? process.env.DAYWALKER_API_KEY ?? "").trim() || null;
71
77
  }
78
+ /** Why a key API call failed, with what to do when the domain is full. */
79
+ function keyApiFailure(what, result) {
80
+ const hint = result.code === "KEY_LIMIT_REACHED"
81
+ ? "\n The domain has 20 live keys: list them with `daywalker keys` and revoke unused ones with `daywalker revoke <keyId>`."
82
+ : "";
83
+ return `${what} failed (${result.status || "network"}${result.code ? ` ${result.code}` : ""}): ${result.error}${hint}`;
84
+ }
72
85
  function skipRegister(opts) {
73
86
  return Boolean(opts["skip-register"] || opts["no-register"]);
74
87
  }
@@ -211,11 +224,13 @@ async function init(opts) {
211
224
  if (apiKey && activeKey && !skipRegister(opts)) {
212
225
  if (!isJson)
213
226
  console.log(`• Registering public key (${activeKey.publicJwk.kid}) with Daywalker Auth...`);
227
+ // Each key gets its own slot, so registering it never retires a key a running deployment still uses.
214
228
  const regResult = await registerPublicKey({
215
229
  authUrl,
216
230
  apiKey,
217
231
  domain,
218
232
  publicJwk: activeKey.publicJwk,
233
+ instanceId: keySlotFor(activeKey.publicJwk.kid),
219
234
  });
220
235
  if (regResult.ok) {
221
236
  registered = true;
@@ -223,7 +238,7 @@ async function init(opts) {
223
238
  console.log(`✔ Public key registered with Daywalker Auth (Push Architecture)`);
224
239
  }
225
240
  else {
226
- fail(`Key registration failed (${regResult.status}): ${regResult.error}\n` +
241
+ fail(`${keyApiFailure("Key registration", regResult)}\n` +
227
242
  ` Nothing was written. Fix the problem and re-run, or pass --skip-register to write the key without registering it.`);
228
243
  }
229
244
  }
@@ -254,8 +269,12 @@ async function init(opts) {
254
269
  console.log(` • Key ID (RFC 7638 thumbprint): ${kid}`);
255
270
  if (registered)
256
271
  console.log(` • Zero-ingress push architecture: public key registered directly`);
257
- if (previousKey)
258
- console.log(` • Previous key kept as DAYWALKER_SIGNING_KEY_PREVIOUS for rollback; remove it once the new key is deployed.`);
272
+ if (previousKey) {
273
+ const previousKid = (await loadSigningKey(previousKey).catch(() => null))?.publicJwk.kid;
274
+ console.log(` • Previous key kept as DAYWALKER_SIGNING_KEY_PREVIOUS for rollback; it stays registered.`);
275
+ if (previousKid)
276
+ console.log(` Once the new key is deployed everywhere: npx daywalker revoke ${previousKid}`);
277
+ }
259
278
  if (opts["print-key"] && signingKeyStr)
260
279
  console.log(`\nDAYWALKER_SIGNING_KEY=${signingKeyStr}`);
261
280
  }
@@ -285,9 +304,10 @@ async function rotate(opts) {
285
304
  apiKey,
286
305
  domain: rotateDomain,
287
306
  publicJwk: key.publicJwk,
307
+ instanceId: keySlotFor(key.publicJwk.kid),
288
308
  });
289
309
  if (!regResult.ok) {
290
- fail(`Key registration failed (${regResult.status}): ${regResult.error}\n Nothing was written; the current key is still active.`);
310
+ fail(`${keyApiFailure("Key registration", regResult)}\n Nothing was written; the current key is still active.`);
291
311
  }
292
312
  if (!isJson)
293
313
  console.log(`✔ New public key registered with Daywalker Auth`);
@@ -305,14 +325,27 @@ async function rotate(opts) {
305
325
  }
306
326
  writeEnvFile(envFile, envValues, { DAYWALKER_SIGNING_KEY_PREVIOUS: PREVIOUS_KEY_COMMENT });
307
327
  console.log(`✔ New key ${key.publicJwk.kid} written; previous key preserved as DAYWALKER_SIGNING_KEY_PREVIOUS.`);
308
- console.log(` After deployment rollout is complete, you may remove DAYWALKER_SIGNING_KEY_PREVIOUS.`);
328
+ const previousKid = (await loadSigningKey(current).catch(() => null))?.publicJwk.kid;
329
+ console.log(` The previous key stays registered, so running deployments keep working.`);
330
+ if (previousKid)
331
+ console.log(` Once the new key is deployed everywhere: npx daywalker revoke ${previousKid}, then remove DAYWALKER_SIGNING_KEY_PREVIOUS.`);
309
332
  if (opts["print-key"])
310
333
  console.log(`\nDAYWALKER_SIGNING_KEY=${key.privateKeyPkcs8Base64}`);
311
334
  }
312
- async function check() {
335
+ async function check(opts) {
313
336
  const env = { ...process.env, ...Object.fromEntries(readEnvFile(path.join(process.cwd(), ".env.local"))) };
314
337
  const client = createDaywalker({ env });
315
338
  const result = await client.selfCheck();
339
+ if (result.ok && !opts.offline) {
340
+ const live = await client.validateServiceCredentials();
341
+ if (!live.ok) {
342
+ result.ok = false;
343
+ result.problems.push(`OAuth client credentials check failed (${live.status} ${live.error}): ${live.message}`);
344
+ }
345
+ else {
346
+ console.log(`✔ OAuth client credentials grant succeeded (services: ${(live.data.services ?? []).join(", ")})`);
347
+ }
348
+ }
316
349
  if (result.ok) {
317
350
  console.log(`✔ Daywalker configuration looks good`);
318
351
  if (result.serviceSlugs.length > 1)
@@ -334,11 +367,58 @@ async function check() {
334
367
  console.error(redactSecrets(` - ${p}`));
335
368
  process.exit(1);
336
369
  }
370
+ /** Domain, API key and auth URL for the key commands, from flags, .env.local or the environment. */
371
+ async function keyCommandContext(opts) {
372
+ const existing = readEnvFile(path.join(process.cwd(), ".env.local"));
373
+ const apiKey = await resolveApiKey(opts, existing);
374
+ if (!apiKey)
375
+ fail("Set DAYWALKER_API_KEY (or pipe it to --api-key-stdin)");
376
+ const rawDomain = String(opts.domain ?? existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? "");
377
+ const domain = publicHostname(rawDomain);
378
+ const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
379
+ const currentKid = existing.get("DAYWALKER_KEY_ID") ?? process.env.DAYWALKER_KEY_ID ?? null;
380
+ const previous = existing.get("DAYWALKER_SIGNING_KEY_PREVIOUS") ?? process.env.DAYWALKER_SIGNING_KEY_PREVIOUS;
381
+ const previousKid = previous ? ((await loadSigningKey(previous).catch(() => null))?.publicJwk.kid ?? null) : null;
382
+ return { apiKey, domain, rawDomain, authUrl, currentKid, previousKid };
383
+ }
384
+ async function keys(opts) {
385
+ const ctx = await keyCommandContext(opts);
386
+ if (!ctx.domain)
387
+ fail(`--domain (${ctx.rawDomain || "not set"}) must be a public hostname (or set DAYWALKER_DOMAIN)`);
388
+ const result = await listSigningKeys({ authUrl: ctx.authUrl, apiKey: ctx.apiKey, domain: ctx.domain });
389
+ if (!result.ok)
390
+ fail(keyApiFailure("Listing keys", result));
391
+ const list = result.keys ?? [];
392
+ if (opts.json) {
393
+ console.log(JSON.stringify(list, null, 2));
394
+ return;
395
+ }
396
+ const when = (t) => (t === null ? "-" : new Date(t * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"));
397
+ console.log(`Signing keys for ${ctx.domain} (${list.filter((k) => k.status !== "REVOKED").length} live of 20 allowed):`);
398
+ for (const k of list) {
399
+ const tag = k.keyId === ctx.currentKid ? " (current)" : k.keyId === ctx.previousKid ? " (previous)" : "";
400
+ console.log(` ${k.keyId}${tag} ${k.status} slot=${k.instanceId ?? "-"} expires=${when(k.expiresAt)} lastUsed=${when(k.lastUsedAt)}`);
401
+ }
402
+ }
403
+ async function revoke(keyId, opts) {
404
+ if (!keyId)
405
+ fail("Usage: daywalker revoke <keyId>");
406
+ const ctx = await keyCommandContext(opts);
407
+ if (keyId === ctx.currentKid && !opts.force) {
408
+ fail(`${keyId} is DAYWALKER_KEY_ID, the key this service signs with: revoking it stops this service from authenticating. Pass --force if that is what you want.`);
409
+ }
410
+ const result = await revokeSigningKey({ authUrl: ctx.authUrl, apiKey: ctx.apiKey, keyId });
411
+ if (!result.ok)
412
+ fail(keyApiFailure(`Revoking ${keyId}`, result));
413
+ console.log(`✔ Revoked signing key ${keyId}; Daywalker ended the OAuth sessions it started.`);
414
+ if (keyId === ctx.previousKid)
415
+ console.log(" You can now remove DAYWALKER_SIGNING_KEY_PREVIOUS from .env.local.");
416
+ }
337
417
  async function main() {
338
418
  const [cmd, ...rest] = process.argv.slice(2);
339
419
  const parsedArgs = args(rest);
340
- if (typeof parsedArgs["api-key"] === "string") {
341
- console.warn("! --api-key exposes the key in shell history and process listings; prefer DAYWALKER_API_KEY in the environment or .env.local.");
420
+ if (parsedArgs["api-key"] !== undefined) {
421
+ fail("--api-key is not supported: it exposes the key in shell history and process listings. Use DAYWALKER_API_KEY in the environment or .env.local, or pipe it to --api-key-stdin.");
342
422
  }
343
423
  if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
344
424
  console.log(HELP);
@@ -351,7 +431,13 @@ async function main() {
351
431
  await rotate(parsedArgs);
352
432
  }
353
433
  else if (cmd === "check") {
354
- await check();
434
+ await check(parsedArgs);
435
+ }
436
+ else if (cmd === "keys") {
437
+ await keys(parsedArgs);
438
+ }
439
+ else if (cmd === "revoke") {
440
+ await revoke(rest[0] && !rest[0].startsWith("--") ? rest[0] : undefined, parsedArgs);
355
441
  }
356
442
  else {
357
443
  fail(`Unknown command "${cmd}". Run \`daywalker --help\` for usage.`);