@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.
- package/dist/chunk-JOADNB5J.js +2918 -0
- package/dist/chunk-JOADNB5J.js.map +1 -0
- package/dist/chunk-NF6IJVES.js +75 -0
- package/dist/chunk-NF6IJVES.js.map +1 -0
- package/dist/{chunk-UZSHFMJN.js → chunk-NSOMFNTK.js} +208 -13
- package/dist/chunk-NSOMFNTK.js.map +1 -0
- package/dist/control-server.js +7 -3
- package/dist/control-server.js.map +1 -1
- package/dist/index.d.ts +46 -8
- package/dist/index.js +12 -12
- package/dist/server.js +3 -3
- package/package.json +5 -2
- package/dist/chunk-UZSHFMJN.js.map +0 -1
- package/dist/chunk-WFENEYOV.js +0 -1266
- package/dist/chunk-WFENEYOV.js.map +0 -1
- package/dist/chunk-XCIM765D.js +0 -1551
- package/dist/chunk-XCIM765D.js.map +0 -1
|
@@ -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-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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-
|
|
995
|
+
//# sourceMappingURL=chunk-NSOMFNTK.js.map
|