@netnodeag/kraftwerk 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +126 -138
  2. package/dist/acp.js +3 -1
  3. package/dist/cli/daemon.d.ts +14 -0
  4. package/dist/cli/daemon.js +60 -0
  5. package/dist/cli/devices.d.ts +14 -0
  6. package/dist/cli/devices.js +53 -0
  7. package/dist/cli/doctor-legacy.js +9 -1
  8. package/dist/cli/doctor.js +17 -30
  9. package/dist/cli/init.js +1 -6
  10. package/dist/cli/kraftwerk.js +8 -3
  11. package/dist/cli/ui.d.ts +9 -0
  12. package/dist/cli/ui.js +47 -23
  13. package/dist/cli/workspaces.js +5 -3
  14. package/dist/client/index.d.ts +17 -0
  15. package/dist/client/index.js +10 -2
  16. package/dist/client/live.d.ts +1 -2
  17. package/dist/client/live.js +4 -2
  18. package/dist/client/routes.d.ts +8 -0
  19. package/dist/client/routes.js +8 -0
  20. package/dist/config.d.ts +18 -55
  21. package/dist/config.js +39 -92
  22. package/dist/core/chat/context.js +1 -1
  23. package/dist/core/chat/pi.js +2 -1
  24. package/dist/core/chat/sessions.d.ts +1 -1
  25. package/dist/core/chat/sessions.js +1 -1
  26. package/dist/core/chat/state.d.ts +2 -0
  27. package/dist/core/chat/state.js +5 -0
  28. package/dist/core/daemon.d.ts +27 -0
  29. package/dist/core/daemon.js +61 -0
  30. package/dist/core/devices.d.ts +35 -0
  31. package/dist/core/devices.js +156 -0
  32. package/dist/core/env.d.ts +16 -0
  33. package/dist/core/env.js +45 -0
  34. package/dist/core/git.d.ts +2 -0
  35. package/dist/core/git.js +20 -4
  36. package/dist/core/instances.d.ts +32 -12
  37. package/dist/core/instances.js +136 -64
  38. package/dist/core/routines.d.ts +6 -0
  39. package/dist/core/routines.js +11 -0
  40. package/dist/core/runner.js +7 -3
  41. package/dist/core/vibeables.d.ts +2 -0
  42. package/dist/core/vibeables.js +17 -5
  43. package/dist/core/workspace.d.ts +13 -0
  44. package/dist/core/workspace.js +25 -0
  45. package/dist/dotenv.d.ts +2 -2
  46. package/dist/dotenv.js +2 -2
  47. package/dist/server/api/devices.d.ts +20 -0
  48. package/dist/server/api/devices.js +55 -0
  49. package/dist/server/api/hub.d.ts +25 -0
  50. package/dist/server/api/hub.js +32 -0
  51. package/dist/server/api/index.d.ts +42 -2
  52. package/dist/server/api/index.js +5 -1
  53. package/dist/server/api/router.d.ts +21 -6
  54. package/dist/server/api/router.js +37 -9
  55. package/dist/server/api/socket.d.ts +15 -1
  56. package/dist/server/api/socket.js +59 -8
  57. package/dist/server/api/workspace.d.ts +2 -0
  58. package/dist/server/api/workspace.js +4 -2
  59. package/dist/server/bind.d.ts +16 -0
  60. package/dist/server/bind.js +25 -0
  61. package/dist/server/cloud.d.ts +5 -1
  62. package/dist/server/cloud.js +89 -56
  63. package/dist/server/hub-context.d.ts +7 -0
  64. package/dist/server/hub-context.js +18 -0
  65. package/dist/server/live.d.ts +2 -0
  66. package/dist/server/live.js +9 -0
  67. package/dist/server/preview.js +2 -1
  68. package/dist/server/server.d.ts +58 -1
  69. package/dist/server/server.js +267 -103
  70. package/dist/server/target.d.ts +20 -0
  71. package/dist/server/target.js +18 -0
  72. package/dist/server/trust.d.ts +41 -0
  73. package/dist/server/trust.js +42 -0
  74. package/package.json +1 -1
  75. package/schema/kraftwerk.schema.json +7 -46
  76. package/web/dist/assets/{dist-Cgcwvf4j.js → dist-1bNkaWWA.js} +1 -1
  77. package/web/dist/assets/dist-BU1SOB5S.js +1 -0
  78. package/web/dist/assets/{dist-CboP0-T1.js → dist-Bk3f-J5U.js} +1 -1
  79. package/web/dist/assets/{dist-2kswkIH2.js → dist-Bx99Puvv.js} +1 -1
  80. package/web/dist/assets/{dist-D2PvEzo7.js → dist-C0M2aITo.js} +1 -1
  81. package/web/dist/assets/{dist-D_BcTzz-.js → dist-C0z19Zge.js} +1 -1
  82. package/web/dist/assets/{dist-36MjITWa.js → dist-COEgBKG6.js} +1 -1
  83. package/web/dist/assets/{dist-Jdx6DRrm.js → dist-CYRrdlae.js} +1 -1
  84. package/web/dist/assets/{dist-DDkd_zrP.js → dist-CbEFnSCD.js} +1 -1
  85. package/web/dist/assets/{dist-BsuA2pgO.js → dist-DCYVKU-H.js} +1 -1
  86. package/web/dist/assets/{dist-B8gPjMPP.js → dist-XyTHWtoN.js} +1 -1
  87. package/web/dist/assets/{editor-ByRUOfrX.js → editor-DPcGvSPy.js} +3 -3
  88. package/web/dist/assets/index-BAYs-2WC.css +2 -0
  89. package/web/dist/assets/index-DKLJdwYE.js +229 -0
  90. package/web/dist/index.html +2 -2
  91. package/dist/cli/tunnel.d.ts +0 -10
  92. package/dist/cli/tunnel.js +0 -280
  93. package/dist/server/access.d.ts +0 -32
  94. package/dist/server/access.js +0 -121
  95. package/web/dist/assets/dist-CVqmJrYd.js +0 -1
  96. package/web/dist/assets/index-BR7HvDzD.js +0 -229
  97. package/web/dist/assets/index-Bra74Nxe.css +0 -2
@@ -15,8 +15,8 @@
15
15
  } catch (e) {}
16
16
  })();
17
17
  </script>
18
- <script type="module" crossorigin src="/assets/index-BR7HvDzD.js"></script>
19
- <link rel="stylesheet" crossorigin href="/assets/index-Bra74Nxe.css">
18
+ <script type="module" crossorigin src="/assets/index-DKLJdwYE.js"></script>
19
+ <link rel="stylesheet" crossorigin href="/assets/index-BAYs-2WC.css">
20
20
  </head>
21
21
  <body>
22
22
  <div id="root"></div>
@@ -1,10 +0,0 @@
1
- import type { Command } from "commander";
2
- import { type ResolvedWorkspace } from "../config.js";
3
- export interface RunningTunnel {
4
- stop(): void;
5
- /** Settles when the tunnel was stopped, or gave up on a cloudflared that keeps dying. */
6
- done: Promise<"stopped" | "failed">;
7
- }
8
- /** Start cloudflared for the project's tunnel, or return undefined (nothing to run, or told why not). */
9
- export declare function startTunnel(project: ResolvedWorkspace, port: number): RunningTunnel | undefined;
10
- export declare function registerTunnelCommands(program: Command): void;
@@ -1,280 +0,0 @@
1
- import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { readFile, writeFile } from "node:fs/promises";
4
- import http from "node:http";
5
- import os from "node:os";
6
- import path from "node:path";
7
- import chalk from "chalk";
8
- import { parseDocument } from "yaml";
9
- import { parsePublic, publicHostFor, resolveWorkspace, tunnelFor } from "../config.js";
10
- /**
11
- * Cloudflare Tunnel next to the inspector. `kraftwerk ui` runs cloudflared
12
- * as a sibling of the server so the loopback bind is reachable at the
13
- * project's public hostname without opening a port. The tunnel itself is
14
- * created once, by `kraftwerk tunnel setup` or by hand:
15
- *
16
- * locally-managed cloudflared tunnel login
17
- * cloudflared tunnel create kraftwerk
18
- * cloudflared tunnel route dns kraftwerk kw.example.com
19
- * → tunnel: { name: kraftwerk }; everything routes to the inspector port
20
- * dashboard-managed create the tunnel in Zero Trust → Networks → Tunnels, route the
21
- * public hostname to http://localhost:<port> there, export TUNNEL_TOKEN
22
- * → tunnel: {} ; cloudflared reads the token from the environment
23
- *
24
- * cloudflared reconnects on its own; this only respawns it when the process
25
- * itself dies, after a pause. A process that keeps dying right after launch
26
- * (no login, unknown tunnel name) is a configuration problem: after a few
27
- * such exits the tunnel gives up and says so, and the UI stays local.
28
- *
29
- * kraftwerk tunnel run the configured tunnel alone (the UI runs elsewhere)
30
- * kraftwerk tunnel setup <hostname> login, create, route dns, write public: + tunnel.name
31
- */
32
- const RESPAWN_DELAY = 5_000;
33
- /** An exit this soon after launch is a configuration problem, not a hiccup. */
34
- const FAST_EXIT = 10_000;
35
- const MAX_FAST_EXITS = 3;
36
- /** Start cloudflared for the project's tunnel, or return undefined (nothing to run, or told why not). */
37
- export function startTunnel(project, port) {
38
- const tunnel = tunnelFor(project);
39
- if (!tunnel)
40
- return undefined;
41
- const host = publicHostFor(project);
42
- let args;
43
- if (tunnel.name) {
44
- args = ["tunnel", "--no-autoupdate", "run", "--url", `http://127.0.0.1:${port}`, tunnel.name];
45
- }
46
- else if (process.env.TUNNEL_TOKEN) {
47
- args = ["tunnel", "--no-autoupdate", "run"];
48
- }
49
- else {
50
- console.error(`${chalk.red("✖")} tunnel: nothing to run — set ${chalk.bold("tunnel.name")} in kraftwerk.yml (locally-managed) ` +
51
- `or the ${chalk.bold("TUNNEL_TOKEN")} environment variable (dashboard-managed). The UI stays local.`);
52
- return undefined;
53
- }
54
- let child;
55
- let stopped = false;
56
- let timer;
57
- let fastExits = 0;
58
- let settle = () => { };
59
- const done = new Promise((resolve) => (settle = resolve));
60
- const launch = () => {
61
- const startedAt = Date.now();
62
- console.log(`${chalk.dim("↗")} tunnel: ${tunnel.name ? `running ${chalk.bold(tunnel.name)}` : "running the dashboard-managed tunnel"}` +
63
- chalk.dim(` → https://${host} → http://127.0.0.1:${port}`));
64
- child = spawn("cloudflared", args, { stdio: ["ignore", "pipe", "pipe"] });
65
- // cloudflared logs on stderr, one line per event; keep them recognisable.
66
- const relay = (chunk) => {
67
- for (const line of chunk.toString().split("\n"))
68
- if (line.trim())
69
- console.log(chalk.dim(` cloudflared │ ${line}`));
70
- };
71
- child.stdout?.on("data", relay);
72
- child.stderr?.on("data", relay);
73
- child.once("error", (err) => {
74
- stopped = true;
75
- if (err.code === "ENOENT") {
76
- console.error(`${chalk.red("✖")} tunnel: cloudflared not found — brew install cloudflared (or see developers.cloudflare.com). The UI stays local.`);
77
- }
78
- else {
79
- console.error(`${chalk.red("✖")} tunnel: cannot start cloudflared: ${err.message}`);
80
- }
81
- settle("failed");
82
- });
83
- // "close", not "exit": the last log lines are still in flight when the
84
- // process ends, and they are what explains a failed launch.
85
- child.once("close", (code, signal) => {
86
- child = undefined;
87
- if (stopped)
88
- return settle("stopped");
89
- fastExits = Date.now() - startedAt < FAST_EXIT ? fastExits + 1 : 0;
90
- if (fastExits >= MAX_FAST_EXITS) {
91
- console.error(`${chalk.red("✖")} tunnel: cloudflared exited ${MAX_FAST_EXITS} times right after launch — giving up. ` +
92
- `Check the messages above (cloudflared tunnel login? does the tunnel exist?), then restart the UI. The UI stays local.`);
93
- return settle("failed");
94
- }
95
- console.error(`${chalk.red("✖")} tunnel: cloudflared exited (${signal ?? `code ${code}`}) — retrying in ${RESPAWN_DELAY / 1000}s`);
96
- timer = setTimeout(launch, RESPAWN_DELAY);
97
- });
98
- };
99
- launch();
100
- return {
101
- done,
102
- stop() {
103
- stopped = true;
104
- if (timer)
105
- clearTimeout(timer);
106
- if (child)
107
- child.kill("SIGTERM");
108
- else
109
- settle("stopped");
110
- },
111
- };
112
- }
113
- // ---------------------------------------------------------------------------
114
- // CLI
115
- const die = (msg, code = 1) => {
116
- console.error(chalk.red(msg));
117
- process.exit(code);
118
- };
119
- /** cloudflared's version line, or undefined when the binary is not on PATH. */
120
- function cloudflaredVersion() {
121
- const r = spawnSync("cloudflared", ["--version"], { encoding: "utf8", timeout: 10_000 });
122
- if (r.error || r.status !== 0)
123
- return undefined;
124
- return (r.stdout || r.stderr).trim().split("\n")[0];
125
- }
126
- /** Run one cloudflared command to completion, capturing its output (stderr carries the log lines). */
127
- function cloudflared(args) {
128
- const r = spawnSync("cloudflared", args, { encoding: "utf8", timeout: 120_000, stdio: ["inherit", "pipe", "pipe"] });
129
- return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
130
- }
131
- /** Whether something already answers on the inspector port (so the tunnel has an origin). */
132
- function inspectorListening(port) {
133
- return new Promise((resolve) => {
134
- const req = http.get({ host: "127.0.0.1", port, path: "/api/meta?probe=1", timeout: 1_500 }, (res) => {
135
- res.resume();
136
- resolve(true);
137
- });
138
- req.on("error", () => resolve(false));
139
- req.on("timeout", () => {
140
- req.destroy();
141
- resolve(false);
142
- });
143
- });
144
- }
145
- /** A tunnel name from the project's display name or folder: "kraftwerk-agent-playground". */
146
- function defaultTunnelName(project) {
147
- const base = (project.config.name ?? path.basename(project.root)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
148
- return `kraftwerk-${base || "inspector"}`;
149
- }
150
- /** The origin certificate `cloudflared tunnel login` writes; its presence means an authorized zone. */
151
- function originCertPath() {
152
- return process.env.TUNNEL_ORIGIN_CERT || path.join(os.homedir(), ".cloudflared", "cert.pem");
153
- }
154
- /** Write public: and tunnel.name into kraftwerk.yml, keeping everything else (comments, an access block) as it is. */
155
- async function writeTunnelConfig(project, publicUrl, name) {
156
- const configPath = project.configPath;
157
- const doc = parseDocument(await readFile(configPath, "utf8"));
158
- if (doc.errors.length > 0)
159
- throw new Error(`${path.basename(configPath)}: ${doc.errors[0].message}`);
160
- doc.set("public", publicUrl);
161
- const tunnel = doc.get("tunnel");
162
- if (tunnel && typeof tunnel === "object") {
163
- doc.setIn(["tunnel", "name"], name);
164
- doc.deleteIn(["tunnel", "enabled"]);
165
- }
166
- else {
167
- doc.set("tunnel", { name });
168
- }
169
- await writeFile(configPath, doc.toString());
170
- return configPath;
171
- }
172
- export function registerTunnelCommands(program) {
173
- const tunnel = program
174
- .command("tunnel")
175
- .description("Cloudflare Tunnel to the inspector: run the configured one alone, or set one up");
176
- tunnel
177
- .command("run", { isDefault: true })
178
- .description("Run the project's tunnel in the foreground (the inspector runs separately, e.g. `kraftwerk ui` elsewhere)")
179
- .option("--port <port>", "Inspector port to route to (default: kraftwerk.yml `port`, else 1981)")
180
- .action(async (opts) => {
181
- const project = await resolveWorkspace(process.cwd()).catch((err) => die(err.message, 2));
182
- if (!tunnelFor(project)) {
183
- die("tunnel is off — `kraftwerk tunnel setup <hostname>` creates one, or add `public:` and `tunnel:` to kraftwerk.yml (see kraftwerk doctor).");
184
- }
185
- const port = opts.port ? Number(opts.port) : (project.config.port ?? 1981);
186
- if (!Number.isInteger(port) || port <= 0)
187
- die("--port must be a port number", 2);
188
- if (!(await inspectorListening(port))) {
189
- console.log(chalk.yellow(`⚠ nothing answers on http://127.0.0.1:${port} yet — start \`kraftwerk ui\`; the tunnel serves errors until then.`));
190
- }
191
- const running = startTunnel(project, port);
192
- if (!running)
193
- process.exit(1);
194
- for (const sig of ["SIGINT", "SIGTERM"])
195
- process.once(sig, () => running.stop());
196
- const outcome = await running.done;
197
- process.exit(outcome === "failed" ? 1 : 0);
198
- });
199
- tunnel
200
- .command("setup <hostname>")
201
- .description("Create a locally-managed tunnel for this project and route the hostname to it (login, create, route dns, kraftwerk.yml)")
202
- .option("--name <name>", "Tunnel name (default: kraftwerk-<project name>)")
203
- .option("--overwrite-dns", "Replace an existing DNS record for the hostname")
204
- .action(async (hostname, opts) => {
205
- const host = parsePublic(hostname)?.hostname ?? die(`"${hostname}" is not a hostname — expected something like kw.example.com`, 2);
206
- const project = await resolveWorkspace(process.cwd()).catch((err) => die(err.message, 2));
207
- if (!project.configPath)
208
- die("no kraftwerk.yml here — `kraftwerk init` scaffolds one, then run setup again.", 2);
209
- const name = opts.name ?? defaultTunnelName(project);
210
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
211
- die(`"${name}" is not a plain tunnel name (letters, digits, ".", "_", "-")`, 2);
212
- const version = cloudflaredVersion();
213
- if (!version)
214
- die("cloudflared not found — brew install cloudflared (or see developers.cloudflare.com), then run setup again.");
215
- console.log(`${chalk.green("✔")} ${version}`);
216
- // 1. Login: one authorized zone at a time, stored as cert.pem.
217
- if (existsSync(originCertPath())) {
218
- console.log(`${chalk.green("✔")} logged in to Cloudflare ${chalk.dim(`(${originCertPath()})`)}`);
219
- }
220
- else {
221
- console.log(`${chalk.dim("↗")} cloudflared tunnel login — pick the zone that ${chalk.bold(host)} belongs to in the browser`);
222
- const login = spawnSync("cloudflared", ["tunnel", "login"], { stdio: "inherit" });
223
- if (login.error || login.status !== 0 || !existsSync(originCertPath()))
224
- die("cloudflared tunnel login did not complete.");
225
- console.log(`${chalk.green("✔")} logged in to Cloudflare`);
226
- }
227
- // 2. The tunnel: reuse one of that name, else create it.
228
- const list = cloudflared(["tunnel", "list", "--output", "json", "--name", name]);
229
- if (!list.ok)
230
- die(`cloudflared tunnel list failed:\n${list.out}`);
231
- let existing;
232
- try {
233
- existing = JSON.parse(list.out || "[]").find((t) => t.name === name);
234
- }
235
- catch {
236
- die(`cloudflared tunnel list returned no JSON:\n${list.out}`);
237
- }
238
- let id = existing?.id;
239
- if (existing) {
240
- console.log(`${chalk.green("✔")} tunnel ${chalk.bold(name)} exists ${chalk.dim(id ?? "")}`);
241
- }
242
- else {
243
- const created = cloudflared(["tunnel", "create", "--output", "json", name]);
244
- if (!created.ok)
245
- die(`cloudflared tunnel create failed:\n${created.out}`);
246
- try {
247
- id = JSON.parse(created.out).id;
248
- }
249
- catch {
250
- // Not fatal: the route step addresses the tunnel by name.
251
- }
252
- console.log(`${chalk.green("✔")} created tunnel ${chalk.bold(name)} ${chalk.dim(id ?? "")}`);
253
- }
254
- // 3. DNS: a CNAME from the hostname to the tunnel. cloudflared can
255
- // exit 0 after quietly appending the authorized zone to a hostname
256
- // outside it — compare the record it reports with the one asked for.
257
- const route = cloudflared(["tunnel", "route", "dns", ...(opts.overwriteDns ? ["--overwrite-dns"] : []), name, host]);
258
- const added = /Added CNAME (\S+)/.exec(route.out)?.[1]?.replace(/\.$/, "").toLowerCase();
259
- if (!route.ok) {
260
- const exists = /already exists/i.test(route.out);
261
- die(`cloudflared tunnel route dns failed:\n${route.out}` +
262
- (exists && !opts.overwriteDns ? `\n\nA record for ${host} exists. If it should point at this tunnel, run setup again with --overwrite-dns.` : ""));
263
- }
264
- if (added && added !== host.toLowerCase()) {
265
- die(`cloudflared routed ${chalk.bold(added)} instead of ${chalk.bold(host)}: the hostname is outside the zone you logged in to.\n` +
266
- `Delete the stray CNAME ${added} in the Cloudflare dashboard, run \`cloudflared tunnel login\` for the right zone, then setup again.`);
267
- }
268
- console.log(`${chalk.green("✔")} ${host} → tunnel ${name}`);
269
- // 4. kraftwerk.yml
270
- const publicUrl = `https://${host}`;
271
- const configPath = await writeTunnelConfig(project, publicUrl, name).catch((err) => die(err.message));
272
- console.log(`${chalk.green("✔")} ${path.basename(configPath)}: public: ${publicUrl}, tunnel.name: ${name}`);
273
- const port = project.config.port ?? 1981;
274
- console.log(`\n${chalk.bold("Next:")} put a Cloudflare Access policy on ${host} — the UI has no login of its own.\n` +
275
- ` Zero Trust → Access → Applications → Add an application → Self-hosted, domain ${host}: https://one.dash.cloudflare.com/\n` +
276
- ` Then copy the team name and the application's Audience tag into kraftwerk.yml so the inspector verifies every login:\n` +
277
- chalk.dim(` tunnel:\n name: ${name}\n access:\n team: <team> # https://<team>.cloudflareaccess.com\n aud: <audience tag>\n`) +
278
- `\n\`kraftwerk ui\` now starts the tunnel with the inspector (port ${port}); \`kraftwerk tunnel\` runs it alone. \`kraftwerk doctor\` checks it.`);
279
- });
280
- }
@@ -1,32 +0,0 @@
1
- import type { AccessConfig } from "../config.js";
2
- /**
3
- * Cloudflare Access token verification for requests that arrive via the
4
- * public hostname. Access authenticates the browser at Cloudflare's edge and
5
- * forwards a signed JWT per request in Cf-Access-Jwt-Assertion; the signing
6
- * keys are public at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs.
7
- * Verifying the token here means the inspector, which has no login of its
8
- * own, does not have to trust that the policy in the dashboard still exists.
9
- *
10
- * Dependency-free: RS256 over node:crypto. Keys are cached per team and
11
- * refetched when a token names an unknown key id (rotation), at most once
12
- * per REFETCH_INTERVAL so a flood of bogus tokens cannot hammer Cloudflare.
13
- */
14
- export declare const ACCESS_HEADER = "cf-access-jwt-assertion";
15
- export type AccessResult = {
16
- ok: true;
17
- email?: string;
18
- } | {
19
- ok: false;
20
- reason: string;
21
- };
22
- /** Team domain Access issues tokens for. Tests override the certs location. */
23
- export declare function teamDomain(team: string): string;
24
- /**
25
- * Verify one Access token against the team's keys, the application's
26
- * audience and the clock. Returns a reason instead of throwing so the
27
- * caller can answer 401 with it; a certs fetch failure is a reason too
28
- * (fail closed).
29
- */
30
- export declare function verifyAccessToken(token: string, access: AccessConfig): Promise<AccessResult>;
31
- /** Forget cached keys (tests). */
32
- export declare function resetAccessKeys(): void;
@@ -1,121 +0,0 @@
1
- import { createPublicKey, verify as verifySignature } from "node:crypto";
2
- /**
3
- * Cloudflare Access token verification for requests that arrive via the
4
- * public hostname. Access authenticates the browser at Cloudflare's edge and
5
- * forwards a signed JWT per request in Cf-Access-Jwt-Assertion; the signing
6
- * keys are public at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs.
7
- * Verifying the token here means the inspector, which has no login of its
8
- * own, does not have to trust that the policy in the dashboard still exists.
9
- *
10
- * Dependency-free: RS256 over node:crypto. Keys are cached per team and
11
- * refetched when a token names an unknown key id (rotation), at most once
12
- * per REFETCH_INTERVAL so a flood of bogus tokens cannot hammer Cloudflare.
13
- */
14
- export const ACCESS_HEADER = "cf-access-jwt-assertion";
15
- const KEY_TTL = 60 * 60 * 1000;
16
- const REFETCH_INTERVAL = 10 * 1000;
17
- const FETCH_TIMEOUT = 5_000;
18
- const cache = new Map();
19
- /** Team domain Access issues tokens for. Tests override the certs location. */
20
- export function teamDomain(team) {
21
- return `https://${team}.cloudflareaccess.com`;
22
- }
23
- function certsUrl(team) {
24
- return process.env.KRAFTWERK_ACCESS_CERTS_URL || `${teamDomain(team)}/cdn-cgi/access/certs`;
25
- }
26
- async function fetchKeys(team) {
27
- const res = await fetch(certsUrl(team), { signal: AbortSignal.timeout(FETCH_TIMEOUT) });
28
- if (!res.ok)
29
- throw new Error(`certs endpoint answered ${res.status}`);
30
- const body = (await res.json());
31
- const keys = new Map();
32
- for (const jwk of body.keys ?? []) {
33
- if (typeof jwk.kid !== "string" || jwk.kty !== "RSA")
34
- continue;
35
- try {
36
- keys.set(jwk.kid, createPublicKey({ key: jwk, format: "jwk" }));
37
- }
38
- catch {
39
- // One malformed key must not take the rest down.
40
- }
41
- }
42
- return keys;
43
- }
44
- async function keyFor(team, kid) {
45
- const now = Date.now();
46
- let entry = cache.get(team);
47
- if (!entry || now - entry.fetchedAt > KEY_TTL) {
48
- entry = { keys: await fetchKeys(team), fetchedAt: now, missedAt: 0 };
49
- cache.set(team, entry);
50
- }
51
- const hit = entry.keys.get(kid);
52
- if (hit)
53
- return hit;
54
- if (now - entry.missedAt < REFETCH_INTERVAL)
55
- return undefined;
56
- entry.missedAt = now;
57
- entry.keys = await fetchKeys(team);
58
- entry.fetchedAt = now;
59
- return entry.keys.get(kid);
60
- }
61
- function decodeSegment(seg) {
62
- try {
63
- const parsed = JSON.parse(Buffer.from(seg, "base64url").toString("utf8"));
64
- return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : undefined;
65
- }
66
- catch {
67
- return undefined;
68
- }
69
- }
70
- /**
71
- * Verify one Access token against the team's keys, the application's
72
- * audience and the clock. Returns a reason instead of throwing so the
73
- * caller can answer 401 with it; a certs fetch failure is a reason too
74
- * (fail closed).
75
- */
76
- export async function verifyAccessToken(token, access) {
77
- const parts = token.split(".");
78
- if (parts.length !== 3)
79
- return { ok: false, reason: "malformed token" };
80
- const [h, p, s] = parts;
81
- const header = decodeSegment(h);
82
- const payload = decodeSegment(p);
83
- if (!header || !payload)
84
- return { ok: false, reason: "malformed token" };
85
- if (header.alg !== "RS256" || typeof header.kid !== "string")
86
- return { ok: false, reason: "unsupported token" };
87
- let key;
88
- try {
89
- key = await keyFor(access.team, header.kid);
90
- }
91
- catch (err) {
92
- return { ok: false, reason: `cannot fetch Access keys: ${err.message}` };
93
- }
94
- if (!key)
95
- return { ok: false, reason: "unknown signing key" };
96
- let valid = false;
97
- try {
98
- valid = verifySignature("RSA-SHA256", Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"));
99
- }
100
- catch {
101
- valid = false;
102
- }
103
- if (!valid)
104
- return { ok: false, reason: "bad signature" };
105
- if (payload.iss !== teamDomain(access.team))
106
- return { ok: false, reason: "wrong issuer" };
107
- const aud = payload.aud;
108
- const audOk = Array.isArray(aud) ? aud.includes(access.aud) : aud === access.aud;
109
- if (!audOk)
110
- return { ok: false, reason: "wrong audience" };
111
- const now = Math.floor(Date.now() / 1000);
112
- if (typeof payload.exp !== "number" || payload.exp <= now)
113
- return { ok: false, reason: "token expired" };
114
- if (typeof payload.nbf === "number" && payload.nbf > now)
115
- return { ok: false, reason: "token not yet valid" };
116
- return { ok: true, email: typeof payload.email === "string" ? payload.email : undefined };
117
- }
118
- /** Forget cached keys (tests). */
119
- export function resetAccessKeys() {
120
- cache.clear();
121
- }
@@ -1 +0,0 @@
1
- import{a as e}from"./editor-ByRUOfrX.js";export{e as markdown};