@shelbyb/daywalker-sdk 0.7.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
@@ -397,6 +397,17 @@ Tokens must be RFC 9068 access tokens (`typ: at+jwt`, `alg: EdDSA`) with a numer
397
397
 
398
398
  ---
399
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
+
400
411
  ## CLI Reference
401
412
 
402
413
  | Command | Description |
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ResolvedConfig } from "./config.js";
2
- import { type DaywalkerConfig, type ValidateTokenOptions, type TokenSet, type GetTokensOptions, type PublicJwk, type RetryOptions, type SelfCheckResult, type TokenPayload, type ValidateResult } from "./types.js";
2
+ import { type DaywalkerConfig, type ValidateTokenOptions, type TokenSet, type GetTokensOptions, type License, type LicenseOptions, type ConfigBundle, 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. */
@@ -122,6 +122,20 @@ export declare class Daywalker {
122
122
  * refresh token is never presented twice.
123
123
  */
124
124
  accessToken(): Promise<string>;
125
+ /**
126
+ * The service's license key from Daywalker (issued with the client credentials grant, valid for one hour,
127
+ * cached in memory until a minute before it expires). A revoked or suspended service gets no license: this
128
+ * throws the DaywalkerAuthError Daywalker answered with (`SERVICE_REVOKED`, `SERVICE_SUSPENDED`,
129
+ * `DOMAIN_REVOKED`, ...). `LICENSE_UNAVAILABLE` (403) means Daywalker granted the service without a license.
130
+ * Never log the key.
131
+ */
132
+ getLicense(options?: LicenseOptions): Promise<License>;
133
+ /**
134
+ * Fetches the config bundle (`POST /api/v1/services/:slug/bundle`) and opens it with the license key
135
+ * (AES-256-GCM). The bundle can only be read by a client that got the license from Daywalker. At a window
136
+ * rollover the key is refreshed once and the bundle opened again. Cached until shortly before it expires.
137
+ */
138
+ fetchBundle(options?: LicenseOptions): Promise<ConfigBundle>;
125
139
  /**
126
140
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
127
141
  * Uses the configured `fetch`, sends no credentials, never follows redirects and times out after `timeoutMs`.
package/dist/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { parseServiceSlugs, resolveConfig } from "./config.js";
2
2
  import { API_KEY_PATTERN, SAFE_IDENTIFIER_PATTERN, SERVICE_SLUG_PATTERN } from "./crypto/signatures.js";
3
3
  import { parseStoredSigningKey } from "./keystore/index.js";
4
- import { loadSigningKey, loadPublicKey, signRequest, verifyJwtLease, leaseServices, maskSecret, redactSecrets, sha256Base64Url, generateDpopKey, createDpopProof, verifyDpopProof, importSigningKey, generateSigningKey, registerPublicKey, revokeSigningKey, keySlotFor, randomInstanceId, calculateJwkThumbprint, base64UrlToBytes, utf8ToString, } from "./crypto/index.js";
4
+ import { loadSigningKey, loadPublicKey, signRequest, verifyJwtLease, leaseServices, maskSecret, redactSecrets, sha256Base64Url, generateDpopKey, createDpopProof, verifyDpopProof, importSigningKey, generateSigningKey, registerPublicKey, revokeSigningKey, keySlotFor, randomInstanceId, calculateJwkThumbprint, base64UrlToBytes, base64ToBytes, hexToBytes, utf8ToString, } from "./crypto/index.js";
5
5
  import { webcrypto } from "./crypto/webcrypto.js";
6
6
  import { DaywalkerAuthError, DaywalkerError, DaywalkerRateLimitError, } from "./types.js";
7
7
  function parseRetryAfter(headerValue) {
@@ -1237,6 +1237,7 @@ export class Daywalker {
1237
1237
  ? Array.from(new Set(json.services.filter((x) => typeof x === "string" && SERVICE_SLUG_PATTERN.test(x.toLowerCase())).map((x) => x.toLowerCase())))
1238
1238
  : [];
1239
1239
  const refreshExpiresIn = Math.max(0, Math.floor(Number(json.refresh_expires_in) || 0));
1240
+ this.#rememberLicenses(json, services);
1240
1241
  return deepFreeze({
1241
1242
  accessToken: json.access_token,
1242
1243
  refreshToken: json.refresh_token,
@@ -1323,6 +1324,115 @@ export class Daywalker {
1323
1324
  }
1324
1325
  return (await this.#tokenFlight).accessToken;
1325
1326
  }
1327
+ /** Licenses from the client credentials grant, by service slug. Kept out of TokenSet so they are never serialized. */
1328
+ #licenses = new Map();
1329
+ #rememberLicenses(json, services) {
1330
+ const found = new Map();
1331
+ const add = (slug, key, expires) => {
1332
+ if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key) || typeof expires !== "string" || !Number.isFinite(Date.parse(expires)))
1333
+ return;
1334
+ if (!SERVICE_SLUG_PATTERN.test(slug))
1335
+ return;
1336
+ found.set(slug, { serviceSlug: slug, licenseKey: key.toLowerCase(), expiresAt: expires });
1337
+ };
1338
+ if (json.licenses && typeof json.licenses === "object") {
1339
+ for (const [slug, v] of Object.entries(json.licenses))
1340
+ add(slug.toLowerCase(), v?.licenseKey, v?.expiresAt);
1341
+ }
1342
+ const primary = services[0];
1343
+ if (primary && !found.has(primary))
1344
+ add(primary, json.licenseKey ?? json.license_key, json.licenseExpiresAt ?? json.license_expires_at);
1345
+ // A refresh carries no licenses; keep the ones we hold until they expire.
1346
+ for (const [slug, license] of found)
1347
+ this.#licenses.set(slug, license);
1348
+ }
1349
+ #licenseCached(slug) {
1350
+ const license = this.#licenses.get(slug);
1351
+ // A minute of margin so a key never expires between fetching it and using it.
1352
+ return license && Date.parse(license.expiresAt) - 60_000 > Date.now() ? license : null;
1353
+ }
1354
+ /**
1355
+ * The service's license key from Daywalker (issued with the client credentials grant, valid for one hour,
1356
+ * cached in memory until a minute before it expires). A revoked or suspended service gets no license: this
1357
+ * throws the DaywalkerAuthError Daywalker answered with (`SERVICE_REVOKED`, `SERVICE_SUSPENDED`,
1358
+ * `DOMAIN_REVOKED`, ...). `LICENSE_UNAVAILABLE` (403) means Daywalker granted the service without a license.
1359
+ * Never log the key.
1360
+ */
1361
+ async getLicense(options = {}) {
1362
+ const slug = String(options.serviceSlug ?? this.#config.serviceSlug ?? "").toLowerCase();
1363
+ if (!slug || !SERVICE_SLUG_PATTERN.test(slug)) {
1364
+ throw new DaywalkerAuthError("getLicense: a valid serviceSlug is required", 500, "CONFIGURATION_ERROR");
1365
+ }
1366
+ if (options.forceRefresh !== true) {
1367
+ await this.accessToken();
1368
+ const cached = this.#licenseCached(slug);
1369
+ if (cached)
1370
+ return { ...cached };
1371
+ }
1372
+ const tokens = await this.getTokens();
1373
+ if (!tokens.services.includes(slug)) {
1374
+ throw new DaywalkerAuthError(`The OAuth client credentials don't cover ${slug}`, 403, "SERVICE_NOT_GRANTED");
1375
+ }
1376
+ const license = this.#licenseCached(slug);
1377
+ if (!license)
1378
+ throw new DaywalkerAuthError(`Daywalker Auth issued no license for ${slug}`, 403, "LICENSE_UNAVAILABLE");
1379
+ return { ...license };
1380
+ }
1381
+ /**
1382
+ * Fetches the config bundle (`POST /api/v1/services/:slug/bundle`) and opens it with the license key
1383
+ * (AES-256-GCM). The bundle can only be read by a client that got the license from Daywalker. At a window
1384
+ * rollover the key is refreshed once and the bundle opened again. Cached until shortly before it expires.
1385
+ */
1386
+ async fetchBundle(options = {}) {
1387
+ const slug = String(options.serviceSlug ?? this.#config.serviceSlug ?? "").toLowerCase();
1388
+ if (!slug || !SERVICE_SLUG_PATTERN.test(slug)) {
1389
+ throw new DaywalkerAuthError("fetchBundle: a valid serviceSlug is required", 500, "CONFIGURATION_ERROR");
1390
+ }
1391
+ const cached = options.forceRefresh === true ? undefined : this.#bundles.get(slug);
1392
+ if (cached && Date.parse(cached.expiresAt) - 60_000 > Date.now())
1393
+ return structuredClone(cached);
1394
+ let license = await this.getLicense(options);
1395
+ for (let attempt = 0;; attempt++) {
1396
+ const res = await this.fetch(`/api/v1/services/${slug}/bundle`, { method: "POST", body: {} });
1397
+ const json = await readJsonObject(res, this.#config.timeoutMs);
1398
+ const str = (v) => (typeof v === "string" ? v.slice(0, 500) : undefined);
1399
+ if (res.status !== 200) {
1400
+ throw new DaywalkerAuthError(redactSecrets(str(json.message) ?? str(json.error) ?? `Bundle request failed with HTTP ${res.status}`), res.status, str(json.code) ?? str(json.error));
1401
+ }
1402
+ const sealed = json.bundle;
1403
+ if (!sealed || typeof sealed.iv !== "string" || typeof sealed.tag !== "string" || typeof sealed.ciphertext !== "string") {
1404
+ throw new DaywalkerAuthError("Daywalker Auth returned a malformed bundle", 502, "SERVICE_UNAVAILABLE");
1405
+ }
1406
+ let bundle = null;
1407
+ try {
1408
+ bundle = await this.#openBundle(license.licenseKey, sealed, slug);
1409
+ }
1410
+ catch {
1411
+ bundle = null;
1412
+ }
1413
+ if (bundle) {
1414
+ this.#bundles.set(slug, bundle);
1415
+ return structuredClone(bundle);
1416
+ }
1417
+ if (attempt >= 1)
1418
+ throw new DaywalkerAuthError("The bundle could not be opened with the license key", 502, "BUNDLE_UNREADABLE");
1419
+ license = await this.getLicense({ ...options, forceRefresh: true });
1420
+ }
1421
+ }
1422
+ #bundles = new Map();
1423
+ async #openBundle(licenseKey, sealed, slug) {
1424
+ const key = await webcrypto.importKey("raw", hexToBytes(licenseKey), { name: "AES-GCM" }, false, ["decrypt"]);
1425
+ const tag = base64ToBytes(sealed.tag);
1426
+ const data = base64ToBytes(sealed.ciphertext);
1427
+ const joined = new Uint8Array(data.length + tag.length);
1428
+ joined.set(data);
1429
+ joined.set(tag, data.length);
1430
+ const plain = await webcrypto.decrypt({ name: "AES-GCM", iv: base64ToBytes(sealed.iv), tagLength: 128 }, key, joined);
1431
+ const parsed = JSON.parse(utf8ToString(new Uint8Array(plain)));
1432
+ if (!parsed || typeof parsed !== "object" || parsed.serviceSlug !== slug || typeof parsed.dataKeySalt !== "string")
1433
+ throw new Error("bad bundle");
1434
+ return deepFreeze(parsed);
1435
+ }
1326
1436
  /**
1327
1437
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
1328
1438
  * Uses the configured `fetch`, sends no credentials, never follows redirects and times out after `timeoutMs`.
@@ -2,6 +2,7 @@ export declare const webcrypto: Readonly<{
2
2
  digest: (algorithm: AlgorithmIdentifier, data: BufferSource) => Promise<ArrayBuffer>;
3
3
  sign: (algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, key: CryptoKey, data: BufferSource) => Promise<ArrayBuffer>;
4
4
  verify: (algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, key: CryptoKey, signature: BufferSource, data: BufferSource) => Promise<boolean>;
5
+ decrypt: (algorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, key: CryptoKey, data: BufferSource) => Promise<ArrayBuffer>;
5
6
  importKey: {
6
7
  (format: "jwk", keyData: JsonWebKey, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: ReadonlyArray<KeyUsage>): Promise<CryptoKey>;
7
8
  (format: Exclude<KeyFormat, "jwk">, keyData: BufferSource, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKey>;
@@ -17,6 +17,7 @@ export const webcrypto = Object.freeze({
17
17
  digest: capture(subtleRef, "digest"),
18
18
  sign: capture(subtleRef, "sign"),
19
19
  verify: capture(subtleRef, "verify"),
20
+ decrypt: capture(subtleRef, "decrypt"),
20
21
  importKey: capture(subtleRef, "importKey"),
21
22
  exportKey: capture(subtleRef, "exportKey"),
22
23
  generateKey: capture(subtleRef, "generateKey"),
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, MAX_CACHE_TTL_MS, MAX_TOKEN_LENGTH, JWKS_CACHE_TTL_MS, JWKS_REFETCH_COOLDOWN_MS, } 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, IncomingDpop, ValidateTokenOptions, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, TokenSet, GetTokensOptions, AccessTokenUser, KeyStore, StoredSigningKey, MessageSigner, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
4
+ export type { DaywalkerConfig, RateLimitWindow, TokenPayload, IncomingDpop, ValidateTokenOptions, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, TokenSet, License, LicenseOptions, ConfigBundle, GetTokensOptions, AccessTokenUser, KeyStore, StoredSigningKey, MessageSigner, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
5
5
  export { resolveConfig, parseServiceSlugs, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
6
6
  export type { ResolvedConfig, } from "./config.js";
7
7
  export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, importSigningKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, listSigningKeys, revokeSigningKey, keySlotFor, MIN_KEY_TTL_SECONDS, MAX_KEY_TTL_SECONDS, verifyDpopProof, MAX_DPOP_PROOF_LENGTH, DPOP_MAX_AGE_SECONDS, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
package/dist/types.d.ts CHANGED
@@ -220,6 +220,36 @@ export interface TokenSet {
220
220
  services: string[];
221
221
  jwksUri?: string;
222
222
  }
223
+ /** A service's license key from Daywalker: derived per service, signing key and one-hour window. */
224
+ export interface License {
225
+ serviceSlug: string;
226
+ /** 64 hex characters (32 bytes). A secret: never log it or send it anywhere. */
227
+ licenseKey: string;
228
+ /** ISO 8601: the end of the window the key is valid for. */
229
+ expiresAt: string;
230
+ }
231
+ /** The config bundle Daywalker seals under the license key. Fields beyond these are passed through. */
232
+ export interface ConfigBundle {
233
+ serviceSlug: string;
234
+ /** Random per issue; derive per-process secrets from it (and the license key), never store it. */
235
+ dataKeySalt: string;
236
+ features: {
237
+ exclusiveTier?: boolean;
238
+ stage?: string;
239
+ rateLimitPerMinute?: number | null;
240
+ rateLimitPerHour?: number | null;
241
+ [key: string]: unknown;
242
+ };
243
+ issuedAt: string;
244
+ expiresAt: string;
245
+ [key: string]: unknown;
246
+ }
247
+ export interface LicenseOptions {
248
+ /** Default: the configured service slug. */
249
+ serviceSlug?: string;
250
+ /** Ignore the cached license and start a new client credentials grant. */
251
+ forceRefresh?: boolean;
252
+ }
223
253
  export interface GetTokensOptions {
224
254
  /** Narrow the grant to these service slugs (least privilege); default: every service the credentials cover. */
225
255
  scope?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shelbyb/daywalker-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
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",