@shelbyb/daywalker-sdk 0.1.4 → 0.3.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.
Files changed (55) hide show
  1. package/README.md +31 -2
  2. package/dist/analytics/index.d.ts +108 -0
  3. package/dist/analytics/index.js +371 -0
  4. package/dist/cli.js +5 -0
  5. package/dist/client.d.ts +15 -1
  6. package/dist/client.js +59 -12
  7. package/dist/config.d.ts +6 -0
  8. package/dist/config.js +22 -0
  9. package/dist/crypto/signatures.d.ts +18 -5
  10. package/dist/crypto/signatures.js +100 -41
  11. package/dist/flags/client.d.ts +102 -0
  12. package/dist/flags/client.js +302 -0
  13. package/dist/flags/common/events.d.ts +59 -0
  14. package/dist/flags/common/events.js +204 -0
  15. package/dist/flags/common/index.d.ts +5 -0
  16. package/dist/flags/common/index.js +12 -0
  17. package/dist/flags/common/sse.d.ts +62 -0
  18. package/dist/flags/common/sse.js +183 -0
  19. package/dist/flags/engine/bucket.d.ts +21 -0
  20. package/dist/flags/engine/bucket.js +41 -0
  21. package/dist/flags/engine/context.d.ts +30 -0
  22. package/dist/flags/engine/context.js +81 -0
  23. package/dist/flags/engine/evaluate.d.ts +54 -0
  24. package/dist/flags/engine/evaluate.js +177 -0
  25. package/dist/flags/engine/index.d.ts +7 -0
  26. package/dist/flags/engine/index.js +7 -0
  27. package/dist/flags/engine/operators.d.ts +13 -0
  28. package/dist/flags/engine/operators.js +139 -0
  29. package/dist/flags/engine/semver.d.ts +13 -0
  30. package/dist/flags/engine/semver.js +35 -0
  31. package/dist/flags/engine/sha1.d.ts +6 -0
  32. package/dist/flags/engine/sha1.js +43 -0
  33. package/dist/flags/engine/types.d.ts +120 -0
  34. package/dist/flags/engine/types.js +17 -0
  35. package/dist/flags/node.d.ts +97 -0
  36. package/dist/flags/node.js +263 -0
  37. package/dist/flags/react.d.ts +27 -0
  38. package/dist/flags/react.js +62 -0
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/next.d.ts +15 -2
  42. package/dist/next.js +41 -9
  43. package/dist/openfeature/server.d.ts +26 -0
  44. package/dist/openfeature/server.js +42 -0
  45. package/dist/openfeature/shared.d.ts +19 -0
  46. package/dist/openfeature/shared.js +40 -0
  47. package/dist/openfeature/web.d.ts +29 -0
  48. package/dist/openfeature/web.js +50 -0
  49. package/dist/types.d.ts +3 -0
  50. package/docs/analytics.md +32 -0
  51. package/docs/flags-node.md +49 -0
  52. package/docs/flags-react.md +35 -0
  53. package/docs/flags.md +53 -0
  54. package/docs/openfeature.md +30 -0
  55. package/package.json +62 -7
package/README.md CHANGED
@@ -62,7 +62,8 @@ The private key is never printed unless you pass `--print-key`.
62
62
  | `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Previous private key, maintained during deployment transitions |
63
63
  | `DAYWALKER_DOMAIN` | optional | Optional caller identifier or host domain name |
64
64
  | `DAYWALKER_PUBLIC_KEY` | optional | Daywalker Auth public key for zero-egress offline JWT lease verification |
65
- | `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev` |
65
+ | `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev`; must be `https://` (except localhost) |
66
+ | `DAYWALKER_LEASE_ISSUER` | optional | Expected `iss` of offline-verified JWT leases |
66
67
 
67
68
  ---
68
69
 
@@ -81,7 +82,8 @@ const client = new Daywalker({
81
82
  });
82
83
 
83
84
  // Option 2: Default lazy singleton (reads process.env automatically)
84
- const result = await daywalker.validateToken();
85
+ const result = await daywalker.validateToken(incomingToken);
86
+ // Service health check of this service's own DAYWALKER_API_KEY: daywalker.validateServiceKey()
85
87
 
86
88
  if (result.ok) {
87
89
  console.log("Validated tenant:", result.data.tenantId);
@@ -135,6 +137,8 @@ export const config = {
135
137
  };
136
138
  ```
137
139
 
140
+ Each request must carry `Authorization: Bearer <token>` or `x-api-token`; without one, API routes get `401` and pages redirect to `errorPath`. Only `/_next/static/`, `/_next/image`, the favicon and static file extensions outside `/api/` skip auth. To gate the app on this service's own API key instead (the self-healing error page), use `createDaywalkerServiceHealthMiddleware` with the same options.
141
+
138
142
  ### Route Handler Wrapper
139
143
 
140
144
  ```ts
@@ -181,6 +185,8 @@ const client = new Daywalker({
181
185
  const result = await client.validateToken(incomingJwt);
182
186
  ```
183
187
 
188
+ 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`.
189
+
184
190
  ---
185
191
 
186
192
  ## CLI Reference
@@ -220,6 +226,29 @@ The exact signature base and test cases are verified against [`test-vectors/sign
220
226
 
221
227
  ---
222
228
 
229
+ ## DevPulse SDKs (one package)
230
+
231
+ The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable and has zero dependencies; `react` and the OpenFeature SDKs are optional peer dependencies used only by their subpaths.
232
+
233
+ | Import | What it is | Docs |
234
+ |---|---|---|
235
+ | `@shelbyb/daywalker-sdk/flags` | Feature flags for browsers and JS clients, real-time over SSE | [docs/flags.md](./docs/flags.md) |
236
+ | `@shelbyb/daywalker-sdk/flags/node` | Server-side flags with local evaluation and streaming updates | [docs/flags-node.md](./docs/flags-node.md) |
237
+ | `@shelbyb/daywalker-sdk/flags/react` | React provider and hooks (SSR bootstrap, no flicker) | [docs/flags-react.md](./docs/flags-react.md) |
238
+ | `@shelbyb/daywalker-sdk/flags/engine` | The deterministic evaluation engine (advanced / testing) | — |
239
+ | `@shelbyb/daywalker-sdk/openfeature/server`, `/openfeature/web` | OpenFeature providers | [docs/openfeature.md](./docs/openfeature.md) |
240
+ | `@shelbyb/daywalker-sdk/analytics` | Product analytics: events, page views, identify, sessions (browser and Node) | [docs/analytics.md](./docs/analytics.md) |
241
+
242
+ ```ts
243
+ import { createClient } from "@shelbyb/daywalker-sdk/flags";
244
+ import { createAnalytics } from "@shelbyb/daywalker-sdk/analytics";
245
+
246
+ const flags = createClient(clientSideId, { kind: "user", key: user.id }, { baseUrl: "https://devpulse.example.com" });
247
+ const analytics = createAnalytics({ writeKey: "dpa_…", host: "https://devpulse.example.com", flags }); // events carry $feature/<key>
248
+ ```
249
+
250
+ ---
251
+
223
252
  ## License
224
253
 
225
254
  MIT
@@ -0,0 +1,108 @@
1
+ /**
2
+ * DevPulse product analytics. Works in browsers (automatic page views,
3
+ * sessions, persistence, sendBeacon on page hide) and in Node (pass a
4
+ * distinctId per call). Events are batched and retried with backoff.
5
+ */
6
+ export type Properties = Record<string, unknown>;
7
+ /** Anything with `allFlags()`, e.g. a DevPulse feature-flag client. */
8
+ export type FlagSource = {
9
+ allFlags(): Record<string, unknown>;
10
+ };
11
+ export type AnalyticsOptions = {
12
+ /** The project's public write key (dpa_…). */
13
+ writeKey: string;
14
+ /** Your DevPulse URL, e.g. https://devpulse.example.com (https required except on localhost). */
15
+ host: string;
16
+ /** Capture $pageview on load and on client-side navigation (browsers). Default true. */
17
+ autocapturePageviews?: boolean;
18
+ /** Send when this many events are queued. Default 20 (1 in Node is not needed: use flush()). */
19
+ flushAt?: number;
20
+ /** Send queued events this often, in ms. Default 2000 in browsers, 10000 in Node. */
21
+ flushInterval?: number;
22
+ /** Events kept while offline; the oldest are dropped past this. Default 1000. */
23
+ maxQueueSize?: number;
24
+ /** Where the anonymous id, super properties and session live. Default localStorage in browsers. */
25
+ persistence?: "localStorage" | "memory";
26
+ /** A new session starts after this much inactivity. Default 30. */
27
+ sessionTimeoutMinutes?: number;
28
+ /** Adds `$feature/<key>` properties with each flag's current value to every event. */
29
+ flags?: FlagSource;
30
+ /** Don't send anything when the browser's Do Not Track is on. Default false. */
31
+ respectDoNotTrack?: boolean;
32
+ /** Start opted out (e.g. until the user accepts a consent banner). */
33
+ optedOut?: boolean;
34
+ /**
35
+ * Send $current_url / $referrer with their full query string and fragment. Default false:
36
+ * the fragment is dropped and only campaign parameters (utm_*, gclid, fbclid, ref) are kept,
37
+ * so OAuth codes, reset tokens and the like never leave the page.
38
+ */
39
+ captureFullUrls?: boolean;
40
+ fetch?: typeof fetch;
41
+ onError?: (err: Error) => void;
42
+ };
43
+ export type CaptureOptions = {
44
+ /** Server-side: who did it. Defaults to the current browser user. */
45
+ distinctId?: string;
46
+ timestamp?: Date;
47
+ $set?: Properties;
48
+ $set_once?: Properties;
49
+ };
50
+ export declare const VERSION = "0.1.0";
51
+ /** Drop the fragment and every query parameter outside the campaign allowlist. */
52
+ export declare function sanitizeUrl(raw: string): string;
53
+ /** Time-ordered UUID (v7). */
54
+ export declare function uuidv7(ms?: number): string;
55
+ export declare class Analytics {
56
+ private opts;
57
+ private store;
58
+ private state;
59
+ private queue;
60
+ private timer;
61
+ private sending;
62
+ private backoff;
63
+ private lastUrl;
64
+ private cleanup;
65
+ private endpoint;
66
+ constructor(options: AnalyticsOptions);
67
+ /** Record an event. */
68
+ capture(event: string, properties?: Properties, opts?: CaptureOptions): void;
69
+ /** Record a page view (automatic unless autocapturePageviews is false). */
70
+ pageview(properties?: Properties): void;
71
+ /**
72
+ * Link this browser to a known user. Events from before (the anonymous id)
73
+ * are merged into the user's profile. Optionally set person properties.
74
+ */
75
+ identify(distinctId: string, set?: Properties, setOnce?: Properties): void;
76
+ /** Update the current person's properties. */
77
+ setPersonProperties(set?: Properties, setOnce?: Properties, opts?: {
78
+ distinctId?: string;
79
+ }): void;
80
+ /** Properties sent with every event from now on (persisted in browsers). */
81
+ register(properties: Properties): void;
82
+ unregister(key: string): void;
83
+ /** On logout: a new anonymous id, no super properties, a new session. */
84
+ reset(): void;
85
+ optOut(): void;
86
+ optIn(): void;
87
+ hasOptedOut(): boolean;
88
+ getDistinctId(): string;
89
+ getSessionId(): string | null;
90
+ /** Send everything queued now. */
91
+ flush(): Promise<void>;
92
+ /** Flush and detach listeners (call before a Node process exits). */
93
+ shutdown(): Promise<void>;
94
+ private disabled;
95
+ private context;
96
+ private url;
97
+ private flagProps;
98
+ /** Rolling session: renewed after inactivity or at most every 24 hours. */
99
+ private session;
100
+ private enqueue;
101
+ private schedule;
102
+ /** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
103
+ private send;
104
+ /** On page hide, hand the queue to sendBeacon so it survives navigation. */
105
+ private beacon;
106
+ private attachBrowser;
107
+ }
108
+ export declare function createAnalytics(options: AnalyticsOptions): Analytics;
@@ -0,0 +1,371 @@
1
+ import { assertSecureUrl } from "../config.js";
2
+ export const VERSION = "0.1.0";
3
+ const KEPT_QUERY_PARAMS = new Set(["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid", "ref"]);
4
+ /** Drop the fragment and every query parameter outside the campaign allowlist. */
5
+ export function sanitizeUrl(raw) {
6
+ if (!raw)
7
+ return raw;
8
+ let url;
9
+ try {
10
+ url = new URL(raw);
11
+ }
12
+ catch {
13
+ return raw.split(/[?#]/)[0];
14
+ }
15
+ url.hash = "";
16
+ for (const key of [...url.searchParams.keys()]) {
17
+ if (!KEPT_QUERY_PARAMS.has(key))
18
+ url.searchParams.delete(key);
19
+ }
20
+ return url.toString();
21
+ }
22
+ const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
23
+ const MAX_SESSION_MS = 24 * 3_600_000;
24
+ /** Time-ordered UUID (v7). */
25
+ export function uuidv7(ms = Date.now()) {
26
+ const b = new Uint8Array(16);
27
+ const c = globalThis.crypto;
28
+ if (c?.getRandomValues)
29
+ c.getRandomValues(b);
30
+ else
31
+ for (let i = 0; i < 16; i++)
32
+ b[i] = Math.floor(Math.random() * 256);
33
+ for (let i = 0; i < 6; i++)
34
+ b[i] = Math.floor(ms / 2 ** (8 * (5 - i))) % 256;
35
+ b[6] = (b[6] & 0x0f) | 0x70;
36
+ b[8] = (b[8] & 0x3f) | 0x80;
37
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
38
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
39
+ }
40
+ class Store {
41
+ key;
42
+ local;
43
+ mem = null;
44
+ constructor(key, local) {
45
+ this.key = key;
46
+ this.local = local;
47
+ }
48
+ load() {
49
+ if (this.mem)
50
+ return this.mem;
51
+ if (this.local) {
52
+ try {
53
+ const raw = localStorage.getItem(this.key);
54
+ if (raw)
55
+ this.mem = JSON.parse(raw);
56
+ }
57
+ catch {
58
+ /* storage blocked: memory only */
59
+ }
60
+ }
61
+ return this.mem;
62
+ }
63
+ save(s) {
64
+ this.mem = s;
65
+ if (!this.local)
66
+ return;
67
+ try {
68
+ localStorage.setItem(this.key, JSON.stringify(s));
69
+ }
70
+ catch {
71
+ /* storage full or blocked */
72
+ }
73
+ }
74
+ }
75
+ export class Analytics {
76
+ opts;
77
+ store;
78
+ state;
79
+ queue = [];
80
+ timer;
81
+ sending = null;
82
+ backoff = 0;
83
+ lastUrl = "";
84
+ cleanup = [];
85
+ endpoint;
86
+ constructor(options) {
87
+ if (!options.writeKey)
88
+ throw new Error("writeKey is required");
89
+ if (!options.host)
90
+ throw new Error("host is required");
91
+ assertSecureUrl("host", options.host);
92
+ this.opts = { flushAt: 20, flushInterval: isBrowser ? 2000 : 10000, maxQueueSize: 1000, sessionTimeoutMinutes: 30, ...options };
93
+ this.endpoint = `${options.host.replace(/\/+$/, "")}/a/batch`;
94
+ this.store = new Store(`dp_analytics_${options.writeKey}`, (options.persistence ?? (isBrowser ? "localStorage" : "memory")) === "localStorage" && isBrowser);
95
+ this.state = this.store.load() ?? { distinctId: uuidv7(), identified: false, props: {} };
96
+ if (options.optedOut !== undefined)
97
+ this.state.optedOut = options.optedOut;
98
+ this.store.save(this.state);
99
+ if (isBrowser)
100
+ this.attachBrowser();
101
+ }
102
+ // ---------- Public API ----------
103
+ /** Record an event. */
104
+ capture(event, properties = {}, opts = {}) {
105
+ if (!event || this.disabled())
106
+ return;
107
+ const now = opts.timestamp ?? new Date();
108
+ const props = { ...this.context(), ...this.state.props, ...this.flagProps(), ...properties };
109
+ if (!opts.distinctId)
110
+ props.$session_id = this.session(now.getTime());
111
+ const e = { uuid: uuidv7(now.getTime()), event, distinct_id: opts.distinctId ?? this.state.distinctId, timestamp: now.toISOString(), properties: props };
112
+ if (opts.$set)
113
+ e.$set = opts.$set;
114
+ if (opts.$set_once)
115
+ e.$set_once = opts.$set_once;
116
+ this.enqueue(e);
117
+ }
118
+ /** Record a page view (automatic unless autocapturePageviews is false). */
119
+ pageview(properties = {}) {
120
+ if (!isBrowser)
121
+ return this.capture("$pageview", properties);
122
+ this.lastUrl = location.href;
123
+ const url = this.url(location.href);
124
+ const utm = Object.fromEntries([...new URLSearchParams(location.search)].filter(([k]) => /^utm_(source|medium|campaign|term|content)$/.test(k)));
125
+ let setOnce;
126
+ if (!this.state.initialSent) {
127
+ setOnce = {
128
+ $initial_referrer: document.referrer ? this.url(document.referrer) : "$direct",
129
+ $initial_referring_domain: referringDomain(document.referrer) ?? "$direct",
130
+ $initial_current_url: url,
131
+ $initial_pathname: location.pathname,
132
+ ...Object.fromEntries(Object.entries(utm).map(([k, v]) => [`$initial_${k}`, v])),
133
+ };
134
+ this.state.initialSent = true;
135
+ this.store.save(this.state);
136
+ }
137
+ this.capture("$pageview", { $title: document.title, ...utm, ...properties }, { $set_once: setOnce });
138
+ }
139
+ /**
140
+ * Link this browser to a known user. Events from before (the anonymous id)
141
+ * are merged into the user's profile. Optionally set person properties.
142
+ */
143
+ identify(distinctId, set, setOnce) {
144
+ if (!distinctId || this.disabled())
145
+ return;
146
+ const previous = this.state.distinctId;
147
+ if (previous === distinctId) {
148
+ if (set || setOnce)
149
+ this.setPersonProperties(set, setOnce);
150
+ return;
151
+ }
152
+ this.state = { ...this.state, distinctId, identified: true };
153
+ this.store.save(this.state);
154
+ this.capture("$identify", { $anon_distinct_id: previous }, { $set: set, $set_once: setOnce });
155
+ }
156
+ /** Update the current person's properties. */
157
+ setPersonProperties(set, setOnce, opts = {}) {
158
+ this.capture("$set", {}, { distinctId: opts.distinctId, $set: set, $set_once: setOnce });
159
+ }
160
+ /** Properties sent with every event from now on (persisted in browsers). */
161
+ register(properties) {
162
+ this.state = { ...this.state, props: { ...this.state.props, ...properties } };
163
+ this.store.save(this.state);
164
+ }
165
+ unregister(key) {
166
+ const props = { ...this.state.props };
167
+ delete props[key];
168
+ this.state = { ...this.state, props };
169
+ this.store.save(this.state);
170
+ }
171
+ /** On logout: a new anonymous id, no super properties, a new session. */
172
+ reset() {
173
+ this.state = { distinctId: uuidv7(), identified: false, props: {}, optedOut: this.state.optedOut, initialSent: this.state.initialSent };
174
+ this.store.save(this.state);
175
+ }
176
+ optOut() {
177
+ this.state = { ...this.state, optedOut: true };
178
+ this.store.save(this.state);
179
+ this.queue = [];
180
+ }
181
+ optIn() {
182
+ this.state = { ...this.state, optedOut: false };
183
+ this.store.save(this.state);
184
+ }
185
+ hasOptedOut() {
186
+ return !!this.state.optedOut;
187
+ }
188
+ getDistinctId() {
189
+ return this.state.distinctId;
190
+ }
191
+ getSessionId() {
192
+ return this.state.session?.id ?? null;
193
+ }
194
+ /** Send everything queued now. */
195
+ async flush() {
196
+ clearTimeout(this.timer);
197
+ this.timer = undefined;
198
+ while (this.queue.length) {
199
+ if (this.sending)
200
+ await this.sending;
201
+ else {
202
+ this.sending = this.send().finally(() => (this.sending = null));
203
+ const ok = await this.sending;
204
+ if (!ok)
205
+ return;
206
+ }
207
+ }
208
+ }
209
+ /** Flush and detach listeners (call before a Node process exits). */
210
+ async shutdown() {
211
+ for (const fn of this.cleanup.splice(0))
212
+ fn();
213
+ await this.flush();
214
+ }
215
+ // ---------- Internals ----------
216
+ disabled() {
217
+ if (this.state.optedOut)
218
+ return true;
219
+ if (this.opts.respectDoNotTrack && isBrowser) {
220
+ const nav = navigator;
221
+ return nav.doNotTrack === "1" || nav.msDoNotTrack === "1" || window.doNotTrack === "1";
222
+ }
223
+ return false;
224
+ }
225
+ context() {
226
+ const base = { $lib: "devpulse-analytics", $lib_version: VERSION };
227
+ if (!isBrowser)
228
+ return base;
229
+ return {
230
+ ...base,
231
+ $current_url: this.url(location.href),
232
+ $host: location.host,
233
+ $pathname: location.pathname,
234
+ $referrer: document.referrer ? this.url(document.referrer) : "$direct",
235
+ $referring_domain: referringDomain(document.referrer) ?? "$direct",
236
+ $screen_width: screen.width,
237
+ $screen_height: screen.height,
238
+ $viewport_width: window.innerWidth,
239
+ $viewport_height: window.innerHeight,
240
+ $browser_language: navigator.language,
241
+ $device_id: this.state.identified ? undefined : this.state.distinctId,
242
+ };
243
+ }
244
+ url(raw) {
245
+ return this.opts.captureFullUrls ? raw : sanitizeUrl(raw);
246
+ }
247
+ flagProps() {
248
+ if (!this.opts.flags)
249
+ return {};
250
+ try {
251
+ const all = this.opts.flags.allFlags();
252
+ return Object.fromEntries(Object.entries(all).map(([k, v]) => [`$feature/${k}`, v]));
253
+ }
254
+ catch {
255
+ return {};
256
+ }
257
+ }
258
+ /** Rolling session: renewed after inactivity or at most every 24 hours. */
259
+ session(now) {
260
+ const s = this.state.session;
261
+ const timeout = this.opts.sessionTimeoutMinutes * 60_000;
262
+ if (!s || now - s.last > timeout || now - s.start > MAX_SESSION_MS)
263
+ this.state.session = { id: uuidv7(now), last: now, start: now };
264
+ else
265
+ s.last = now;
266
+ this.store.save(this.state);
267
+ return this.state.session.id;
268
+ }
269
+ enqueue(e) {
270
+ this.queue.push(e);
271
+ if (this.queue.length > this.opts.maxQueueSize)
272
+ this.queue.splice(0, this.queue.length - this.opts.maxQueueSize);
273
+ if (this.queue.length >= this.opts.flushAt && !this.backoff)
274
+ void this.flush();
275
+ else
276
+ this.schedule();
277
+ }
278
+ schedule() {
279
+ if (this.timer)
280
+ return;
281
+ this.timer = setTimeout(() => {
282
+ this.timer = undefined;
283
+ void this.flush();
284
+ }, this.backoff || this.opts.flushInterval);
285
+ this.timer.unref?.();
286
+ }
287
+ /** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
288
+ async send() {
289
+ const batch = this.queue.splice(0, 100);
290
+ if (!batch.length)
291
+ return true;
292
+ const f = this.opts.fetch ?? globalThis.fetch;
293
+ try {
294
+ const res = await f(this.endpoint, {
295
+ method: "POST",
296
+ headers: { "content-type": "application/json" },
297
+ body: JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: new Date().toISOString() }),
298
+ keepalive: isBrowser,
299
+ });
300
+ if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
301
+ if (!res.ok)
302
+ this.opts.onError?.(new Error(`DevPulse analytics rejected ${batch.length} events: HTTP ${res.status}`));
303
+ this.backoff = 0;
304
+ return true;
305
+ }
306
+ throw new Error(`HTTP ${res.status}`);
307
+ }
308
+ catch (err) {
309
+ this.queue.unshift(...batch);
310
+ this.backoff = Math.min(60_000, (this.backoff || 1000) * 2);
311
+ this.opts.onError?.(err);
312
+ this.schedule();
313
+ return false;
314
+ }
315
+ }
316
+ /** On page hide, hand the queue to sendBeacon so it survives navigation. */
317
+ beacon() {
318
+ if (!this.queue.length || typeof navigator.sendBeacon !== "function")
319
+ return;
320
+ const batch = this.queue.splice(0, 100);
321
+ const body = JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: new Date().toISOString() });
322
+ // text/plain avoids a CORS preflight, which beacons can't do.
323
+ if (!navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" })))
324
+ this.queue.unshift(...batch);
325
+ }
326
+ attachBrowser() {
327
+ const onHide = () => document.visibilityState === "hidden" && this.beacon();
328
+ const onPageHide = () => this.beacon();
329
+ document.addEventListener("visibilitychange", onHide);
330
+ window.addEventListener("pagehide", onPageHide);
331
+ this.cleanup.push(() => {
332
+ document.removeEventListener("visibilitychange", onHide);
333
+ window.removeEventListener("pagehide", onPageHide);
334
+ });
335
+ if (this.opts.autocapturePageviews === false)
336
+ return;
337
+ const onNav = () => {
338
+ // Route changes fire before the new title is set; give the app a tick.
339
+ setTimeout(() => location.href !== this.lastUrl && this.pageview(), 0);
340
+ };
341
+ const { pushState, replaceState } = history;
342
+ history.pushState = function (...args) {
343
+ pushState.apply(this, args);
344
+ onNav();
345
+ };
346
+ history.replaceState = function (...args) {
347
+ replaceState.apply(this, args);
348
+ onNav();
349
+ };
350
+ window.addEventListener("popstate", onNav);
351
+ this.cleanup.push(() => {
352
+ history.pushState = pushState;
353
+ history.replaceState = replaceState;
354
+ window.removeEventListener("popstate", onNav);
355
+ });
356
+ this.pageview();
357
+ }
358
+ }
359
+ function referringDomain(ref) {
360
+ if (!ref)
361
+ return undefined;
362
+ try {
363
+ return new URL(ref).hostname;
364
+ }
365
+ catch {
366
+ return undefined;
367
+ }
368
+ }
369
+ export function createAnalytics(options) {
370
+ return new Analytics(options);
371
+ }
package/dist/cli.js CHANGED
@@ -76,6 +76,8 @@ function writeEnvFile(file, values) {
76
76
  out.push("# Daywalker Auth (managed by `daywalker init`)", ...added.map(([k, v]) => `${k}="${v}"`));
77
77
  }
78
78
  fs.writeFileSync(file, out.join("\n") + "\n", { mode: 0o600 });
79
+ // `mode` only applies when the file is created; tighten an existing file too.
80
+ fs.chmodSync(file, 0o600);
79
81
  }
80
82
  function ensureGitignored(dir, entry) {
81
83
  const file = path.join(dir, ".gitignore");
@@ -233,6 +235,9 @@ async function check() {
233
235
  async function main() {
234
236
  const [cmd, ...rest] = process.argv.slice(2);
235
237
  const parsedArgs = args(rest);
238
+ if (typeof parsedArgs["api-key"] === "string") {
239
+ console.warn("! --api-key exposes the key in shell history and process listings; prefer DAYWALKER_API_KEY in the environment or .env.local.");
240
+ }
236
241
  if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
237
242
  console.log(HELP);
238
243
  return;
package/dist/client.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type ResolvedConfig } from "./config.js";
2
2
  import { type DaywalkerConfig, type PublicJwk, type RetryOptions, type SelfCheckResult, type TokenPayload, type ValidateResult } from "./types.js";
3
+ /** Validation results kept in memory; the least recently used are evicted past this. */
4
+ export declare const MAX_VALIDATION_CACHE_ENTRIES = 1000;
3
5
  export type DaywalkerFetchInit = Omit<RequestInit, "body"> & {
4
6
  body?: BodyInit | object | null;
5
7
  retry?: RetryOptions;
@@ -18,9 +20,13 @@ export declare class Daywalker {
18
20
  * Signs and executes requests with automatic retry and exponential backoff on 429 responses.
19
21
  */
20
22
  fetch(path: string, init?: DaywalkerFetchInit): Promise<Response>;
23
+ private _cacheGet;
24
+ private _cacheSet;
21
25
  /**
22
- * Validates incoming bearer tokens with memory caching and discriminated union return.
26
+ * Validates an incoming bearer token with memory caching and discriminated union return.
23
27
  * Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
28
+ * A missing token is always rejected (401); it never falls back to this service's own API key —
29
+ * use `validateServiceKey()` for that.
24
30
  */
25
31
  validateToken(tokenOrOptions?: string | {
26
32
  token?: string;
@@ -33,6 +39,14 @@ export declare class Daywalker {
33
39
  offlineOnly?: boolean;
34
40
  }): Promise<ValidateResult>;
35
41
  private _validateOnline;
42
+ /**
43
+ * Service health check: validates this service's own configured API key (DAYWALKER_API_KEY).
44
+ * Never use this to authenticate an incoming request.
45
+ */
46
+ validateServiceKey(options?: {
47
+ serviceSlug?: string;
48
+ forceRefresh?: boolean;
49
+ }): Promise<ValidateResult>;
36
50
  /**
37
51
  * Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
38
52
  */