fbi-proxy 1.9.1 → 1.10.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/ts/cli.ts CHANGED
@@ -6,19 +6,70 @@ import yargs from "yargs";
6
6
  import { hideBin } from "yargs/helpers";
7
7
  import { getFbiProxyBinary } from "./buildFbiProxy";
8
8
  import { $ } from "./dSpawn";
9
+ import {
10
+ configFromEnv,
11
+ defaultConfigPath,
12
+ helpfulSetupMessage,
13
+ readConfigOrNull,
14
+ writeConfig,
15
+ } from "./auth/authConfig";
16
+ import { spawnFbiAuth, type FbiAuthHandle } from "./auth/spawnFbiAuth";
17
+ import { isTty, readlinePrompter, runWizard } from "./auth/setupWizard";
18
+ import {
19
+ defaultCaddyfilePath,
20
+ writeCaddyfile,
21
+ type CaddyfileOpts,
22
+ } from "./auth/caddyfileGen";
23
+ import {
24
+ caddyNotFoundMessage,
25
+ resolveCaddyBinary,
26
+ spawnCaddy,
27
+ type CaddyHandle,
28
+ } from "./auth/spawnCaddy";
9
29
 
10
- // Save original cwd before changing (user might have local build there)
11
30
  const originalCwd = process.cwd();
12
- process.chdir(path.resolve(import.meta.dir, "..")); // Change to project root directory
31
+ process.chdir(path.resolve(import.meta.dir, ".."));
13
32
 
14
- // Parse command line arguments with yargs
15
- await yargs(hideBin(process.argv))
33
+ const argv = await yargs(hideBin(process.argv))
16
34
  .option("dev", {
17
- alias: "d",
18
35
  type: "boolean",
19
36
  default: false,
20
37
  description: "Run in development mode",
21
38
  })
39
+ .option("with-auth", {
40
+ type: "boolean",
41
+ default: false,
42
+ description:
43
+ "Start the fbi-auth gateway alongside the proxy (Phase 1: Google OAuth)",
44
+ })
45
+ .option("with-caddy", {
46
+ type: "boolean",
47
+ default: false,
48
+ description:
49
+ "Auto-generate a Caddyfile and spawn Caddy alongside the proxy",
50
+ })
51
+ .option("domain", {
52
+ type: "string",
53
+ default: "fbi.com",
54
+ description: "Domain to gate (default: fbi.com)",
55
+ })
56
+ .option("reconfigure", {
57
+ type: "boolean",
58
+ default: false,
59
+ description:
60
+ "Run the interactive fbi-auth setup wizard to (re)write auth.json (requires a TTY)",
61
+ })
62
+ .option("acme-email", {
63
+ type: "string",
64
+ description:
65
+ "Optional ACME account email for the generated Caddyfile (Let's Encrypt notifications)",
66
+ })
67
+ .option("tls-mode", {
68
+ type: "string",
69
+ choices: ["auto", "internal"] as const,
70
+ description:
71
+ "TLS strategy for --with-caddy. 'auto' uses ACME (Let's Encrypt); 'internal' uses Caddy's local CA. Defaults to 'internal' for fbi.com, 'auto' otherwise.",
72
+ })
22
73
  .help().argv;
23
74
 
24
75
  console.log("Preparing Binaries");
@@ -32,7 +83,7 @@ const proxyProcess = await hotMemo(async () => {
32
83
  const p = $.opt({
33
84
  env: {
34
85
  ...process.env,
35
- FBI_PROXY_PORT, // Rust proxy server port
86
+ FBI_PROXY_PORT,
36
87
  },
37
88
  })`${proxy}`.process;
38
89
 
@@ -43,12 +94,37 @@ const proxyProcess = await hotMemo(async () => {
43
94
  return p;
44
95
  });
45
96
 
46
- console.log("All services started successfully!");
47
97
  console.log(`Proxy server PID: ${proxyProcess.pid}`);
48
98
  console.log(`Proxy server running on port: ${FBI_PROXY_PORT}`);
49
99
 
100
+ let authHandle: FbiAuthHandle | undefined;
101
+ if (argv["with-auth"]) {
102
+ authHandle = await startFbiAuth({
103
+ domain: argv.domain,
104
+ reconfigure: argv.reconfigure,
105
+ });
106
+ }
107
+
108
+ let caddyHandle: CaddyHandle | undefined;
109
+ if (argv["with-caddy"]) {
110
+ caddyHandle =
111
+ (await startCaddy({
112
+ domain: argv.domain,
113
+ fbiProxyPort: Number(FBI_PROXY_PORT),
114
+ fbiAuthPort: authHandle?.port,
115
+ withAuth: Boolean(argv["with-auth"]),
116
+ acmeEmail: argv["acme-email"],
117
+ tlsMode:
118
+ (argv["tls-mode"] as "auto" | "internal" | undefined) ?? undefined,
119
+ })) ?? undefined;
120
+ }
121
+
122
+ console.log("All services started successfully!");
123
+
50
124
  const exit = () => {
51
125
  console.log("Shutting down...");
126
+ caddyHandle?.kill();
127
+ authHandle?.kill();
52
128
  proxyProcess?.kill?.();
53
129
  process.exit(0);
54
130
  };
@@ -58,3 +134,110 @@ process.on("uncaughtException", (err) => {
58
134
  console.error("Uncaught exception:", err);
59
135
  exit();
60
136
  });
137
+
138
+ async function startFbiAuth(opts: {
139
+ domain: string;
140
+ reconfigure: boolean;
141
+ }): Promise<FbiAuthHandle | undefined> {
142
+ const configPath = defaultConfigPath();
143
+ let cfg = await readConfigOrNull(configPath);
144
+
145
+ if (opts.reconfigure) {
146
+ if (!isTty()) {
147
+ console.error(
148
+ "[fbi-auth] --reconfigure requires a TTY (interactive terminal).",
149
+ );
150
+ return undefined;
151
+ }
152
+ const prompter = readlinePrompter();
153
+ cfg = await runWizard(prompter, { domain: opts.domain, existing: cfg });
154
+ console.log(`[fbi-auth] writing config from wizard → ${configPath}`);
155
+ await writeConfig(cfg, configPath);
156
+ } else if (!cfg) {
157
+ if (isTty()) {
158
+ const prompter = readlinePrompter();
159
+ cfg = await runWizard(prompter, { domain: opts.domain, existing: null });
160
+ console.log(`[fbi-auth] writing config from wizard → ${configPath}`);
161
+ await writeConfig(cfg, configPath);
162
+ } else {
163
+ const fromEnv = configFromEnv(opts.domain);
164
+ if (fromEnv) {
165
+ console.log(`[fbi-auth] writing config from env vars → ${configPath}`);
166
+ await writeConfig(fromEnv, configPath);
167
+ cfg = fromEnv;
168
+ } else {
169
+ console.error(helpfulSetupMessage(opts.domain, configPath));
170
+ console.error(
171
+ "[fbi-auth] not started — --with-auth requires a config, env vars, or a TTY for the wizard.",
172
+ );
173
+ return undefined;
174
+ }
175
+ }
176
+ }
177
+
178
+ console.log(
179
+ `[fbi-auth] starting (domain=${cfg.domain}, provider=${cfg.provider})`,
180
+ );
181
+ const handle = await spawnFbiAuth({ configPath });
182
+ console.log(
183
+ `[fbi-auth] PID ${handle.pid} listening on 127.0.0.1:${handle.port}`,
184
+ );
185
+ return handle;
186
+ }
187
+
188
+ async function startCaddy(opts: {
189
+ domain: string;
190
+ fbiProxyPort: number;
191
+ fbiAuthPort: number | undefined;
192
+ withAuth: boolean;
193
+ acmeEmail?: string;
194
+ tlsMode?: "auto" | "internal";
195
+ }): Promise<CaddyHandle | null> {
196
+ // Verify Caddy is present before generating anything, so the error message
197
+ // is the first thing the user sees instead of a stray Caddyfile on disk.
198
+ const binary = await resolveCaddyBinary();
199
+ if (!binary) {
200
+ console.error(caddyNotFoundMessage());
201
+ return null;
202
+ }
203
+
204
+ if (opts.withAuth && opts.fbiAuthPort === undefined) {
205
+ console.error(
206
+ "[caddy] --with-auth was requested but fbi-auth failed to start; refusing to spawn Caddy with a broken forward_auth target.",
207
+ );
208
+ return null;
209
+ }
210
+
211
+ const domain = opts.domain.startsWith(".")
212
+ ? opts.domain.slice(1)
213
+ : opts.domain;
214
+ const tlsMode: "auto" | "internal" =
215
+ opts.tlsMode ?? (domain === "fbi.com" ? "internal" : "auto");
216
+
217
+ const caddyOpts: CaddyfileOpts = {
218
+ domain,
219
+ fbiProxyPort: opts.fbiProxyPort,
220
+ tlsMode,
221
+ acmeEmail: opts.acmeEmail,
222
+ withAuth: opts.withAuth,
223
+ ...(opts.withAuth && opts.fbiAuthPort !== undefined
224
+ ? {
225
+ ssoHost: `sso.${domain}`,
226
+ fbiAuthPort: opts.fbiAuthPort,
227
+ }
228
+ : {}),
229
+ };
230
+
231
+ const caddyfilePath = defaultCaddyfilePath();
232
+ const { path: writtenPath } = await writeCaddyfile(caddyOpts, caddyfilePath);
233
+ console.log(`[caddy] wrote Caddyfile → ${writtenPath}`);
234
+ console.log(
235
+ `[caddy] domain=${domain} tlsMode=${tlsMode} withAuth=${opts.withAuth}`,
236
+ );
237
+
238
+ const handle = await spawnCaddy({ caddyfilePath: writtenPath, binary });
239
+ if (handle) {
240
+ console.log(`[caddy] PID ${handle.pid}`);
241
+ }
242
+ return handle;
243
+ }
package/ts/dSpawn.ts CHANGED
@@ -15,10 +15,7 @@ type DRunProc = Promise<{
15
15
  process: ChildProcess;
16
16
  };
17
17
 
18
- const dSpawn = ({
19
- cwd = process.cwd(),
20
- env = process.env as Record<string, string>,
21
- } = {}) =>
18
+ const dSpawn = ({ cwd = process.cwd(), env = process.env as Record<string, string> } = {}) =>
22
19
  tsaComposer(
23
20
  // slot is un dividable
24
21
  (slot: string | { raw: string }) =>
@@ -64,11 +61,9 @@ const dSpawn = ({
64
61
  });
65
62
 
66
63
  // Main promise that resolves with combined result (also lazy)
67
- const mainPromise = Promise.all([
68
- outPromise,
69
- errPromise,
70
- codePromise,
71
- ]).then(([out, err, code]) => ({ out, err, code }));
64
+ const mainPromise = Promise.all([outPromise, errPromise, codePromise]).then(
65
+ ([out, err, code]) => ({ out, err, code }),
66
+ );
72
67
 
73
68
  // Create the proxy object that combines Promise with additional properties
74
69
  const result = new Proxy(mainPromise, {
@@ -1,11 +1,13 @@
1
1
  export function getFbiProxyFilename() {
2
- return {
3
- "darwin-arm64": "fbi-proxy-darwin",
4
- "darwin-x64": "fbi-proxy-darwin",
5
- "linux-arm64": "fbi-proxy-linux-arm64",
6
- "linux-x64": "fbi-proxy-linux-x64",
7
- "linux-x86_64": "fbi-proxy-linux-x64",
8
- "win32-arm64": "fbi-proxy-windows-arm64.exe",
9
- "win32-x64": "fbi-proxy-windows-x64.exe",
10
- }[process.platform + "-" + process.arch] || "fbi-proxy-linux-x64";
2
+ return (
3
+ {
4
+ "darwin-arm64": "fbi-proxy-darwin",
5
+ "darwin-x64": "fbi-proxy-darwin",
6
+ "linux-arm64": "fbi-proxy-linux-arm64",
7
+ "linux-x64": "fbi-proxy-linux-x64",
8
+ "linux-x86_64": "fbi-proxy-linux-x64",
9
+ "win32-arm64": "fbi-proxy-windows-arm64.exe",
10
+ "win32-x64": "fbi-proxy-windows-x64.exe",
11
+ }[process.platform + "-" + process.arch] || "fbi-proxy-linux-x64"
12
+ );
11
13
  }
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseRoutesYaml, validateRoute, type RouteConfig } from "./routes.ts";
3
+
4
+ const defaultYaml = `
5
+ version: 1
6
+ routes:
7
+ - name: port-as-host
8
+ match: "{port:int}.{domain}"
9
+ target: "127.0.0.1:{port}"
10
+
11
+ - name: host-double-dash-port
12
+ match: "{host}--{port:int}.{domain}"
13
+ target: "{host}:{port}"
14
+ headers:
15
+ Host: "{host}"
16
+
17
+ - name: subdomain-hoisting
18
+ match: "{prefix}.{host}.{domain}"
19
+ target: "{host}:80"
20
+ headers:
21
+ Host: "{prefix}"
22
+
23
+ - name: direct-forward
24
+ match: "{host}.{domain}"
25
+ target: "{host}:80"
26
+ headers:
27
+ Host: "{host}"
28
+ `;
29
+
30
+ describe("parseRoutesYaml", () => {
31
+ it("parses the default 4-rule config", () => {
32
+ const f = parseRoutesYaml(defaultYaml);
33
+ expect(f.version).toBe(1);
34
+ expect(f.routes).toHaveLength(4);
35
+ expect(f.routes[0]).toEqual({
36
+ name: "port-as-host",
37
+ match: "{port:int}.{domain}",
38
+ target: "127.0.0.1:{port}",
39
+ headers: undefined,
40
+ });
41
+ expect(f.routes[1].headers).toEqual({ Host: "{host}" });
42
+ });
43
+
44
+ it("defaults missing version to 1", () => {
45
+ const f = parseRoutesYaml(
46
+ `routes:\n - name: x\n match: "{a}"\n target: "b"\n`,
47
+ );
48
+ expect(f.version).toBe(1);
49
+ });
50
+
51
+ it("rejects unsupported version", () => {
52
+ expect(() => parseRoutesYaml(`version: 2\nroutes: []\n`)).toThrow(
53
+ /unsupported version/,
54
+ );
55
+ });
56
+
57
+ it("rejects non-mapping top-level", () => {
58
+ expect(() => parseRoutesYaml(`- a\n- b\n`)).toThrow(/mapping/);
59
+ });
60
+
61
+ it("rejects missing routes field", () => {
62
+ expect(() => parseRoutesYaml(`version: 1\n`)).toThrow(
63
+ /`routes` must be a list/,
64
+ );
65
+ });
66
+
67
+ it("rejects entry missing name", () => {
68
+ expect(() =>
69
+ parseRoutesYaml(
70
+ `version: 1\nroutes:\n - match: "{a}"\n target: "b"\n`,
71
+ ),
72
+ ).toThrow(/missing a string `name`/);
73
+ });
74
+
75
+ it("rejects entry missing match", () => {
76
+ expect(() =>
77
+ parseRoutesYaml(`version: 1\nroutes:\n - name: x\n target: "b"\n`),
78
+ ).toThrow(/missing a string `match`/);
79
+ });
80
+
81
+ it("rejects non-string header value", () => {
82
+ expect(() =>
83
+ parseRoutesYaml(
84
+ `version: 1\nroutes:\n - name: x\n match: "{a}"\n target: "b"\n headers:\n Host: 123\n`,
85
+ ),
86
+ ).toThrow(/must be a string/);
87
+ });
88
+ });
89
+
90
+ describe("validateRoute", () => {
91
+ const good: RouteConfig = {
92
+ name: "ok",
93
+ match: "{port:int}.{domain}",
94
+ target: "127.0.0.1:{port}",
95
+ headers: { Host: "{domain}" },
96
+ };
97
+
98
+ it("accepts a valid route", () => {
99
+ expect(validateRoute(good)).toEqual({ valid: true });
100
+ });
101
+
102
+ it("rejects empty name", () => {
103
+ expect(validateRoute({ ...good, name: "" })).toEqual({
104
+ valid: false,
105
+ reason: "route name is required",
106
+ });
107
+ });
108
+
109
+ it("rejects unbalanced braces in match", () => {
110
+ expect(validateRoute({ ...good, match: "{port" })).toMatchObject({
111
+ valid: false,
112
+ reason: /unbalanced braces in `match`/,
113
+ });
114
+ });
115
+
116
+ it("rejects unknown placeholder kind", () => {
117
+ const result = validateRoute({ ...good, match: "{port:zzz}.{domain}" });
118
+ expect(result.valid).toBe(false);
119
+ if (!result.valid)
120
+ expect(result.reason).toMatch(/unknown placeholder kind ':zzz'/);
121
+ });
122
+
123
+ it("rejects undeclared placeholder in target", () => {
124
+ const result = validateRoute({
125
+ ...good,
126
+ match: "{a}.{b}",
127
+ target: "{c}",
128
+ });
129
+ expect(result.valid).toBe(false);
130
+ if (!result.valid) expect(result.reason).toMatch(/'{c}' used in `target`/);
131
+ });
132
+
133
+ it("rejects undeclared placeholder in header", () => {
134
+ const result = validateRoute({
135
+ ...good,
136
+ match: "{a}.{b}",
137
+ target: "x",
138
+ headers: { Host: "{nope}" },
139
+ });
140
+ expect(result.valid).toBe(false);
141
+ if (!result.valid)
142
+ expect(result.reason).toMatch(/'{nope}' used in header 'Host'/);
143
+ });
144
+
145
+ it("rejects duplicate placeholder in match", () => {
146
+ const result = validateRoute({ ...good, match: "{a}.{a}", target: "x" });
147
+ expect(result.valid).toBe(false);
148
+ if (!result.valid) expect(result.reason).toMatch(/'{a}' declared twice/);
149
+ });
150
+
151
+ it("rejects invalid placeholder name", () => {
152
+ const result = validateRoute({ ...good, match: "{1foo}", target: "x" });
153
+ expect(result.valid).toBe(false);
154
+ if (!result.valid)
155
+ expect(result.reason).toMatch(/invalid placeholder name/);
156
+ });
157
+
158
+ it("validates all four default rules", () => {
159
+ const f = parseRoutesYaml(defaultYaml);
160
+ for (const r of f.routes) {
161
+ expect(validateRoute(r)).toEqual({ valid: true });
162
+ }
163
+ });
164
+
165
+ it("accepts {name:multi} for DNS-passthrough patterns", () => {
166
+ const dnsRoute: RouteConfig = {
167
+ name: "dns-passthrough",
168
+ match: "{upstream:multi}.{domain}",
169
+ target: "{upstream}:80",
170
+ };
171
+ expect(validateRoute(dnsRoute)).toEqual({ valid: true });
172
+ });
173
+
174
+ it("rejects {name:wrong} but accepts {name:multi}", () => {
175
+ expect(
176
+ validateRoute({ ...good, match: "{x:wrong}.{domain}", target: "x" }),
177
+ ).toMatchObject({ valid: false });
178
+ expect(
179
+ validateRoute({ ...good, match: "{x:multi}.{domain}", target: "{x}" }),
180
+ ).toEqual({ valid: true });
181
+ });
182
+ });
package/ts/routes.ts ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Route configuration types for fbi-proxy's rule-based router.
3
+ *
4
+ * The matching engine itself lives in `rs/routes.rs` (Rust). This
5
+ * TypeScript module mirrors the public configuration shape so that
6
+ * CLI / wizard code can author, parse, and validate `routes.yaml`
7
+ * documents without invoking the Rust binary.
8
+ *
9
+ * See `docs/routing.md` for the user-facing reference.
10
+ */
11
+
12
+ import YAML from "yaml";
13
+
14
+ /** A placeholder declared in a route's `match` pattern. */
15
+ export type Placeholder = {
16
+ name: string;
17
+ kind: "any" | "int" | "slug" | "multi";
18
+ };
19
+
20
+ /** A single route entry in `routes.yaml`. */
21
+ export type RouteConfig = {
22
+ /** Human-readable name (also used as a debug label). */
23
+ name: string;
24
+ /**
25
+ * Pattern matched against the (port-stripped, lowercased) Host header.
26
+ * Placeholders: `{name}` (any segment), `{name:int}`, `{name:slug}`,
27
+ * `{name:multi}` (one or more dot-segments — for DNS-passthrough).
28
+ */
29
+ match: string;
30
+ /**
31
+ * Target template. Expanded with placeholder captures from `match`.
32
+ * E.g. `"127.0.0.1:{port}"`.
33
+ */
34
+ target: string;
35
+ /**
36
+ * Optional header templates. `Host` is treated specially by the
37
+ * proxy (it rewrites the outgoing Host header); other entries are
38
+ * added to the upstream request as-is.
39
+ */
40
+ headers?: Record<string, string>;
41
+ };
42
+
43
+ /** Top-level shape of `routes.yaml`. */
44
+ export type RoutesFile = {
45
+ version: 1;
46
+ routes: RouteConfig[];
47
+ };
48
+
49
+ /** Result type for `validateRoute`. */
50
+ export type ValidationResult =
51
+ | { valid: true }
52
+ | { valid: false; reason: string };
53
+
54
+ /**
55
+ * Parse a YAML string into a `RoutesFile`. Throws on syntactically
56
+ * invalid YAML or on missing required fields. Does NOT compile the
57
+ * `match` patterns — call into the Rust engine for that.
58
+ */
59
+ export function parseRoutesYaml(yaml: string): RoutesFile {
60
+ const raw = YAML.parse(yaml);
61
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
62
+ throw new Error("routes.yaml must be a YAML mapping at the top level");
63
+ }
64
+ const obj = raw as Record<string, unknown>;
65
+ const version = (obj.version ?? 1) as number;
66
+ if (version !== 1) {
67
+ throw new Error(`routes.yaml: unsupported version ${version} (expected 1)`);
68
+ }
69
+ if (!Array.isArray(obj.routes)) {
70
+ throw new Error("routes.yaml: `routes` must be a list");
71
+ }
72
+ const routes: RouteConfig[] = [];
73
+ for (let i = 0; i < obj.routes.length; i++) {
74
+ const entry = obj.routes[i];
75
+ if (entry == null || typeof entry !== "object") {
76
+ throw new Error(`routes.yaml: entry #${i} must be a mapping`);
77
+ }
78
+ const e = entry as Record<string, unknown>;
79
+ if (typeof e.name !== "string" || e.name.length === 0) {
80
+ throw new Error(`routes.yaml: entry #${i} is missing a string \`name\``);
81
+ }
82
+ if (typeof e.match !== "string" || e.match.length === 0) {
83
+ throw new Error(
84
+ `routes.yaml: entry '${e.name}' is missing a string \`match\``,
85
+ );
86
+ }
87
+ if (typeof e.target !== "string" || e.target.length === 0) {
88
+ throw new Error(
89
+ `routes.yaml: entry '${e.name}' is missing a string \`target\``,
90
+ );
91
+ }
92
+ let headers: Record<string, string> | undefined;
93
+ if (e.headers != null) {
94
+ if (typeof e.headers !== "object" || Array.isArray(e.headers)) {
95
+ throw new Error(
96
+ `routes.yaml: entry '${e.name}': \`headers\` must be a mapping`,
97
+ );
98
+ }
99
+ headers = {};
100
+ for (const [hk, hv] of Object.entries(
101
+ e.headers as Record<string, unknown>,
102
+ )) {
103
+ if (typeof hv !== "string") {
104
+ throw new Error(
105
+ `routes.yaml: entry '${e.name}': header '${hk}' must be a string`,
106
+ );
107
+ }
108
+ headers[hk] = hv;
109
+ }
110
+ }
111
+ routes.push({
112
+ name: e.name,
113
+ match: e.match,
114
+ target: e.target,
115
+ headers,
116
+ });
117
+ }
118
+ return { version: 1, routes };
119
+ }
120
+
121
+ const PLACEHOLDER_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
122
+ const VALID_KINDS = new Set(["", "int", "slug", "multi"]);
123
+
124
+ /** Find all `{name[:kind]}` placeholders in `s`. */
125
+ function placeholdersIn(s: string): Placeholder[] {
126
+ const out: Placeholder[] = [];
127
+ const re = /\{([^}]*)\}/g;
128
+ let m: RegExpExecArray | null;
129
+ while ((m = re.exec(s)) !== null) {
130
+ const spec = m[1];
131
+ const idx = spec.indexOf(":");
132
+ const name = idx === -1 ? spec : spec.slice(0, idx);
133
+ const rawKind = idx === -1 ? "" : spec.slice(idx + 1);
134
+ let kind: Placeholder["kind"];
135
+ if (rawKind === "" || rawKind === "any") kind = "any";
136
+ else if (rawKind === "int") kind = "int";
137
+ else if (rawKind === "slug") kind = "slug";
138
+ else if (rawKind === "multi") kind = "multi";
139
+ else kind = "any"; // validation pass will reject if not in VALID_KINDS
140
+ out.push({ name, kind });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function bracesBalanced(s: string): boolean {
146
+ let depth = 0;
147
+ for (const c of s) {
148
+ if (c === "{") depth++;
149
+ else if (c === "}") {
150
+ depth--;
151
+ if (depth < 0) return false;
152
+ }
153
+ }
154
+ return depth === 0;
155
+ }
156
+
157
+ /**
158
+ * Validate a single route entry. Returns `{valid: true}` or
159
+ * `{valid: false, reason}`. This is the same set of checks the Rust
160
+ * compiler runs at startup, kept in sync for editor-time validation.
161
+ */
162
+ export function validateRoute(r: RouteConfig): ValidationResult {
163
+ if (!r.name) return { valid: false, reason: "route name is required" };
164
+ if (!r.match) return { valid: false, reason: "route `match` is required" };
165
+ if (!r.target) return { valid: false, reason: "route `target` is required" };
166
+
167
+ if (!bracesBalanced(r.match))
168
+ return { valid: false, reason: "unbalanced braces in `match`" };
169
+ if (!bracesBalanced(r.target))
170
+ return { valid: false, reason: "unbalanced braces in `target`" };
171
+
172
+ // Verify placeholder names + kinds in match
173
+ const declared = new Set<string>();
174
+ const matchPhs = placeholdersIn(r.match);
175
+ // Re-scan match raw to catch unknown kinds (placeholdersIn coerces them).
176
+ const rawMatchRe = /\{([^}]*)\}/g;
177
+ let mm: RegExpExecArray | null;
178
+ while ((mm = rawMatchRe.exec(r.match)) !== null) {
179
+ const spec = mm[1];
180
+ const idx = spec.indexOf(":");
181
+ const name = idx === -1 ? spec : spec.slice(0, idx);
182
+ const kind = idx === -1 ? "" : spec.slice(idx + 1);
183
+ if (!PLACEHOLDER_NAME_RE.test(name))
184
+ return {
185
+ valid: false,
186
+ reason: `invalid placeholder name '{${spec}}' in \`match\``,
187
+ };
188
+ if (!VALID_KINDS.has(kind))
189
+ return {
190
+ valid: false,
191
+ reason: `unknown placeholder kind ':${kind}' for '{${name}}' (expected int|slug|multi)`,
192
+ };
193
+ if (declared.has(name))
194
+ return {
195
+ valid: false,
196
+ reason: `placeholder '{${name}}' declared twice in \`match\``,
197
+ };
198
+ declared.add(name);
199
+ }
200
+ void matchPhs;
201
+
202
+ // Verify target / headers reference only declared placeholders
203
+ for (const ph of placeholdersIn(r.target)) {
204
+ if (!PLACEHOLDER_NAME_RE.test(ph.name))
205
+ return {
206
+ valid: false,
207
+ reason: `invalid placeholder name '{${ph.name}}' in \`target\``,
208
+ };
209
+ if (!declared.has(ph.name))
210
+ return {
211
+ valid: false,
212
+ reason: `placeholder '{${ph.name}}' used in \`target\` but not declared in \`match\``,
213
+ };
214
+ }
215
+ if (r.headers) {
216
+ for (const [hk, hv] of Object.entries(r.headers)) {
217
+ if (!bracesBalanced(hv))
218
+ return {
219
+ valid: false,
220
+ reason: `unbalanced braces in header '${hk}'`,
221
+ };
222
+ for (const ph of placeholdersIn(hv)) {
223
+ if (!PLACEHOLDER_NAME_RE.test(ph.name))
224
+ return {
225
+ valid: false,
226
+ reason: `invalid placeholder name '{${ph.name}}' in header '${hk}'`,
227
+ };
228
+ if (!declared.has(ph.name))
229
+ return {
230
+ valid: false,
231
+ reason: `placeholder '{${ph.name}}' used in header '${hk}' but not declared in \`match\``,
232
+ };
233
+ }
234
+ }
235
+ }
236
+
237
+ return { valid: true };
238
+ }