@shelbyb/daywalker-sdk 0.4.0 → 0.5.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
@@ -4,6 +4,10 @@
4
4
 
5
5
  # @shelbyb/daywalker-sdk
6
6
 
7
+ [![npm](https://img.shields.io/npm/v/@shelbyb/daywalker-sdk)](https://www.npmjs.com/package/@shelbyb/daywalker-sdk)
8
+ [![CI](https://github.com/Shelbyb/daywalker-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/Shelbyb/daywalker-sdk/actions/workflows/ci.yml)
9
+ [![license](https://img.shields.io/npm/l/@shelbyb/daywalker-sdk)](./LICENSE)
10
+
7
11
  Runtime-agnostic client library for [Daywalker Auth](https://auth.daywalker.dev). Supports **RFC 9421 (HTTP Message Signatures)**, **RFC 9530 (Digest Fields)**, **Web Crypto standards**, **Push Architecture key registration**, and **hardened web & Next.js middleware**.
8
12
 
9
13
  Runs on **Node.js 20.19+**, **Bun**, **Deno**, **Cloudflare Workers**, **Next.js Edge**, and browsers. ESM only; `require()` works on Node 20.19+ and 22.12+.
@@ -44,7 +48,7 @@ DAYWALKER_API_KEY=srv_live_... npx daywalker init --service my-service-slug --do
44
48
 
45
49
  `daywalker init`:
46
50
  - Generates an Ed25519 keypair locally using WebCrypto (`crypto.subtle`)
47
- - Registers the public JWK directly with `POST https://auth.daywalker.dev/api/v1/keys/register` (`Authorization: Bearer <API key>`, body `{ keyId, domain, publicKey }`; responds `201`)
51
+ - Registers the public JWK directly with `POST https://auth.daywalker.dev/api/v1/keys/register` (`Authorization: Bearer <API key>`, body `{ keyId, domain, publicKey, serviceSlug }`; responds `201`, or `403 service_not_covered` when the API key doesn't cover the service)
48
52
  - Writes `DAYWALKER_SERVICE_SLUG`, `DAYWALKER_KEY_ID`, `DAYWALKER_SIGNING_KEY`, and `DAYWALKER_AUTH_URL` to `.env.local` (atomically, mode `0600`), and only after registration succeeds. `--skip-register` writes without registering.
49
53
  - `--force` replaces an existing key and keeps the old one as `DAYWALKER_SIGNING_KEY_PREVIOUS`
50
54
  - No route injectors or public `/.well-known/` endpoints needed
@@ -58,7 +62,7 @@ The private key is never printed unless you pass `--print-key`.
58
62
  | Variable | Required | Purpose |
59
63
  |---|---|---|
60
64
  | `DAYWALKER_API_KEY` | ✅ | The service's Daywalker API token (`srv_live_…`) or JWT lease |
61
- | `DAYWALKER_SERVICE_SLUG` | ✅ | The service slug to validate against (e.g. `sonalyze`) |
65
+ | `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) |
62
66
  | `DAYWALKER_SIGNING_KEY` | for signed requests | Ed25519 private key from `daywalker init` (PKCS#8 Base64 DER or PEM) |
63
67
  | `DAYWALKER_KEY_ID` | optional | RFC 7638 JWK thumbprint derived during key generation |
64
68
  | `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Previous private key, maintained during deployment transitions |
@@ -177,6 +181,62 @@ app.get("/api/me", (c) => {
177
181
 
178
182
  ---
179
183
 
184
+ ## Express / Connect / Node `http` (`@shelbyb/daywalker-sdk/express`)
185
+
186
+ ```ts
187
+ import express from "express";
188
+ import { daywalkerAuth, daywalkerKeys } from "@shelbyb/daywalker-sdk/express";
189
+
190
+ const app = express();
191
+ app.use(daywalkerKeys()); // serves GET /.well-known/daywalker-keys.json
192
+ app.use("/api", daywalkerAuth({ serviceSlug: "sonalyze" }));
193
+
194
+ app.get("/api/me", (req, res) => res.json({ auth: req.auth }));
195
+ ```
196
+
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.
198
+
199
+ ---
200
+
201
+ ## One API Key, Several Services
202
+
203
+ A Daywalker API token can cover several services (say `gigpulse` and `devpulse`) and carry one allowlist of domains and IPs, so you manage a single set of client credentials instead of one per service. Ask for the extra services when you create the token in the Daywalker dashboard (**Also cover these services**), or with `additionalServiceIds` on `POST /api/v1/tokens` and `PATCH /api/v1/tokens/:id`.
204
+
205
+ **Each app keeps its own service slug, with the same API key.** Every app (domain) generates and registers its own signing key:
206
+
207
+ ```bash
208
+ # gigpulse app
209
+ DAYWALKER_API_KEY=srv_live_… npx daywalker init --service gigpulse --domain gigpulse.daywalker.dev
210
+ # devpulse app, same API key
211
+ DAYWALKER_API_KEY=srv_live_… npx daywalker init --service devpulse --domain devpulse.daywalker.dev
212
+ ```
213
+
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.
215
+
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):
217
+
218
+ ```ts
219
+ const lease = await client.issueLease();
220
+ lease.services; // ["gigpulse", "devpulse"]; also the JWT's `services` and `aud` claims
221
+
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 });
224
+ ```
225
+
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.
227
+
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`:
229
+
230
+ ```ts
231
+ const verifier = new Daywalker({ serviceSlug: "devpulse", daywalkerPublicKey: process.env.DAYWALKER_PUBLIC_KEY });
232
+ const result = await verifier.validateToken(incomingJwt);
233
+ if (result.ok) result.data.services; // ["gigpulse", "devpulse"]: everything this lease covers
234
+ ```
235
+
236
+ 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).
237
+
238
+ ---
239
+
180
240
  ## Zero-Egress Offline Verification
181
241
 
182
242
  When high-throughput internal microservices need zero latency and zero external egress, Daywalker Auth can issue asymmetric JWT leases. Configure `DAYWALKER_PUBLIC_KEY` (PEM or standard JWK) and token validation runs entirely in memory without network calls:
@@ -193,7 +253,7 @@ const client = new Daywalker({
193
253
  const result = await client.validateToken(incomingJwt);
194
254
  ```
195
255
 
196
- Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your service slug, so `serviceSlug` must be configured. Set `leaseIssuer` (or `DAYWALKER_LEASE_ISSUER`) to also check `iss`.
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`.
197
257
 
198
258
  ---
199
259
 
@@ -201,9 +261,9 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your s
201
261
 
202
262
  | Command | Description |
203
263
  |---|---|
204
- | `npx daywalker init --service <slug> [--domain <host>] [--api-key-stdin] [--skip-register] [--force] [--json]` | Generate Ed25519 key, register with Daywalker Auth, and write `.env.local` or output JSON |
264
+ | `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 |
205
265
  | `npx daywalker check` | Validate configuration, signature readiness, and connection |
206
- | `npx daywalker rotate [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
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` |
207
267
 
208
268
  ---
209
269
 
@@ -212,8 +272,8 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your s
212
272
  | Endpoint | SDK usage | Notes |
213
273
  |---|---|---|
214
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 |
215
- | `POST /api/v1/keys/register` | `npx daywalker init` / `rotate` | Bearer auth; body `{ keyId, domain, publicKey }` |
216
- | `POST /api/v1/leases/issue` | `client.issueLease(ttlSeconds?)` | Signed request returns `{ lease, tokenType, expiresAt, jwksUri }` (EdDSA JWT) |
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) |
217
277
  | `GET /.well-known/jwks.json` | `client.fetchJwks()` | Daywalker public JWKS for offline lease verification |
218
278
 
219
279
  Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization: Bearer <JWT or API key>`.
@@ -257,6 +317,18 @@ const analytics = createAnalytics({ writeKey: "dpa_…", host: "https://devpulse
257
317
 
258
318
  ---
259
319
 
320
+ ## Provenance & Releases
321
+
322
+ Releases are published from GitHub Actions ([`publish.yml`](./.github/workflows/publish.yml)) with [npm trusted publishing](https://docs.npmjs.com/trusted-publishers): npm accepts a short-lived OIDC token from this repository's workflow, so there is no long-lived npm token anywhere. Each version carries a signed [provenance attestation](https://docs.npmjs.com/generating-provenance-statements) linking it to the commit and workflow run that built it. Check it on the npm page, or after installing:
323
+
324
+ ```bash
325
+ npm audit signatures
326
+ ```
327
+
328
+ To cut a release (maintainers): bump `version` in `package.json`, add a `CHANGELOG.md` entry, merge to `main`, then either push a matching tag (`git tag v0.5.0 && git push origin v0.5.0`) or run the **Publish** workflow from `main` with the version. The workflow refuses tags not on `main`, versions that don't match `package.json`, and versions already on npm. Tests and packing run in a job without the OIDC token; only the final job, which uploads the tested tarball, can publish.
329
+
330
+ ---
331
+
260
332
  ## License
261
333
 
262
334
  MIT
package/dist/cli.js CHANGED
@@ -2,13 +2,13 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createDaywalker } from "./client.js";
5
- import { publicHostname } from "./config.js";
5
+ import { parseServiceSlugs, publicHostname } from "./config.js";
6
6
  import { generateSigningKey, loadSigningKey, registerPublicKey, redactSecrets } 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
- daywalker init --service <slug> [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--force] [--skip-register] [--json|--export] [--print-key]
11
- daywalker rotate [--service <slug>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--skip-register] [--json|--export] [--print-key]
10
+ daywalker init --service <slug[,slug…]> [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--force] [--skip-register] [--json|--export] [--print-key]
11
+ daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--skip-register] [--json|--export] [--print-key]
12
12
  daywalker check
13
13
 
14
14
  init Generates an Ed25519 keypair, registers the public key directly with Daywalker Auth
@@ -180,12 +180,11 @@ async function init(opts) {
180
180
  const dir = process.cwd();
181
181
  const envFile = path.join(dir, ".env.local");
182
182
  const existing = readEnvFile(envFile);
183
- const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "")
184
- .trim()
185
- .toLowerCase();
186
- if (!slug || !/^[a-z0-9_-]+$/.test(slug)) {
187
- fail("--service must be this service's Daywalker slug (e.g. --service sonalyze)");
183
+ const slugs = parseServiceSlugs(String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? ""));
184
+ if (slugs.length === 0 || !slugs.every((s) => /^[a-z0-9_-]+$/.test(s))) {
185
+ fail("--service must be this service's Daywalker slug (e.g. --service sonalyze), or a comma-separated list when one API key covers several (--service gigpulse,devpulse)");
188
186
  }
187
+ const slug = slugs.join(",");
189
188
  const rawDomain = opts.domain ? String(opts.domain) : existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? "";
190
189
  let domain = null;
191
190
  if (rawDomain) {
@@ -268,7 +267,7 @@ async function rotate(opts) {
268
267
  const current = existing.get("DAYWALKER_SIGNING_KEY") ?? process.env.DAYWALKER_SIGNING_KEY;
269
268
  if (!current)
270
269
  fail("No DAYWALKER_SIGNING_KEY found; run `daywalker init` first");
271
- const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "").trim().toLowerCase();
270
+ const slug = parseServiceSlugs(String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "")).join(",");
272
271
  const apiKey = await resolveApiKey(opts, existing);
273
272
  const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
274
273
  const rotateDomain = publicHostname(String(opts.domain ?? existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? ""));
@@ -317,7 +316,9 @@ async function check() {
317
316
  const result = await client.selfCheck();
318
317
  if (result.ok) {
319
318
  console.log(`✔ Daywalker configuration looks good`);
320
- if (result.serviceSlug)
319
+ if (result.serviceSlugs.length > 1)
320
+ console.log(` • Services: ${result.serviceSlugs.join(", ")} (default: ${result.serviceSlug})`);
321
+ else if (result.serviceSlug)
321
322
  console.log(` • Service slug: ${result.serviceSlug}`);
322
323
  if (result.domain)
323
324
  console.log(` • Caller domain: ${result.domain}`);
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ResolvedConfig } from "./config.js";
2
- import { type DaywalkerConfig, type PublicJwk, type RetryOptions, type SelfCheckResult, type TokenPayload, type ValidateResult } from "./types.js";
2
+ import { type DaywalkerConfig, type IssueLeaseOptions, type LeaseResponse, type PublicJwk, type RetryOptions, type SelfCheckResult, type TokenPayload, type ValidateResult } from "./types.js";
3
3
  /** Validation results kept in memory; the least recently used are evicted past this. */
4
4
  export declare const MAX_VALIDATION_CACHE_ENTRIES = 1000;
5
5
  /** Longest 429 `Retry-After` waited out by default (`retry.maxRetryAfterMs`); longer ones return the 429. */
@@ -85,13 +85,10 @@ export declare class Daywalker {
85
85
  }): Promise<TokenPayload>;
86
86
  /**
87
87
  * Exchanges an RFC 9421 signed request for a short-lived EdDSA JWT lease (POST /api/v1/leases/issue).
88
+ * The lease lists every service slug the API key covers (`services`, also the JWT's `services` and `aud`
89
+ * claims), so one set of credentials serves all of them. Pass `{ services: [...] }` to narrow it.
88
90
  */
89
- issueLease(ttlSeconds?: number): Promise<{
90
- lease: string;
91
- tokenType?: string;
92
- expiresAt?: string;
93
- jwksUri?: string;
94
- }>;
91
+ issueLease(options?: number | IssueLeaseOptions): Promise<LeaseResponse>;
95
92
  /**
96
93
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
97
94
  * Uses the configured `fetch`, sends no credentials, never follows redirects and times out after `timeoutMs`.
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { resolveConfig } from "./config.js";
2
- import { loadSigningKey, signRequest, verifyJwtLease, maskSecret, redactSecrets, sha256Base64Url, } from "./crypto/index.js";
1
+ import { parseServiceSlugs, resolveConfig } from "./config.js";
2
+ import { loadSigningKey, signRequest, verifyJwtLease, leaseServices, maskSecret, redactSecrets, sha256Base64Url, } from "./crypto/index.js";
3
3
  import { DaywalkerAuthError, DaywalkerError, DaywalkerRateLimitError, } from "./types.js";
4
4
  function parseRetryAfter(headerValue) {
5
5
  if (!headerValue)
@@ -159,6 +159,9 @@ function malformedValidation(json, payloadData) {
159
159
  if (src[k] !== undefined && src[k] !== null && typeof src[k] !== "string")
160
160
  return `${k} is not a string`;
161
161
  }
162
+ if (src.services !== undefined && src.services !== null && !(Array.isArray(src.services) && src.services.every((s) => typeof s === "string"))) {
163
+ return "services is not a list of strings";
164
+ }
162
165
  for (const k of ["rateLimit", "rateLimitHourly"]) {
163
166
  if (src[k] !== undefined && src[k] !== null && !isRateLimitWindow(src[k]))
164
167
  return `${k} is not a rate limit window`;
@@ -416,7 +419,7 @@ export class Daywalker {
416
419
  const result = {
417
420
  ok: true,
418
421
  data: {
419
- serviceSlug: serviceSlug || (payload.serviceSlug || payload.aud || ""),
422
+ serviceSlug: serviceSlug || (payload.serviceSlug || payload.service || ""),
420
423
  tenantId: (payload.tenantId || payload.sub || ""),
421
424
  rateLimit: payload.rateLimit ?? {
422
425
  limit: 1000,
@@ -430,6 +433,8 @@ export class Daywalker {
430
433
  stage: payload.stage,
431
434
  offlineVerified: true,
432
435
  ...payload,
436
+ // Every service the lease covers: its `services` claim, else its audience.
437
+ services: leaseServices(payload),
433
438
  },
434
439
  };
435
440
  if (this._config.cacheTtlMs > 0) {
@@ -535,6 +540,7 @@ export class Daywalker {
535
540
  userId: (payloadData.userId || json.userId),
536
541
  stage: (payloadData.stage || json.stage),
537
542
  ...payloadData,
543
+ services: (payloadData.services ?? json.services) ?? [json.serviceSlug || serviceSlug],
538
544
  };
539
545
  const result = {
540
546
  ok: true,
@@ -604,17 +610,29 @@ export class Daywalker {
604
610
  }
605
611
  /**
606
612
  * Exchanges an RFC 9421 signed request for a short-lived EdDSA JWT lease (POST /api/v1/leases/issue).
613
+ * The lease lists every service slug the API key covers (`services`, also the JWT's `services` and `aud`
614
+ * claims), so one set of credentials serves all of them. Pass `{ services: [...] }` to narrow it.
607
615
  */
608
- async issueLease(ttlSeconds) {
616
+ async issueLease(options) {
617
+ const opts = typeof options === "number" ? { ttlSeconds: options } : (options ?? {});
618
+ const services = opts.services ? parseServiceSlugs(opts.services) : undefined;
619
+ if (opts.services && (!services || services.length === 0)) {
620
+ throw new DaywalkerAuthError("issueLease: services must list at least one service slug", 400, "INVALID_REQUEST");
621
+ }
609
622
  const res = await this.fetch("/api/v1/leases/issue", {
610
623
  method: "POST",
611
- body: ttlSeconds ? { ttlSeconds } : {},
624
+ body: { ...(opts.ttlSeconds ? { ttlSeconds: opts.ttlSeconds } : {}), ...(services ? { services } : {}) },
612
625
  });
613
626
  const json = (await res.json().catch(() => ({})));
614
627
  if (res.status !== 201 && res.status !== 200) {
615
628
  throw new DaywalkerAuthError(redactSecrets(String(json.message || json.error || `Lease issue failed with HTTP ${res.status}`)), res.status, json.error);
616
629
  }
617
- return json;
630
+ if (typeof json.lease !== "string") {
631
+ throw new DaywalkerAuthError("Daywalker Auth returned a malformed lease response (no lease)", res.status, "SERVICE_UNAVAILABLE");
632
+ }
633
+ // Servers from before multi-service tokens don't list services; their lease covers the one configured slug.
634
+ const listed = Array.isArray(json.services) ? json.services.filter((s) => typeof s === "string") : [];
635
+ return { ...json, lease: json.lease, services: listed.length > 0 ? listed : this._config.serviceSlug ? [this._config.serviceSlug] : [] };
618
636
  }
619
637
  /**
620
638
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
@@ -685,6 +703,7 @@ export class Daywalker {
685
703
  return {
686
704
  ok: problems.length === 0,
687
705
  serviceSlug: this._config.serviceSlug,
706
+ serviceSlugs: this._config.serviceSlugs,
688
707
  domain: this._config.domain,
689
708
  signing: Boolean(signingKey),
690
709
  keyId,
package/dist/config.d.ts CHANGED
@@ -5,7 +5,10 @@ export interface ResolvedConfig {
5
5
  /** Never signs; `keys()` publishes its public JWK alongside the current key during rotation. */
6
6
  previousSigningKey: string | SigningKey | null;
7
7
  keyId: string | null;
8
+ /** The slug used when a call doesn't name one: the first of `serviceSlugs`. */
8
9
  serviceSlug: string | null;
10
+ /** Every service this deployment is configured for (one API key can cover several). */
11
+ serviceSlugs: string[];
9
12
  domain: string | null;
10
13
  domainSource: string | null;
11
14
  daywalkerPublicKey: string | PublicJwk | PublicJwks | null;
@@ -31,4 +34,9 @@ export declare function publicHostname(value: string | undefined | null): string
31
34
  * so API keys, SDK keys and write keys never travel in cleartext.
32
35
  */
33
36
  export declare function assertSecureUrl(name: string, value: string): void;
37
+ /**
38
+ * Service slugs from a comma- or whitespace-separated string (`DAYWALKER_SERVICE_SLUG="gigpulse,devpulse"`)
39
+ * or an array: lower-cased, de-duplicated, in the order given (the first is the default service).
40
+ */
41
+ export declare function parseServiceSlugs(value: string | readonly string[] | null | undefined): string[];
34
42
  export declare function resolveConfig(options?: DaywalkerConfig): ResolvedConfig;
package/dist/config.js CHANGED
@@ -66,11 +66,20 @@ export function assertSecureUrl(name, value) {
66
66
  return;
67
67
  throw new Error(`${name} must use https:// (http:// is only allowed for localhost); got "${value}"`);
68
68
  }
69
+ /**
70
+ * Service slugs from a comma- or whitespace-separated string (`DAYWALKER_SERVICE_SLUG="gigpulse,devpulse"`)
71
+ * or an array: lower-cased, de-duplicated, in the order given (the first is the default service).
72
+ */
73
+ export function parseServiceSlugs(value) {
74
+ const parts = typeof value === "string" ? value.split(/[\s,]+/) : Array.isArray(value) ? value.flatMap((v) => String(v).split(/[\s,]+/)) : [];
75
+ return Array.from(new Set(parts.map((v) => v.trim().toLowerCase()).filter(Boolean)));
76
+ }
69
77
  export function resolveConfig(options = {}) {
70
78
  const env = options.env ?? (typeof process !== "undefined" && process.env ? process.env : {});
71
79
  const problems = [];
72
80
  const apiKey = (options.apiKey ?? env.DAYWALKER_API_KEY ?? "").trim() || null;
73
- const serviceSlug = (options.serviceSlug ?? env.DAYWALKER_SERVICE_SLUG ?? "").trim().toLowerCase() || null;
81
+ const serviceSlugs = parseServiceSlugs(options.serviceSlugs ?? options.serviceSlug ?? env.DAYWALKER_SERVICE_SLUG);
82
+ const serviceSlug = serviceSlugs[0] ?? null;
74
83
  if (!apiKey)
75
84
  problems.push("DAYWALKER_API_KEY is not set");
76
85
  if (!serviceSlug)
@@ -123,6 +132,7 @@ export function resolveConfig(options = {}) {
123
132
  previousSigningKey,
124
133
  keyId,
125
134
  serviceSlug,
135
+ serviceSlugs,
126
136
  domain,
127
137
  domainSource,
128
138
  daywalkerPublicKey,
@@ -1,5 +1,5 @@
1
1
  export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
2
2
  export { DEFAULT_MAX_BODY_SIZE_BYTES, computeContentDigest, sha256Base64, sha256Base64Url, } from "./digest.js";
3
3
  export { calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, } from "./keys.js";
4
- export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
4
+ export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, } from "./signatures.js";
5
5
  export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./signatures.js";
@@ -1,4 +1,4 @@
1
1
  export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
2
2
  export { DEFAULT_MAX_BODY_SIZE_BYTES, computeContentDigest, sha256Base64, sha256Base64Url, } from "./digest.js";
3
3
  export { calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, } from "./keys.js";
4
- export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
4
+ export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, } from "./signatures.js";
@@ -88,6 +88,11 @@ export declare function signRequest(params: SignRequestParams): Promise<SignedHe
88
88
  * Replay protection uses `replayStore` (default: per-process memory; use a shared store across instances).
89
89
  */
90
90
  export declare function verifyRequestSignature(params: VerifySignatureParams): Promise<VerifySignatureResult>;
91
+ /**
92
+ * Every service slug a (verified) JWT lease covers: its `services` claim, else its audience, else the
93
+ * single `serviceSlug` / `service` claim of leases issued before multi-service tokens.
94
+ */
95
+ export declare function leaseServices(payload: Record<string, unknown>): string[];
91
96
  /** Clock skew tolerated on JWT lease `exp` / `nbf`. */
92
97
  export declare const JWT_CLOCK_SKEW_SECONDS = 60;
93
98
  /**
@@ -405,6 +405,16 @@ export async function verifyRequestSignature(params) {
405
405
  return { valid: false, error: `Verification error: ${err.message}` };
406
406
  }
407
407
  }
408
+ /**
409
+ * Every service slug a (verified) JWT lease covers: its `services` claim, else its audience, else the
410
+ * single `serviceSlug` / `service` claim of leases issued before multi-service tokens.
411
+ */
412
+ export function leaseServices(payload) {
413
+ const strings = (v) => (Array.isArray(v) ? v : v === undefined || v === null ? [] : [v]).filter((s) => typeof s === "string" && s !== "");
414
+ const listed = strings(payload.services);
415
+ const fallback = listed.length > 0 ? listed : strings(payload.aud).length > 0 ? strings(payload.aud) : strings(payload.serviceSlug ?? payload.service);
416
+ return Array.from(new Set(fallback.map((s) => s.toLowerCase())));
417
+ }
408
418
  /** Clock skew tolerated on JWT lease `exp` / `nbf`. */
409
419
  export const JWT_CLOCK_SKEW_SECONDS = 60;
410
420
  /**
@@ -490,6 +500,16 @@ export async function verifyJwtLease(params) {
490
500
  error: `JWT audience mismatch: expected ${expected}, got ${audiences.join(", ").toLowerCase()}`,
491
501
  };
492
502
  }
503
+ // A lease that lists its services must list this one (belt and braces next to `aud`).
504
+ if (payload.services !== undefined) {
505
+ const listed = Array.isArray(payload.services) ? payload.services : null;
506
+ if (!listed || !listed.some((s) => typeof s === "string" && s.toLowerCase() === expected)) {
507
+ return {
508
+ valid: false,
509
+ error: `JWT services mismatch: expected ${expected}, got ${listed ? listed.map(String).join(", ").toLowerCase() : "(not a list)"}`,
510
+ };
511
+ }
512
+ }
493
513
  if (params.expectedIssuer && payload.iss !== params.expectedIssuer) {
494
514
  return { valid: false, error: `JWT issuer mismatch: expected ${params.expectedIssuer}, got ${payload.iss ?? "(none)"}` };
495
515
  }
@@ -527,6 +547,8 @@ export async function registerPublicKey(params) {
527
547
  keyId: params.publicJwk.kid,
528
548
  domain: params.domain,
529
549
  publicKey: params.publicJwk,
550
+ // Lets Daywalker refuse right away when the API key doesn't cover the service(s); a comma-separated list is fine
551
+ ...(params.serviceSlug ? { serviceSlug: params.serviceSlug } : {}),
530
552
  }),
531
553
  });
532
554
  if (res.ok) {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
2
2
  export type { DaywalkerFetchInit, } from "./client.js";
3
3
  export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
4
- export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
5
- export { resolveConfig, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
4
+ export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, LeaseResponse, IssueLeaseOptions, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
5
+ export { resolveConfig, parseServiceSlugs, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
6
6
  export type { ResolvedConfig, } from "./config.js";
7
- export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
7
+ export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
8
8
  export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./crypto/index.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
2
2
  export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
3
- export { resolveConfig, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
4
- export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
3
+ export { resolveConfig, parseServiceSlugs, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
4
+ export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
package/dist/types.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface TokenPayload {
12
12
  tokenName?: string;
13
13
  userId?: string;
14
14
  stage?: string;
15
+ /**
16
+ * Every service slug the token (or JWT lease) covers. One API key can cover several services, so this
17
+ * can hold more than `serviceSlug`, which is the service this call validated for.
18
+ */
19
+ services?: string[];
15
20
  offlineVerified?: boolean;
16
21
  [key: string]: unknown;
17
22
  }
@@ -76,7 +81,13 @@ export interface DaywalkerConfig {
76
81
  */
77
82
  previousSigningKey?: string | SigningKey;
78
83
  keyId?: string;
84
+ /**
85
+ * The service this deployment validates tokens for (env DAYWALKER_SERVICE_SLUG). One API key can cover
86
+ * several services: a comma-separated list ("gigpulse,devpulse") is accepted, and the first is the default.
87
+ */
79
88
  serviceSlug?: string;
89
+ /** Same as a comma-separated `serviceSlug`, as an array. Takes precedence over `serviceSlug`. */
90
+ serviceSlugs?: string[];
80
91
  domain?: string;
81
92
  /** A JWK, a JWKS (`{ keys: [...] }`, selected by the lease's `kid`), PEM, or Base64 SPKI / raw key. */
82
93
  daywalkerPublicKey?: string | PublicJwk | PublicJwks;
@@ -98,9 +109,30 @@ export interface DaywalkerConfig {
98
109
  fetch?: typeof fetch;
99
110
  env?: Record<string, string | undefined>;
100
111
  }
112
+ /** Response of `client.issueLease()` (POST /api/v1/leases/issue). */
113
+ export interface LeaseResponse {
114
+ /** The signed EdDSA JWT. Verify it with Daywalker's public JWKS. */
115
+ lease: string;
116
+ tokenType?: string;
117
+ /** Unix seconds. */
118
+ expiresAt?: number | string;
119
+ jwksUri?: string;
120
+ /** Every service slug the lease covers (also its `services` and `aud` claims). */
121
+ services: string[];
122
+ }
123
+ export interface IssueLeaseOptions {
124
+ /** Lease lifetime in seconds (Daywalker clamps it to 1h-24h). */
125
+ ttlSeconds?: number;
126
+ /**
127
+ * Narrow the lease to these service slugs (least privilege). Each must be one the API key covers;
128
+ * otherwise Daywalker answers 403 `SERVICE_NOT_GRANTED`. Default: every service the key covers.
129
+ */
130
+ services?: string[];
131
+ }
101
132
  export interface SelfCheckResult {
102
133
  ok: boolean;
103
134
  serviceSlug: string | null;
135
+ serviceSlugs: string[];
104
136
  domain: string | null;
105
137
  signing: boolean;
106
138
  keyId: string | null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shelbyb/daywalker-sdk",
3
- "version": "0.4.0",
4
- "description": "Daywalker Auth service validation plus the DevPulse client SDKs (feature flags, OpenFeature, product analytics) in one zero-dependency package.",
3
+ "version": "0.5.0",
4
+ "description": "Daywalker Auth service validation plus the DevPulse client SDKs (feature flags, OpenFeature, product analytics) in one package with a single runtime dependency (re2js).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -68,6 +68,7 @@
68
68
  "files": [
69
69
  "dist",
70
70
  "docs",
71
+ "!docs/maintenance",
71
72
  "README.md",
72
73
  "LICENSE"
73
74
  ],