@frankieai/server 0.2.1 → 0.2.2

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.
@@ -0,0 +1,75 @@
1
+ // src/config.ts
2
+ import { readFile } from "fs/promises";
3
+ import { resolve } from "path";
4
+ import { parseFrankieConfig } from "@frankieai/core";
5
+ var ConfigError = class extends Error {
6
+ };
7
+ async function loadConfigFromFile(path) {
8
+ const target = resolve(path ?? process.env["FRANKIE_CONFIG"] ?? "frankie.config.json");
9
+ let raw;
10
+ try {
11
+ raw = await readFile(target, "utf8");
12
+ } catch {
13
+ throw new ConfigError(
14
+ `Could not read Frankie config at ${target}. Set FRANKIE_CONFIG to point at your frankie.config.json.`
15
+ );
16
+ }
17
+ try {
18
+ return parseFrankieConfig(applyEnvOverrides(JSON.parse(raw)));
19
+ } catch (error) {
20
+ throw new ConfigError(`Invalid Frankie config at ${target}: ${error.message}`);
21
+ }
22
+ }
23
+ function applyEnvOverrides(raw, env = process.env) {
24
+ const out = { ...raw };
25
+ const model = { ...isRecord(out["model"]) ? out["model"] : {} };
26
+ if (env["FRANKIE_MODEL_PROVIDER"]) model["provider"] = env["FRANKIE_MODEL_PROVIDER"];
27
+ if (env["FRANKIE_MODEL"]) model["model"] = env["FRANKIE_MODEL"];
28
+ if (env["FRANKIE_MODEL_BASE_URL"]) model["baseUrl"] = env["FRANKIE_MODEL_BASE_URL"];
29
+ if (Object.keys(model).length > 0) out["model"] = model;
30
+ const auth = { ...isRecord(out["auth"]) ? out["auth"] : {} };
31
+ if (env["FRANKIE_ALLOWED_ORIGINS"]) {
32
+ auth["allowedOrigins"] = env["FRANKIE_ALLOWED_ORIGINS"].split(",").map((o) => o.trim()).filter(Boolean);
33
+ }
34
+ if (env["FRANKIE_PUBLISHABLE_KEY"]) auth["publishableKey"] = env["FRANKIE_PUBLISHABLE_KEY"];
35
+ if (Object.keys(auth).length > 0) out["auth"] = auth;
36
+ return out;
37
+ }
38
+ function isRecord(value) {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+ function resolveSecrets(config, env = process.env) {
42
+ const missing = [];
43
+ const jwtSecret = env[config.auth.jwtSecretEnv];
44
+ if (!config.auth.jwksUrl && !jwtSecret) missing.push(config.auth.jwtSecretEnv);
45
+ const sessionSecret = env[config.auth.sessionSecretEnv];
46
+ if (!sessionSecret) missing.push(config.auth.sessionSecretEnv);
47
+ const providerApiKey = env[config.model.apiKeyEnv];
48
+ if (!providerApiKey) missing.push(config.model.apiKeyEnv);
49
+ if (missing.length) {
50
+ throw new ConfigError(`Missing required environment variables: ${missing.join(", ")}`);
51
+ }
52
+ if (sessionSecret.length < 32) {
53
+ throw new ConfigError(`${config.auth.sessionSecretEnv} must be at least 32 characters.`);
54
+ }
55
+ return {
56
+ config,
57
+ secrets: {
58
+ jwtSecret,
59
+ sessionSecret,
60
+ providerApiKey
61
+ }
62
+ };
63
+ }
64
+ async function loadResolvedConfig(path) {
65
+ return resolveSecrets(await loadConfigFromFile(path));
66
+ }
67
+
68
+ export {
69
+ ConfigError,
70
+ loadConfigFromFile,
71
+ applyEnvOverrides,
72
+ resolveSecrets,
73
+ loadResolvedConfig
74
+ };
75
+ //# sourceMappingURL=chunk-NF6IJVES.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { parseFrankieConfig, type FrankieConfig } from \"@frankieai/core\";\n\n/**\n * Secrets never live in the config file — the file names the env var that\n * holds each one. `ResolvedConfig` is the config plus the resolved values.\n */\nexport interface ResolvedConfig {\n config: FrankieConfig;\n secrets: {\n /** HS256 secret the host signs end-user tokens with. Absent when using JWKS. */\n jwtSecret?: string;\n /** Signs Frankie's own session tokens. */\n sessionSecret: string;\n /** Key for the model provider named in `model.provider`. */\n providerApiKey: string;\n };\n}\n\nexport class ConfigError extends Error {}\n\nexport async function loadConfigFromFile(path?: string): Promise<FrankieConfig> {\n const target = resolve(path ?? process.env[\"FRANKIE_CONFIG\"] ?? \"frankie.config.json\");\n let raw: string;\n try {\n raw = await readFile(target, \"utf8\");\n } catch {\n throw new ConfigError(\n `Could not read Frankie config at ${target}. Set FRANKIE_CONFIG to point at your frankie.config.json.`,\n );\n }\n try {\n return parseFrankieConfig(applyEnvOverrides(JSON.parse(raw) as Record<string, unknown>));\n } catch (error) {\n throw new ConfigError(`Invalid Frankie config at ${target}: ${(error as Error).message}`);\n }\n}\n\n/**\n * A handful of fields have to move with the deployment rather than the config\n * file — the model and the origin allowlist are what an operator most often\n * wants to flip without a redeploy.\n */\nexport function applyEnvOverrides(\n raw: Record<string, unknown>,\n env: NodeJS.ProcessEnv = process.env,\n): Record<string, unknown> {\n const out = { ...raw };\n\n const model = { ...(isRecord(out[\"model\"]) ? out[\"model\"] : {}) };\n if (env[\"FRANKIE_MODEL_PROVIDER\"]) model[\"provider\"] = env[\"FRANKIE_MODEL_PROVIDER\"];\n if (env[\"FRANKIE_MODEL\"]) model[\"model\"] = env[\"FRANKIE_MODEL\"];\n if (env[\"FRANKIE_MODEL_BASE_URL\"]) model[\"baseUrl\"] = env[\"FRANKIE_MODEL_BASE_URL\"];\n if (Object.keys(model).length > 0) out[\"model\"] = model;\n\n const auth = { ...(isRecord(out[\"auth\"]) ? out[\"auth\"] : {}) };\n if (env[\"FRANKIE_ALLOWED_ORIGINS\"]) {\n auth[\"allowedOrigins\"] = env[\"FRANKIE_ALLOWED_ORIGINS\"].split(\",\").map((o) => o.trim()).filter(Boolean);\n }\n if (env[\"FRANKIE_PUBLISHABLE_KEY\"]) auth[\"publishableKey\"] = env[\"FRANKIE_PUBLISHABLE_KEY\"];\n if (Object.keys(auth).length > 0) out[\"auth\"] = auth;\n\n return out;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nexport function resolveSecrets(config: FrankieConfig, env: NodeJS.ProcessEnv = process.env): ResolvedConfig {\n const missing: string[] = [];\n\n const jwtSecret = env[config.auth.jwtSecretEnv];\n if (!config.auth.jwksUrl && !jwtSecret) missing.push(config.auth.jwtSecretEnv);\n\n const sessionSecret = env[config.auth.sessionSecretEnv];\n if (!sessionSecret) missing.push(config.auth.sessionSecretEnv);\n\n const providerApiKey = env[config.model.apiKeyEnv];\n if (!providerApiKey) missing.push(config.model.apiKeyEnv);\n\n if (missing.length) {\n throw new ConfigError(`Missing required environment variables: ${missing.join(\", \")}`);\n }\n\n if (sessionSecret!.length < 32) {\n throw new ConfigError(`${config.auth.sessionSecretEnv} must be at least 32 characters.`);\n }\n\n return {\n config,\n secrets: {\n jwtSecret,\n sessionSecret: sessionSecret!,\n providerApiKey: providerApiKey!,\n },\n };\n}\n\nexport async function loadResolvedConfig(path?: string): Promise<ResolvedConfig> {\n return resolveSecrets(await loadConfigFromFile(path));\n}\n"],"mappings":";AAAA,SAAS,gBAAgB;AACzB,SAAS,eAAe;AACxB,SAAS,0BAA8C;AAkBhD,IAAM,cAAN,cAA0B,MAAM;AAAC;AAExC,eAAsB,mBAAmB,MAAuC;AAC9E,QAAM,SAAS,QAAQ,QAAQ,QAAQ,IAAI,gBAAgB,KAAK,qBAAqB;AACrF,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,SAAS,QAAQ,MAAM;AAAA,EACrC,QAAQ;AACN,UAAM,IAAI;AAAA,MACR,oCAAoC,MAAM;AAAA,IAC5C;AAAA,EACF;AACA,MAAI;AACF,WAAO,mBAAmB,kBAAkB,KAAK,MAAM,GAAG,CAA4B,CAAC;AAAA,EACzF,SAAS,OAAO;AACd,UAAM,IAAI,YAAY,6BAA6B,MAAM,KAAM,MAAgB,OAAO,EAAE;AAAA,EAC1F;AACF;AAOO,SAAS,kBACd,KACA,MAAyB,QAAQ,KACR;AACzB,QAAM,MAAM,EAAE,GAAG,IAAI;AAErB,QAAM,QAAQ,EAAE,GAAI,SAAS,IAAI,OAAO,CAAC,IAAI,IAAI,OAAO,IAAI,CAAC,EAAG;AAChE,MAAI,IAAI,wBAAwB,EAAG,OAAM,UAAU,IAAI,IAAI,wBAAwB;AACnF,MAAI,IAAI,eAAe,EAAG,OAAM,OAAO,IAAI,IAAI,eAAe;AAC9D,MAAI,IAAI,wBAAwB,EAAG,OAAM,SAAS,IAAI,IAAI,wBAAwB;AAClF,MAAI,OAAO,KAAK,KAAK,EAAE,SAAS,EAAG,KAAI,OAAO,IAAI;AAElD,QAAM,OAAO,EAAE,GAAI,SAAS,IAAI,MAAM,CAAC,IAAI,IAAI,MAAM,IAAI,CAAC,EAAG;AAC7D,MAAI,IAAI,yBAAyB,GAAG;AAClC,SAAK,gBAAgB,IAAI,IAAI,yBAAyB,EAAE,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AAAA,EACxG;AACA,MAAI,IAAI,yBAAyB,EAAG,MAAK,gBAAgB,IAAI,IAAI,yBAAyB;AAC1F,MAAI,OAAO,KAAK,IAAI,EAAE,SAAS,EAAG,KAAI,MAAM,IAAI;AAEhD,SAAO;AACT;AAEA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEO,SAAS,eAAe,QAAuB,MAAyB,QAAQ,KAAqB;AAC1G,QAAM,UAAoB,CAAC;AAE3B,QAAM,YAAY,IAAI,OAAO,KAAK,YAAY;AAC9C,MAAI,CAAC,OAAO,KAAK,WAAW,CAAC,UAAW,SAAQ,KAAK,OAAO,KAAK,YAAY;AAE7E,QAAM,gBAAgB,IAAI,OAAO,KAAK,gBAAgB;AACtD,MAAI,CAAC,cAAe,SAAQ,KAAK,OAAO,KAAK,gBAAgB;AAE7D,QAAM,iBAAiB,IAAI,OAAO,MAAM,SAAS;AACjD,MAAI,CAAC,eAAgB,SAAQ,KAAK,OAAO,MAAM,SAAS;AAExD,MAAI,QAAQ,QAAQ;AAClB,UAAM,IAAI,YAAY,2CAA2C,QAAQ,KAAK,IAAI,CAAC,EAAE;AAAA,EACvF;AAEA,MAAI,cAAe,SAAS,IAAI;AAC9B,UAAM,IAAI,YAAY,GAAG,OAAO,KAAK,gBAAgB,kCAAkC;AAAA,EACzF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AAEA,eAAsB,mBAAmB,MAAwC;AAC/E,SAAO,eAAe,MAAM,mBAAmB,IAAI,CAAC;AACtD;","names":[]}
@@ -1,12 +1,22 @@
1
1
  import {
2
2
  MemoryRateLimiter,
3
+ applySecurity,
4
+ buildForwardedHeaders,
5
+ buildUrl,
6
+ collectParameters,
3
7
  connectMcpTools,
4
8
  connectorFetch,
5
9
  inspectApiCapabilities,
10
+ isRecord,
11
+ loadSpec,
6
12
  resolveModel,
13
+ resolveOperationBaseUrl,
7
14
  safeEqual,
15
+ securitySchemes,
16
+ slug,
17
+ specBaseUrl,
8
18
  toPublicTenant
9
- } from "./chunk-WFENEYOV.js";
19
+ } from "./chunk-JOADNB5J.js";
10
20
 
11
21
  // src/control/app.ts
12
22
  import { randomBytes } from "crypto";
@@ -49,10 +59,104 @@ async function refreshApiSource(source, spec) {
49
59
  }
50
60
  if (!source.baseUrl && (!before || JSON.stringify(before.servers ?? []) !== JSON.stringify(after.servers ?? []))) throw new Error("Set an explicit API base URL in Tools & MCP before refreshing a remote spec or changing its servers.");
51
61
  const changes = compareApiDocuments(before ?? { paths: {} }, after);
52
- const selectedTools = (source.selectedTools ?? []).filter((id) => changes.unchangedIds.includes(id));
62
+ const selectedTools = source.selectedTools ? source.selectedTools.filter((id) => changes.unchangedIds.includes(id)) : [...changes.unchangedIds];
63
+ const priorApprovals = source.selectedTools?.length ?? changes.unchangedIds.length + changes.changed.length + changes.removed.length;
53
64
  const updated = { ...source, spec: JSON.stringify(after), selectedTools };
54
65
  const capabilities = await inspectApiCapabilities(updated);
55
- return { source: { ...updated, capabilities }, changes: { added: changes.added, changed: changes.changed, removed: changes.removed, retainedApprovals: selectedTools.length, revokedApprovals: (source.selectedTools?.length ?? 0) - selectedTools.length } };
66
+ const newWritesWithAuthGap = capabilities.filter((capability) => capability.authGap && changes.added.includes(`${capability.method} ${capability.path}`)).map((capability) => capability.operationId);
67
+ return {
68
+ source: { ...updated, capabilities },
69
+ changes: {
70
+ added: changes.added,
71
+ changed: changes.changed,
72
+ removed: changes.removed,
73
+ newWritesWithAuthGap,
74
+ retainedApprovals: selectedTools.length,
75
+ revokedApprovals: priorApprovals - selectedTools.length
76
+ }
77
+ };
78
+ }
79
+
80
+ // src/control/api-connectivity-test.ts
81
+ var MAX_SAMPLED_OPERATIONS = 5;
82
+ function redact(message, source) {
83
+ let text = message;
84
+ for (const value of [...Object.values(source.security ?? {}), ...Object.values(source.headers ?? {})]) {
85
+ if (value) text = text.split(value).join("[redacted]");
86
+ }
87
+ return text;
88
+ }
89
+ async function probeOperation(doFetch, source, doc, schemes, baseUrl, path, item, operation, operationId, testUserToken) {
90
+ const params = collectParameters(doc, item, operation);
91
+ const opBaseUrl = resolveOperationBaseUrl(source, doc, item, operation, baseUrl);
92
+ const timeout = () => AbortSignal.timeout(source.timeoutMs);
93
+ try {
94
+ const unauthenticated = await doFetch(buildUrl(opBaseUrl, path, params, {}), {
95
+ method: "GET",
96
+ headers: { accept: "application/json, */*;q=0.8" },
97
+ signal: timeout()
98
+ });
99
+ const { headers: securedHeaders, query: securityQuery } = applySecurity(doc, source, operation, schemes);
100
+ const session = {
101
+ id: "connectivity-test",
102
+ tenantId: source.name,
103
+ user: { id: "connectivity-test", anonymous: !testUserToken, claims: {} },
104
+ ...testUserToken ? { userToken: testUserToken } : {},
105
+ context: {},
106
+ messages: [],
107
+ createdAt: Date.now(),
108
+ lastActiveAt: Date.now(),
109
+ messageCount: 0,
110
+ costUsd: 0,
111
+ busy: false
112
+ };
113
+ const headers = buildForwardedHeaders({ ...source, headers: securedHeaders }, session);
114
+ const authenticated = await doFetch(buildUrl(opBaseUrl, path, params, {}, securityQuery), {
115
+ method: "GET",
116
+ headers: { ...headers, accept: "application/json, */*;q=0.8" },
117
+ signal: timeout()
118
+ });
119
+ return {
120
+ operationId,
121
+ method: "GET",
122
+ path,
123
+ rejectsUnauthenticated: unauthenticated.status === 401 || unauthenticated.status === 403,
124
+ authenticatedOk: authenticated.ok,
125
+ deployed: !(unauthenticated.status === 404 && authenticated.status === 404)
126
+ };
127
+ } catch (error) {
128
+ return { operationId, method: "GET", path, deployed: false, error: redact(error instanceof Error ? error.message : String(error), source) };
129
+ }
130
+ }
131
+ async function testApiConnectivity(source, options = {}) {
132
+ const doFetch = options.fetchImpl ?? connectorFetch;
133
+ let doc;
134
+ try {
135
+ doc = await loadSpec(source.spec, doFetch);
136
+ } catch {
137
+ return { specReachable: false, baseUrlReachable: false, operations: [], ok: false };
138
+ }
139
+ const baseUrl = (source.baseUrl ?? specBaseUrl(doc))?.replace(/\/+$/, "");
140
+ if (!baseUrl) return { specReachable: true, baseUrlReachable: false, operations: [], ok: false };
141
+ const schemes = securitySchemes(doc);
142
+ const allowedMethods = new Set(source.methods);
143
+ const candidates = [];
144
+ for (const [path, item] of Object.entries(doc.paths ?? {})) {
145
+ if (candidates.length >= MAX_SAMPLED_OPERATIONS) break;
146
+ if (!isRecord(item)) continue;
147
+ const operation = item["get"];
148
+ if (!isRecord(operation) || !allowedMethods.has("GET")) continue;
149
+ const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `get_${slug(path)}`;
150
+ if (source.selectedTools && !source.selectedTools.includes(operationId)) continue;
151
+ if (collectParameters(doc, item, operation).some((param) => param.location === "path" && param.required)) continue;
152
+ candidates.push({ operationId, path, item, operation });
153
+ }
154
+ const operations = [];
155
+ for (const candidate of candidates) {
156
+ operations.push(await probeOperation(doFetch, source, doc, schemes, baseUrl, candidate.path, candidate.item, candidate.operation, candidate.operationId, options.testUserToken));
157
+ }
158
+ const ok = operations.length > 0 && operations.every((op) => !op.error && op.deployed && op.authenticatedOk);
159
+ return { specReachable: true, baseUrlReachable: true, operations, ok };
56
160
  }
57
161
 
58
162
  // src/control/automatic-skills.ts
@@ -155,6 +259,7 @@ async function automaticSkills(config, inventory, resolved, generate = generateS
155
259
  }
156
260
 
157
261
  // src/control/app.ts
262
+ import { jwtVerify } from "jose";
158
263
  import { Hono } from "hono";
159
264
  import { parseFrankieConfig, skillSchema as skillSchema3, approvedSkillTools } from "@frankieai/core";
160
265
  function createControlApp(options) {
@@ -163,6 +268,9 @@ function createControlApp(options) {
163
268
  const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event })));
164
269
  const app = new Hono();
165
270
  const generationLimits = new MemoryRateLimiter();
271
+ const assertionSecret = options.ownerAssertionSecret ?? process.env["FRANKIE_CONTROL_ASSERTION_SECRET"];
272
+ const assertionKey = assertionSecret ? new TextEncoder().encode(assertionSecret) : void 0;
273
+ const asserted = /* @__PURE__ */ new WeakMap();
166
274
  app.use("/admin/*", async (c, next) => {
167
275
  if (!adminToken) {
168
276
  return c.json({ error: "Control plane is locked: set FRANKIE_ADMIN_TOKEN." }, 503);
@@ -171,11 +279,32 @@ function createControlApp(options) {
171
279
  if (!safeEqual(bearer, adminToken)) {
172
280
  return c.json({ error: "Unauthorized." }, 401);
173
281
  }
282
+ if (assertionKey) {
283
+ const raw = c.req.header("x-frankie-owner-assertion");
284
+ if (raw) {
285
+ try {
286
+ const { payload } = await jwtVerify(raw, assertionKey, {
287
+ algorithms: ["HS256"],
288
+ issuer: "frankie-dashboard",
289
+ audience: "frankie-control",
290
+ clockTolerance: 30
291
+ });
292
+ asserted.set(c.req.raw, {
293
+ ...typeof payload.sub === "string" ? { owner: payload.sub } : {},
294
+ ...typeof payload["login"] === "string" ? { login: payload["login"].toLowerCase() } : {}
295
+ });
296
+ } catch {
297
+ return c.json({ error: "Invalid owner assertion." }, 401);
298
+ }
299
+ } else if (c.req.header("x-frankie-owner")) {
300
+ return c.json({ error: "A signed owner assertion is required." }, 401);
301
+ }
302
+ }
174
303
  await next();
175
304
  return;
176
305
  });
177
- const ownerOf = (c) => c.req.header("x-frankie-owner") || void 0;
178
- const loginOf = (c) => c.req.header("x-frankie-login")?.toLowerCase() || void 0;
306
+ const ownerOf = (c) => assertionKey ? asserted.get(c.req.raw)?.owner : c.req.header("x-frankie-owner") || void 0;
307
+ const loginOf = (c) => (assertionKey ? asserted.get(c.req.raw)?.login : c.req.header("x-frankie-login")?.toLowerCase()) || void 0;
179
308
  const bootstrapAdmins = new Set((options.bootstrapSuperAdmins ?? (process.env["FRANKIE_BOOTSTRAP_SUPER_ADMINS"] ?? "").split(",")).map((id) => id.trim()).filter((id) => /^github:[1-9]\d*$/.test(id)));
180
309
  const isSuperAdmin = async (c) => {
181
310
  const owner = ownerOf(c);
@@ -238,7 +367,7 @@ function createControlApp(options) {
238
367
  const body = await readJson(c) ?? {};
239
368
  if (!body.name?.trim()) return c.json({ error: "`name` is required." }, 400);
240
369
  const ownerId = ownerOf(c);
241
- let id = slug(body.name);
370
+ let id = slug2(body.name);
242
371
  if (await store.get(id)) id = `${id}-${randomBytes(3).toString("hex")}`;
243
372
  const publishableKey = `pk_live_${randomBytes(12).toString("hex")}`;
244
373
  const jwtSecret = randomBytes(36).toString("base64");
@@ -294,15 +423,25 @@ function createControlApp(options) {
294
423
  if (!record.ownerId) return null;
295
424
  return orgs.has(record.ownerId) ? record : null;
296
425
  };
426
+ const canAdminister = async (c, record) => {
427
+ if (!record.ownerId) return await accessibleOrgs(c) === void 0;
428
+ return canManageMembers(c, record.ownerId);
429
+ };
430
+ const adminForbidden = (c) => c.json({ error: "This action requires the workspace owner or an organization admin." }, 403);
297
431
  app.get("/admin/tenants", async (c) => {
432
+ const counts = sessions ? await sessions.countsByTenant() : {};
433
+ const withCounts = (record) => ({
434
+ ...toPublicTenant(record),
435
+ conversationCount: counts[record.id] ?? 0
436
+ });
298
437
  const orgs = await accessibleOrgs(c);
299
438
  if (!orgs) {
300
439
  if (ownerOf(c)) log({ level: "info", event: "platform_workspace_directory", actor: ownerOf(c) });
301
- return c.json({ tenants: (await store.list()).map(toPublicTenant) });
440
+ return c.json({ tenants: (await store.list()).map(withCounts) });
302
441
  }
303
442
  const records = [];
304
443
  for (const org of orgs) records.push(...await store.list(org));
305
- return c.json({ tenants: records.map(toPublicTenant) });
444
+ return c.json({ tenants: records.map(withCounts) });
306
445
  });
307
446
  const LOGIN_RE = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
308
447
  app.get("/admin/orgs/:org/members", async (c) => {
@@ -366,12 +505,14 @@ function createControlApp(options) {
366
505
  app.get("/admin/tenants/:id/jwt-secret", async (c) => {
367
506
  const record = await own(c);
368
507
  if (!record) return c.json({ error: "No such tenant." }, 404);
508
+ if (!await canAdminister(c, record)) return adminForbidden(c);
369
509
  if (!record.jwtSecret) return c.json({ error: "This workspace has no JWT secret." }, 404);
370
510
  return c.json({ secrets: { jwtSecret: record.jwtSecret } });
371
511
  });
372
512
  app.delete("/admin/tenants/:id", async (c) => {
373
513
  const record = await own(c);
374
514
  if (!record) return c.json({ error: "No such tenant." }, 404);
515
+ if (!await canAdminister(c, record)) return adminForbidden(c);
375
516
  if (record.locked) {
376
517
  return c.json({ error: "This workspace is locked. Unlock it before deleting." }, 409);
377
518
  }
@@ -521,15 +662,24 @@ function createControlApp(options) {
521
662
  if (body.pullRequestUrl && (!pullRequestUrl || !pullRequestUrl.startsWith(`https://github.com/${repository}/pull/`))) {
522
663
  return c.json({ error: "Pull request must belong to the connected repository." }, 400);
523
664
  }
665
+ const prev = record.repositoryInstall?.repository === repository ? record.repositoryInstall : void 0;
666
+ const rollup = ["success", "pending", "failure", "none"].includes(String(body.checkRollup)) ? body.checkRollup : void 0;
667
+ const pullState = ["draft", "open", "closed", "merged"].includes(String(body.pullRequestState)) ? body.pullRequestState : void 0;
668
+ const nextStatus = prev && prev.status !== "installing" && status === "installing" ? prev.status : status;
524
669
  const updated = {
525
670
  ...record,
526
671
  repositoryInstall: {
672
+ ...prev,
527
673
  repository,
528
- status,
674
+ status: nextStatus,
529
675
  ...typeof body.runId === "string" && body.runId ? { runId: body.runId } : {},
530
676
  ...pullRequestUrl ? { pullRequestUrl } : {},
531
677
  requiresJwtSecret: Boolean(body.requiresJwtSecret),
532
678
  ...typeof body.failureMessage === "string" && body.failureMessage ? { failureMessage: body.failureMessage } : {},
679
+ ...typeof body.step === "string" && body.step ? { step: body.step } : {},
680
+ ...pullState ? { pullRequestState: pullState } : {},
681
+ ...rollup ? { checkRollup: rollup } : {},
682
+ ...typeof body.checkedAt === "string" && body.checkedAt ? { checkedAt: body.checkedAt } : {},
533
683
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
534
684
  },
535
685
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -559,9 +709,25 @@ function createControlApp(options) {
559
709
  return c.json({ error: error instanceof Error ? error.message : "Could not refresh the API spec." }, 400);
560
710
  }
561
711
  });
712
+ app.post("/admin/tenants/:id/api-test", async (c) => {
713
+ const record = await own(c);
714
+ if (!record) return c.json({ error: "No such tenant." }, 404);
715
+ const body = await readJson(c);
716
+ const source = record.config.apis.find((api) => api.name === body?.name);
717
+ if (!source) return c.json({ error: "API connection not found." }, 404);
718
+ const testUserToken = typeof body?.testUserToken === "string" && body.testUserToken.trim() ? body.testUserToken.trim() : void 0;
719
+ const result = await testApiConnectivity(source, { testUserToken });
720
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
721
+ const apiConnectivityChecks = { ...record.apiConnectivityChecks, [source.name]: { checkedAt, ok: result.ok } };
722
+ const updated = { ...record, apiConnectivityChecks, updatedAt: checkedAt };
723
+ await store.put(updated);
724
+ log({ level: "info", event: "api_connectivity_tested", tenant: record.id, api: source.name, actor: ownerOf(c), ok: result.ok });
725
+ return c.json({ tenant: toPublicTenant(updated), result });
726
+ });
562
727
  app.put("/admin/tenants/:id/connection", async (c) => {
563
728
  const record = await own(c);
564
729
  if (!record) return c.json({ error: "No such tenant." }, 404);
730
+ if (!await canAdminister(c, record)) return adminForbidden(c);
565
731
  const body = await readJson(c) ?? {};
566
732
  let config;
567
733
  try {
@@ -587,7 +753,8 @@ function createControlApp(options) {
587
753
  policy: config.agent.tools,
588
754
  fetchImpl: (input, init) => connectorFetch(input, { ...init, signal: AbortSignal.timeout(8e3) })
589
755
  });
590
- return { ...api, capabilities };
756
+ const selectedTools = api.selectedTools?.filter((id) => capabilities.some((operation) => operation.operationId === id));
757
+ return { ...api, capabilities, ...api.selectedTools ? { selectedTools } : {} };
591
758
  } catch (error) {
592
759
  log({ level: "warn", event: "api_capability_inspection_failed", tenant: record.id, api: api.name, error: error instanceof Error ? error.message : String(error) });
593
760
  return { ...api, capabilities: [] };
@@ -652,7 +819,8 @@ function createControlApp(options) {
652
819
  try {
653
820
  if (result.failed.length) return c.json({ error: "Tool discovery could not connect. If this MCP requires a signed-in user, enter tool names from its tools/list response; Frankie will validate availability when that user chats." }, 422);
654
821
  const discoveredTools = result.tools.map((tool) => ({ name: tool.name.slice(("mcp__" + source.name + "__").length), description: tool.description }));
655
- const config = parseFrankieConfig({ ...record.config, mcpServers: record.config.mcpServers.map((s) => s.name === source.name ? { ...s, discoveredTools } : s) });
822
+ const selectedTools = source.selectedTools?.filter((name) => discoveredTools.some((tool) => tool.name === name));
823
+ const config = parseFrankieConfig({ ...record.config, mcpServers: record.config.mcpServers.map((s) => s.name === source.name ? { ...s, discoveredTools, ...source.selectedTools ? { selectedTools } : {} } : s) });
656
824
  const updated = { ...record, config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
657
825
  await store.put(updated);
658
826
  return c.json({ tenant: toPublicTenant(updated) });
@@ -683,9 +851,35 @@ function createControlApp(options) {
683
851
  await store.put(updated);
684
852
  return c.json({ tenant: toPublicTenant(updated) });
685
853
  });
854
+ app.put("/admin/tenants/:id/api-security", async (c) => {
855
+ const record = await own(c);
856
+ if (!record) return c.json({ error: "No such tenant." }, 404);
857
+ if (!await canAdminister(c, record)) return adminForbidden(c);
858
+ const body = await readJson(c);
859
+ const api = record.config.apis.find((s) => s.name === body?.name);
860
+ if (!api) return c.json({ error: "API source not found." }, 404);
861
+ if (!body?.security || typeof body.security !== "object") return c.json({ error: "`security` credentials are required." }, 400);
862
+ const security = { ...api.security };
863
+ for (const [scheme, value] of Object.entries(body.security)) {
864
+ if (typeof value !== "string") continue;
865
+ if (value.trim() === "") delete security[scheme];
866
+ else security[scheme] = value;
867
+ }
868
+ let config;
869
+ try {
870
+ config = parseFrankieConfig({ ...record.config, apis: record.config.apis.map((s) => s.name === api.name ? { ...s, security } : s) });
871
+ } catch (error) {
872
+ return c.json({ error: `Invalid security credentials: ${error.message}` }, 400);
873
+ }
874
+ const updated = { ...record, config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
875
+ await store.put(updated);
876
+ log({ level: "info", event: "tenant_api_security_updated", tenant: record.id, api: api.name, schemes: Object.keys(security) });
877
+ return c.json({ tenant: toPublicTenant(updated) });
878
+ });
686
879
  app.put("/admin/tenants/:id/origins", async (c) => {
687
880
  const record = await own(c);
688
881
  if (!record) return c.json({ error: "No such tenant." }, 404);
882
+ if (!await canAdminister(c, record)) return adminForbidden(c);
689
883
  const body = await readJson(c) ?? {};
690
884
  let config;
691
885
  try {
@@ -733,6 +927,7 @@ function createControlApp(options) {
733
927
  app.post("/admin/tenants/:id/rotate-jwt-secret", async (c) => {
734
928
  const record = await own(c);
735
929
  if (!record) return c.json({ error: "No such tenant." }, 404);
930
+ if (!await canAdminister(c, record)) return adminForbidden(c);
736
931
  const jwtSecret = randomBytes(36).toString("base64");
737
932
  const updated = { ...record, jwtSecret, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
738
933
  await store.put(updated);
@@ -781,7 +976,7 @@ async function readJson(c) {
781
976
  return null;
782
977
  }
783
978
  }
784
- function slug(value) {
979
+ function slug2(value) {
785
980
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "tenant";
786
981
  }
787
982
  function mergeSources(current, updates) {
@@ -797,4 +992,4 @@ function mergeSources(current, updates) {
797
992
  export {
798
993
  createControlApp
799
994
  };
800
- //# sourceMappingURL=chunk-UZSHFMJN.js.map
995
+ //# sourceMappingURL=chunk-NSOMFNTK.js.map