@shelbyb/daywalker-sdk 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +232 -0
- package/dist/client.d.ts +78 -0
- package/dist/client.js +143 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +87 -0
- package/dist/express.d.ts +18 -0
- package/dist/express.js +20 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/next.d.ts +10 -0
- package/dist/next.js +14 -0
- package/dist/signing.d.ts +53 -0
- package/dist/signing.js +51 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shelby Brayton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shelbyb/daywalker-sdk
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Setup (once per service)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
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
|
+
```
|
|
13
|
+
|
|
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
|
|
19
|
+
|
|
20
|
+
The private key is never printed unless you pass `--print-key`.
|
|
21
|
+
|
|
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.
|
|
23
|
+
|
|
24
|
+
## Environment
|
|
25
|
+
|
|
26
|
+
| Variable | Required | Purpose |
|
|
27
|
+
|---|---|---|
|
|
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 |
|
|
33
|
+
| `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev` |
|
|
34
|
+
|
|
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.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { daywalker } from "@shelbyb/daywalker-sdk";
|
|
41
|
+
|
|
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);
|
|
45
|
+
|
|
46
|
+
// Or validate a token your own users send you
|
|
47
|
+
await daywalker().requireValidToken({ token: req.headers["x-api-token"] }); // throws DaywalkerError
|
|
48
|
+
|
|
49
|
+
// Signed call to any Daywalker Auth API path
|
|
50
|
+
const res = await daywalker().fetch("/api/v1/tokens/validate", { body: { token, serviceSlug: "other" } });
|
|
51
|
+
```
|
|
52
|
+
|
|
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.
|
|
54
|
+
|
|
55
|
+
### Serving the public key
|
|
56
|
+
|
|
57
|
+
Next.js (App Router), which `init` creates for you:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// app/.well-known/daywalker-keys.json/route.ts
|
|
61
|
+
export { GET, dynamic } from "@shelbyb/daywalker-sdk/next";
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Express / Connect / Node http:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { daywalkerKeys } from "@shelbyb/daywalker-sdk/express";
|
|
68
|
+
app.use(daywalkerKeys());
|
|
69
|
+
```
|
|
70
|
+
|
|
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.
|
|
72
|
+
|
|
73
|
+
### Custom clients
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { createDaywalker } from "@shelbyb/daywalker-sdk";
|
|
77
|
+
const client = createDaywalker({ serviceSlug: "gigpulse", domain: "gigpulse.daywalker.dev", cacheTtlMs: 0 });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Commands
|
|
81
|
+
|
|
82
|
+
| Command | Does |
|
|
83
|
+
|---|---|
|
|
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 |
|
|
87
|
+
|
|
88
|
+
## How signing works
|
|
89
|
+
|
|
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.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createDaywalker } from "./client.js";
|
|
6
|
+
import { publicHostname } from "./config.js";
|
|
7
|
+
import { generateSigningKey } from "./signing.js";
|
|
8
|
+
const HELP = `daywalker: connect a service to Daywalker Auth
|
|
9
|
+
|
|
10
|
+
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]
|
|
13
|
+
daywalker check
|
|
14
|
+
|
|
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.
|
|
22
|
+
|
|
23
|
+
The private key is never printed unless you pass --print-key.`;
|
|
24
|
+
function args(argv) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (!a.startsWith("--"))
|
|
29
|
+
continue;
|
|
30
|
+
const next = argv[i + 1];
|
|
31
|
+
if (next && !next.startsWith("--")) {
|
|
32
|
+
out[a.slice(2)] = next;
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
else
|
|
36
|
+
out[a.slice(2)] = true;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function fail(message) {
|
|
41
|
+
console.error(`✖ ${message}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
// --- .env.local ---------------------------------------------------------------
|
|
45
|
+
function readEnvFile(file) {
|
|
46
|
+
const map = new Map();
|
|
47
|
+
if (!fs.existsSync(file))
|
|
48
|
+
return map;
|
|
49
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
50
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
|
|
51
|
+
if (m)
|
|
52
|
+
map.set(m[1], m[2].replace(/^"(.*)"$/, "$1"));
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
function writeEnvFile(file, values) {
|
|
57
|
+
const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").split("\n") : [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const key = line.match(/^\s*([A-Z0-9_]+)\s*=/)?.[1];
|
|
62
|
+
if (key && key in values) {
|
|
63
|
+
seen.add(key);
|
|
64
|
+
if (values[key] !== null)
|
|
65
|
+
out.push(`${key}="${values[key]}"`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
out.push(line);
|
|
69
|
+
}
|
|
70
|
+
while (out.length && out[out.length - 1] === "")
|
|
71
|
+
out.pop();
|
|
72
|
+
const added = Object.entries(values).filter(([k, v]) => !seen.has(k) && v !== null);
|
|
73
|
+
if (added.length) {
|
|
74
|
+
if (out.length)
|
|
75
|
+
out.push("");
|
|
76
|
+
out.push("# Daywalker Auth (managed by `daywalker init`)", ...added.map(([k, v]) => `${k}="${v}"`));
|
|
77
|
+
}
|
|
78
|
+
fs.writeFileSync(file, out.join("\n") + "\n", { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
function ensureGitignored(dir, entry) {
|
|
81
|
+
const file = path.join(dir, ".gitignore");
|
|
82
|
+
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
83
|
+
const covered = content.split("\n").some((l) => [entry, ".env*", ".env.*", ".env*.local", "*.local"].includes(l.trim()));
|
|
84
|
+
if (!covered)
|
|
85
|
+
fs.appendFileSync(file, `${content.endsWith("\n") || !content ? "" : "\n"}${entry}\n`);
|
|
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
|
+
async function init(opts) {
|
|
142
|
+
const dir = process.cwd();
|
|
143
|
+
const envFile = path.join(dir, ".env.local");
|
|
144
|
+
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");
|
|
151
|
+
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 },
|
|
178
|
+
});
|
|
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.`);
|
|
181
|
+
}
|
|
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.`);
|
|
185
|
+
}
|
|
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.`);
|
|
189
|
+
}
|
|
190
|
+
async function rotate(opts) {
|
|
191
|
+
const envFile = path.join(process.cwd(), ".env.local");
|
|
192
|
+
const existing = readEnvFile(envFile);
|
|
193
|
+
const current = existing.get("DAYWALKER_SIGNING_KEY");
|
|
194
|
+
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 },
|
|
203
|
+
});
|
|
204
|
+
console.log(` • DigitalOcean app ${appId} updated; a redeploy has started.`);
|
|
205
|
+
}
|
|
206
|
+
console.log(` After the deploy is live, remove DAYWALKER_SIGNING_KEY_PREVIOUS to retire the old key.`);
|
|
207
|
+
if (opts["print-key"])
|
|
208
|
+
console.log(`\nDAYWALKER_SIGNING_KEY=${key.privateKey}`);
|
|
209
|
+
}
|
|
210
|
+
async function check() {
|
|
211
|
+
// Merge .env.local over the process env, like Next.js does in development
|
|
212
|
+
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);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
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
|
+
}
|
|
232
|
+
commands[command](opts).catch((err) => fail(err.message));
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type DaywalkerOptions, type ResolvedConfig } from "./config.js";
|
|
2
|
+
import { type PublicJwk } from "./signing.js";
|
|
3
|
+
export interface RateLimitWindow {
|
|
4
|
+
limit: number;
|
|
5
|
+
remaining: number;
|
|
6
|
+
/** Unix seconds when `remaining` next increases */
|
|
7
|
+
reset: number;
|
|
8
|
+
}
|
|
9
|
+
/** Body of POST /api/v1/tokens/validate */
|
|
10
|
+
export interface TokenValidation {
|
|
11
|
+
valid: boolean;
|
|
12
|
+
serviceSlug?: string;
|
|
13
|
+
tokenId?: string;
|
|
14
|
+
tokenName?: string;
|
|
15
|
+
userId?: string;
|
|
16
|
+
stage?: string;
|
|
17
|
+
rateLimit?: RateLimitWindow;
|
|
18
|
+
rateLimitHourly?: RateLimitWindow;
|
|
19
|
+
retryAfter?: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ValidationResult {
|
|
24
|
+
valid: boolean;
|
|
25
|
+
/** HTTP status from Daywalker, or 503 when it couldn't be reached */
|
|
26
|
+
status: number;
|
|
27
|
+
data: TokenValidation;
|
|
28
|
+
/** Seconds to wait before retrying (429 responses) */
|
|
29
|
+
retryAfter?: number;
|
|
30
|
+
/** Whether the request carried a domain signature */
|
|
31
|
+
signed: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface SelfCheckResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
domain: string | null;
|
|
36
|
+
keysUrl: string | null;
|
|
37
|
+
signing: boolean;
|
|
38
|
+
problems: string[];
|
|
39
|
+
}
|
|
40
|
+
export declare class DaywalkerError extends Error {
|
|
41
|
+
readonly status: number;
|
|
42
|
+
readonly result?: ValidationResult | undefined;
|
|
43
|
+
constructor(message: string, status: number, result?: ValidationResult | undefined);
|
|
44
|
+
}
|
|
45
|
+
export interface Daywalker {
|
|
46
|
+
readonly config: ResolvedConfig;
|
|
47
|
+
/**
|
|
48
|
+
* Validate an API token (defaults to DAYWALKER_API_KEY) for a service (defaults to
|
|
49
|
+
* DAYWALKER_SERVICE_SLUG). Successful results are cached briefly.
|
|
50
|
+
*/
|
|
51
|
+
validateToken(options?: {
|
|
52
|
+
token?: string;
|
|
53
|
+
serviceSlug?: string;
|
|
54
|
+
forceRefresh?: boolean;
|
|
55
|
+
}): Promise<ValidationResult>;
|
|
56
|
+
/** Like validateToken, but throws DaywalkerError when the token isn't valid. */
|
|
57
|
+
requireValidToken(options?: {
|
|
58
|
+
token?: string;
|
|
59
|
+
serviceSlug?: string;
|
|
60
|
+
}): Promise<ValidationResult>;
|
|
61
|
+
/**
|
|
62
|
+
* fetch() for any Daywalker Auth API path (e.g. "/api/v1/tokens/validate"), domain-signed
|
|
63
|
+
* when a signing key and domain are configured. Pass the body as a string, or as an object
|
|
64
|
+
* to send JSON.
|
|
65
|
+
*/
|
|
66
|
+
fetch(path: string, init?: Omit<RequestInit, "body"> & {
|
|
67
|
+
body?: string | object;
|
|
68
|
+
}): Promise<Response>;
|
|
69
|
+
/** The JWKS document to serve at /.well-known/daywalker-keys.json */
|
|
70
|
+
keys(): {
|
|
71
|
+
keys: PublicJwk[];
|
|
72
|
+
};
|
|
73
|
+
/** Check configuration and that the domain serves this service's public key. */
|
|
74
|
+
selfCheck(): Promise<SelfCheckResult>;
|
|
75
|
+
}
|
|
76
|
+
export declare function createDaywalker(options?: DaywalkerOptions): Daywalker;
|
|
77
|
+
/** A process-wide client configured from environment variables. */
|
|
78
|
+
export declare function daywalker(): Daywalker;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { resolveConfig } from "./config.js";
|
|
2
|
+
import { signRequest, KEYS_PATH } from "./signing.js";
|
|
3
|
+
export class DaywalkerError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
result;
|
|
6
|
+
constructor(message, status, result) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.result = result;
|
|
10
|
+
this.name = "DaywalkerError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function createDaywalker(options = {}) {
|
|
14
|
+
const config = resolveConfig(options);
|
|
15
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
16
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
17
|
+
const cacheTtlMs = options.cacheTtlMs ?? 60_000;
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
const signing = Boolean(config.signingKey && config.domain);
|
|
20
|
+
async function signedFetch(path, init = {}) {
|
|
21
|
+
const url = new URL(path, config.authUrl);
|
|
22
|
+
const method = (init.method ?? (init.body !== undefined ? "POST" : "GET")).toUpperCase();
|
|
23
|
+
const body = init.body === undefined ? "" : typeof init.body === "string" ? init.body : JSON.stringify(init.body);
|
|
24
|
+
const headers = new Headers(init.headers);
|
|
25
|
+
if (init.body !== undefined && typeof init.body !== "string" && !headers.has("content-type")) {
|
|
26
|
+
headers.set("content-type", "application/json");
|
|
27
|
+
}
|
|
28
|
+
if (!headers.has("accept"))
|
|
29
|
+
headers.set("accept", "application/json");
|
|
30
|
+
if (config.domain) {
|
|
31
|
+
// A hint for DNS-based allowlist checks; the signature below is the actual proof
|
|
32
|
+
headers.set("x-caller-origin", `https://${config.domain}`);
|
|
33
|
+
}
|
|
34
|
+
if (signing) {
|
|
35
|
+
for (const [k, v] of Object.entries(signRequest({ method, path: url.pathname, domain: config.domain, body, key: config.signingKey }))) {
|
|
36
|
+
headers.set(k, v);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
41
|
+
try {
|
|
42
|
+
return await doFetch(url, {
|
|
43
|
+
...init,
|
|
44
|
+
method,
|
|
45
|
+
headers,
|
|
46
|
+
body: method === "GET" || method === "HEAD" ? undefined : body,
|
|
47
|
+
signal: init.signal ?? controller.signal,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function validateToken(opts = {}) {
|
|
55
|
+
const token = opts.token ?? config.apiKey;
|
|
56
|
+
const serviceSlug = (opts.serviceSlug ?? config.serviceSlug ?? "").toLowerCase();
|
|
57
|
+
if (!token || !serviceSlug) {
|
|
58
|
+
const message = !token ? "DAYWALKER_API_KEY is not set" : "DAYWALKER_SERVICE_SLUG is not set";
|
|
59
|
+
return { valid: false, status: 500, signed: false, data: { valid: false, error: "Configuration Error", message } };
|
|
60
|
+
}
|
|
61
|
+
const cacheKey = `${serviceSlug}:${token}`;
|
|
62
|
+
const cached = cache.get(cacheKey);
|
|
63
|
+
if (!opts.forceRefresh && cached && cached.expiresAt > Date.now())
|
|
64
|
+
return cached.result;
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
const res = await signedFetch("/api/v1/tokens/validate", { method: "POST", body: { token, serviceSlug } });
|
|
68
|
+
const data = (await res.json().catch(() => ({ valid: false })));
|
|
69
|
+
const retryAfter = Number(res.headers.get("retry-after")) || data.retryAfter || undefined;
|
|
70
|
+
result = { valid: res.ok && data.valid === true, status: res.status, data, retryAfter, signed: signing };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const timedOut = err.name === "AbortError";
|
|
74
|
+
result = {
|
|
75
|
+
valid: false,
|
|
76
|
+
status: 503,
|
|
77
|
+
signed: signing,
|
|
78
|
+
data: {
|
|
79
|
+
valid: false,
|
|
80
|
+
error: timedOut ? "Request Timeout" : "Service Unavailable",
|
|
81
|
+
message: timedOut ? "Daywalker Auth did not respond in time" : `Could not reach Daywalker Auth: ${err.message}`,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (result.valid && cacheTtlMs > 0)
|
|
86
|
+
cache.set(cacheKey, { result, expiresAt: Date.now() + cacheTtlMs });
|
|
87
|
+
else
|
|
88
|
+
cache.delete(cacheKey);
|
|
89
|
+
if (!result.valid && result.status !== 503) {
|
|
90
|
+
console.warn(`[daywalker] Token validation failed (${result.status}): ${result.data.message ?? result.data.error}`);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
function keys() {
|
|
95
|
+
const list = [config.signingKey, config.previousSigningKey]
|
|
96
|
+
.filter((k) => Boolean(k))
|
|
97
|
+
.map((k) => k.publicJwk);
|
|
98
|
+
return { keys: list.filter((k, i) => list.findIndex((o) => o.kid === k.kid) === i) };
|
|
99
|
+
}
|
|
100
|
+
async function selfCheck() {
|
|
101
|
+
const problems = [...config.problems];
|
|
102
|
+
if (!config.signingKey)
|
|
103
|
+
problems.push("DAYWALKER_SIGNING_KEY is not set: requests are unsigned, so domain allowlists can't match");
|
|
104
|
+
const keysUrl = config.domain ? `https://${config.domain}${KEYS_PATH}` : null;
|
|
105
|
+
if (keysUrl && config.signingKey) {
|
|
106
|
+
try {
|
|
107
|
+
const res = await doFetch(keysUrl, { headers: { accept: "application/json" }, redirect: "manual" });
|
|
108
|
+
if (res.status !== 200) {
|
|
109
|
+
problems.push(`${keysUrl} returned HTTP ${res.status} (it must serve the key directly, without redirects)`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const doc = (await res.json().catch(() => null));
|
|
113
|
+
if (!doc?.keys?.some((k) => k.kid === config.signingKey.publicJwk.kid)) {
|
|
114
|
+
problems.push(`${keysUrl} doesn't publish this service's current key (kid ${config.signingKey.publicJwk.kid})`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
problems.push(`Could not fetch ${keysUrl}: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { ok: problems.length === 0, domain: config.domain, keysUrl, signing, problems };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
config,
|
|
126
|
+
validateToken,
|
|
127
|
+
async requireValidToken(opts = {}) {
|
|
128
|
+
const result = await validateToken(opts);
|
|
129
|
+
if (!result.valid)
|
|
130
|
+
throw new DaywalkerError(result.data.message ?? "Token validation failed", result.status, result);
|
|
131
|
+
return result;
|
|
132
|
+
},
|
|
133
|
+
fetch: signedFetch,
|
|
134
|
+
keys,
|
|
135
|
+
selfCheck,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
let shared = null;
|
|
139
|
+
/** A process-wide client configured from environment variables. */
|
|
140
|
+
export function daywalker() {
|
|
141
|
+
shared ??= createDaywalker();
|
|
142
|
+
return shared;
|
|
143
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type SigningKey } from "./signing.js";
|
|
2
|
+
/**
|
|
3
|
+
* Everything a service needs, read from its environment:
|
|
4
|
+
*
|
|
5
|
+
* DAYWALKER_API_KEY the service's Daywalker API token (srv_live_…) required
|
|
6
|
+
* DAYWALKER_SERVICE_SLUG the service it validates against (e.g. "sonalyze") required
|
|
7
|
+
* DAYWALKER_SIGNING_KEY Ed25519 private key from `daywalker init` required for allowlisted tokens
|
|
8
|
+
* DAYWALKER_SIGNING_KEY_PREVIOUS old key, still published during a rotation optional
|
|
9
|
+
* DAYWALKER_DOMAIN public hostname that serves /.well-known/daywalker-keys.json
|
|
10
|
+
* (defaults to the hostname of APP_URL, NEXTAUTH_URL, …)
|
|
11
|
+
* DAYWALKER_AUTH_URL defaults to https://auth.daywalker.dev
|
|
12
|
+
*/
|
|
13
|
+
export interface DaywalkerOptions {
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
serviceSlug?: string;
|
|
16
|
+
/** Private key (PEM or base64 DER) or an already loaded key. */
|
|
17
|
+
signingKey?: string | SigningKey | null;
|
|
18
|
+
previousSigningKey?: string | SigningKey | null;
|
|
19
|
+
domain?: string;
|
|
20
|
+
authUrl?: string;
|
|
21
|
+
/** Override for tests or custom runtimes. */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
/** Request timeout in ms (default 5000). */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
/** How long a successful validation is cached, in ms (default 60000, 0 disables). */
|
|
26
|
+
cacheTtlMs?: number;
|
|
27
|
+
env?: Record<string, string | undefined>;
|
|
28
|
+
}
|
|
29
|
+
export interface ResolvedConfig {
|
|
30
|
+
apiKey: string | null;
|
|
31
|
+
serviceSlug: string | null;
|
|
32
|
+
signingKey: SigningKey | null;
|
|
33
|
+
previousSigningKey: SigningKey | null;
|
|
34
|
+
domain: string | null;
|
|
35
|
+
/** Where the domain came from, for diagnostics. */
|
|
36
|
+
domainSource: string | null;
|
|
37
|
+
authUrl: string;
|
|
38
|
+
problems: string[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A public hostname from a URL or bare host, or null for anything that can't be fetched over
|
|
42
|
+
* the internet (localhost, 0.0.0.0, IP addresses, single labels). Containers often report their
|
|
43
|
+
* bind address (http://0.0.0.0:3000) as the request origin, which must never be used.
|
|
44
|
+
*/
|
|
45
|
+
export declare function publicHostname(value: string | undefined | null): string | null;
|
|
46
|
+
export declare function resolveConfig(options?: DaywalkerOptions): ResolvedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { loadSigningKey } from "./signing.js";
|
|
3
|
+
// Platform variables that commonly hold the app's public URL or hostname, in priority order
|
|
4
|
+
const URL_ENV_VARS = [
|
|
5
|
+
"APP_URL",
|
|
6
|
+
"NEXTAUTH_URL",
|
|
7
|
+
"NEXT_PUBLIC_APP_URL",
|
|
8
|
+
"BETTER_AUTH_URL",
|
|
9
|
+
"PUBLIC_URL",
|
|
10
|
+
"SITE_URL",
|
|
11
|
+
"URL", // Netlify
|
|
12
|
+
"VERCEL_PROJECT_PRODUCTION_URL",
|
|
13
|
+
"RENDER_EXTERNAL_HOSTNAME",
|
|
14
|
+
"RAILWAY_PUBLIC_DOMAIN",
|
|
15
|
+
"VERCEL_URL",
|
|
16
|
+
];
|
|
17
|
+
const DOMAIN_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/;
|
|
18
|
+
/**
|
|
19
|
+
* A public hostname from a URL or bare host, or null for anything that can't be fetched over
|
|
20
|
+
* the internet (localhost, 0.0.0.0, IP addresses, single labels). Containers often report their
|
|
21
|
+
* bind address (http://0.0.0.0:3000) as the request origin, which must never be used.
|
|
22
|
+
*/
|
|
23
|
+
export function publicHostname(value) {
|
|
24
|
+
if (!value || !value.trim())
|
|
25
|
+
return null;
|
|
26
|
+
let host = value.trim().toLowerCase();
|
|
27
|
+
try {
|
|
28
|
+
host = new URL(/^[a-z][a-z0-9+.-]*:\/\//.test(host) ? host : `https://${host}`).hostname;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
host = host.replace(/\.$/, "");
|
|
34
|
+
if (net.isIP(host.replace(/^\[|\]$/g, "")) || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return DOMAIN_PATTERN.test(host) ? host : null;
|
|
38
|
+
}
|
|
39
|
+
function keyFrom(value, name, problems) {
|
|
40
|
+
if (!value)
|
|
41
|
+
return null;
|
|
42
|
+
if (typeof value !== "string")
|
|
43
|
+
return value;
|
|
44
|
+
try {
|
|
45
|
+
return loadSigningKey(value);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
problems.push(`${name} could not be loaded: ${err.message}`);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function resolveConfig(options = {}) {
|
|
53
|
+
const env = options.env ?? process.env;
|
|
54
|
+
const problems = [];
|
|
55
|
+
const apiKey = (options.apiKey ?? env.DAYWALKER_API_KEY ?? "").trim() || null;
|
|
56
|
+
const serviceSlug = (options.serviceSlug ?? env.DAYWALKER_SERVICE_SLUG ?? "").trim().toLowerCase() || null;
|
|
57
|
+
if (!apiKey)
|
|
58
|
+
problems.push("DAYWALKER_API_KEY is not set");
|
|
59
|
+
if (!serviceSlug)
|
|
60
|
+
problems.push("DAYWALKER_SERVICE_SLUG is not set");
|
|
61
|
+
const signingKey = keyFrom(options.signingKey ?? env.DAYWALKER_SIGNING_KEY, "DAYWALKER_SIGNING_KEY", problems);
|
|
62
|
+
const previousSigningKey = keyFrom(options.previousSigningKey ?? env.DAYWALKER_SIGNING_KEY_PREVIOUS, "DAYWALKER_SIGNING_KEY_PREVIOUS", problems);
|
|
63
|
+
let domain = null;
|
|
64
|
+
let domainSource = null;
|
|
65
|
+
const explicit = options.domain ?? env.DAYWALKER_DOMAIN ?? env.DAYWALKER_SIGNING_DOMAIN;
|
|
66
|
+
if (explicit) {
|
|
67
|
+
domain = publicHostname(explicit);
|
|
68
|
+
domainSource = options.domain ? "options.domain" : env.DAYWALKER_DOMAIN ? "DAYWALKER_DOMAIN" : "DAYWALKER_SIGNING_DOMAIN";
|
|
69
|
+
if (!domain)
|
|
70
|
+
problems.push(`${domainSource} (${explicit}) is not a public domain name`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
for (const name of URL_ENV_VARS) {
|
|
74
|
+
const host = publicHostname(env[name]);
|
|
75
|
+
if (host) {
|
|
76
|
+
domain = host;
|
|
77
|
+
domainSource = name;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (signingKey && !domain) {
|
|
83
|
+
problems.push("No public domain found: set DAYWALKER_DOMAIN (requests will be sent unsigned)");
|
|
84
|
+
}
|
|
85
|
+
const authUrl = (options.authUrl ?? env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
86
|
+
return { apiKey, serviceSlug, signingKey, previousSigningKey, domain, domainSource, authUrl, problems };
|
|
87
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Daywalker } from "./client.js";
|
|
2
|
+
interface Req {
|
|
3
|
+
method?: string;
|
|
4
|
+
path?: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
}
|
|
7
|
+
interface Res {
|
|
8
|
+
setHeader(name: string, value: string): unknown;
|
|
9
|
+
statusCode?: number;
|
|
10
|
+
end(body?: string): unknown;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Express / Connect / Node http middleware that serves /.well-known/daywalker-keys.json.
|
|
14
|
+
*
|
|
15
|
+
* app.use(daywalkerKeys());
|
|
16
|
+
*/
|
|
17
|
+
export declare function daywalkerKeys(client?: Daywalker): (req: Req, res: Res, next?: () => void) => void;
|
|
18
|
+
export {};
|
package/dist/express.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { daywalker } from "./client.js";
|
|
2
|
+
import { KEYS_PATH } from "./signing.js";
|
|
3
|
+
/**
|
|
4
|
+
* Express / Connect / Node http middleware that serves /.well-known/daywalker-keys.json.
|
|
5
|
+
*
|
|
6
|
+
* app.use(daywalkerKeys());
|
|
7
|
+
*/
|
|
8
|
+
export function daywalkerKeys(client = daywalker()) {
|
|
9
|
+
return (req, res, next) => {
|
|
10
|
+
const path = req.path ?? (req.url ?? "").split("?")[0];
|
|
11
|
+
if ((req.method ?? "GET") === "GET" && path === KEYS_PATH) {
|
|
12
|
+
res.statusCode = 200;
|
|
13
|
+
res.setHeader("Content-Type", "application/json");
|
|
14
|
+
res.setHeader("Cache-Control", "public, max-age=300");
|
|
15
|
+
res.end(JSON.stringify(client.keys()));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
next?.();
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createDaywalker, daywalker, DaywalkerError } from "./client.js";
|
|
2
|
+
export type { Daywalker, ValidationResult, TokenValidation, RateLimitWindow, SelfCheckResult } from "./client.js";
|
|
3
|
+
export { resolveConfig, publicHostname } from "./config.js";
|
|
4
|
+
export type { DaywalkerOptions, ResolvedConfig } from "./config.js";
|
|
5
|
+
export { generateSigningKey, loadSigningKey, signRequest, canonicalPayload, KEYS_PATH, SIGNATURE_VERSION } from "./signing.js";
|
|
6
|
+
export type { SigningKey, PublicJwk } from "./signing.js";
|
package/dist/index.js
ADDED
package/dist/next.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Daywalker } from "./client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Next.js App Router route handler for /.well-known/daywalker-keys.json.
|
|
4
|
+
*
|
|
5
|
+
* // app/.well-known/daywalker-keys.json/route.ts
|
|
6
|
+
* export { GET, dynamic } from "@shelbyb/daywalker-sdk/next";
|
|
7
|
+
*/
|
|
8
|
+
export declare const dynamic = "force-dynamic";
|
|
9
|
+
export declare function keysRouteHandler(client?: Daywalker): () => Response;
|
|
10
|
+
export declare const GET: () => Response;
|
package/dist/next.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { daywalker } from "./client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Next.js App Router route handler for /.well-known/daywalker-keys.json.
|
|
4
|
+
*
|
|
5
|
+
* // app/.well-known/daywalker-keys.json/route.ts
|
|
6
|
+
* export { GET, dynamic } from "@shelbyb/daywalker-sdk/next";
|
|
7
|
+
*/
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
9
|
+
export function keysRouteHandler(client = daywalker()) {
|
|
10
|
+
return function GET() {
|
|
11
|
+
return Response.json(client.keys(), { headers: { "Cache-Control": "public, max-age=300" } });
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export const GET = () => keysRouteHandler()();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Domain-signed requests to Daywalker Auth.
|
|
4
|
+
*
|
|
5
|
+
* Each request is signed with an Ed25519 key whose public half the calling service serves at
|
|
6
|
+
* https://<domain>/.well-known/daywalker-keys.json. Daywalker fetches that key over HTTPS, so
|
|
7
|
+
* a valid signature proves the request comes from whoever controls the domain, wherever the
|
|
8
|
+
* service is hosted. This must match Daywalker's verifier exactly (src/lib/request-signature.ts
|
|
9
|
+
* in daywalker-service-auth).
|
|
10
|
+
*/
|
|
11
|
+
export declare const SIGNATURE_VERSION = "daywalker-v1";
|
|
12
|
+
export declare const KEYS_PATH = "/.well-known/daywalker-keys.json";
|
|
13
|
+
export interface PublicJwk {
|
|
14
|
+
kty: "OKP";
|
|
15
|
+
crv: "Ed25519";
|
|
16
|
+
x: string;
|
|
17
|
+
kid: string;
|
|
18
|
+
use: "sig";
|
|
19
|
+
alg: "EdDSA";
|
|
20
|
+
}
|
|
21
|
+
export interface SigningKey {
|
|
22
|
+
privateKey: KeyObject;
|
|
23
|
+
publicJwk: PublicJwk;
|
|
24
|
+
}
|
|
25
|
+
/** Generate a new key; `privateKey` is base64 PKCS#8 DER, the format DAYWALKER_SIGNING_KEY expects. */
|
|
26
|
+
export declare function generateSigningKey(): {
|
|
27
|
+
privateKey: string;
|
|
28
|
+
publicJwk: PublicJwk;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Load a private key from env: PKCS#8 PEM (literal "\n" escapes are fine) or base64 PKCS#8 DER.
|
|
32
|
+
* The key id is the RFC 7638 JWK thumbprint, so it's stable without extra configuration.
|
|
33
|
+
*/
|
|
34
|
+
export declare function loadSigningKey(raw: string): SigningKey;
|
|
35
|
+
export declare function canonicalPayload(p: {
|
|
36
|
+
method: string;
|
|
37
|
+
path: string;
|
|
38
|
+
domain: string;
|
|
39
|
+
timestamp: string;
|
|
40
|
+
nonce: string;
|
|
41
|
+
body: string;
|
|
42
|
+
}): string;
|
|
43
|
+
/** The X-Daywalker-* headers for one request. Send exactly `body` as the request body. */
|
|
44
|
+
export declare function signRequest(params: {
|
|
45
|
+
method: string;
|
|
46
|
+
/** URL path only, e.g. /api/v1/tokens/validate */
|
|
47
|
+
path: string;
|
|
48
|
+
domain: string;
|
|
49
|
+
body: string;
|
|
50
|
+
key: SigningKey;
|
|
51
|
+
timestamp?: number;
|
|
52
|
+
nonce?: string;
|
|
53
|
+
}): Record<string, string>;
|
package/dist/signing.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Domain-signed requests to Daywalker Auth.
|
|
4
|
+
*
|
|
5
|
+
* Each request is signed with an Ed25519 key whose public half the calling service serves at
|
|
6
|
+
* https://<domain>/.well-known/daywalker-keys.json. Daywalker fetches that key over HTTPS, so
|
|
7
|
+
* a valid signature proves the request comes from whoever controls the domain, wherever the
|
|
8
|
+
* service is hosted. This must match Daywalker's verifier exactly (src/lib/request-signature.ts
|
|
9
|
+
* in daywalker-service-auth).
|
|
10
|
+
*/
|
|
11
|
+
export const SIGNATURE_VERSION = "daywalker-v1";
|
|
12
|
+
export const KEYS_PATH = "/.well-known/daywalker-keys.json";
|
|
13
|
+
/** Generate a new key; `privateKey` is base64 PKCS#8 DER, the format DAYWALKER_SIGNING_KEY expects. */
|
|
14
|
+
export function generateSigningKey() {
|
|
15
|
+
const { privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
16
|
+
const der = privateKey.export({ format: "der", type: "pkcs8" }).toString("base64");
|
|
17
|
+
return { privateKey: der, publicJwk: loadSigningKey(der).publicJwk };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Load a private key from env: PKCS#8 PEM (literal "\n" escapes are fine) or base64 PKCS#8 DER.
|
|
21
|
+
* The key id is the RFC 7638 JWK thumbprint, so it's stable without extra configuration.
|
|
22
|
+
*/
|
|
23
|
+
export function loadSigningKey(raw) {
|
|
24
|
+
const value = raw.trim();
|
|
25
|
+
const privateKey = value.includes("-----BEGIN")
|
|
26
|
+
? crypto.createPrivateKey(value.replace(/\\n/g, "\n"))
|
|
27
|
+
: crypto.createPrivateKey({ key: Buffer.from(value, "base64"), format: "der", type: "pkcs8" });
|
|
28
|
+
if (privateKey.asymmetricKeyType !== "ed25519") {
|
|
29
|
+
throw new Error(`DAYWALKER_SIGNING_KEY must be an Ed25519 key (got ${privateKey.asymmetricKeyType})`);
|
|
30
|
+
}
|
|
31
|
+
const { x } = crypto.createPublicKey(privateKey).export({ format: "jwk" });
|
|
32
|
+
const kid = crypto.createHash("sha256").update(JSON.stringify({ crv: "Ed25519", kty: "OKP", x })).digest("base64url");
|
|
33
|
+
return { privateKey, publicJwk: { kty: "OKP", crv: "Ed25519", x, kid, use: "sig", alg: "EdDSA" } };
|
|
34
|
+
}
|
|
35
|
+
export function canonicalPayload(p) {
|
|
36
|
+
const bodySha256 = crypto.createHash("sha256").update(p.body, "utf8").digest("hex");
|
|
37
|
+
return [SIGNATURE_VERSION, p.method.toUpperCase(), p.path, p.domain.toLowerCase(), p.timestamp, p.nonce, bodySha256].join("\n");
|
|
38
|
+
}
|
|
39
|
+
/** The X-Daywalker-* headers for one request. Send exactly `body` as the request body. */
|
|
40
|
+
export function signRequest(params) {
|
|
41
|
+
const timestamp = String(params.timestamp ?? Math.floor(Date.now() / 1000));
|
|
42
|
+
const nonce = params.nonce ?? crypto.randomBytes(24).toString("base64url");
|
|
43
|
+
const payload = canonicalPayload({ ...params, timestamp, nonce });
|
|
44
|
+
return {
|
|
45
|
+
"X-Daywalker-Domain": params.domain.toLowerCase(),
|
|
46
|
+
"X-Daywalker-Key-Id": params.key.publicJwk.kid,
|
|
47
|
+
"X-Daywalker-Timestamp": timestamp,
|
|
48
|
+
"X-Daywalker-Nonce": nonce,
|
|
49
|
+
"X-Daywalker-Signature": crypto.sign(null, Buffer.from(payload), params.key.privateKey).toString("base64url"),
|
|
50
|
+
};
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shelbyb/daywalker-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Validate Daywalker API tokens and make domain-signed calls to Daywalker Auth from any service.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./next": {
|
|
14
|
+
"types": "./dist/next.d.ts",
|
|
15
|
+
"import": "./dist/next.js"
|
|
16
|
+
},
|
|
17
|
+
"./express": {
|
|
18
|
+
"types": "./dist/express.d.ts",
|
|
19
|
+
"import": "./dist/express.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"daywalker": "./dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.19"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
36
|
+
"prepublishOnly": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"registry": "https://registry.npmjs.org",
|
|
41
|
+
"provenance": true
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/Shelbyb/daywalker-service-auth.git",
|
|
46
|
+
"directory": "packages/sdk"
|
|
47
|
+
},
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.13.5",
|
|
51
|
+
"typescript": "^5.9.3"
|
|
52
|
+
},
|
|
53
|
+
"keywords": [
|
|
54
|
+
"daywalker",
|
|
55
|
+
"api-token",
|
|
56
|
+
"authentication",
|
|
57
|
+
"ed25519",
|
|
58
|
+
"request-signing"
|
|
59
|
+
],
|
|
60
|
+
"homepage": "https://auth.daywalker.dev/docs"
|
|
61
|
+
}
|