@shelbyb/daywalker-sdk 0.1.0 → 0.1.2

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/dist/client.d.ts CHANGED
@@ -1,78 +1,60 @@
1
- import { type DaywalkerOptions, type ResolvedConfig } from "./config.js";
2
- import { type PublicJwk } from "./signing.js";
3
- export interface RateLimitWindow {
4
- limit: number;
5
- remaining: number;
6
- /** Unix seconds when `remaining` next increases */
7
- reset: number;
8
- }
9
- /** Body of POST /api/v1/tokens/validate */
10
- export interface TokenValidation {
11
- valid: boolean;
12
- serviceSlug?: string;
13
- tokenId?: string;
14
- tokenName?: string;
15
- userId?: string;
16
- stage?: string;
17
- rateLimit?: RateLimitWindow;
18
- rateLimitHourly?: RateLimitWindow;
19
- retryAfter?: number;
20
- error?: string;
21
- message?: string;
22
- }
23
- export interface ValidationResult {
24
- valid: boolean;
25
- /** HTTP status from Daywalker, or 503 when it couldn't be reached */
26
- status: number;
27
- data: TokenValidation;
28
- /** Seconds to wait before retrying (429 responses) */
29
- retryAfter?: number;
30
- /** Whether the request carried a domain signature */
31
- signed: boolean;
32
- }
33
- export interface SelfCheckResult {
34
- ok: boolean;
35
- domain: string | null;
36
- keysUrl: string | null;
37
- signing: boolean;
38
- problems: string[];
39
- }
40
- export declare class DaywalkerError extends Error {
41
- readonly status: number;
42
- readonly result?: ValidationResult | undefined;
43
- constructor(message: string, status: number, result?: ValidationResult | undefined);
44
- }
45
- export interface Daywalker {
46
- readonly config: ResolvedConfig;
1
+ import { type ResolvedConfig } from "./config.js";
2
+ import { type DaywalkerConfig, type PublicJwk, type RetryOptions, type SelfCheckResult, type TokenPayload, type ValidateResult } from "./types.js";
3
+ export type DaywalkerFetchInit = Omit<RequestInit, "body"> & {
4
+ body?: BodyInit | object | null;
5
+ retry?: RetryOptions;
6
+ };
7
+ export declare class Daywalker {
8
+ private readonly _config;
9
+ private readonly _rawConfig;
10
+ private _signingKeyPromise?;
11
+ private readonly _cache;
12
+ constructor(config?: DaywalkerConfig);
13
+ get config(): ResolvedConfig;
14
+ toJSON(): Record<string, unknown>;
15
+ private getLoadedSigningKey;
16
+ /**
17
+ * Signs and executes requests with automatic retry and exponential backoff on 429 responses.
18
+ */
19
+ fetch(path: string, init?: DaywalkerFetchInit): Promise<Response>;
47
20
  /**
48
- * Validate an API token (defaults to DAYWALKER_API_KEY) for a service (defaults to
49
- * DAYWALKER_SERVICE_SLUG). Successful results are cached briefly.
21
+ * Validates incoming bearer tokens with memory caching and discriminated union return.
22
+ * Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
50
23
  */
51
- validateToken(options?: {
24
+ validateToken(tokenOrOptions?: string | {
52
25
  token?: string;
53
26
  serviceSlug?: string;
54
27
  forceRefresh?: boolean;
55
- }): Promise<ValidationResult>;
56
- /** Like validateToken, but throws DaywalkerError when the token isn't valid. */
57
- requireValidToken(options?: {
28
+ offlineOnly?: boolean;
29
+ }, options?: {
30
+ serviceSlug?: string;
31
+ forceRefresh?: boolean;
32
+ offlineOnly?: boolean;
33
+ }): Promise<ValidateResult>;
34
+ /**
35
+ * Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
36
+ */
37
+ requireValidToken(tokenOrOptions?: string | {
58
38
  token?: string;
59
39
  serviceSlug?: string;
60
- }): Promise<ValidationResult>;
40
+ offlineOnly?: boolean;
41
+ }, options?: {
42
+ serviceSlug?: string;
43
+ offlineOnly?: boolean;
44
+ }): Promise<TokenPayload>;
61
45
  /**
62
- * fetch() for any Daywalker Auth API path (e.g. "/api/v1/tokens/validate"), domain-signed
63
- * when a signing key and domain are configured. Pass the body as a string, or as an object
64
- * to send JSON.
46
+ * The public JWK(s) configured for this service.
65
47
  */
66
- fetch(path: string, init?: Omit<RequestInit, "body"> & {
67
- body?: string | object;
68
- }): Promise<Response>;
69
- /** The JWKS document to serve at /.well-known/daywalker-keys.json */
70
- keys(): {
48
+ keys(): Promise<{
71
49
  keys: PublicJwk[];
72
- };
73
- /** Check configuration and that the domain serves this service's public key. */
50
+ }>;
51
+ /**
52
+ * Check configuration and verify signature readiness.
53
+ */
74
54
  selfCheck(): Promise<SelfCheckResult>;
75
55
  }
76
- export declare function createDaywalker(options?: DaywalkerOptions): Daywalker;
77
- /** A process-wide client configured from environment variables. */
78
- export declare function daywalker(): Daywalker;
56
+ /**
57
+ * Factory function for creating instantiable Daywalker client instances.
58
+ */
59
+ export declare function createDaywalker(config?: DaywalkerConfig): Daywalker;
60
+ export declare const daywalker: Daywalker & (() => Daywalker);
package/dist/client.js CHANGED
@@ -1,143 +1,376 @@
1
1
  import { resolveConfig } from "./config.js";
2
- import { signRequest, KEYS_PATH } from "./signing.js";
3
- export class DaywalkerError extends Error {
4
- status;
5
- result;
6
- constructor(message, status, result) {
7
- super(message);
8
- this.status = status;
9
- this.result = result;
10
- this.name = "DaywalkerError";
2
+ import { loadSigningKey, signRequest, verifyJwtLease, maskSecret, redactSecrets, } from "./crypto/index.js";
3
+ import { DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
4
+ function parseRetryAfter(headerValue) {
5
+ if (!headerValue)
6
+ return undefined;
7
+ const seconds = parseInt(headerValue.trim(), 10);
8
+ if (!Number.isNaN(seconds) && seconds >= 0) {
9
+ return seconds;
11
10
  }
11
+ const date = Date.parse(headerValue);
12
+ if (!Number.isNaN(date)) {
13
+ const diff = Math.ceil((date - Date.now()) / 1000);
14
+ return diff > 0 ? diff : 0;
15
+ }
16
+ return undefined;
12
17
  }
13
- export function createDaywalker(options = {}) {
14
- const config = resolveConfig(options);
15
- const doFetch = options.fetch ?? globalThis.fetch;
16
- const timeoutMs = options.timeoutMs ?? 5000;
17
- const cacheTtlMs = options.cacheTtlMs ?? 60_000;
18
- const cache = new Map();
19
- const signing = Boolean(config.signingKey && config.domain);
20
- async function signedFetch(path, init = {}) {
21
- const url = new URL(path, config.authUrl);
22
- const method = (init.method ?? (init.body !== undefined ? "POST" : "GET")).toUpperCase();
23
- const body = init.body === undefined ? "" : typeof init.body === "string" ? init.body : JSON.stringify(init.body);
24
- const headers = new Headers(init.headers);
25
- if (init.body !== undefined && typeof init.body !== "string" && !headers.has("content-type")) {
26
- headers.set("content-type", "application/json");
27
- }
28
- if (!headers.has("accept"))
29
- headers.set("accept", "application/json");
30
- if (config.domain) {
31
- // A hint for DNS-based allowlist checks; the signature below is the actual proof
32
- headers.set("x-caller-origin", `https://${config.domain}`);
33
- }
34
- if (signing) {
35
- for (const [k, v] of Object.entries(signRequest({ method, path: url.pathname, domain: config.domain, body, key: config.signingKey }))) {
36
- headers.set(k, v);
37
- }
38
- }
39
- const controller = new AbortController();
40
- const timer = setTimeout(() => controller.abort(), timeoutMs);
41
- try {
42
- return await doFetch(url, {
18
+ export class Daywalker {
19
+ _config;
20
+ _rawConfig;
21
+ _signingKeyPromise;
22
+ _cache = new Map();
23
+ constructor(config = {}) {
24
+ this._rawConfig = config;
25
+ this._config = resolveConfig(config);
26
+ }
27
+ get config() {
28
+ return this._config;
29
+ }
30
+ toJSON() {
31
+ return {
32
+ serviceSlug: this._config.serviceSlug,
33
+ domain: this._config.domain,
34
+ authUrl: this._config.authUrl,
35
+ keyId: this._config.keyId,
36
+ apiKey: maskSecret(this._config.apiKey),
37
+ signingKey: this._config.signingKey ? "[configured]" : null,
38
+ daywalkerPublicKey: this._config.daywalkerPublicKey ? "[configured]" : null,
39
+ };
40
+ }
41
+ // Node.js custom inspect masking
42
+ [Symbol.for("nodejs.util.inspect.custom")]() {
43
+ return this.toJSON();
44
+ }
45
+ async getLoadedSigningKey() {
46
+ if (!this._config.signingKey)
47
+ return null;
48
+ if (typeof this._config.signingKey !== "string" && "privateKey" in this._config.signingKey) {
49
+ return this._config.signingKey;
50
+ }
51
+ if (!this._signingKeyPromise) {
52
+ this._signingKeyPromise = loadSigningKey(this._config.signingKey);
53
+ }
54
+ return this._signingKeyPromise;
55
+ }
56
+ /**
57
+ * Signs and executes requests with automatic retry and exponential backoff on 429 responses.
58
+ */
59
+ async fetch(path, init) {
60
+ const authUrl = this._config.authUrl;
61
+ const url = new URL(path, authUrl);
62
+ const method = (init?.method ?? (init?.body !== undefined ? "POST" : "GET")).toUpperCase();
63
+ const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
64
+ const retryOpts = {
65
+ maxRetries: init?.retry?.maxRetries ?? this._config.maxRetries ?? 3,
66
+ initialDelayMs: init?.retry?.initialDelayMs ?? 500,
67
+ maxDelayMs: init?.retry?.maxDelayMs ?? 30_000,
68
+ backoffFactor: init?.retry?.backoffFactor ?? 2,
69
+ jitter: init?.retry?.jitter ?? true,
70
+ ...init?.retry,
71
+ };
72
+ const rawBody = init?.body;
73
+ let bodyString = null;
74
+ if (rawBody !== undefined && rawBody !== null) {
75
+ if (typeof rawBody === "string") {
76
+ bodyString = rawBody;
77
+ }
78
+ else if (rawBody instanceof Uint8Array) {
79
+ bodyString = rawBody;
80
+ }
81
+ else if (typeof rawBody === "object") {
82
+ bodyString = JSON.stringify(rawBody);
83
+ }
84
+ }
85
+ const signingKey = await this.getLoadedSigningKey();
86
+ let attempt = 0;
87
+ while (true) {
88
+ const headers = new Headers(init?.headers);
89
+ if (rawBody !== undefined && !headers.has("content-type") && typeof rawBody === "object" && !(rawBody instanceof Uint8Array)) {
90
+ headers.set("content-type", "application/json");
91
+ }
92
+ if (!headers.has("accept")) {
93
+ headers.set("accept", "application/json");
94
+ }
95
+ if (this._config.apiKey && !headers.has("authorization")) {
96
+ headers.set("authorization", `Bearer ${this._config.apiKey}`);
97
+ }
98
+ if (signingKey) {
99
+ const signedHeaders = await signRequest({
100
+ method,
101
+ path: url.pathname,
102
+ authority: url.host,
103
+ body: bodyString,
104
+ key: signingKey,
105
+ keyid: this._config.keyId ?? undefined,
106
+ apiKey: this._config.apiKey ?? undefined,
107
+ });
108
+ for (const [k, v] of Object.entries(signedHeaders)) {
109
+ if (v !== undefined) {
110
+ headers.set(k, v);
111
+ }
112
+ }
113
+ }
114
+ const reqInit = {
43
115
  ...init,
44
116
  method,
45
117
  headers,
46
- body: method === "GET" || method === "HEAD" ? undefined : body,
47
- signal: init.signal ?? controller.signal,
48
- });
49
- }
50
- finally {
51
- clearTimeout(timer);
118
+ body: bodyString ?? undefined,
119
+ };
120
+ let res;
121
+ try {
122
+ res = await doFetch(url.toString(), reqInit);
123
+ }
124
+ catch (err) {
125
+ if (attempt < (retryOpts.maxRetries ?? 3)) {
126
+ attempt++;
127
+ const delay = (retryOpts.initialDelayMs ?? 500) * Math.pow(retryOpts.backoffFactor ?? 2, attempt - 1);
128
+ await new Promise((r) => setTimeout(r, delay));
129
+ continue;
130
+ }
131
+ throw err;
132
+ }
133
+ if (res.status === 429 && attempt < (retryOpts.maxRetries ?? 3)) {
134
+ attempt++;
135
+ const retryAfterSec = parseRetryAfter(res.headers.get("retry-after"));
136
+ let delayMs = (retryOpts.initialDelayMs ?? 500) * Math.pow(retryOpts.backoffFactor ?? 2, attempt - 1);
137
+ if (retryOpts.jitter) {
138
+ delayMs = delayMs * (0.8 + Math.random() * 0.4);
139
+ }
140
+ if (retryAfterSec !== undefined) {
141
+ delayMs = Math.max(delayMs, retryAfterSec * 1000);
142
+ }
143
+ delayMs = Math.min(delayMs, retryOpts.maxDelayMs ?? 30_000);
144
+ await new Promise((r) => setTimeout(r, delayMs));
145
+ continue;
146
+ }
147
+ return res;
52
148
  }
53
149
  }
54
- async function validateToken(opts = {}) {
55
- const token = opts.token ?? config.apiKey;
56
- const serviceSlug = (opts.serviceSlug ?? config.serviceSlug ?? "").toLowerCase();
57
- if (!token || !serviceSlug) {
58
- const message = !token ? "DAYWALKER_API_KEY is not set" : "DAYWALKER_SERVICE_SLUG is not set";
59
- return { valid: false, status: 500, signed: false, data: { valid: false, error: "Configuration Error", message } };
60
- }
61
- const cacheKey = `${serviceSlug}:${token}`;
62
- const cached = cache.get(cacheKey);
63
- if (!opts.forceRefresh && cached && cached.expiresAt > Date.now())
150
+ /**
151
+ * Validates incoming bearer tokens with memory caching and discriminated union return.
152
+ * Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
153
+ */
154
+ async validateToken(tokenOrOptions, options) {
155
+ let token;
156
+ let serviceSlug;
157
+ let forceRefresh = false;
158
+ let offlineOnly = false;
159
+ if (typeof tokenOrOptions === "string") {
160
+ token = tokenOrOptions;
161
+ serviceSlug = (options?.serviceSlug ?? this._config.serviceSlug ?? "").toLowerCase();
162
+ forceRefresh = options?.forceRefresh ?? false;
163
+ offlineOnly = options?.offlineOnly ?? false;
164
+ }
165
+ else {
166
+ token = tokenOrOptions?.token ?? this._config.apiKey ?? undefined;
167
+ serviceSlug = (tokenOrOptions?.serviceSlug ?? this._config.serviceSlug ?? "").toLowerCase();
168
+ forceRefresh = tokenOrOptions?.forceRefresh ?? false;
169
+ offlineOnly = tokenOrOptions?.offlineOnly ?? false;
170
+ }
171
+ if (!token) {
172
+ return {
173
+ ok: false,
174
+ error: "CONFIGURATION_ERROR",
175
+ status: 400,
176
+ message: "Missing token to validate",
177
+ };
178
+ }
179
+ const cacheKey = `${serviceSlug || "default"}:${token}`;
180
+ const cached = this._cache.get(cacheKey);
181
+ if (!forceRefresh && cached && cached.expiresAt > Date.now()) {
64
182
  return cached.result;
65
- let result;
183
+ }
184
+ // Check for offline JWT lease verification
185
+ if (this._config.daywalkerPublicKey && token.split(".").length === 3) {
186
+ const jwtResult = await verifyJwtLease({
187
+ token,
188
+ publicKey: this._config.daywalkerPublicKey,
189
+ expectedServiceSlug: serviceSlug || undefined,
190
+ });
191
+ if (jwtResult.valid && jwtResult.payload) {
192
+ const payload = jwtResult.payload;
193
+ const result = {
194
+ ok: true,
195
+ data: {
196
+ serviceSlug: serviceSlug || (payload.serviceSlug || payload.aud || ""),
197
+ tenantId: (payload.tenantId || payload.sub || ""),
198
+ rateLimit: payload.rateLimit ?? {
199
+ limit: 1000,
200
+ remaining: 999,
201
+ reset: Math.floor(Date.now() / 1000) + 60,
202
+ },
203
+ rateLimitHourly: payload.rateLimitHourly,
204
+ tokenId: (payload.tokenId || payload.sub),
205
+ tokenName: payload.tokenName,
206
+ userId: payload.userId,
207
+ stage: payload.stage,
208
+ offlineVerified: true,
209
+ ...payload,
210
+ },
211
+ };
212
+ if (this._config.cacheTtlMs > 0) {
213
+ this._cache.set(cacheKey, { result, expiresAt: Date.now() + this._config.cacheTtlMs });
214
+ }
215
+ return result;
216
+ }
217
+ else if (offlineOnly) {
218
+ return {
219
+ ok: false,
220
+ error: "INVALID_TOKEN",
221
+ status: 401,
222
+ message: jwtResult.error ?? "Offline token verification failed",
223
+ };
224
+ }
225
+ }
226
+ else if (offlineOnly) {
227
+ return {
228
+ ok: false,
229
+ error: "CONFIGURATION_ERROR",
230
+ status: 500,
231
+ message: "Offline verification requires DAYWALKER_PUBLIC_KEY and a valid JWT token lease",
232
+ };
233
+ }
234
+ // Online verification
66
235
  try {
67
- const res = await signedFetch("/api/v1/tokens/validate", { method: "POST", body: { token, serviceSlug } });
68
- const data = (await res.json().catch(() => ({ valid: false })));
69
- const retryAfter = Number(res.headers.get("retry-after")) || data.retryAfter || undefined;
70
- result = { valid: res.ok && data.valid === true, status: res.status, data, retryAfter, signed: signing };
236
+ const validateBody = { token };
237
+ if (serviceSlug) {
238
+ validateBody.serviceSlug = serviceSlug;
239
+ }
240
+ const res = await this.fetch("/api/v1/tokens/validate", {
241
+ method: "POST",
242
+ body: validateBody,
243
+ });
244
+ const json = (await res.json().catch(() => ({})));
245
+ if (res.status === 200 && json.valid !== false) {
246
+ const payloadData = (typeof json.data === "object" && json.data ? json.data : json);
247
+ const data = {
248
+ serviceSlug: json.serviceSlug || serviceSlug,
249
+ tenantId: (payloadData.tenantId || payloadData.userId || json.tenantId || json.userId || ""),
250
+ rateLimit: (payloadData.rateLimit || json.rateLimit || { limit: 0, remaining: 0, reset: 0 }),
251
+ rateLimitHourly: (payloadData.rateLimitHourly || json.rateLimitHourly),
252
+ tokenId: (payloadData.tokenId || json.tokenId),
253
+ tokenName: (payloadData.tokenName || json.tokenName),
254
+ userId: (payloadData.userId || json.userId),
255
+ stage: (payloadData.stage || json.stage),
256
+ ...payloadData,
257
+ };
258
+ const result = {
259
+ ok: true,
260
+ data,
261
+ };
262
+ if (this._config.cacheTtlMs > 0) {
263
+ this._cache.set(cacheKey, { result, expiresAt: Date.now() + this._config.cacheTtlMs });
264
+ }
265
+ return result;
266
+ }
267
+ if (res.status === 429) {
268
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after")) ?? json.retryAfter;
269
+ return {
270
+ ok: false,
271
+ error: "RATE_LIMITED",
272
+ status: 429,
273
+ message: redactSecrets(json.message || "Rate limit exceeded"),
274
+ retryAfter,
275
+ };
276
+ }
277
+ let error = "INVALID_TOKEN";
278
+ const errMsg = (json.message || json.error || "");
279
+ if (json.error === "EXPIRED" || /expired/i.test(errMsg)) {
280
+ error = "EXPIRED";
281
+ }
282
+ else if (json.error === "REVOKED" || /revoked/i.test(errMsg)) {
283
+ error = "REVOKED";
284
+ }
285
+ return {
286
+ ok: false,
287
+ error,
288
+ status: res.status,
289
+ message: redactSecrets(errMsg || `Validation failed with HTTP ${res.status}`),
290
+ };
71
291
  }
72
292
  catch (err) {
73
- const timedOut = err.name === "AbortError";
74
- result = {
75
- valid: false,
293
+ return {
294
+ ok: false,
295
+ error: "NETWORK_ERROR",
76
296
  status: 503,
77
- signed: signing,
78
- data: {
79
- valid: false,
80
- error: timedOut ? "Request Timeout" : "Service Unavailable",
81
- message: timedOut ? "Daywalker Auth did not respond in time" : `Could not reach Daywalker Auth: ${err.message}`,
82
- },
297
+ message: redactSecrets(`Could not reach Daywalker Auth: ${err.message}`),
83
298
  };
84
299
  }
85
- if (result.valid && cacheTtlMs > 0)
86
- cache.set(cacheKey, { result, expiresAt: Date.now() + cacheTtlMs });
87
- else
88
- cache.delete(cacheKey);
89
- if (!result.valid && result.status !== 503) {
90
- console.warn(`[daywalker] Token validation failed (${result.status}): ${result.data.message ?? result.data.error}`);
300
+ }
301
+ /**
302
+ * Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
303
+ */
304
+ async requireValidToken(tokenOrOptions, options) {
305
+ const result = await this.validateToken(tokenOrOptions, options);
306
+ if (!result.ok) {
307
+ if (result.status === 429) {
308
+ throw new DaywalkerRateLimitError(result.message, result.retryAfter);
309
+ }
310
+ throw new DaywalkerAuthError(result.message, result.status, result.error);
91
311
  }
92
- return result;
312
+ return result.data;
93
313
  }
94
- function keys() {
95
- const list = [config.signingKey, config.previousSigningKey]
96
- .filter((k) => Boolean(k))
97
- .map((k) => k.publicJwk);
98
- return { keys: list.filter((k, i) => list.findIndex((o) => o.kid === k.kid) === i) };
314
+ /**
315
+ * The public JWK(s) configured for this service.
316
+ */
317
+ async keys() {
318
+ const key = await this.getLoadedSigningKey();
319
+ return { keys: key ? [key.publicJwk] : [] };
99
320
  }
100
- async function selfCheck() {
101
- const problems = [...config.problems];
102
- if (!config.signingKey)
103
- problems.push("DAYWALKER_SIGNING_KEY is not set: requests are unsigned, so domain allowlists can't match");
104
- const keysUrl = config.domain ? `https://${config.domain}${KEYS_PATH}` : null;
105
- if (keysUrl && config.signingKey) {
106
- try {
107
- const res = await doFetch(keysUrl, { headers: { accept: "application/json" }, redirect: "manual" });
108
- if (res.status !== 200) {
109
- problems.push(`${keysUrl} returned HTTP ${res.status} (it must serve the key directly, without redirects)`);
110
- }
111
- else {
112
- const doc = (await res.json().catch(() => null));
113
- if (!doc?.keys?.some((k) => k.kid === config.signingKey.publicJwk.kid)) {
114
- problems.push(`${keysUrl} doesn't publish this service's current key (kid ${config.signingKey.publicJwk.kid})`);
115
- }
116
- }
117
- }
118
- catch (err) {
119
- problems.push(`Could not fetch ${keysUrl}: ${err.message}`);
120
- }
321
+ /**
322
+ * Check configuration and verify signature readiness.
323
+ */
324
+ async selfCheck() {
325
+ const problems = [...this._config.problems];
326
+ const signingKey = await this.getLoadedSigningKey().catch((err) => {
327
+ problems.push(`Signing key load error: ${err.message}`);
328
+ return null;
329
+ });
330
+ if (!signingKey) {
331
+ problems.push("DAYWALKER_SIGNING_KEY is not set: requests will be sent without RFC 9421 signature");
121
332
  }
122
- return { ok: problems.length === 0, domain: config.domain, keysUrl, signing, problems };
333
+ const keyId = signingKey?.publicJwk.kid ?? null;
334
+ const offlineVerificationReady = Boolean(this._config.daywalkerPublicKey);
335
+ return {
336
+ ok: problems.length === 0,
337
+ serviceSlug: this._config.serviceSlug,
338
+ domain: this._config.domain,
339
+ signing: Boolean(signingKey),
340
+ keyId,
341
+ offlineVerificationReady,
342
+ problems,
343
+ };
123
344
  }
124
- return {
125
- config,
126
- validateToken,
127
- async requireValidToken(opts = {}) {
128
- const result = await validateToken(opts);
129
- if (!result.valid)
130
- throw new DaywalkerError(result.data.message ?? "Token validation failed", result.status, result);
131
- return result;
132
- },
133
- fetch: signedFetch,
134
- keys,
135
- selfCheck,
136
- };
137
345
  }
138
- let shared = null;
139
- /** A process-wide client configured from environment variables. */
140
- export function daywalker() {
141
- shared ??= createDaywalker();
142
- return shared;
346
+ /**
347
+ * Factory function for creating instantiable Daywalker client instances.
348
+ */
349
+ export function createDaywalker(config) {
350
+ return new Daywalker(config);
143
351
  }
352
+ // Retain lazy default client instance for plug-and-play simplicity,
353
+ // callable as `daywalker()` or usable directly as `daywalker.fetch(...)`.
354
+ let defaultInstance = null;
355
+ function getDefaultInstance() {
356
+ if (!defaultInstance) {
357
+ defaultInstance = new Daywalker();
358
+ }
359
+ return defaultInstance;
360
+ }
361
+ export const daywalker = new Proxy((() => getDefaultInstance()), {
362
+ get(_target, prop) {
363
+ const instance = getDefaultInstance();
364
+ const val = instance[prop];
365
+ if (typeof val === "function") {
366
+ return val.bind(instance);
367
+ }
368
+ return val;
369
+ },
370
+ apply(_target, _thisArg, args) {
371
+ if (args.length > 0) {
372
+ return new Daywalker(args[0]);
373
+ }
374
+ return getDefaultInstance();
375
+ },
376
+ });