@shelbyb/daywalker-sdk 0.1.0 → 0.1.1

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
@@ -1,90 +1,210 @@
1
+ <p align="center">
2
+ <img src="./assets/logo-512.png" alt="Daywalker" width="160" />
3
+ </p>
4
+
1
5
  # @shelbyb/daywalker-sdk
2
6
 
3
- Connect any service to [Daywalker Auth](https://auth.daywalker.dev): validate API tokens and make **domain-signed** API calls that pass token allowlists from any host, CDN, container, or serverless platform.
7
+ Zero-dependency, 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**.
4
8
 
5
- ## Setup (once per service)
9
+ Runs seamlessly across **Node.js 18+**, **Bun**, **Deno**, **Cloudflare Workers**, **Next.js Edge**, and standard browser runtimes.
10
+
11
+ - **Zero-Dependency Web Standards Core**: 100% standard `crypto.subtle`, `Uint8Array`, `fetch`, `Request`, and `Response`.
12
+ - **RFC 9421 (HTTP Message Signatures)**: Cryptographically binds `@method`, `@path`, `@authority`, and `content-digest` using Ed25519 signatures (`Signature-Input`, `Signature`).
13
+ - **RFC 9530 (Digest Fields)**: Computes standard `Content-Digest: sha-256=:<base64>:` headers for payload integrity.
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**: Automatic exponential backoff with jitter and `Retry-After` header parsing directly inside client fetch requests.
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), health check bypass, and self-healing diagnostic error routing.
18
+ - **Zero-Egress Inbound Verification**: In-memory caching (60–300s TTL) and offline JWT token lease validation using Daywalker's asymmetric public key.
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
+
21
+ ---
22
+
23
+ ## Installation
6
24
 
7
25
  ```bash
8
26
  npm install @shelbyb/daywalker-sdk
9
- npx daywalker init --domain myservice.daywalker.dev --service my-service-slug
10
- # On DigitalOcean App Platform, also set the env vars on the live app:
11
- npx daywalker init --domain myservice.daywalker.dev --service my-service-slug --do-app my-app
12
27
  ```
13
28
 
14
- `init`:
15
- - generates an Ed25519 signing key
16
- - adds the `/.well-known/daywalker-keys.json` route (Next.js; for Express it prints the one line to add)
17
- - writes `DAYWALKER_SERVICE_SLUG`, `DAYWALKER_DOMAIN`, and `DAYWALKER_SIGNING_KEY` to `.env.local` (git-ignored)
18
- - with `--do-app`, sets them on your DigitalOcean app through `doctl`, with the key as an encrypted secret
29
+ ---
30
+
31
+ ## Setup (once per service)
32
+
33
+ ```bash
34
+ # Generate Ed25519 keypair and write .env.local
35
+ npx daywalker init --service my-service-slug
36
+
37
+ # Or register the public key immediately with Daywalker Auth:
38
+ npx daywalker init --service my-service-slug --api-key srv_live_...
39
+
40
+ # Pipe JSON secrets directly into Vercel, Fly.io, DigitalOcean, or AWS SSM:
41
+ npx daywalker init --service my-service-slug --api-key srv_live_... --json
42
+ ```
43
+
44
+ `daywalker init`:
45
+ - Generates an Ed25519 keypair locally using WebCrypto (`crypto.subtle`)
46
+ - Registers the public JWK directly with `POST https://auth.daywalker.dev/api/v1/keys/register`
47
+ - Writes `DAYWALKER_SERVICE_SLUG`, `DAYWALKER_KEY_ID`, `DAYWALKER_SIGNING_KEY`, and `DAYWALKER_AUTH_URL` to `.env.local`
48
+ - No route injectors or public `/.well-known/` endpoints needed
19
49
 
20
50
  The private key is never printed unless you pass `--print-key`.
21
51
 
22
- Then set `DAYWALKER_API_KEY` to the service's token, deploy, run `npx daywalker check`, and in the Daywalker dashboard click **Connect** on the token to add your domain to its allowed callers.
52
+ ---
23
53
 
24
54
  ## Environment
25
55
 
26
56
  | Variable | Required | Purpose |
27
57
  |---|---|---|
28
- | `DAYWALKER_API_KEY` | ✅ | The service's Daywalker API token (`srv_live_…`) |
29
- | `DAYWALKER_SERVICE_SLUG` | ✅ | The service slug to validate against. Each service sets its own |
30
- | `DAYWALKER_SIGNING_KEY` | for allowlisted tokens | Ed25519 private key from `daywalker init` (secret) |
31
- | `DAYWALKER_DOMAIN` | recommended | Public hostname that serves the key. Falls back to `APP_URL`, `NEXTAUTH_URL`, `NEXT_PUBLIC_APP_URL`, `BETTER_AUTH_URL`, `PUBLIC_URL`, `SITE_URL`, `URL`, `VERCEL_PROJECT_PRODUCTION_URL`, `RENDER_EXTERNAL_HOSTNAME`, `RAILWAY_PUBLIC_DOMAIN`, `VERCEL_URL` |
32
- | `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Old key, still published so in-flight requests verify |
58
+ | `DAYWALKER_API_KEY` | ✅ | The service's Daywalker API token (`srv_live_…`) or JWT lease |
59
+ | `DAYWALKER_SERVICE_SLUG` | ✅ | The service slug to validate against (e.g. `sonalyze`) |
60
+ | `DAYWALKER_SIGNING_KEY` | for signed requests | Ed25519 private key from `daywalker init` (PKCS#8 Base64 DER or PEM) |
61
+ | `DAYWALKER_KEY_ID` | optional | RFC 7638 JWK thumbprint derived during key generation |
62
+ | `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Previous private key, maintained during deployment transitions |
63
+ | `DAYWALKER_DOMAIN` | optional | Optional caller identifier or host domain name |
64
+ | `DAYWALKER_PUBLIC_KEY` | optional | Daywalker Auth public key for zero-egress offline JWT lease verification |
33
65
  | `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev` |
34
66
 
35
- Internal addresses (`0.0.0.0`, `localhost`, IPs) are never used as the domain, so a container's bind address can't end up in a signature.
67
+ ---
36
68
 
37
69
  ## Usage
38
70
 
71
+ ### Client Class & Instantiation
72
+
39
73
  ```ts
40
- import { daywalker } from "@shelbyb/daywalker-sdk";
74
+ import { Daywalker, daywalker } from "@shelbyb/daywalker-sdk";
75
+
76
+ // Option 1: Instantiable client with custom options / dependency injection
77
+ const client = new Daywalker({
78
+ apiKey: process.env.DAYWALKER_API_KEY,
79
+ serviceSlug: "sonalyze",
80
+ maxRetries: 3,
81
+ });
82
+
83
+ // Option 2: Default lazy singleton (reads process.env automatically)
84
+ const result = await daywalker.validateToken();
85
+
86
+ if (result.ok) {
87
+ console.log("Validated tenant:", result.data.tenantId);
88
+ console.log("Rate limit remaining:", result.data.rateLimit.remaining);
89
+ } else {
90
+ console.error(`Validation failed (${result.status} ${result.error}): ${result.message}`);
91
+ }
92
+ ```
93
+
94
+ ### Type-Safe Verification & Error Handling
41
95
 
42
- // Validate DAYWALKER_API_KEY for DAYWALKER_SERVICE_SLUG (successful results cached for 60s)
43
- const result = await daywalker().validateToken();
44
- if (!result.valid) console.error(result.status, result.data.message);
96
+ ```ts
97
+ // requireValidToken returns data or throws DaywalkerAuthError / DaywalkerRateLimitError
98
+ try {
99
+ const auth = await client.requireValidToken(req.headers.get("x-api-token"));
100
+ console.log("Authenticated service:", auth.serviceSlug);
101
+ } catch (err) {
102
+ if (err instanceof DaywalkerRateLimitError) {
103
+ console.warn(`Rate limited. Retry after ${err.retryAfter}s`);
104
+ }
105
+ }
106
+ ```
45
107
 
46
- // Or validate a token your own users send you
47
- await daywalker().requireValidToken({ token: req.headers["x-api-token"] }); // throws DaywalkerError
108
+ ### RFC 9421 Signed Requests with Automatic Backoff
48
109
 
49
- // Signed call to any Daywalker Auth API path
50
- const res = await daywalker().fetch("/api/v1/tokens/validate", { body: { token, serviceSlug: "other" } });
110
+ ```ts
111
+ // Outbound request automatically computes Content-Digest (RFC 9530)
112
+ // and attaches RFC 9421 signature headers. Retries 429 rate limits automatically.
113
+ const res = await client.fetch("/api/v1/tokens/validate", {
114
+ method: "POST",
115
+ body: { token: "user_token", serviceSlug: "other-service" },
116
+ });
51
117
  ```
52
118
 
53
- `result.data` includes `rateLimit` (per minute) and `rateLimitHourly` (per hour), each `{ limit, remaining, reset }`. On a `429`, `result.retryAfter` is the number of seconds to wait.
119
+ ---
54
120
 
55
- ### Serving the public key
121
+ ## Hardened Next.js Middleware (`@shelbyb/daywalker-sdk/next`)
56
122
 
57
- Next.js (App Router), which `init` creates for you:
123
+ ```ts
124
+ // middleware.ts
125
+ import { createDaywalkerMiddleware } from "@shelbyb/daywalker-sdk/next";
126
+
127
+ export const middleware = createDaywalkerMiddleware({
128
+ serviceSlug: "sonalyze",
129
+ errorPath: "/service-error", // redirect unauthorized web visitors
130
+ validateServerActions: true, // drops malformed CVE-2025-55182 scanner probes
131
+ });
132
+
133
+ export const config = {
134
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
135
+ };
136
+ ```
137
+
138
+ ### Route Handler Wrapper
58
139
 
59
140
  ```ts
60
- // app/.well-known/daywalker-keys.json/route.ts
61
- export { GET, dynamic } from "@shelbyb/daywalker-sdk/next";
141
+ // app/api/protected/route.ts
142
+ import { withDaywalkerAuth } from "@shelbyb/daywalker-sdk/next";
143
+
144
+ export const POST = withDaywalkerAuth(async (req, { auth }) => {
145
+ return Response.json({ message: "Hello " + auth.serviceSlug, auth });
146
+ });
62
147
  ```
63
148
 
64
- Express / Connect / Node http:
149
+ ---
150
+
151
+ ## Web Standard & Hono Middleware (`@shelbyb/daywalker-sdk/middleware`)
65
152
 
66
153
  ```ts
67
- import { daywalkerKeys } from "@shelbyb/daywalker-sdk/express";
68
- app.use(daywalkerKeys());
154
+ import { Hono } from "hono";
155
+ import { daywalkerAuth } from "@shelbyb/daywalker-sdk/middleware";
156
+
157
+ const app = new Hono();
158
+ app.use("/api/*", daywalkerAuth());
159
+
160
+ app.get("/api/me", (c) => {
161
+ const auth = c.get("auth");
162
+ return c.json({ auth });
163
+ });
69
164
  ```
70
165
 
71
- Anything else: serve `JSON.stringify(daywalker().keys())` at `GET /.well-known/daywalker-keys.json`. It must be on the domain itself over HTTPS, without redirects.
166
+ ---
167
+
168
+ ## Zero-Egress Offline Verification
72
169
 
73
- ### Custom clients
170
+ When high-throughput internal microservices need zero latency and zero external egress, Daywalker Auth can issue asymmetric JWT leases. Configure `DAYWALKER_PUBLIC_KEY` (PEM or standard JWK) and token validation runs entirely in memory without network calls:
74
171
 
75
172
  ```ts
76
- import { createDaywalker } from "@shelbyb/daywalker-sdk";
77
- const client = createDaywalker({ serviceSlug: "gigpulse", domain: "gigpulse.daywalker.dev", cacheTtlMs: 0 });
173
+ import { Daywalker } from "@shelbyb/daywalker-sdk";
174
+
175
+ const client = new Daywalker({
176
+ serviceSlug: "myservice",
177
+ daywalkerPublicKey: process.env.DAYWALKER_PUBLIC_KEY,
178
+ });
179
+
180
+ // Verifies JWT signature, expiration, and service claims locally
181
+ const result = await client.validateToken(incomingJwt);
78
182
  ```
79
183
 
80
- ## Commands
184
+ ---
81
185
 
82
- | Command | Does |
186
+ ## CLI Reference
187
+
188
+ | Command | Description |
83
189
  |---|---|
84
- | `npx daywalker init --domain <host> --service <slug> [--do-app <app>]` | Generate a key, add the key route, write env (re-running keeps the existing key; `--force` replaces it) |
85
- | `npx daywalker check` | Validate config and confirm the domain publishes the current key |
86
- | `npx daywalker rotate [--do-app <app>]` | New key; the old one stays published as `DAYWALKER_SIGNING_KEY_PREVIOUS`. Remove that after the deploy is live |
190
+ | `npx daywalker init --service <slug> [--api-key <key>] [--json]` | Generate Ed25519 key, register with Daywalker Auth, and write `.env.local` or output JSON |
191
+ | `npx daywalker check` | Validate configuration, signature readiness, and connection |
192
+ | `npx daywalker rotate [--api-key <key>] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
193
+
194
+ ---
195
+
196
+ ## Request Signing Specification (RFC 9421 & RFC 9530)
197
+
198
+ Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
199
+ - `Content-Digest: sha-256=:<base64-encoded-hash>:`
200
+ - `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519"`
201
+ - `Signature: sig1=:<base64-encoded-signature>:`
202
+ - `Authorization: Bearer <DAYWALKER_API_KEY>`
203
+
204
+ The exact signature base and test cases are verified against [`test-vectors/signing-rfc9421.json`](./test-vectors/signing-rfc9421.json).
205
+
206
+ ---
87
207
 
88
- ## How signing works
208
+ ## License
89
209
 
90
- Each request carries `X-Daywalker-Domain`, `-Key-Id`, `-Timestamp`, `-Nonce`, and `-Signature`, an Ed25519 signature over the method, path, domain, timestamp, nonce, and the body's SHA-256. Daywalker fetches the public key from your domain over HTTPS, so a valid signature proves the request comes from whoever controls the domain. Timestamps must be within 5 minutes and nonces are single-use. See the Daywalker README for the full protocol.
210
+ MIT
Binary file
package/dist/cli.js CHANGED
@@ -1,26 +1,27 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from "node:child_process";
3
2
  import fs from "node:fs";
4
3
  import path from "node:path";
5
4
  import { createDaywalker } from "./client.js";
6
5
  import { publicHostname } from "./config.js";
7
- import { generateSigningKey } from "./signing.js";
8
- const HELP = `daywalker: connect a service to Daywalker Auth
6
+ import { generateSigningKey, loadSigningKey, registerPublicKey, redactSecrets } from "./crypto/index.js";
7
+ const HELP = `daywalker: connect a service to Daywalker Auth (RFC 9421 / Push Architecture)
9
8
 
10
9
  Usage:
11
- daywalker init --domain <host> --service <slug> [--do-app <name|id>] [--framework next|express] [--print-key]
12
- daywalker rotate [--do-app <name|id>] [--print-key]
10
+ daywalker init --service <slug> [--api-key <key>] [--domain <host>] [--auth-url <url>] [--json|--export] [--print-key]
11
+ daywalker rotate [--service <slug>] [--api-key <key>] [--auth-url <url>] [--json|--export] [--print-key]
13
12
  daywalker check
14
13
 
15
- init Generates a signing key, adds the /.well-known/daywalker-keys.json route for your
16
- framework, and writes DAYWALKER_* settings to .env.local. With --do-app it also sets
17
- them on your DigitalOcean App Platform app (as an encrypted secret, via doctl).
18
- rotate Generates a new key and keeps the old one published as DAYWALKER_SIGNING_KEY_PREVIOUS,
19
- so requests signed with either key verify during the switch. Remove the previous key
20
- a few minutes after deploying.
21
- check Validates config and confirms your domain serves the current public key.
14
+ init Generates an Ed25519 keypair, registers the public key directly with Daywalker Auth
15
+ (POST /api/v1/keys/register), and writes DAYWALKER_* settings to .env.local (or outputs JSON with --json).
16
+ No public ingress or /.well-known route handlers are required.
17
+ rotate Generates a new Ed25519 key, registers it with Daywalker Auth, and keeps the old one
18
+ in .env.local as DAYWALKER_SIGNING_KEY_PREVIOUS during deployment transition.
19
+ check Validates configuration and confirms Daywalker Auth connection and signature readiness.
22
20
 
23
- The private key is never printed unless you pass --print-key.`;
21
+ Options:
22
+ --json, --export Output configuration as JSON for piping into Vercel, DigitalOcean, Fly.io, AWS SSM, etc.
23
+ --print-key Print the private key to stdout (disabled by default for security).
24
+ `;
24
25
  function args(argv) {
25
26
  const out = {};
26
27
  for (let i = 0; i < argv.length; i++) {
@@ -38,10 +39,9 @@ function args(argv) {
38
39
  return out;
39
40
  }
40
41
  function fail(message) {
41
- console.error(`✖ ${message}`);
42
+ console.error(redactSecrets(`✖ ${message}`));
42
43
  process.exit(1);
43
44
  }
44
- // --- .env.local ---------------------------------------------------------------
45
45
  function readEnvFile(file) {
46
46
  const map = new Map();
47
47
  if (!fs.existsSync(file))
@@ -84,149 +84,171 @@ function ensureGitignored(dir, entry) {
84
84
  if (!covered)
85
85
  fs.appendFileSync(file, `${content.endsWith("\n") || !content ? "" : "\n"}${entry}\n`);
86
86
  }
87
- // --- framework route ------------------------------------------------------------
88
- function detectFramework(dir) {
89
- try {
90
- const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
91
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
92
- if (deps.next)
93
- return "next";
94
- if (deps.express || deps.fastify || deps.koa || deps.hono)
95
- return "express";
96
- }
97
- catch {
98
- // no package.json
99
- }
100
- return null;
101
- }
102
- function addNextRoute(dir) {
103
- const appDir = ["src/app", "app"].map((d) => path.join(dir, d)).find((d) => fs.existsSync(d)) ?? path.join(dir, "app");
104
- const routeDir = path.join(appDir, ".well-known", "daywalker-keys.json");
105
- const routeFile = path.join(routeDir, "route.ts");
106
- const rel = path.relative(dir, routeFile);
107
- if (fs.existsSync(routeFile))
108
- return `${rel} already exists (left unchanged)`;
109
- // A static file here would shadow the route and pin an old key
110
- const staticFile = path.join(dir, "public", ".well-known", "daywalker-keys.json");
111
- if (fs.existsSync(staticFile))
112
- console.warn(`! Remove ${path.relative(dir, staticFile)}; the route serves keys from env now.`);
113
- fs.mkdirSync(routeDir, { recursive: true });
114
- fs.writeFileSync(routeFile, `// Serves this service's public signing key so Daywalker Auth can verify its requests.\nexport { GET, dynamic } from "@shelbyb/daywalker-sdk/next";\n`);
115
- return `created ${rel}`;
116
- }
117
- // --- DigitalOcean ---------------------------------------------------------------
118
- function setDigitalOceanEnv(app, values) {
119
- const doctl = (a, input) => execFileSync("doctl", a, { encoding: "utf8", input, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
120
- let appId = app;
121
- if (!/^[0-9a-f-]{36}$/.test(app)) {
122
- const apps = JSON.parse(doctl(["apps", "list", "--output", "json"]));
123
- appId = apps.find((a) => a.spec.name === app)?.id ?? fail(`No DigitalOcean app named "${app}"`);
124
- }
125
- const spec = JSON.parse(doctl(["apps", "spec", "get", appId, "--format", "json"]));
126
- const components = [...(spec.services ?? []), ...(spec.workers ?? []), ...(spec.jobs ?? [])];
127
- if (!components.length)
128
- fail("The app spec has no services, workers, or jobs to configure");
129
- for (const component of components) {
130
- component.envs = (component.envs ?? []).filter((e) => !(e.key in values));
131
- for (const [key, { value, secret }] of Object.entries(values)) {
132
- if (value !== null)
133
- component.envs.push({ key, value, scope: "RUN_TIME", ...(secret ? { type: "SECRET" } : {}) });
134
- }
135
- }
136
- // Spec goes in via stdin so secrets never appear in process arguments
137
- doctl(["apps", "update", appId, "--spec", "-", "--output", "json"], JSON.stringify(spec));
138
- return appId;
139
- }
140
- // --- commands ---------------------------------------------------------------
141
87
  async function init(opts) {
88
+ const isJson = Boolean(opts.json || opts.export);
142
89
  const dir = process.cwd();
143
90
  const envFile = path.join(dir, ".env.local");
144
91
  const existing = readEnvFile(envFile);
145
- const domain = publicHostname(String(opts.domain ?? existing.get("DAYWALKER_DOMAIN") ?? ""));
146
- if (!domain)
147
- fail("--domain must be the public hostname that serves this app, e.g. --domain sonalyze.daywalker.dev");
148
- const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? "").trim().toLowerCase();
149
- if (!/^[a-z0-9_-]+$/.test(slug))
150
- fail("--service must be this service's Daywalker slug, e.g. --service sonalyze");
92
+ const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "")
93
+ .trim()
94
+ .toLowerCase();
95
+ if (!slug || !/^[a-z0-9_-]+$/.test(slug)) {
96
+ fail("--service must be this service's Daywalker slug (e.g. --service sonalyze)");
97
+ }
98
+ const rawDomain = opts.domain ? String(opts.domain) : existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? "";
99
+ let domain = null;
100
+ if (rawDomain) {
101
+ domain = publicHostname(rawDomain);
102
+ if (!domain)
103
+ fail(`--domain (${rawDomain}) is not a valid public hostname`);
104
+ }
105
+ const apiKey = String(opts["api-key"] ?? existing.get("DAYWALKER_API_KEY") ?? process.env.DAYWALKER_API_KEY ?? "").trim() || null;
106
+ const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
151
107
  const keyExists = Boolean(existing.get("DAYWALKER_SIGNING_KEY")) && !opts.force;
152
- const key = keyExists ? null : generateSigningKey();
153
- const framework = opts.framework || detectFramework(dir);
154
- let routeNote;
155
- if (framework === "next")
156
- routeNote = addNextRoute(dir);
157
- else if (framework === "express")
158
- routeNote = `add app.use(daywalkerKeys()) from "@shelbyb/daywalker-sdk/express" before your routes`;
159
- else
160
- routeNote = `serve GET /.well-known/daywalker-keys.json with daywalker().keys() (see the SDK README)`;
161
- writeEnvFile(envFile, {
162
- DAYWALKER_SERVICE_SLUG: slug,
163
- DAYWALKER_DOMAIN: domain,
164
- ...(key ? { DAYWALKER_SIGNING_KEY: key.privateKey } : {}),
165
- });
166
- ensureGitignored(dir, ".env.local");
167
- console.log(`✔ Daywalker configured for ${domain} (service "${slug}")`);
168
- console.log(` • ${routeNote}`);
169
- console.log(` • .env.local: DAYWALKER_SERVICE_SLUG, DAYWALKER_DOMAIN${key ? ", DAYWALKER_SIGNING_KEY" : " (kept existing signing key)"}`);
170
- if (key)
171
- console.log(` • key id ${key.publicJwk.kid}`);
172
- if (opts["do-app"]) {
173
- const signingKey = key?.privateKey ?? existing.get("DAYWALKER_SIGNING_KEY");
174
- const appId = setDigitalOceanEnv(String(opts["do-app"]), {
175
- DAYWALKER_SERVICE_SLUG: { value: slug, secret: false },
176
- DAYWALKER_DOMAIN: { value: domain, secret: false },
177
- DAYWALKER_SIGNING_KEY: { value: signingKey, secret: true },
108
+ const key = keyExists ? null : await generateSigningKey();
109
+ const activeKey = key ?? (existing.get("DAYWALKER_SIGNING_KEY") ? await loadSigningKey(existing.get("DAYWALKER_SIGNING_KEY")) : null);
110
+ const kid = activeKey?.publicJwk.kid ?? String(opts["key-id"] ?? "");
111
+ // Register public key directly with Daywalker Auth if API key is provided
112
+ let registered = false;
113
+ if (apiKey && activeKey && !opts["skip-register"]) {
114
+ if (!isJson)
115
+ console.log(`• Registering public key (${activeKey.publicJwk.kid}) with Daywalker Auth...`);
116
+ const regResult = await registerPublicKey({
117
+ authUrl,
118
+ apiKey,
119
+ serviceSlug: slug,
120
+ domain,
121
+ publicJwk: activeKey.publicJwk,
178
122
  });
179
- console.log(` • DigitalOcean app ${appId}: env vars set (signing key encrypted); a redeploy has started`);
180
- console.log(` Pull the updated spec into .do/app.yaml before your next spec deploy, or the key will be removed.`);
123
+ if (regResult.ok) {
124
+ registered = true;
125
+ if (!isJson)
126
+ console.log(`✔ Public key registered with Daywalker Auth (Push Architecture)`);
127
+ }
128
+ else {
129
+ if (!isJson) {
130
+ console.warn(redactSecrets(`! Key registration warning (${regResult.status}): ${regResult.error}`));
131
+ console.warn(` You can register the key later by re-running init with your DAYWALKER_API_KEY.`);
132
+ }
133
+ }
181
134
  }
182
- else {
183
- console.log(` • Production: set DAYWALKER_SERVICE_SLUG, DAYWALKER_DOMAIN, and DAYWALKER_SIGNING_KEY (as a secret)`);
184
- console.log(` on your host, or re-run with --do-app <name> for DigitalOcean App Platform.`);
135
+ else if (!apiKey && !opts["skip-register"] && !isJson) {
136
+ console.log(`• Note: Set DAYWALKER_API_KEY in .env.local to automatically register public keys.`);
185
137
  }
186
- if (opts["print-key"] && key)
187
- console.log(`\nDAYWALKER_SIGNING_KEY=${key.privateKey}`);
188
- console.log(`\nNext: deploy, then run \`npx daywalker check\`, and add ${domain} (or *.${domain.split(".").slice(1).join(".")}) to your token's allowed callers.`);
138
+ const signingKeyStr = key?.privateKeyPkcs8Base64 ?? existing.get("DAYWALKER_SIGNING_KEY") ?? "";
139
+ const envValues = {
140
+ DAYWALKER_SERVICE_SLUG: slug,
141
+ DAYWALKER_KEY_ID: kid,
142
+ DAYWALKER_AUTH_URL: authUrl,
143
+ ...(domain ? { DAYWALKER_DOMAIN: domain } : {}),
144
+ ...(apiKey ? { DAYWALKER_API_KEY: apiKey } : {}),
145
+ ...(signingKeyStr ? { DAYWALKER_SIGNING_KEY: signingKeyStr } : {}),
146
+ };
147
+ if (isJson) {
148
+ console.log(JSON.stringify(envValues, null, 2));
149
+ return;
150
+ }
151
+ writeEnvFile(envFile, envValues);
152
+ ensureGitignored(dir, ".env.local");
153
+ console.log(`✔ Daywalker configured for service "${slug}"${domain ? ` (${domain})` : ""}`);
154
+ console.log(` • RFC 9421 / RFC 9530 HTTP Message Signing active`);
155
+ console.log(` • .env.local: DAYWALKER_SERVICE_SLUG, DAYWALKER_KEY_ID, DAYWALKER_AUTH_URL${domain ? ", DAYWALKER_DOMAIN" : ""}${apiKey ? ", DAYWALKER_API_KEY" : ""}${signingKeyStr ? ", DAYWALKER_SIGNING_KEY" : ""}`);
156
+ if (kid)
157
+ console.log(` • Key ID (RFC 7638 thumbprint): ${kid}`);
158
+ if (registered)
159
+ console.log(` • Zero-ingress push architecture: public key registered directly`);
160
+ if (opts["print-key"] && signingKeyStr)
161
+ console.log(`\nDAYWALKER_SIGNING_KEY=${signingKeyStr}`);
189
162
  }
190
163
  async function rotate(opts) {
191
- const envFile = path.join(process.cwd(), ".env.local");
164
+ const isJson = Boolean(opts.json || opts.export);
165
+ const dir = process.cwd();
166
+ const envFile = path.join(dir, ".env.local");
192
167
  const existing = readEnvFile(envFile);
193
- const current = existing.get("DAYWALKER_SIGNING_KEY");
168
+ const current = existing.get("DAYWALKER_SIGNING_KEY") ?? process.env.DAYWALKER_SIGNING_KEY;
194
169
  if (!current)
195
- fail("No DAYWALKER_SIGNING_KEY in .env.local; run `daywalker init` first");
196
- const key = generateSigningKey();
197
- writeEnvFile(envFile, { DAYWALKER_SIGNING_KEY: key.privateKey, DAYWALKER_SIGNING_KEY_PREVIOUS: current });
198
- console.log(`✔ New key ${key.publicJwk.kid} written; the old key stays published as DAYWALKER_SIGNING_KEY_PREVIOUS.`);
199
- if (opts["do-app"]) {
200
- const appId = setDigitalOceanEnv(String(opts["do-app"]), {
201
- DAYWALKER_SIGNING_KEY: { value: key.privateKey, secret: true },
202
- DAYWALKER_SIGNING_KEY_PREVIOUS: { value: current, secret: true },
170
+ fail("No DAYWALKER_SIGNING_KEY found; run `daywalker init` first");
171
+ const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "").trim().toLowerCase();
172
+ const apiKey = String(opts["api-key"] ?? existing.get("DAYWALKER_API_KEY") ?? process.env.DAYWALKER_API_KEY ?? "").trim() || null;
173
+ const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
174
+ const key = await generateSigningKey();
175
+ if (apiKey && slug) {
176
+ if (!isJson)
177
+ console.log(`• Registering new public key (${key.publicJwk.kid}) with Daywalker Auth...`);
178
+ const regResult = await registerPublicKey({
179
+ authUrl,
180
+ apiKey,
181
+ serviceSlug: slug,
182
+ publicJwk: key.publicJwk,
203
183
  });
204
- console.log(` • DigitalOcean app ${appId} updated; a redeploy has started.`);
184
+ if (regResult.ok) {
185
+ if (!isJson)
186
+ console.log(`✔ New public key registered with Daywalker Auth`);
187
+ }
188
+ else {
189
+ if (!isJson)
190
+ console.warn(redactSecrets(`! Key registration warning: ${regResult.error}`));
191
+ }
205
192
  }
206
- console.log(` After the deploy is live, remove DAYWALKER_SIGNING_KEY_PREVIOUS to retire the old key.`);
193
+ const envValues = {
194
+ DAYWALKER_KEY_ID: key.publicJwk.kid,
195
+ DAYWALKER_SIGNING_KEY: key.privateKeyPkcs8Base64 ?? "",
196
+ DAYWALKER_SIGNING_KEY_PREVIOUS: current,
197
+ };
198
+ if (isJson) {
199
+ console.log(JSON.stringify(envValues, null, 2));
200
+ return;
201
+ }
202
+ writeEnvFile(envFile, envValues);
203
+ console.log(`✔ New key ${key.publicJwk.kid} written; previous key preserved as DAYWALKER_SIGNING_KEY_PREVIOUS.`);
204
+ console.log(` After deployment rollout is complete, you may remove DAYWALKER_SIGNING_KEY_PREVIOUS.`);
207
205
  if (opts["print-key"])
208
- console.log(`\nDAYWALKER_SIGNING_KEY=${key.privateKey}`);
206
+ console.log(`\nDAYWALKER_SIGNING_KEY=${key.privateKeyPkcs8Base64}`);
209
207
  }
210
208
  async function check() {
211
- // Merge .env.local over the process env, like Next.js does in development
212
209
  const env = { ...process.env, ...Object.fromEntries(readEnvFile(path.join(process.cwd(), ".env.local"))) };
213
- const result = await createDaywalker({ env }).selfCheck();
214
- console.log(`domain: ${result.domain ?? "(none)"}`);
215
- console.log(`keys: ${result.keysUrl ?? "(none)"}`);
216
- console.log(`signing: ${result.signing ? "yes" : "no"}`);
217
- if (result.ok)
218
- console.log("✔ Ready: requests will be signed and your key is published.");
219
- else {
220
- for (const p of result.problems)
221
- console.log(`✖ ${p}`);
222
- process.exit(1);
210
+ const client = createDaywalker({ env });
211
+ const result = await client.selfCheck();
212
+ if (result.ok) {
213
+ console.log(`✔ Daywalker configuration looks good`);
214
+ if (result.serviceSlug)
215
+ console.log(` • Service slug: ${result.serviceSlug}`);
216
+ if (result.domain)
217
+ console.log(` • Caller domain: ${result.domain}`);
218
+ if (result.keyId)
219
+ console.log(` • Key ID: ${result.keyId}`);
220
+ if (result.signing)
221
+ console.log(` • RFC 9421 signature signing: ready`);
222
+ if (result.offlineVerificationReady)
223
+ console.log(` • Zero-egress offline JWT lease verification: ready`);
224
+ return;
223
225
  }
226
+ console.error(`✖ Daywalker self-check found problems:`);
227
+ for (const p of result.problems)
228
+ console.error(redactSecrets(` - ${p}`));
229
+ process.exit(1);
224
230
  }
225
- const [command, ...rest] = process.argv.slice(2);
226
- const opts = args(rest);
227
- const commands = { init, rotate, check };
228
- if (!command || !commands[command] || opts.help) {
229
- console.log(HELP);
230
- process.exit(command && !opts.help ? 1 : 0);
231
+ async function main() {
232
+ const [cmd, ...rest] = process.argv.slice(2);
233
+ const parsedArgs = args(rest);
234
+ if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
235
+ console.log(HELP);
236
+ return;
237
+ }
238
+ if (cmd === "init") {
239
+ await init(parsedArgs);
240
+ }
241
+ else if (cmd === "rotate") {
242
+ await rotate(parsedArgs);
243
+ }
244
+ else if (cmd === "check") {
245
+ await check();
246
+ }
247
+ else {
248
+ fail(`Unknown command "${cmd}". Run \`daywalker --help\` for usage.`);
249
+ }
231
250
  }
232
- commands[command](opts).catch((err) => fail(err.message));
251
+ main().catch((err) => {
252
+ console.error(redactSecrets(String(err?.stack || err)));
253
+ process.exit(1);
254
+ });