History

feat(selfhost): allow additional trusted browser origins (#1441)

* feat(selfhost): allow additional trusted browser origins * fix(selfhost): support sessions on HTTP trusted origins * Scope the insecure-origin warning and document the trusted-origins knob The Secure-cookie warning fired whenever any trusted origin was http, which includes the plain http://localhost default, so every local boot printed it. Warn only for the mixed case an operator opts into: an http alias alongside an https canonical URL, where the canonical origin really does lose Secure cookies. Move the resolver below loadConfig with the other env knobs and state why each rejected origin shape is refused rather than trimmed. Cover the rejected shapes as a table, plus the blank-list and bare-hostname cases. ---------

Rusty Shackleford committed 2 months ago

Commit a9bf6231 parent d74865f9 files · +259 −14Browse files

Unified · Split

9 files
  1. .changeset/selfhost-additional-trusted-origins.md +10 −0
  2. apps/docs/hosted/docker.mdx +17 −0
  3. apps/host-selfhost/.env.example +5 −0
  4. apps/host-selfhost/src/auth/better-auth.test.ts +79 −0
  5. apps/host-selfhost/src/auth/better-auth.ts +33 −9
  6. apps/host-selfhost/src/auth/invalid-origin-help.test.ts +2 −0
  7. apps/host-selfhost/src/auth/invalid-origin-help.ts +4 −3
  8. apps/host-selfhost/src/auth/origin-resolution.test.ts +53 −1
  9. apps/host-selfhost/src/config.ts +56 −1
.changeset/selfhost-additional-trusted-origins.md+10−0
@@ -0,0 +1,10 @@
1+---
2+"executor": patch
3+---
4+
5+Self-hosted instances can now allow additional browser origins without changing
6+their canonical public URL. Set `EXECUTOR_TRUSTED_ORIGINS` to a comma-separated
7+list of exact HTTP or HTTPS origins when one instance is intentionally reachable
8+through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval
9+links, and other generated URLs remain pinned to `EXECUTOR_WEB_BASE_URL`, and
10+origins are never inferred from request headers.
apps/docs/hosted/docker.mdx+17−0
@@ -60,6 +60,7 @@ the container defaults. · 59 unmodified lines
6060 | `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
6161 | `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
6262 | `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
63+| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. |
6364 | `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
6465 | `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
6566 | `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |
@@ -91,6 +92,22 @@ For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL · 25 unmodified lines
9192 and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser
9293 setup screen.
9394
95+### Additional browser aliases
96+
97+Keep `EXECUTOR_WEB_BASE_URL` pinned to the canonical public HTTPS origin used for
98+OAuth callbacks and generated links. If the same instance is intentionally
99+available through additional browser addresses, allow those exact origins
100+separately:
101+
102+```bash
103+-e EXECUTOR_WEB_BASE_URL=https://executor.example.com \
104+-e EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788
105+```
106+
107+Only cookie-authenticated browser requests use this allowlist. OAuth callbacks,
108+MCP metadata, approval links, and other absolute URLs remain pinned to
109+`EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers.
110+
94111 ## Connect an agent
95112
96113 The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at
apps/host-selfhost/.env.example+5−0
@@ -8,6 +8,11 @@ · 7 unmodified lines
88 # rejected. Behind a reverse proxy / TLS, set this to your public https URL.
99 # EXECUTOR_WEB_BASE_URL=https://executor.example.com
1010
11+# Additional browser origins allowed to authenticate with this instance, as a
12+# comma-separated list. These aliases do not change OAuth callbacks or generated
13+# links, which remain pinned to EXECUTOR_WEB_BASE_URL. Use exact origins only.
14+# EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788
15+
1116 # --- Session secret -----------------------------------------------------------
1217 # Generated and persisted under the data volume on first boot if unset. Set this
1318 # to manage it yourself (must be at least 32 characters). Rotating it signs every
apps/host-selfhost/src/auth/better-auth.test.ts+79−0
@@ -7,10 +7,16 @@ import { afterAll, expect, test } from "@effect/vitest"; · 6 unmodified lines
77 import { mintInviteCode } from "../testing/mint-invite";
88
99 // Real Better Auth path: set a secret + bootstrap admin before importing.
10+// Better Auth skips origin checks in test mode by default; this suite exercises
11+// the production check so the trusted-origin cases below cover the real path.
12+process.env.NODE_ENV = "production";
13+process.env.TEST = "false";
1014 process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-"));
1115 process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv";
1216 process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local";
1317 process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123";
18+process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
19+process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788";
1420
1521 const { makeSelfHostApiHandler } = await import("../app");
1622
@@ -19,6 +25,79 @@ afterAll(() => dispose()); · 2 unmodified lines
1925
2026 const BASE = "http://localhost:4788";
2127
28+test("an HTTP trusted alias receives a usable session cookie with an HTTPS canonical URL", async () => {
29+ const alias = "http://executor.home.arpa:4788";
30+ const signIn = await handler(
31+ new Request(`${alias}/api/auth/sign-in/email`, {
32+ method: "POST",
33+ headers: {
34+ "content-type": "application/json",
35+ origin: alias,
36+ "sec-fetch-dest": "empty",
37+ "sec-fetch-mode": "cors",
38+ "sec-fetch-site": "same-origin",
39+ },
40+ body: JSON.stringify({
41+ email: "admin@test.local",
42+ password: "admin-password-123",
43+ }),
44+ }),
45+ );
46+ expect(signIn.status).toBe(200);
47+ const sessionCookie = signIn.headers.get("set-cookie");
48+ expect(sessionCookie).toContain("better-auth.session_token=");
49+ expect(sessionCookie).not.toMatch(/(?:^|;\s*)Secure(?:;|$)/i);
50+ expect(sessionCookie).not.toContain("__Secure-");
51+});
52+
53+test("an explicitly trusted browser alias can sign up without changing the canonical base URL", async () => {
54+ const alias = "http://executor.home.arpa:4788";
55+ const inviteCode = await mintInviteCode(handler);
56+ const signUp = await handler(
57+ new Request(`${alias}/api/auth/sign-up/email`, {
58+ method: "POST",
59+ headers: {
60+ "content-type": "application/json",
61+ origin: alias,
62+ "sec-fetch-dest": "empty",
63+ "sec-fetch-mode": "cors",
64+ "sec-fetch-site": "same-origin",
65+ },
66+ body: JSON.stringify({
67+ email: "trusted-alias@test.local",
68+ password: "member-password-123",
69+ name: "Trusted Alias",
70+ inviteCode,
71+ }),
72+ }),
73+ );
74+ expect(signUp.status).toBe(200);
75+});
76+
77+test("an unlisted browser alias remains blocked", async () => {
78+ const alias = "http://untrusted.home.arpa:4788";
79+ const inviteCode = await mintInviteCode(handler);
80+ const signUp = await handler(
81+ new Request(`${alias}/api/auth/sign-up/email`, {
82+ method: "POST",
83+ headers: {
84+ "content-type": "application/json",
85+ origin: alias,
86+ "sec-fetch-dest": "empty",
87+ "sec-fetch-mode": "cors",
88+ "sec-fetch-site": "same-origin",
89+ },
90+ body: JSON.stringify({
91+ email: "untrusted-alias@test.local",
92+ password: "member-password-123",
93+ name: "Untrusted Alias",
94+ inviteCode,
95+ }),
96+ }),
97+ );
98+ expect(signUp.status).toBe(403);
99+});
100+
22101 test("migrations create both the Better Auth and FumaDB executor schema regions", async () => {
23102 // Open a SEPARATE libSQL connection to the same file Better Auth (via its own
24103 // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this
apps/host-selfhost/src/auth/better-auth.ts+33−9
@@ -25,6 +25,8 @@ interface SignupGate { · 24 unmodified lines
2525 // creation (the seed, or a future admin "add user") flows through other paths.
2626 const SIGNUP_PATH = "/sign-up/email";
2727
28+let warnedInsecureTrustedOrigin = false;
29+
2830 // ---------------------------------------------------------------------------
2931 // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor
3032 // tables ("one connection, two schema regions").
@@ -63,6 +65,27 @@ const SIGNUP_PATH = "/sign-up/email"; · 32 unmodified lines
6365
6466 const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => {
6567 const config = loadConfig();
68+ // A `Secure` session cookie is never sent back over plain HTTP, so an HTTP
69+ // alias can sign in and then look signed out on every later request. Drop the
70+ // attribute when ANY trusted origin is HTTP. This is not a new relaxation for
71+ // the common cases: Better Auth already infers `useSecureCookies` from the
72+ // baseURL scheme, so an all-HTTPS instance still gets `true` and the plain
73+ // `http://localhost` default still gets `false`. It only changes the mixed
74+ // case an operator opts into with EXECUTOR_TRUSTED_ORIGINS.
75+ const hasInsecureTrustedOrigin = config.trustedOrigins.some((origin) =>
76+ origin.startsWith("http://"),
77+ );
78+ // Warn only for that mixed case. An HTTP-only instance (local dev, a LAN
79+ // deploy) never had Secure cookies to lose, and warning there would fire on
80+ // every default boot.
81+ const downgradesCanonicalCookies =
82+ hasInsecureTrustedOrigin && config.webBaseUrl.startsWith("https://");
83+ if (downgradesCanonicalCookies && !warnedInsecureTrustedOrigin) {
84+ warnedInsecureTrustedOrigin = true;
85+ console.warn(
86+ "[executor] EXECUTOR_TRUSTED_ORIGINS contains an http:// origin, so session cookies drop the Secure attribute for every origin — including the https:// canonical URL. Use https:// aliases to keep session cookies transport-secure.",
87+ );
88+ }
6689 // Always resolved (generated + persisted when no env is set); this guards only
6790 // an explicitly-set env secret that is too weak.
6891 const secret = config.authSecret;
@@ -88,16 +111,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 19 unmodified lines
88111 type: "sqlite" as const,
89112 },
90113 secret,
91− // The browser Origin must match this exactly; CLI/MCP bearer requests carry
92− // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit
93− // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/
94− // …), else localhost — so a PaaS deploy is zero-config and any other host
95− // sets the one variable (a loud warning fires on the localhost fallback).
96− // See config.ts. We deliberately do NOT derive this from the request `Host`:
97− // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned
98− // origin keeps host-header injection out of OAuth redirects and links.
114+ // The canonical browser Origin is config.webBaseUrl; explicitly configured
115+ // aliases may also send cookie-authenticated requests. CLI/MCP bearer
116+ // requests carry no Origin and are unaffected. We deliberately do NOT derive
117+ // either value from the request `Host`: matching the ecosystem (Windmill
118+ // `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection
119+ // out of OAuth redirects and links. Additional trusted origins affect only
120+ // Better Auth's request validation; generated links and OAuth callbacks stay
121+ // pinned to config.webBaseUrl.
99122 baseURL: config.webBaseUrl,
100− trustedOrigins: [config.webBaseUrl],
123+ trustedOrigins: [...config.trustedOrigins],
124+ advanced: { useSecureCookies: !hasInsecureTrustedOrigin },
101125 emailAndPassword: { enabled: true },
102126 // `apiKey` issues long-lived personal keys (the API-keys page). With
103127 // `enableSessionForAPIKeys`, presenting a key resolves to its owner's
apps/host-selfhost/src/auth/invalid-origin-help.test.ts+2−0
@@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => { · 15 unmodified lines
1616 test("the help message names the URL to set", () => {
1717 const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788");
1818 expect(msg).toContain("EXECUTOR_WEB_BASE_URL");
19+ expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS");
1920 expect(msg).toContain("https://app.example.com");
2021 expect(msg).toContain("http://localhost:4788");
2122 });
@@ -38,6 +39,7 @@ test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup messag · 16 unmodified lines
3839 const body = (await rewritten!.json()) as { code: string; message: string };
3940 expect(body.code).toBe("INVALID_ORIGIN");
4041 expect(body.message).toContain("EXECUTOR_WEB_BASE_URL");
42+ expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS");
4143 expect(body.message).toContain("https://app.example.com");
4244 });
4345
apps/host-selfhost/src/auth/invalid-origin-help.ts+4−3
@@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => { · 20 unmodified lines
2121 export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string =>
2222 requestOrigin
2323 ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` +
24− `Set the EXECUTOR_WEB_BASE_URL environment variable to ${requestOrigin} and restart the server, then try again. ` +
25− `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)`
24+ `Set EXECUTOR_WEB_BASE_URL to ${requestOrigin} if it should be the canonical public URL, or add it to ` +
25+ `EXECUTOR_TRUSTED_ORIGINS if it is an intentional browser alias, then restart the server. ` +
26+ `(Railway, Render, Fly, Vercel, and similar hosts detect the canonical public URL automatically.)`
2627 : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` +
27− `set EXECUTOR_WEB_BASE_URL to that address and restart the server.`;
28+ `set EXECUTOR_WEB_BASE_URL to that canonical address or add the alias to EXECUTOR_TRUSTED_ORIGINS, then restart the server.`;
2829
2930 /**
3031 * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy
apps/host-selfhost/src/auth/origin-resolution.test.ts+53−1
@@ -20,6 +20,7 @@ process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-")); · 19 unmodified lines
2020 // try/finally — these are the only env vars these cases read).
2121 const PLATFORM_VARS = [
2222 "EXECUTOR_WEB_BASE_URL",
23+ "EXECUTOR_TRUSTED_ORIGINS",
2324 "RAILWAY_PUBLIC_DOMAIN",
2425 "RENDER_EXTERNAL_URL",
2526 "RENDER_EXTERNAL_HOSTNAME",
@@ -33,7 +34,9 @@ const resetOriginEnv = (): void => { · 7 unmodified lines
3334
3435 test("webBaseUrl falls back to localhost with no public origin", () => {
3536 resetOriginEnv();
36− expect(loadConfig().webBaseUrl).toBe("http://localhost:4788");
37+ const config = loadConfig();
38+ expect(config.webBaseUrl).toBe("http://localhost:4788");
39+ expect(config.trustedOrigins).toEqual(["http://localhost:4788"]);
3740 });
3841
3942 test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => {
@@ -60,3 +63,52 @@ test("an explicit EXECUTOR_WEB_BASE_URL always wins over a platform var", () => · 20 unmodified lines
6063 process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com";
6164 expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com");
6265 });
66+
67+test("additional trusted origins are trimmed, normalized, and deduplicated", () => {
68+ resetOriginEnv();
69+ process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
70+ process.env.EXECUTOR_TRUSTED_ORIGINS =
71+ " http://executor.home.arpa:4788/, https://executor.example.com, http://192.0.2.10:4788 ";
72+ expect(loadConfig().trustedOrigins).toEqual([
73+ "https://executor.example.com",
74+ "http://executor.home.arpa:4788",
75+ "http://192.0.2.10:4788",
76+ ]);
77+});
78+
79+test("an empty or blank trusted-origins list leaves the canonical origin alone", () => {
80+ resetOriginEnv();
81+ process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
82+ process.env.EXECUTOR_TRUSTED_ORIGINS = " , , ";
83+ expect(loadConfig().trustedOrigins).toEqual(["https://executor.example.com"]);
84+});
85+
86+// Every rejected shape is one an operator could plausibly type and then believe
87+// was in force. A wildcard host is the dangerous one: accepting it as a literal
88+// hostname would silently allow nothing while reading like it allows a whole
89+// domain. A path/query/fragment reads like a scoped grant that origins cannot
90+// express, and credentials in the URL are almost always a copy-paste mistake.
91+test.each([
92+ "https://executor.example.com/login",
93+ "https://executor.example.com/?next=/",
94+ "https://executor.example.com/#top",
95+ "https://user:pass@executor.example.com",
96+ "https://*.example.com",
97+ "file:///tmp/executor",
98+ "ftp://executor.example.com",
99+])("a trusted origin that is not an exact http(s) origin (%s) refuses to boot", (raw) => {
100+ resetOriginEnv();
101+ process.env.EXECUTOR_TRUSTED_ORIGINS = raw;
102+ expect(() => loadConfig()).toThrow(/exact http\(s\) origin/);
103+});
104+
105+// A bare hostname is the most common typo, and it never parses as a URL at all,
106+// so it gets the other message. Both name the variable.
107+test.each(["executor.example.com", "//executor.example.com", "not a url"])(
108+ "a trusted origin that is not a URL (%s) refuses to boot",
109+ (raw) => {
110+ resetOriginEnv();
111+ process.env.EXECUTOR_TRUSTED_ORIGINS = raw;
112+ expect(() => loadConfig()).toThrow(/EXECUTOR_TRUSTED_ORIGINS/);
113+ },
114+);
apps/host-selfhost/src/config.ts+56−1
@@ -26,6 +26,8 @@ export interface SelfHostConfig { · 25 unmodified lines
2626 readonly dbPath: string;
2727 /** Public base URL used by core tools that build absolute links. */
2828 readonly webBaseUrl: string;
29+ /** Browser origins allowed to send cookie-authenticated requests. */
30+ readonly trustedOrigins: readonly string[];
2931 /**
3032 * Whether sandboxed code may reach loopback/private network addresses.
3133 * Defaults to false — adversarial LLM code should not hit the host's
@@ -155,11 +157,13 @@ const resolveWebBaseUrl = (port: number): string => { · 123 unmodified lines
155157 export const loadConfig = (): SelfHostConfig => {
156158 const port = Number.parseInt(process.env.PORT ?? "4788", 10);
157159 const dataDir = resolveDataDir();
160+ const webBaseUrl = resolveWebBaseUrl(port);
158161 return {
159162 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",
160163 port,
161164 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
162− webBaseUrl: resolveWebBaseUrl(port),
165+ webBaseUrl,
166+ trustedOrigins: resolveTrustedOrigins(webBaseUrl),
163167 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
164168 authSecret: resolveAuthSecret(),
165169 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
@@ -206,6 +210,57 @@ const resolveMcpSessionIdleTtlMs = (): number | undefined => { · 40 unmodified lines
206210 return Math.floor(parsed);
207211 };
208212
213+// EXECUTOR_TRUSTED_ORIGINS — extra browser origins allowed to send
214+// cookie-authenticated requests when one instance is deliberately reachable
215+// under more than one address (a LAN IP as well as a domain, say).
216+//
217+// This list widens ONLY Better Auth's origin/CSRF check. `webBaseUrl` stays the
218+// single canonical origin for OAuth callbacks, MCP metadata, and every other
219+// generated link, so an alias can never redirect a callback somewhere else.
220+//
221+// Entries must be exact origins. A path, query, fragment, credential, wildcard
222+// host, or non-http(s) scheme is refused rather than trimmed off: an operator
223+// who writes `https://*.example.com` means a pattern, and silently accepting it
224+// as the literal host would leave them believing a wildcard is in force. Like
225+// the other knobs here, a malformed value refuses to boot instead of quietly
226+// leaving the browser locked out with an "Invalid origin" page.
227+const normalizeTrustedOrigin = (value: string): string => {
228+ if (!URL.canParse(value)) {
229+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
230+ throw new Error(
231+ `EXECUTOR_TRUSTED_ORIGINS contains ${JSON.stringify(value)}, which is not a valid URL origin`,
232+ );
233+ }
234+ const url = new URL(value);
235+ if (
236+ (url.protocol !== "http:" && url.protocol !== "https:") ||
237+ url.username.length > 0 ||
238+ url.password.length > 0 ||
239+ url.hostname.includes("*") ||
240+ (url.pathname !== "" && url.pathname !== "/") ||
241+ url.search.length > 0 ||
242+ url.hash.length > 0
243+ ) {
244+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
245+ throw new Error(
246+ `EXECUTOR_TRUSTED_ORIGINS entry ${JSON.stringify(value)} must be an exact http(s) origin (scheme, host, and optional port only)`,
247+ );
248+ }
249+ return url.origin;
250+};
251+
252+// The canonical origin always leads the list, so the unset case reproduces the
253+// previous `[webBaseUrl]` exactly and an operator who repeats it in the env var
254+// does not get a duplicate.
255+const resolveTrustedOrigins = (webBaseUrl: string): readonly string[] => {
256+ const additional = (process.env.EXECUTOR_TRUSTED_ORIGINS ?? "")
257+ .split(",")
258+ .map((value) => value.trim())
259+ .filter((value) => value.length > 0)
260+ .map(normalizeTrustedOrigin);
261+ return [...new Set([webBaseUrl, ...additional])];
262+};
263+
209264 // The org slug doubles as a URL segment (`/<slug>/policies`), so an
210265 // operator-set value must fit the shared grammar and avoid reserved root
211266 // segments (api, mcp, login, …) — a colliding slug would shadow real routes.