@netnodeag/kraftwerk 0.46.2 → 0.48.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 (31) hide show
  1. package/README.md +122 -2
  2. package/dist/cli/doctor.js +39 -1
  3. package/dist/cli/init.js +15 -8
  4. package/dist/cli/kraftwerk.js +14 -1
  5. package/dist/cli/tunnel.d.ts +10 -0
  6. package/dist/cli/tunnel.js +280 -0
  7. package/dist/cli/ui.js +13 -3
  8. package/dist/config.d.ts +55 -0
  9. package/dist/config.js +87 -1
  10. package/dist/dotenv.d.ts +40 -0
  11. package/dist/dotenv.js +84 -0
  12. package/dist/inspector/access.d.ts +32 -0
  13. package/dist/inspector/access.js +121 -0
  14. package/dist/inspector/server.js +48 -4
  15. package/inspector/dist/assets/{dist-CmOlZvwa.js → dist-BNGsfwfO.js} +1 -1
  16. package/inspector/dist/assets/{dist-BOQhQPN9.js → dist-BWkBM49t.js} +1 -1
  17. package/inspector/dist/assets/{dist-CUeUDZ9j.js → dist-Bz3XVWXl.js} +1 -1
  18. package/inspector/dist/assets/{dist-DnmDnMK2.js → dist-CmH9PvLb.js} +1 -1
  19. package/inspector/dist/assets/{dist-C2Mu5bT-.js → dist-D6fPO3Jz.js} +1 -1
  20. package/inspector/dist/assets/{dist-DfAFwiGQ.js → dist-DUq31xBw.js} +1 -1
  21. package/inspector/dist/assets/{dist-D8pjVMTl.js → dist-Dk5ZcwMb.js} +1 -1
  22. package/inspector/dist/assets/{dist-CjMETDfE.js → dist-Dv40dCIn.js} +1 -1
  23. package/inspector/dist/assets/dist-JaBY_kZV.js +1 -0
  24. package/inspector/dist/assets/{dist-BNUGzZS7.js → dist-O97r5Ket.js} +1 -1
  25. package/inspector/dist/assets/{dist-CdyUhzAt.js → dist-n8GR1Slo.js} +1 -1
  26. package/inspector/dist/assets/{editor-DJbeG376.js → editor-toVFopCO.js} +3 -3
  27. package/inspector/dist/assets/{index-DgEUOA11.js → index-CC0tY40i.js} +12 -12
  28. package/inspector/dist/index.html +1 -1
  29. package/package.json +1 -1
  30. package/schema/kraftwerk.schema.json +75 -2
  31. package/inspector/dist/assets/dist-rrIMDK_-.js +0 -1
package/dist/cli/ui.js CHANGED
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import chalk from "chalk";
7
- import { absolutePath, resolveProject } from "../config.js";
7
+ import { absolutePath, publicUrlFor, resolveProject } from "../config.js";
8
+ import { startTunnel } from "./tunnel.js";
8
9
  import { selfCommand } from "../inspector/self-command.js";
9
10
  import { RESTART_EXIT_CODE, startInspector } from "../inspector/server.js";
10
11
  /**
@@ -49,7 +50,7 @@ function ensureBuilt() {
49
50
  }
50
51
  export async function runUi(cwd, opts) {
51
52
  if (process.env.KRAFTWERK_UI_SUPERVISED !== "1")
52
- return superviseUi(opts);
53
+ return superviseUi(cwd, opts);
53
54
  const staticDir = ensureBuilt();
54
55
  const project = await resolveProject(cwd);
55
56
  const outputDir = opts.output ? absolutePath(opts.output, cwd) : project.outputDir;
@@ -58,6 +59,9 @@ export async function runUi(cwd, opts) {
58
59
  await startInspector({ outputDir, staticDir, port, projectRoot: project.root });
59
60
  console.log(`${chalk.green("✔")} Kraftwerk UI: ${chalk.cyan(`http://localhost:${port}`)} ` +
60
61
  chalk.dim(`(output: ${outputDir})`));
62
+ const publicUrl = publicUrlFor(project);
63
+ if (publicUrl)
64
+ console.log(`${chalk.green("✔")} Public URL: ${chalk.cyan(publicUrl)}`);
61
65
  }
62
66
  /**
63
67
  * Respawn loop around the real server. Spawns this same bin script via the
@@ -68,18 +72,23 @@ export async function runUi(cwd, opts) {
68
72
  * projects start` reports) takes the whole UI down; Ctrl-C signals the
69
73
  * foreground group and reaches both anyway.
70
74
  */
71
- async function superviseUi(opts) {
75
+ async function superviseUi(cwd, opts) {
72
76
  const { cmd, args } = selfCommand([
73
77
  "ui",
74
78
  ...(opts.port ? ["--port", opts.port] : []),
75
79
  ...(opts.output ? ["--output", opts.output] : []),
76
80
  ]);
81
+ // The tunnel lives with the supervisor, not the server: a self-restart
82
+ // (new version) swaps the server process and the tunnel stays up.
83
+ const project = await resolveProject(cwd).catch(() => null);
84
+ const tunnel = project ? startTunnel(project, opts.port ? Number(opts.port) : (project.config.port ?? 1981)) : undefined;
77
85
  for (;;) {
78
86
  const child = spawn(cmd, args, {
79
87
  stdio: "inherit",
80
88
  env: { ...process.env, KRAFTWERK_UI_SUPERVISED: "1" },
81
89
  });
82
90
  const forward = (sig) => () => {
91
+ tunnel?.stop();
83
92
  child.kill(sig);
84
93
  };
85
94
  const handlers = { SIGTERM: forward("SIGTERM"), SIGINT: forward("SIGINT") };
@@ -91,6 +100,7 @@ async function superviseUi(opts) {
91
100
  process.off("SIGTERM", handlers.SIGTERM);
92
101
  process.off("SIGINT", handlers.SIGINT);
93
102
  if (result.code !== RESTART_EXIT_CODE) {
103
+ tunnel?.stop();
94
104
  // A signal-killed server is not a clean exit — say so in the exit code
95
105
  // (128 + signal, the shell convention) so callers can tell.
96
106
  if (result.signal)
package/dist/config.d.ts CHANGED
@@ -30,6 +30,12 @@
30
30
  * root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
31
31
  * vibeables: # small apps built live in a chat, rendered in the inspector (absent = off, bare key = on)
32
32
  * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
33
+ * public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
34
+ * tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
35
+ * name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
36
+ * access: # verify the Cloudflare Access login on every request that arrives via `public`
37
+ * team: acme # Zero Trust team name (https://<team>.cloudflareaccess.com)
38
+ * aud: 4714c135… # Application Audience tag of the Access application
33
39
  */
34
40
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
35
41
  export declare const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
@@ -96,6 +102,47 @@ export interface VibeablesConfig {
96
102
  export declare const VIBEABLES_DEFAULT_ROOT = "kraftwerk-data/vibeables";
97
103
  /** Absolute vibeables root when the feature is on, undefined otherwise. */
98
104
  export declare function vibeablesRootFor(project: Project): string | undefined;
105
+ /**
106
+ * Cloudflare Access in front of the public hostname. Access puts a login
107
+ * page on the hostname at Cloudflare's edge and hands the origin a signed
108
+ * JWT per request (Cf-Access-Jwt-Assertion). With this block the inspector
109
+ * verifies that token itself, so a removed or misconfigured Access policy
110
+ * fails closed instead of exposing the UI, which has no login of its own.
111
+ */
112
+ export interface AccessConfig {
113
+ /** Zero Trust team name: the subdomain of https://<team>.cloudflareaccess.com */
114
+ team: string;
115
+ /** Application Audience (AUD) tag of the Access application, from its overview page. */
116
+ aud: string;
117
+ }
118
+ /**
119
+ * Cloudflare Tunnel: `kraftwerk ui` runs cloudflared next to the inspector
120
+ * so the loopback bind is reachable at `public` without an open port. The
121
+ * tunnel itself is created once with cloudflared (or in the Zero Trust
122
+ * dashboard); this block only says which one to run.
123
+ */
124
+ export interface TunnelConfig {
125
+ /** false keeps the block but turns the feature off. Default: true. */
126
+ enabled?: boolean;
127
+ /**
128
+ * Name of a locally-managed tunnel (`cloudflared tunnel create <name>`,
129
+ * `cloudflared tunnel route dns <name> <public host>`); cloudflared routes
130
+ * everything to the inspector port. Absent: a dashboard-managed tunnel,
131
+ * whose token comes from the TUNNEL_TOKEN environment variable and whose
132
+ * route to http://localhost:<port> is configured in the dashboard.
133
+ */
134
+ name?: string;
135
+ /** Verify the Cloudflare Access token on every request that arrives via `public`. */
136
+ access?: AccessConfig;
137
+ }
138
+ /** The public hostname (lowercase, no port) when `public` is set, undefined otherwise. */
139
+ export declare function publicHostFor(project: Project): string | undefined;
140
+ /** The public origin ("https://kw.example.com") when `public` is set, undefined otherwise. */
141
+ export declare function publicUrlFor(project: Project): string | undefined;
142
+ /** The tunnel block when the feature is on, undefined otherwise. */
143
+ export declare function tunnelFor(project: Project): TunnelConfig | undefined;
144
+ /** "kw.example.com" or "https://kw.example.com[:port]" → its URL; undefined when it is neither. */
145
+ export declare function parsePublic(value: string): URL | undefined;
99
146
  /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
100
147
  export declare function ignoreEntryFor(projectRoot: string, dir: string): string | undefined;
101
148
  /** True when .gitignore text already covers the entry (with or without a leading or trailing slash). */
@@ -136,6 +183,14 @@ export interface ProjectConfig {
136
183
  repos?: ReposConfig;
137
184
  /** Vibeables: apps built live in a chat. Absent = off. */
138
185
  vibeables?: VibeablesConfig;
186
+ /**
187
+ * Hostname the inspector is reached at through a tunnel or reverse proxy,
188
+ * e.g. "https://kw.example.com". The loopback bind then answers requests
189
+ * carrying that Host even without X-Forwarded-Host (cloudflared sends none).
190
+ */
191
+ public?: string;
192
+ /** Cloudflare Tunnel run alongside the inspector. Absent = off. */
193
+ tunnel?: TunnelConfig;
139
194
  }
140
195
  export interface Project {
141
196
  /** Absolute project root the CLI operates on. */
package/dist/config.js CHANGED
@@ -34,6 +34,12 @@ import { parse } from "yaml";
34
34
  * root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
35
35
  * vibeables: # small apps built live in a chat, rendered in the inspector (absent = off, bare key = on)
36
36
  * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
37
+ * public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
38
+ * tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
39
+ * name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
40
+ * access: # verify the Cloudflare Access login on every request that arrives via `public`
41
+ * team: acme # Zero Trust team name (https://<team>.cloudflareaccess.com)
42
+ * aud: 4714c135… # Application Audience tag of the Access application
37
43
  */
38
44
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
39
45
  export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
@@ -78,6 +84,34 @@ export function vibeablesRootFor(project) {
78
84
  return undefined;
79
85
  return path.resolve(project.root, v.root ?? VIBEABLES_DEFAULT_ROOT);
80
86
  }
87
+ /** The public hostname (lowercase, no port) when `public` is set, undefined otherwise. */
88
+ export function publicHostFor(project) {
89
+ return project.config.public ? parsePublic(project.config.public)?.hostname : undefined;
90
+ }
91
+ /** The public origin ("https://kw.example.com") when `public` is set, undefined otherwise. */
92
+ export function publicUrlFor(project) {
93
+ return project.config.public ? parsePublic(project.config.public)?.origin : undefined;
94
+ }
95
+ /** The tunnel block when the feature is on, undefined otherwise. */
96
+ export function tunnelFor(project) {
97
+ const t = project.config.tunnel;
98
+ if (!t || t.enabled === false)
99
+ return undefined;
100
+ return t;
101
+ }
102
+ /** "kw.example.com" or "https://kw.example.com[:port]" → its URL; undefined when it is neither. */
103
+ export function parsePublic(value) {
104
+ const text = value.trim();
105
+ try {
106
+ const url = new URL(/^https?:\/\//i.test(text) ? text : `https://${text}`);
107
+ if (!url.hostname || url.pathname !== "/" || url.search || url.hash || url.username)
108
+ return undefined;
109
+ return url;
110
+ }
111
+ catch {
112
+ return undefined;
113
+ }
114
+ }
81
115
  /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
82
116
  export function ignoreEntryFor(projectRoot, dir) {
83
117
  const rel = path.relative(projectRoot, path.resolve(projectRoot, dir)).split(path.sep).join("/");
@@ -153,7 +187,7 @@ export async function resolveProject(cwd) {
153
187
  const root = gitFallback ?? start;
154
188
  return { root, config: {}, outputDir: path.join(root, "output") };
155
189
  }
156
- const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables"];
190
+ const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "public", "tunnel"];
157
191
  async function loadConfig(configPath) {
158
192
  let raw;
159
193
  try {
@@ -204,12 +238,64 @@ async function loadConfig(configPath) {
204
238
  config[key] = {};
205
239
  validateRootBlock(configPath, "vibeables", config[key]);
206
240
  }
241
+ else if (key === "public") {
242
+ if (typeof config[key] !== "string" || !parsePublic(config[key])) {
243
+ throw new Error(`${path.basename(configPath)}: public must be a hostname or https URL like "https://kw.example.com"`);
244
+ }
245
+ }
246
+ else if (key === "tunnel") {
247
+ if (config[key] === null)
248
+ config[key] = {};
249
+ validateTunnel(configPath, config[key]);
250
+ }
207
251
  else if (typeof config[key] !== "string") {
208
252
  throw new Error(`${path.basename(configPath)}: ${key} must be a string`);
209
253
  }
210
254
  }
255
+ const tunnel = config.tunnel;
256
+ if (tunnel && tunnel.enabled !== false && !config.public) {
257
+ throw new Error(`${path.basename(configPath)}: tunnel needs public: the hostname the tunnel routes to`);
258
+ }
211
259
  return config;
212
260
  }
261
+ /** tunnel: { enabled?, name?, access?: { team, aud } } */
262
+ function validateTunnel(configPath, value) {
263
+ const file = path.basename(configPath);
264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
265
+ throw new Error(`${file}: tunnel must be a mapping (enabled, name, access)`);
266
+ }
267
+ const t = value;
268
+ for (const key of Object.keys(t)) {
269
+ if (!["enabled", "name", "access"].includes(key)) {
270
+ throw new Error(`${file}: tunnel.${key} is unknown (allowed: enabled, name, access)`);
271
+ }
272
+ }
273
+ if (t.enabled !== undefined && typeof t.enabled !== "boolean") {
274
+ throw new Error(`${file}: tunnel.enabled must be true or false`);
275
+ }
276
+ // The name is handed to cloudflared as a positional argument.
277
+ if (t.name !== undefined && (typeof t.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(t.name))) {
278
+ throw new Error(`${file}: tunnel.name must be a plain tunnel name (letters, digits, ".", "_", "-")`);
279
+ }
280
+ if (t.access !== undefined) {
281
+ if (typeof t.access !== "object" || t.access === null || Array.isArray(t.access)) {
282
+ throw new Error(`${file}: tunnel.access must be a mapping (team, aud)`);
283
+ }
284
+ const a = t.access;
285
+ for (const key of Object.keys(a)) {
286
+ if (!["team", "aud"].includes(key)) {
287
+ throw new Error(`${file}: tunnel.access.${key} is unknown (allowed: team, aud)`);
288
+ }
289
+ }
290
+ // The team becomes a hostname (<team>.cloudflareaccess.com).
291
+ if (typeof a.team !== "string" || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(a.team)) {
292
+ throw new Error(`${file}: tunnel.access.team must be the Zero Trust team name (the subdomain of cloudflareaccess.com)`);
293
+ }
294
+ if (typeof a.aud !== "string" || !/^[a-f0-9]{64}$/i.test(a.aud)) {
295
+ throw new Error(`${file}: tunnel.access.aud must be the 64-character Application Audience tag`);
296
+ }
297
+ }
298
+ }
213
299
  function validateSwitcher(configPath, value) {
214
300
  const file = path.basename(configPath);
215
301
  if (!Array.isArray(value)) {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
3
+ * this process at CLI start (a commander preAction hook in kraftwerk.ts),
4
+ * so every kraftwerk process sees them and everything it spawns inherits
5
+ * them — the inspector, the chat agents and routines, workflow runs, the
6
+ * tunnel (TUNNEL_TOKEN), `requires:` checks.
7
+ *
8
+ * A variable already set in the shell wins over the file, the dotenv
9
+ * convention, with one exception that makes restarts work: a value this
10
+ * process inherited from an earlier kraftwerk process that itself took it
11
+ * from a `.env` is not a shell setting. Those keys are listed in
12
+ * KRAFTWERK_DOTENV_KEYS by whoever injected them, so a restarted server
13
+ * (the `kraftwerk ui` supervisor respawns it with its own environment)
14
+ * re-reads the file and applies changed values, and a project launched
15
+ * from another workspace's inspector drops that workspace's variables
16
+ * instead of running with them.
17
+ *
18
+ * Never synced: `.env` is on the workspace git's deny list (git.ts) and in
19
+ * the .gitignore `kraftwerk init` writes.
20
+ */
21
+ export declare const DOTENV_FILE = ".env";
22
+ export declare const DOTENV_MARKER = "KRAFTWERK_DOTENV_KEYS";
23
+ /** Parse dotenv text: comments, `export ` prefixes, single/double quotes, `\n` in double quotes. */
24
+ export declare function parseDotenv(text: string): Record<string, string>;
25
+ export interface DotenvResult {
26
+ /** Absolute path of the file, undefined when the project has none. */
27
+ file?: string;
28
+ /** Keys set from the file. */
29
+ applied: string[];
30
+ /** Keys in the file left alone because the shell had set them. */
31
+ kept: string[];
32
+ /** Keys an earlier kraftwerk process had injected that this file no longer has. */
33
+ removed: string[];
34
+ }
35
+ /**
36
+ * Apply `<root>/.env` to process.env (see the module comment for the
37
+ * precedence). Idempotent: a second call re-applies the same keys. Without
38
+ * a file it still drops keys an earlier process injected.
39
+ */
40
+ export declare function applyDotenv(root: string): Promise<DotenvResult>;
package/dist/dotenv.js ADDED
@@ -0,0 +1,84 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
5
+ * this process at CLI start (a commander preAction hook in kraftwerk.ts),
6
+ * so every kraftwerk process sees them and everything it spawns inherits
7
+ * them — the inspector, the chat agents and routines, workflow runs, the
8
+ * tunnel (TUNNEL_TOKEN), `requires:` checks.
9
+ *
10
+ * A variable already set in the shell wins over the file, the dotenv
11
+ * convention, with one exception that makes restarts work: a value this
12
+ * process inherited from an earlier kraftwerk process that itself took it
13
+ * from a `.env` is not a shell setting. Those keys are listed in
14
+ * KRAFTWERK_DOTENV_KEYS by whoever injected them, so a restarted server
15
+ * (the `kraftwerk ui` supervisor respawns it with its own environment)
16
+ * re-reads the file and applies changed values, and a project launched
17
+ * from another workspace's inspector drops that workspace's variables
18
+ * instead of running with them.
19
+ *
20
+ * Never synced: `.env` is on the workspace git's deny list (git.ts) and in
21
+ * the .gitignore `kraftwerk init` writes.
22
+ */
23
+ export const DOTENV_FILE = ".env";
24
+ export const DOTENV_MARKER = "KRAFTWERK_DOTENV_KEYS";
25
+ const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
26
+ /** Parse dotenv text: comments, `export ` prefixes, single/double quotes, `\n` in double quotes. */
27
+ export function parseDotenv(text) {
28
+ const out = {};
29
+ for (const raw of text.split(/\r?\n/)) {
30
+ const line = raw.trim();
31
+ if (!line || line.startsWith("#"))
32
+ continue;
33
+ const eq = line.indexOf("=");
34
+ if (eq < 0)
35
+ continue;
36
+ const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
37
+ if (!KEY.test(key))
38
+ continue;
39
+ let value = line.slice(eq + 1).trim();
40
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
41
+ value = value.slice(1, -1).replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
42
+ }
43
+ else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
44
+ value = value.slice(1, -1);
45
+ }
46
+ else {
47
+ // An unquoted value ends at a comment: `PORT=1981 # inspector`.
48
+ value = value.replace(/\s+#.*$/, "").trim();
49
+ }
50
+ out[key] = value;
51
+ }
52
+ return out;
53
+ }
54
+ /**
55
+ * Apply `<root>/.env` to process.env (see the module comment for the
56
+ * precedence). Idempotent: a second call re-applies the same keys. Without
57
+ * a file it still drops keys an earlier process injected.
58
+ */
59
+ export async function applyDotenv(root) {
60
+ const file = path.join(root, DOTENV_FILE);
61
+ const text = await readFile(file, "utf8").catch(() => undefined);
62
+ const parsed = text === undefined ? {} : parseDotenv(text);
63
+ const injected = new Set((process.env[DOTENV_MARKER] ?? "").split(",").filter(Boolean));
64
+ const applied = [];
65
+ const kept = [];
66
+ for (const [key, value] of Object.entries(parsed)) {
67
+ // An empty shell value is as good as unset (`requires:` treats it so too).
68
+ if (!process.env[key] || injected.has(key)) {
69
+ process.env[key] = value;
70
+ applied.push(key);
71
+ }
72
+ else {
73
+ kept.push(key);
74
+ }
75
+ }
76
+ const removed = [...injected].filter((key) => !(key in parsed));
77
+ for (const key of removed)
78
+ delete process.env[key];
79
+ if (applied.length > 0)
80
+ process.env[DOTENV_MARKER] = applied.join(",");
81
+ else
82
+ delete process.env[DOTENV_MARKER];
83
+ return { file: text === undefined ? undefined : file, applied, kept, removed };
84
+ }
@@ -0,0 +1,32 @@
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;
@@ -0,0 +1,121 @@
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
+ }
@@ -3,7 +3,8 @@ import http from "node:http";
3
3
  import path from "node:path";
4
4
  import { attachmentPath, saveAttachment } from "./chat/store.js";
5
5
  import { setOutputDir, setProjectRoot, getOutputDir, getProjectRoot } from "./context.js";
6
- import { resolveProject } from "../config.js";
6
+ import { publicHostFor, publicUrlFor, resolveProject, tunnelFor } from "../config.js";
7
+ import { ACCESS_HEADER, verifyAccessToken } from "./access.js";
7
8
  import { listRuns, getRun, readRunFile, deleteRun } from "./runs.js";
8
9
  import { canSelfUpdate, startUpdate, updateStatus } from "./update.js";
9
10
  import { listWorkflows, getWorkflow } from "./workflows.js";
@@ -99,6 +100,16 @@ const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv
99
100
  const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
100
101
  const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
101
102
  const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
103
+ /**
104
+ * The hostname from kraftwerk.yml `public`, when set: the name a tunnel or
105
+ * reverse proxy delivers as Host. A Cloudflare Tunnel forwards the
106
+ * browser's Host untouched and sets no X-Forwarded-Host, so without this
107
+ * the loopback bind would refuse every request that came through it. A
108
+ * rebinding page cannot exploit it: the name is one the operator owns.
109
+ */
110
+ let publicHost = "";
111
+ /** Access verification for requests arriving via the public hostname (kraftwerk.yml `tunnel.access`). */
112
+ let access;
102
113
  /**
103
114
  * Whether the Host header names this server. A loopback bind alone does not
104
115
  * keep other sites out: a page on evil.example can re-point that name at
@@ -123,7 +134,32 @@ function hostAllowed(req) {
123
134
  if (!host)
124
135
  return true;
125
136
  const name = hostnameOf(host);
126
- return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost");
137
+ return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost") || (!!publicHost && name === publicHost);
138
+ }
139
+ /** Whether the browser addressed the public hostname (directly or, via a proxy, in X-Forwarded-Host). */
140
+ function viaPublicHost(req) {
141
+ if (!publicHost)
142
+ return false;
143
+ const host = forwardedHost(req) || req.headers.host;
144
+ return !!host && hostnameOf(host) === publicHost;
145
+ }
146
+ /**
147
+ * The Access gate: a request that arrived via the public hostname must
148
+ * carry a valid Cloudflare Access token when `tunnel.access` is configured.
149
+ * Requests addressed to a loopback name are the operator's own browser on
150
+ * this machine and pass; bound to loopback, only the tunnel (or a local
151
+ * proxy) can deliver the public name in the first place. Returns the reason
152
+ * to refuse, or undefined to proceed.
153
+ */
154
+ async function accessRefusal(req) {
155
+ if (!access || !viaPublicHost(req))
156
+ return undefined;
157
+ const raw = req.headers[ACCESS_HEADER];
158
+ const token = Array.isArray(raw) ? raw[0] : raw;
159
+ if (!token)
160
+ return "Cloudflare Access token missing";
161
+ const result = await verifyAccessToken(token, access);
162
+ return result.ok ? undefined : `Cloudflare Access token refused: ${result.reason}`;
127
163
  }
128
164
  /** First X-Forwarded-Host value, or undefined when no proxy set one. */
129
165
  function forwardedHost(req) {
@@ -348,6 +384,7 @@ async function handleApi(req, res, url) {
348
384
  git: (project?.config.git && project.config.git.enabled !== false) === true,
349
385
  repos: (project?.config.repos && project.config.repos.enabled !== false) === true,
350
386
  vibeables: (project?.config.vibeables && project.config.vibeables.enabled !== false) === true,
387
+ publicUrl: (project && publicUrlFor(project)) ?? "",
351
388
  switcher,
352
389
  });
353
390
  }
@@ -1181,10 +1218,14 @@ async function serveStatic(res, staticDir, pathname) {
1181
1218
  res.end(buf);
1182
1219
  }
1183
1220
  /** Start the server; resolves once it listens. Runs until the process ends. */
1184
- export function startInspector(opts) {
1221
+ export async function startInspector(opts) {
1185
1222
  setOutputDir(opts.outputDir);
1186
1223
  if (opts.projectRoot)
1187
1224
  setProjectRoot(opts.projectRoot);
1225
+ // Read once: like the port, the public hostname takes effect on restart.
1226
+ const project = await resolveProject(getProjectRoot()).catch(() => null);
1227
+ publicHost = (project && publicHostFor(project)) ?? "";
1228
+ access = project ? tunnelFor(project)?.access : undefined;
1188
1229
  startRoutineScheduler();
1189
1230
  startGitSync();
1190
1231
  // Chat agent subprocesses must die with the server — signals bypass
@@ -1211,6 +1252,9 @@ export function startInspector(opts) {
1211
1252
  const url = new URL(req.url ?? "/", "http://localhost");
1212
1253
  if (!hostAllowed(req))
1213
1254
  return json(res, { error: "unexpected Host header" }, 421);
1255
+ const refusal = await accessRefusal(req);
1256
+ if (refusal)
1257
+ return json(res, { error: refusal }, 401);
1214
1258
  if (url.pathname.startsWith("/api/"))
1215
1259
  await handleApi(req, res, url);
1216
1260
  // /vibeables/<slug>/… is an app's own files, served for the preview pane.
@@ -1227,7 +1271,7 @@ export function startInspector(opts) {
1227
1271
  server.on("upgrade", (req, socket, head) => {
1228
1272
  if (!hostAllowed(req))
1229
1273
  return void socket.destroy();
1230
- proxyUpgrade(req, socket, head);
1274
+ accessRefusal(req).then((refusal) => (refusal ? socket.destroy() : proxyUpgrade(req, socket, head)), () => socket.destroy());
1231
1275
  });
1232
1276
  return new Promise((resolve, reject) => {
1233
1277
  server.once("error", reject);