@shelbyb/daywalker-sdk 0.3.0 → 0.4.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 +31 -23
- package/dist/analytics/index.d.ts +16 -2
- package/dist/analytics/index.js +79 -14
- package/dist/cli.js +133 -31
- package/dist/client.d.ts +29 -2
- package/dist/client.js +314 -62
- package/dist/config.d.ts +8 -2
- package/dist/config.js +14 -0
- package/dist/crypto/encoding.d.ts +5 -0
- package/dist/crypto/encoding.js +14 -0
- package/dist/crypto/index.d.ts +3 -3
- package/dist/crypto/index.js +2 -2
- package/dist/crypto/keys.d.ts +3 -3
- package/dist/crypto/keys.js +28 -7
- package/dist/crypto/signatures.d.ts +42 -12
- package/dist/crypto/signatures.js +236 -79
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +32 -4
- package/dist/flags/client.js +74 -12
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +51 -6
- package/dist/flags/common/index.d.ts +3 -3
- package/dist/flags/common/index.js +3 -3
- package/dist/flags/common/sse.d.ts +24 -3
- package/dist/flags/common/sse.js +85 -15
- package/dist/flags/common/version.d.ts +2 -0
- package/dist/flags/common/version.js +2 -0
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +20 -3
- package/dist/flags/engine/operators.js +38 -32
- package/dist/flags/node.d.ts +26 -14
- package/dist/flags/node.js +51 -7
- package/dist/flags/react.d.ts +6 -4
- package/dist/flags/react.js +47 -17
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +28 -7
- package/dist/next.d.ts +52 -8
- package/dist/next.js +151 -37
- package/dist/openfeature/server.d.ts +3 -0
- package/dist/openfeature/server.js +14 -2
- package/dist/openfeature/shared.d.ts +1 -1
- package/dist/openfeature/shared.js +22 -2
- package/dist/openfeature/web.js +9 -3
- package/dist/types.d.ts +56 -4
- package/dist/types.js +6 -0
- package/docs/analytics.md +1 -1
- package/docs/flags-node.md +1 -1
- package/docs/flags.md +2 -1
- package/package.json +32 -14
- package/assets/logo-512.png +0 -0
package/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Shelbyb/daywalker-sdk/main/assets/logo-512.png" alt="Daywalker" width="160" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# @shelbyb/daywalker-sdk
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
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
8
|
|
|
9
|
-
Runs
|
|
9
|
+
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+.
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
- **RFC 9421 (HTTP Message Signatures)**: Cryptographically binds `@method`, `@path`, `@authority`, and `content-digest` using Ed25519 signatures (`Signature-Input`, `Signature`).
|
|
11
|
+
- **Web Standards Core**: standard `crypto.subtle`, `Uint8Array`, `fetch`, `Request`, and `Response`. The one runtime dependency is [`re2js`](https://www.npmjs.com/package/re2js), so flag regex rules run in linear time.
|
|
12
|
+
- **RFC 9421 (HTTP Message Signatures)**: Cryptographically binds `@method`, `@path`, `@query` (when the URL has one), `@authority`, and `content-digest` (POST/PUT/PATCH and any request with a body) using Ed25519 signatures (`Signature-Input`, `Signature`).
|
|
13
13
|
- **RFC 9530 (Digest Fields)**: Computes standard `Content-Digest: sha-256=:<base64>:` headers for payload integrity.
|
|
14
14
|
- **Class-Based Client Architecture**: Instantiable `Daywalker` client supporting dependency injection, with a lazy default singleton for plug-and-play simplicity.
|
|
15
|
-
- **Resilience & Rate Limiting**:
|
|
15
|
+
- **Resilience & Rate Limiting**: 5s per-attempt timeout, exponential backoff with jitter, `Retry-After` honored up to 60s. Network errors are retried only for idempotent requests (or with an `idempotencyKey`), and redirects are never followed, so the API key can't leak to another host.
|
|
16
16
|
- **Deterministic Type-Safe Results**: Discriminated union returns (`Result<T, E>`) for clean handling without try/catch boilerplate.
|
|
17
|
-
- **Security-Hardened Next.js Middleware**: Built-in Server Action probe mitigation (CVE-2025-55182 defense),
|
|
17
|
+
- **Security-Hardened Next.js Middleware**: Built-in Server Action probe mitigation (CVE-2025-55182 defense), normalized paths (no `%2e%2e` / `//` bypasses), open-redirect-safe error routing, and fail-closed validation (503 when Daywalker is unreachable).
|
|
18
18
|
- **Zero-Egress Inbound Verification**: In-memory caching (60–300s TTL) and offline JWT token lease validation using Daywalker's asymmetric public key.
|
|
19
19
|
- **Decoupled Push Architecture CLI**: `npx daywalker init --json` registers keys directly via `POST /api/v1/keys/register` with zero runtime reverse-HTTP calls.
|
|
20
20
|
|
|
@@ -34,17 +34,19 @@ npm install @shelbyb/daywalker-sdk
|
|
|
34
34
|
# Generate Ed25519 keypair and write .env.local
|
|
35
35
|
npx daywalker init --service my-service-slug
|
|
36
36
|
|
|
37
|
-
#
|
|
38
|
-
|
|
37
|
+
# Register the public key with Daywalker Auth (domain is required by the API).
|
|
38
|
+
# The API key comes from DAYWALKER_API_KEY, or from stdin so it stays out of shell history:
|
|
39
|
+
op read op://vault/daywalker/api-key | npx daywalker init --service my-service-slug --domain app.example.com --api-key-stdin
|
|
39
40
|
|
|
40
|
-
#
|
|
41
|
-
npx daywalker init --service my-service-slug --
|
|
41
|
+
# Print JSON for Vercel, Fly.io, DigitalOcean, or AWS SSM (nothing written to disk):
|
|
42
|
+
DAYWALKER_API_KEY=srv_live_... npx daywalker init --service my-service-slug --domain app.example.com --json
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
`daywalker init`:
|
|
45
46
|
- Generates an Ed25519 keypair locally using WebCrypto (`crypto.subtle`)
|
|
46
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`)
|
|
47
|
-
- Writes `DAYWALKER_SERVICE_SLUG`, `DAYWALKER_KEY_ID`, `DAYWALKER_SIGNING_KEY`, and `DAYWALKER_AUTH_URL` to `.env.local`
|
|
48
|
+
- 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
|
+
- `--force` replaces an existing key and keeps the old one as `DAYWALKER_SIGNING_KEY_PREVIOUS`
|
|
48
50
|
- No route injectors or public `/.well-known/` endpoints needed
|
|
49
51
|
|
|
50
52
|
The private key is never printed unless you pass `--print-key`.
|
|
@@ -79,6 +81,7 @@ const client = new Daywalker({
|
|
|
79
81
|
apiKey: process.env.DAYWALKER_API_KEY,
|
|
80
82
|
serviceSlug: "sonalyze",
|
|
81
83
|
maxRetries: 3,
|
|
84
|
+
timeoutMs: 5000, // per attempt (default)
|
|
82
85
|
});
|
|
83
86
|
|
|
84
87
|
// Option 2: Default lazy singleton (reads process.env automatically)
|
|
@@ -89,6 +92,7 @@ if (result.ok) {
|
|
|
89
92
|
console.log("Validated tenant:", result.data.tenantId);
|
|
90
93
|
console.log("Rate limit remaining:", result.data.rateLimit.remaining);
|
|
91
94
|
} else {
|
|
95
|
+
// SERVICE_UNAVAILABLE (503) when Daywalker can't be reached: the token is NOT treated as valid.
|
|
92
96
|
console.error(`Validation failed (${result.status} ${result.error}): ${result.message}`);
|
|
93
97
|
}
|
|
94
98
|
```
|
|
@@ -96,6 +100,8 @@ if (result.ok) {
|
|
|
96
100
|
### Type-Safe Verification & Error Handling
|
|
97
101
|
|
|
98
102
|
```ts
|
|
103
|
+
import { DaywalkerRateLimitError } from "@shelbyb/daywalker-sdk";
|
|
104
|
+
|
|
99
105
|
// requireValidToken returns data or throws DaywalkerAuthError / DaywalkerRateLimitError
|
|
100
106
|
try {
|
|
101
107
|
const auth = await client.requireValidToken(req.headers.get("x-api-token"));
|
|
@@ -115,6 +121,7 @@ try {
|
|
|
115
121
|
const res = await client.fetch("/api/v1/tokens/validate", {
|
|
116
122
|
method: "POST",
|
|
117
123
|
body: { token: "user_token", serviceSlug: "other-service" },
|
|
124
|
+
idempotencyKey: crypto.randomUUID(), // optional: lets a POST be retried after a network error
|
|
118
125
|
});
|
|
119
126
|
```
|
|
120
127
|
|
|
@@ -123,21 +130,22 @@ const res = await client.fetch("/api/v1/tokens/validate", {
|
|
|
123
130
|
## Hardened Next.js Middleware (`@shelbyb/daywalker-sdk/next`)
|
|
124
131
|
|
|
125
132
|
```ts
|
|
126
|
-
// middleware.ts
|
|
127
|
-
import {
|
|
133
|
+
// proxy.ts (Next.js 16; middleware.ts with `export const middleware = createDaywalkerMiddleware(...)` on 15)
|
|
134
|
+
import { createDaywalkerProxy } from "@shelbyb/daywalker-sdk/next";
|
|
128
135
|
|
|
129
|
-
export const
|
|
136
|
+
export const proxy = createDaywalkerProxy({
|
|
130
137
|
serviceSlug: "sonalyze",
|
|
131
138
|
errorPath: "/service-error", // redirect unauthorized web visitors
|
|
132
139
|
validateServerActions: true, // drops malformed CVE-2025-55182 scanner probes
|
|
133
140
|
});
|
|
134
141
|
|
|
135
142
|
export const config = {
|
|
136
|
-
matcher:
|
|
143
|
+
// Keep public files out with the matcher: list what is public, not what isn't.
|
|
144
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|images/).*)"],
|
|
137
145
|
};
|
|
138
146
|
```
|
|
139
147
|
|
|
140
|
-
Each request must carry `Authorization: Bearer <token>` or `x-api-token`; without one, API routes get `401` and pages redirect to `errorPath
|
|
148
|
+
Each request must carry `Authorization: Bearer <token>` or `x-api-token`; without one, API routes get `401` and pages redirect to `errorPath` (without an error message in the URL). Paths are decoded and normalized before any check, so `/_next/static/../admin` is authenticated. Only `/_next/static/`, `/_next/image`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml` and the health paths skip auth by default. File extensions say nothing about whether a route is public, so they no longer skip auth; opt in with `staticFileExtensions: STATIC_FILE_EXTENSIONS` if you rely on that. When Daywalker is unreachable the middleware answers `503`, and `500` for a configuration error. To gate the app on this service's own API key instead (the self-healing error page), use `createDaywalkerServiceHealthMiddleware` with the same options.
|
|
141
149
|
|
|
142
150
|
### Route Handler Wrapper
|
|
143
151
|
|
|
@@ -193,9 +201,9 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your s
|
|
|
193
201
|
|
|
194
202
|
| Command | Description |
|
|
195
203
|
|---|---|
|
|
196
|
-
| `npx daywalker init --service <slug> [--api-key
|
|
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 |
|
|
197
205
|
| `npx daywalker check` | Validate configuration, signature readiness, and connection |
|
|
198
|
-
| `npx daywalker rotate [--api-key
|
|
206
|
+
| `npx daywalker rotate [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
|
|
199
207
|
|
|
200
208
|
---
|
|
201
209
|
|
|
@@ -215,20 +223,20 @@ Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization:
|
|
|
215
223
|
## Request Signing Specification (RFC 9421 & RFC 9530)
|
|
216
224
|
|
|
217
225
|
Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
|
|
218
|
-
- `Content-Digest: sha-256=:<base64-encoded-hash>:`
|
|
219
|
-
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`
|
|
226
|
+
- `Content-Digest: sha-256=:<base64-encoded-hash>:` (POST/PUT/PATCH, and any request with a body)
|
|
227
|
+
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`. `"@query"` is added after `"@path"` when the URL has a query string; `"content-digest"` is left out only for bodyless GET/HEAD/DELETE/OPTIONS requests.
|
|
220
228
|
- `Signature: sig1=:<base64-encoded-signature>:`
|
|
221
229
|
- `Authorization: Bearer <DAYWALKER_API_KEY>`
|
|
222
230
|
|
|
223
231
|
A fresh random `nonce` is generated for every attempt (including retries), so identical requests in the same second still produce unique signatures and never trip replay protection. `validateToken()` also shares one in-flight request between concurrent callers for the same token, and briefly caches failures (`negativeCacheTtlMs`, default 3000ms; 429s and network errors are never cached).
|
|
224
232
|
|
|
225
|
-
The exact signature base and test cases are verified against [`test-vectors/signing-rfc9421.json`](./test-vectors/signing-rfc9421.json).
|
|
233
|
+
Verification reads the covered components from `Signature-Input`, requires `content-digest` to be covered whenever there is a body, and rejects replays. The default replay cache is per process: with several instances, pass a shared `replayStore` (e.g. Redis `SET NX PX`). The exact signature base and test cases are verified against [`test-vectors/signing-rfc9421.json`](./test-vectors/signing-rfc9421.json).
|
|
226
234
|
|
|
227
235
|
---
|
|
228
236
|
|
|
229
237
|
## DevPulse SDKs (one package)
|
|
230
238
|
|
|
231
|
-
The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable
|
|
239
|
+
The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable; `react` and the OpenFeature SDKs are optional peer dependencies used only by their subpaths.
|
|
232
240
|
|
|
233
241
|
| Import | What it is | Docs |
|
|
234
242
|
|---|---|---|
|
|
@@ -37,6 +37,14 @@ export type AnalyticsOptions = {
|
|
|
37
37
|
* so OAuth codes, reset tokens and the like never leave the page.
|
|
38
38
|
*/
|
|
39
39
|
captureFullUrls?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Rewrite URL paths before they leave the page: applied to $pathname, $initial_pathname and the
|
|
42
|
+
* path of every URL sent ($current_url, $referrer, $initial_current_url, $initial_referrer).
|
|
43
|
+
* Use it for secrets embedded in paths, e.g.
|
|
44
|
+
* `sanitizePath: (p) => p.replace(/^\/reset\/[^/]+/, "/reset/:token")`. Default: unchanged.
|
|
45
|
+
* If it throws, the path is sent as "/".
|
|
46
|
+
*/
|
|
47
|
+
sanitizePath?: (path: string) => string;
|
|
40
48
|
fetch?: typeof fetch;
|
|
41
49
|
onError?: (err: Error) => void;
|
|
42
50
|
};
|
|
@@ -47,7 +55,7 @@ export type CaptureOptions = {
|
|
|
47
55
|
$set?: Properties;
|
|
48
56
|
$set_once?: Properties;
|
|
49
57
|
};
|
|
50
|
-
export declare const VERSION = "0.
|
|
58
|
+
export declare const VERSION = "0.4.0";
|
|
51
59
|
/** Drop the fragment and every query parameter outside the campaign allowlist. */
|
|
52
60
|
export declare function sanitizeUrl(raw: string): string;
|
|
53
61
|
/** Time-ordered UUID (v7). */
|
|
@@ -94,14 +102,20 @@ export declare class Analytics {
|
|
|
94
102
|
private disabled;
|
|
95
103
|
private context;
|
|
96
104
|
private url;
|
|
105
|
+
private path;
|
|
97
106
|
private flagProps;
|
|
98
107
|
/** Rolling session: renewed after inactivity or at most every 24 hours. */
|
|
99
108
|
private session;
|
|
100
109
|
private enqueue;
|
|
101
110
|
private schedule;
|
|
111
|
+
/** Take up to MAX_BATCH_EVENTS queued events whose body stays under maxBytes (always at least one). */
|
|
112
|
+
private takeBatch;
|
|
102
113
|
/** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
|
|
103
114
|
private send;
|
|
104
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* On page hide, hand the queue to sendBeacon so it survives navigation, in batches under the
|
|
117
|
+
* browser's 64 KiB beacon budget; whatever doesn't fit stays queued (and persists in memory).
|
|
118
|
+
*/
|
|
105
119
|
private beacon;
|
|
106
120
|
private attachBrowser;
|
|
107
121
|
}
|
package/dist/analytics/index.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { assertSecureUrl } from "../config.js";
|
|
2
|
-
|
|
2
|
+
import { SDK_VERSION } from "../flags/common/version.js";
|
|
3
|
+
export const VERSION = SDK_VERSION;
|
|
4
|
+
/** keepalive fetches and sendBeacon share a 64 KiB in-flight budget in browsers. */
|
|
5
|
+
const KEEPALIVE_MAX_BYTES = 60_000;
|
|
6
|
+
/** Stay well under DevPulse's 2 MB capture body limit. */
|
|
7
|
+
const MAX_BATCH_BYTES = 1_000_000;
|
|
8
|
+
const MAX_BATCH_EVENTS = 100;
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const byteLength = (s) => encoder.encode(s).length;
|
|
3
11
|
const KEPT_QUERY_PARAMS = new Set(["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid", "ref"]);
|
|
4
12
|
/** Drop the fragment and every query parameter outside the campaign allowlist. */
|
|
5
13
|
export function sanitizeUrl(raw) {
|
|
@@ -128,7 +136,7 @@ export class Analytics {
|
|
|
128
136
|
$initial_referrer: document.referrer ? this.url(document.referrer) : "$direct",
|
|
129
137
|
$initial_referring_domain: referringDomain(document.referrer) ?? "$direct",
|
|
130
138
|
$initial_current_url: url,
|
|
131
|
-
$initial_pathname: location.pathname,
|
|
139
|
+
$initial_pathname: this.path(location.pathname),
|
|
132
140
|
...Object.fromEntries(Object.entries(utm).map(([k, v]) => [`$initial_${k}`, v])),
|
|
133
141
|
};
|
|
134
142
|
this.state.initialSent = true;
|
|
@@ -196,6 +204,10 @@ export class Analytics {
|
|
|
196
204
|
clearTimeout(this.timer);
|
|
197
205
|
this.timer = undefined;
|
|
198
206
|
while (this.queue.length) {
|
|
207
|
+
if (this.state.optedOut) {
|
|
208
|
+
this.queue = [];
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
199
211
|
if (this.sending)
|
|
200
212
|
await this.sending;
|
|
201
213
|
else {
|
|
@@ -230,7 +242,7 @@ export class Analytics {
|
|
|
230
242
|
...base,
|
|
231
243
|
$current_url: this.url(location.href),
|
|
232
244
|
$host: location.host,
|
|
233
|
-
$pathname: location.pathname,
|
|
245
|
+
$pathname: this.path(location.pathname),
|
|
234
246
|
$referrer: document.referrer ? this.url(document.referrer) : "$direct",
|
|
235
247
|
$referring_domain: referringDomain(document.referrer) ?? "$direct",
|
|
236
248
|
$screen_width: screen.width,
|
|
@@ -242,7 +254,28 @@ export class Analytics {
|
|
|
242
254
|
};
|
|
243
255
|
}
|
|
244
256
|
url(raw) {
|
|
245
|
-
|
|
257
|
+
const out = this.opts.captureFullUrls ? raw : sanitizeUrl(raw);
|
|
258
|
+
if (!this.opts.sanitizePath || !out)
|
|
259
|
+
return out;
|
|
260
|
+
try {
|
|
261
|
+
const u = new URL(out);
|
|
262
|
+
u.pathname = this.path(u.pathname);
|
|
263
|
+
return u.toString();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return this.path(out);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
path(p) {
|
|
270
|
+
if (!this.opts.sanitizePath)
|
|
271
|
+
return p;
|
|
272
|
+
try {
|
|
273
|
+
return this.opts.sanitizePath(p);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
this.opts.onError?.(err);
|
|
277
|
+
return "/";
|
|
278
|
+
}
|
|
246
279
|
}
|
|
247
280
|
flagProps() {
|
|
248
281
|
if (!this.opts.flags)
|
|
@@ -284,9 +317,29 @@ export class Analytics {
|
|
|
284
317
|
}, this.backoff || this.opts.flushInterval);
|
|
285
318
|
this.timer.unref?.();
|
|
286
319
|
}
|
|
320
|
+
/** Take up to MAX_BATCH_EVENTS queued events whose body stays under maxBytes (always at least one). */
|
|
321
|
+
takeBatch(maxBytes) {
|
|
322
|
+
const sentAt = new Date().toISOString();
|
|
323
|
+
const encode = (batch) => JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: sentAt });
|
|
324
|
+
let size = byteLength(encode([]));
|
|
325
|
+
let n = 0;
|
|
326
|
+
while (n < Math.min(MAX_BATCH_EVENTS, this.queue.length)) {
|
|
327
|
+
const add = byteLength(JSON.stringify(this.queue[n])) + (n ? 1 : 0);
|
|
328
|
+
if (n && size + add > maxBytes)
|
|
329
|
+
break;
|
|
330
|
+
size += add;
|
|
331
|
+
n++;
|
|
332
|
+
}
|
|
333
|
+
const batch = this.queue.splice(0, n);
|
|
334
|
+
return { batch, body: encode(batch) };
|
|
335
|
+
}
|
|
287
336
|
/** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
|
|
288
337
|
async send() {
|
|
289
|
-
|
|
338
|
+
if (this.state.optedOut) {
|
|
339
|
+
this.queue = [];
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
const { batch, body } = this.takeBatch(MAX_BATCH_BYTES);
|
|
290
343
|
if (!batch.length)
|
|
291
344
|
return true;
|
|
292
345
|
const f = this.opts.fetch ?? globalThis.fetch;
|
|
@@ -294,8 +347,9 @@ export class Analytics {
|
|
|
294
347
|
const res = await f(this.endpoint, {
|
|
295
348
|
method: "POST",
|
|
296
349
|
headers: { "content-type": "application/json" },
|
|
297
|
-
body
|
|
298
|
-
keepalive
|
|
350
|
+
body,
|
|
351
|
+
// Browsers reject keepalive bodies over 64 KiB outright, so only small ones get it.
|
|
352
|
+
keepalive: isBrowser && byteLength(body) < KEEPALIVE_MAX_BYTES,
|
|
299
353
|
});
|
|
300
354
|
if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
|
|
301
355
|
if (!res.ok)
|
|
@@ -306,6 +360,11 @@ export class Analytics {
|
|
|
306
360
|
throw new Error(`HTTP ${res.status}`);
|
|
307
361
|
}
|
|
308
362
|
catch (err) {
|
|
363
|
+
// Opted out while this was in flight: the batch must not be retried.
|
|
364
|
+
if (this.state.optedOut) {
|
|
365
|
+
this.queue = [];
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
309
368
|
this.queue.unshift(...batch);
|
|
310
369
|
this.backoff = Math.min(60_000, (this.backoff || 1000) * 2);
|
|
311
370
|
this.opts.onError?.(err);
|
|
@@ -313,15 +372,21 @@ export class Analytics {
|
|
|
313
372
|
return false;
|
|
314
373
|
}
|
|
315
374
|
}
|
|
316
|
-
/**
|
|
375
|
+
/**
|
|
376
|
+
* On page hide, hand the queue to sendBeacon so it survives navigation, in batches under the
|
|
377
|
+
* browser's 64 KiB beacon budget; whatever doesn't fit stays queued (and persists in memory).
|
|
378
|
+
*/
|
|
317
379
|
beacon() {
|
|
318
|
-
if (
|
|
380
|
+
if (this.state.optedOut || typeof navigator.sendBeacon !== "function")
|
|
319
381
|
return;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
382
|
+
while (this.queue.length) {
|
|
383
|
+
const { batch, body } = this.takeBatch(KEEPALIVE_MAX_BYTES);
|
|
384
|
+
// text/plain avoids a CORS preflight, which beacons can't do.
|
|
385
|
+
if (byteLength(body) >= KEEPALIVE_MAX_BYTES || !navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" }))) {
|
|
386
|
+
this.queue.unshift(...batch);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
325
390
|
}
|
|
326
391
|
attachBrowser() {
|
|
327
392
|
const onHide = () => document.visibilityState === "hidden" && this.beacon();
|
package/dist/cli.js
CHANGED
|
@@ -7,20 +7,27 @@ import { generateSigningKey, loadSigningKey, registerPublicKey, redactSecrets }
|
|
|
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
|
|
11
|
-
daywalker rotate [--service <slug>] [--api-key <
|
|
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]
|
|
12
12
|
daywalker check
|
|
13
13
|
|
|
14
14
|
init Generates an Ed25519 keypair, registers the public key directly with Daywalker Auth
|
|
15
15
|
(POST /api/v1/keys/register), and writes DAYWALKER_* settings to .env.local (or outputs JSON with --json).
|
|
16
|
-
|
|
16
|
+
Nothing is written if registration fails. --force replaces an existing key (kept as
|
|
17
|
+
DAYWALKER_SIGNING_KEY_PREVIOUS for rollback). No public ingress or /.well-known route handlers are required.
|
|
17
18
|
rotate Generates a new Ed25519 key, registers it with Daywalker Auth, and keeps the old one
|
|
18
19
|
in .env.local as DAYWALKER_SIGNING_KEY_PREVIOUS during deployment transition.
|
|
20
|
+
Nothing is written, and the exit code is non-zero, unless registration succeeds.
|
|
19
21
|
check Validates configuration and confirms Daywalker Auth connection and signature readiness.
|
|
20
22
|
|
|
21
23
|
Options:
|
|
24
|
+
--api-key-stdin Read the Daywalker API key from stdin (e.g. \`op read ... | daywalker init --api-key-stdin\`).
|
|
25
|
+
The key is otherwise taken from DAYWALKER_API_KEY in the environment or .env.local.
|
|
26
|
+
--api-key <key> also works but exposes the key in shell history and process listings.
|
|
27
|
+
--skip-register Do not register the public key (alias: --no-register); register it yourself before deploying.
|
|
22
28
|
--json, --export Output configuration as JSON for piping into Vercel, DigitalOcean, Fly.io, AWS SSM, etc.
|
|
23
|
-
|
|
29
|
+
The JSON includes the private key (DAYWALKER_SIGNING_KEY); nothing is written to .env.local.
|
|
30
|
+
--print-key Print the private key to stdout after writing .env.local (disabled by default for security).
|
|
24
31
|
`;
|
|
25
32
|
function args(argv) {
|
|
26
33
|
const out = {};
|
|
@@ -42,6 +49,35 @@ function fail(message) {
|
|
|
42
49
|
console.error(redactSecrets(`✖ ${message}`));
|
|
43
50
|
process.exit(1);
|
|
44
51
|
}
|
|
52
|
+
/** Read all of stdin (for --api-key-stdin), so the key never appears in argv. */
|
|
53
|
+
async function readStdin() {
|
|
54
|
+
if (process.stdin.isTTY)
|
|
55
|
+
fail("--api-key-stdin expects the key on stdin (pipe it in)");
|
|
56
|
+
const chunks = [];
|
|
57
|
+
for await (const chunk of process.stdin)
|
|
58
|
+
chunks.push(Buffer.from(chunk));
|
|
59
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
60
|
+
}
|
|
61
|
+
async function resolveApiKey(opts, existing) {
|
|
62
|
+
if (typeof opts["api-key"] === "string")
|
|
63
|
+
return opts["api-key"].trim() || null;
|
|
64
|
+
if (opts["api-key-stdin"]) {
|
|
65
|
+
const fromStdin = await readStdin();
|
|
66
|
+
if (!fromStdin)
|
|
67
|
+
fail("--api-key-stdin: no API key received on stdin");
|
|
68
|
+
return fromStdin;
|
|
69
|
+
}
|
|
70
|
+
return String(existing.get("DAYWALKER_API_KEY") ?? process.env.DAYWALKER_API_KEY ?? "").trim() || null;
|
|
71
|
+
}
|
|
72
|
+
function skipRegister(opts) {
|
|
73
|
+
return Boolean(opts["skip-register"] || opts["no-register"]);
|
|
74
|
+
}
|
|
75
|
+
/** --json prints the private key by design (for secret stores); say so on stderr so it is never a surprise. */
|
|
76
|
+
function warnJsonIncludesKey(values) {
|
|
77
|
+
if (values.DAYWALKER_SIGNING_KEY) {
|
|
78
|
+
console.warn("! --json output includes the private signing key (DAYWALKER_SIGNING_KEY); pipe it straight into your secret store.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
45
81
|
function readEnvFile(file) {
|
|
46
82
|
const map = new Map();
|
|
47
83
|
if (!fs.existsSync(file))
|
|
@@ -53,8 +89,35 @@ function readEnvFile(file) {
|
|
|
53
89
|
}
|
|
54
90
|
return map;
|
|
55
91
|
}
|
|
56
|
-
|
|
57
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Values are written as KEY="value". Anything a dotenv parser could interpret (quotes, `$` expansion
|
|
94
|
+
* as done by Next.js / dotenv-expand, backslash escapes, newlines or other control characters) is
|
|
95
|
+
* rejected instead of escaped, since parsers disagree on escaping rules.
|
|
96
|
+
*/
|
|
97
|
+
const UNSAFE_ENV_VALUE = /["'`$\\\u0000-\u001f\u007f]/;
|
|
98
|
+
function assertSafeEnvValues(values) {
|
|
99
|
+
for (const [key, value] of Object.entries(values)) {
|
|
100
|
+
if (value !== null && UNSAFE_ENV_VALUE.test(value)) {
|
|
101
|
+
fail(`${key} contains quotes, "$", backslashes, newlines or control characters; refusing to write it to .env.local`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function writeEnvFile(file, values, comments = {}) {
|
|
106
|
+
assertSafeEnvValues(values);
|
|
107
|
+
let stat = null;
|
|
108
|
+
try {
|
|
109
|
+
stat = fs.lstatSync(file);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err.code !== "ENOENT")
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
// Never follow a symlink (it could point anywhere, e.g. a world-readable file); refuse non-files.
|
|
116
|
+
if (stat && stat.isSymbolicLink())
|
|
117
|
+
fail(`${path.basename(file)} is a symlink; refusing to write secrets through it`);
|
|
118
|
+
if (stat && !stat.isFile())
|
|
119
|
+
fail(`${path.basename(file)} is not a regular file`);
|
|
120
|
+
const lines = stat ? fs.readFileSync(file, "utf8").split("\n") : [];
|
|
58
121
|
const seen = new Set();
|
|
59
122
|
const out = [];
|
|
60
123
|
for (const line of lines) {
|
|
@@ -73,12 +136,38 @@ function writeEnvFile(file, values) {
|
|
|
73
136
|
if (added.length) {
|
|
74
137
|
if (out.length)
|
|
75
138
|
out.push("");
|
|
76
|
-
out.push("# Daywalker Auth (managed by `daywalker init`)"
|
|
139
|
+
out.push("# Daywalker Auth (managed by `daywalker init`)");
|
|
140
|
+
for (const [k, v] of added) {
|
|
141
|
+
if (comments[k])
|
|
142
|
+
out.push(`# ${comments[k]}`);
|
|
143
|
+
out.push(`${k}="${v}"`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Atomic replace: write a fresh 0600 temp file in the same directory, then rename it over the
|
|
147
|
+
// target, so the secrets never sit in a file with looser permissions and readers never see a
|
|
148
|
+
// half-written file.
|
|
149
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
150
|
+
const fd = fs.openSync(tmp, "wx", 0o600);
|
|
151
|
+
try {
|
|
152
|
+
fs.fchmodSync(fd, 0o600);
|
|
153
|
+
fs.writeFileSync(fd, out.join("\n") + "\n");
|
|
154
|
+
fs.fsyncSync(fd);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
fs.closeSync(fd);
|
|
158
|
+
fs.rmSync(tmp, { force: true });
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
fs.closeSync(fd);
|
|
162
|
+
try {
|
|
163
|
+
fs.renameSync(tmp, file);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
fs.rmSync(tmp, { force: true });
|
|
167
|
+
throw err;
|
|
77
168
|
}
|
|
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);
|
|
81
169
|
}
|
|
170
|
+
const PREVIOUS_KEY_COMMENT = "Previous signing key, kept for rollback while the new key rolls out; remove it once deployed.";
|
|
82
171
|
function ensureGitignored(dir, entry) {
|
|
83
172
|
const file = path.join(dir, ".gitignore");
|
|
84
173
|
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
@@ -104,15 +193,22 @@ async function init(opts) {
|
|
|
104
193
|
if (!domain)
|
|
105
194
|
fail(`--domain (${rawDomain}) is not a valid public hostname`);
|
|
106
195
|
}
|
|
107
|
-
const apiKey =
|
|
196
|
+
const apiKey = await resolveApiKey(opts, existing);
|
|
108
197
|
const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
109
|
-
const
|
|
198
|
+
const existingKey = existing.get("DAYWALKER_SIGNING_KEY") || null;
|
|
199
|
+
const keyExists = Boolean(existingKey) && !opts.force;
|
|
110
200
|
const key = keyExists ? null : await generateSigningKey();
|
|
111
|
-
|
|
201
|
+
// --force over an existing key: keep the old one so a deployment can roll back to it.
|
|
202
|
+
const previousKey = key && existingKey ? existingKey : null;
|
|
203
|
+
const activeKey = key ?? (existingKey ? await loadSigningKey(existingKey) : null);
|
|
112
204
|
const kid = activeKey?.publicJwk.kid ?? String(opts["key-id"] ?? "");
|
|
113
|
-
// Register public key directly with Daywalker Auth if API key is provided
|
|
205
|
+
// Register public key directly with Daywalker Auth if API key is provided. Replacing a key
|
|
206
|
+
// requires registration: writing an unregistered key would break request signing.
|
|
114
207
|
let registered = false;
|
|
115
|
-
if (
|
|
208
|
+
if (previousKey && !apiKey && !skipRegister(opts)) {
|
|
209
|
+
fail("Replacing DAYWALKER_SIGNING_KEY requires registering the new key: set DAYWALKER_API_KEY (or use --api-key-stdin), or pass --skip-register to write it anyway");
|
|
210
|
+
}
|
|
211
|
+
if (apiKey && activeKey && !skipRegister(opts)) {
|
|
116
212
|
if (!isJson)
|
|
117
213
|
console.log(`• Registering public key (${activeKey.publicJwk.kid}) with Daywalker Auth...`);
|
|
118
214
|
const regResult = await registerPublicKey({
|
|
@@ -128,13 +224,11 @@ async function init(opts) {
|
|
|
128
224
|
console.log(`✔ Public key registered with Daywalker Auth (Push Architecture)`);
|
|
129
225
|
}
|
|
130
226
|
else {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
console.warn(` You can register the key later by re-running init with your DAYWALKER_API_KEY.`);
|
|
134
|
-
}
|
|
227
|
+
fail(`Key registration failed (${regResult.status}): ${regResult.error}\n` +
|
|
228
|
+
` Nothing was written. Fix the problem and re-run, or pass --skip-register to write the key without registering it.`);
|
|
135
229
|
}
|
|
136
230
|
}
|
|
137
|
-
else if (!apiKey && !opts
|
|
231
|
+
else if (!apiKey && !skipRegister(opts) && !isJson) {
|
|
138
232
|
console.log(`• Note: Set DAYWALKER_API_KEY in .env.local to automatically register public keys.`);
|
|
139
233
|
}
|
|
140
234
|
const signingKeyStr = key?.privateKeyPkcs8Base64 ?? existing.get("DAYWALKER_SIGNING_KEY") ?? "";
|
|
@@ -145,12 +239,14 @@ async function init(opts) {
|
|
|
145
239
|
...(domain ? { DAYWALKER_DOMAIN: domain } : {}),
|
|
146
240
|
...(apiKey ? { DAYWALKER_API_KEY: apiKey } : {}),
|
|
147
241
|
...(signingKeyStr ? { DAYWALKER_SIGNING_KEY: signingKeyStr } : {}),
|
|
242
|
+
...(previousKey ? { DAYWALKER_SIGNING_KEY_PREVIOUS: previousKey } : {}),
|
|
148
243
|
};
|
|
149
244
|
if (isJson) {
|
|
245
|
+
warnJsonIncludesKey(envValues);
|
|
150
246
|
console.log(JSON.stringify(envValues, null, 2));
|
|
151
247
|
return;
|
|
152
248
|
}
|
|
153
|
-
writeEnvFile(envFile, envValues);
|
|
249
|
+
writeEnvFile(envFile, envValues, { DAYWALKER_SIGNING_KEY_PREVIOUS: PREVIOUS_KEY_COMMENT });
|
|
154
250
|
ensureGitignored(dir, ".env.local");
|
|
155
251
|
console.log(`✔ Daywalker configured for service "${slug}"${domain ? ` (${domain})` : ""}`);
|
|
156
252
|
console.log(` • RFC 9421 / RFC 9530 HTTP Message Signing active`);
|
|
@@ -159,6 +255,8 @@ async function init(opts) {
|
|
|
159
255
|
console.log(` • Key ID (RFC 7638 thumbprint): ${kid}`);
|
|
160
256
|
if (registered)
|
|
161
257
|
console.log(` • Zero-ingress push architecture: public key registered directly`);
|
|
258
|
+
if (previousKey)
|
|
259
|
+
console.log(` • Previous key kept as DAYWALKER_SIGNING_KEY_PREVIOUS for rollback; remove it once the new key is deployed.`);
|
|
162
260
|
if (opts["print-key"] && signingKeyStr)
|
|
163
261
|
console.log(`\nDAYWALKER_SIGNING_KEY=${signingKeyStr}`);
|
|
164
262
|
}
|
|
@@ -171,11 +269,16 @@ async function rotate(opts) {
|
|
|
171
269
|
if (!current)
|
|
172
270
|
fail("No DAYWALKER_SIGNING_KEY found; run `daywalker init` first");
|
|
173
271
|
const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "").trim().toLowerCase();
|
|
174
|
-
const apiKey =
|
|
272
|
+
const apiKey = await resolveApiKey(opts, existing);
|
|
175
273
|
const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
176
274
|
const rotateDomain = publicHostname(String(opts.domain ?? existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? ""));
|
|
177
275
|
const key = await generateSigningKey();
|
|
178
|
-
|
|
276
|
+
// The new key only becomes active once Daywalker Auth knows it, so registration must succeed
|
|
277
|
+
// before anything is written (unless the caller explicitly registers it some other way).
|
|
278
|
+
if (!skipRegister(opts)) {
|
|
279
|
+
if (!apiKey || !slug) {
|
|
280
|
+
fail("rotate needs DAYWALKER_API_KEY and DAYWALKER_SERVICE_SLUG to register the new key (or pass --skip-register to write it unregistered)");
|
|
281
|
+
}
|
|
179
282
|
if (!isJson)
|
|
180
283
|
console.log(`• Registering new public key (${key.publicJwk.kid}) with Daywalker Auth...`);
|
|
181
284
|
const regResult = await registerPublicKey({
|
|
@@ -185,25 +288,24 @@ async function rotate(opts) {
|
|
|
185
288
|
domain: rotateDomain,
|
|
186
289
|
publicJwk: key.publicJwk,
|
|
187
290
|
});
|
|
188
|
-
if (regResult.ok) {
|
|
189
|
-
|
|
190
|
-
console.log(`✔ New public key registered with Daywalker Auth`);
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
if (!isJson)
|
|
194
|
-
console.warn(redactSecrets(`! Key registration warning: ${regResult.error}`));
|
|
291
|
+
if (!regResult.ok) {
|
|
292
|
+
fail(`Key registration failed (${regResult.status}): ${regResult.error}\n Nothing was written; the current key is still active.`);
|
|
195
293
|
}
|
|
294
|
+
if (!isJson)
|
|
295
|
+
console.log(`✔ New public key registered with Daywalker Auth`);
|
|
196
296
|
}
|
|
197
297
|
const envValues = {
|
|
198
298
|
DAYWALKER_KEY_ID: key.publicJwk.kid,
|
|
199
299
|
DAYWALKER_SIGNING_KEY: key.privateKeyPkcs8Base64 ?? "",
|
|
300
|
+
// Kept for rollback: services still signing with the old key keep working during rollout.
|
|
200
301
|
DAYWALKER_SIGNING_KEY_PREVIOUS: current,
|
|
201
302
|
};
|
|
202
303
|
if (isJson) {
|
|
304
|
+
warnJsonIncludesKey(envValues);
|
|
203
305
|
console.log(JSON.stringify(envValues, null, 2));
|
|
204
306
|
return;
|
|
205
307
|
}
|
|
206
|
-
writeEnvFile(envFile, envValues);
|
|
308
|
+
writeEnvFile(envFile, envValues, { DAYWALKER_SIGNING_KEY_PREVIOUS: PREVIOUS_KEY_COMMENT });
|
|
207
309
|
console.log(`✔ New key ${key.publicJwk.kid} written; previous key preserved as DAYWALKER_SIGNING_KEY_PREVIOUS.`);
|
|
208
310
|
console.log(` After deployment rollout is complete, you may remove DAYWALKER_SIGNING_KEY_PREVIOUS.`);
|
|
209
311
|
if (opts["print-key"])
|