@frankieai/server 0.2.2 → 0.2.4
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 → chunk-7J5Z5MIK.js} +337 -45
- package/dist/chunk-7J5Z5MIK.js.map +1 -0
- package/dist/{chunk-NSOMFNTK.js → chunk-YDXEBJON.js} +150 -15
- package/dist/chunk-YDXEBJON.js.map +1 -0
- package/dist/control-server.js +2 -2
- package/dist/index.d.ts +78 -1
- package/dist/index.js +2 -2
- package/dist/server.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-JOADNB5J.js.map +0 -1
- package/dist/chunk-NSOMFNTK.js.map +0 -1
|
@@ -441,7 +441,6 @@ var OpenApiError = class extends Error {
|
|
|
441
441
|
};
|
|
442
442
|
async function inspectApiCapabilities(source, options = {}) {
|
|
443
443
|
const doc = await loadSpec(source.spec, options.fetchImpl);
|
|
444
|
-
const allowedMethods = new Set(source.methods);
|
|
445
444
|
const hasPerUserAuth = Boolean(source.forwardUserTokenAs) || source.forwardUserClaims.length > 0;
|
|
446
445
|
const capabilities = [];
|
|
447
446
|
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
@@ -451,7 +450,6 @@ async function inspectApiCapabilities(source, options = {}) {
|
|
|
451
450
|
const operation = item[method];
|
|
452
451
|
if (!isRecord(operation)) continue;
|
|
453
452
|
const httpMethod = method.toUpperCase();
|
|
454
|
-
if (!allowedMethods.has(httpMethod)) continue;
|
|
455
453
|
const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
|
|
456
454
|
if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
|
|
457
455
|
const toolName = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
|
|
@@ -482,8 +480,9 @@ async function buildApiTools(options) {
|
|
|
482
480
|
}
|
|
483
481
|
async function buildOne(source, options) {
|
|
484
482
|
const doc = await loadSpec(source.spec, options.fetchImpl);
|
|
483
|
+
const clientMode = source.execution === "client";
|
|
485
484
|
const baseUrl = (source.baseUrl ?? specBaseUrl(doc))?.replace(/\/+$/, "");
|
|
486
|
-
if (!baseUrl) {
|
|
485
|
+
if (!baseUrl && !clientMode) {
|
|
487
486
|
throw new OpenApiError(
|
|
488
487
|
`API "${source.name}" needs a baseUrl: its spec declares no \`servers\` entry to fall back to.`
|
|
489
488
|
);
|
|
@@ -498,17 +497,17 @@ async function buildOne(source, options) {
|
|
|
498
497
|
for (const method of ["get", "post", "put", "patch", "delete"]) {
|
|
499
498
|
const operation = item[method];
|
|
500
499
|
if (!isRecord(operation)) continue;
|
|
501
|
-
if (!allowedMethods.has(method.toUpperCase())) continue;
|
|
502
500
|
const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
|
|
503
501
|
if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
|
|
504
|
-
if (source.selectedTools
|
|
502
|
+
if (source.selectedTools ? !source.selectedTools.includes(operationId) : !allowedMethods.has(method.toUpperCase())) continue;
|
|
505
503
|
const name = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
|
|
506
504
|
if (!evaluateToolPolicy(name, options.policy).allowed) continue;
|
|
507
505
|
const params = collectParameters(doc, item, operation);
|
|
508
506
|
const body = collectRequestBody(doc, operation);
|
|
507
|
+
const scopeRules = parseScopeRules(operation);
|
|
509
508
|
const { headers: securedHeaders, query: securityQuery } = applySecurity(doc, source, operation, schemes);
|
|
510
509
|
const headers = buildForwardedHeaders({ ...source, headers: securedHeaders }, options.session);
|
|
511
|
-
const opBaseUrl = resolveOperationBaseUrl(source, doc, item, operation, baseUrl);
|
|
510
|
+
const opBaseUrl = resolveOperationBaseUrl(source, doc, item, operation, baseUrl ?? "");
|
|
512
511
|
tools.push({
|
|
513
512
|
name,
|
|
514
513
|
label: describeTool(name, {}),
|
|
@@ -517,7 +516,29 @@ async function buildOne(source, options) {
|
|
|
517
516
|
${retrySafetyNote(method, doc, item, operation)}`,
|
|
518
517
|
parameters: Type.Unsafe(toolSchema(params, body)),
|
|
519
518
|
async execute(_id, args, signal) {
|
|
519
|
+
if (clientMode && options.clientBridge?.available) {
|
|
520
|
+
const clientInput = isRecord(args) ? args : {};
|
|
521
|
+
const relPath = buildRelativePath(path, params, clientInput);
|
|
522
|
+
const clientBody = body ? clientInput["body"] : void 0;
|
|
523
|
+
const result = await options.clientBridge.execute(
|
|
524
|
+
name,
|
|
525
|
+
{
|
|
526
|
+
source: source.name,
|
|
527
|
+
method: method.toUpperCase(),
|
|
528
|
+
path: relPath,
|
|
529
|
+
...clientBody !== void 0 ? { body: clientBody } : {}
|
|
530
|
+
},
|
|
531
|
+
signal ?? AbortSignal.timeout(source.timeoutMs)
|
|
532
|
+
);
|
|
533
|
+
return formatClientResult(operationId, result, source, options.session);
|
|
534
|
+
}
|
|
535
|
+
if (!opBaseUrl) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`"${operationId}" runs in the user's browser and has no server base URL, so it can't run over the hosted MCP endpoint or a headless caller. Set a baseUrl (and a user-token forwarding header) to expose it that way too. Report this to the user.`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
520
540
|
const input = enforceIdentityParams(params, isRecord(args) ? args : {}, options.session);
|
|
541
|
+
enforceResourceScope(scopeRules, input, options.session);
|
|
521
542
|
const url = buildUrl(opBaseUrl, path, params, input, securityQuery);
|
|
522
543
|
const rawBody = body ? input["body"] : void 0;
|
|
523
544
|
const requestBody = isRecord(rawBody) ? enforceServerDerivedIdentity(rawBody, options.session) : rawBody;
|
|
@@ -531,7 +552,7 @@ ${retrySafetyNote(method, doc, item, operation)}`,
|
|
|
531
552
|
...requestBody !== void 0 ? { body: JSON.stringify(requestBody) } : {},
|
|
532
553
|
signal: signal ?? AbortSignal.timeout(source.timeoutMs)
|
|
533
554
|
});
|
|
534
|
-
const text = redactSecrets(
|
|
555
|
+
const text = truncate(redactSecrets(await response.text().catch(() => ""), source, options.session), source.maxResponseChars);
|
|
535
556
|
if (!response.ok) {
|
|
536
557
|
const detail = `${operationId} failed with HTTP ${response.status}: ${truncate(text, 400)}`;
|
|
537
558
|
throw new Error(
|
|
@@ -691,6 +712,42 @@ function buildUrl(baseUrl, path, params, input, extraQuery = {}) {
|
|
|
691
712
|
}
|
|
692
713
|
return url.toString();
|
|
693
714
|
}
|
|
715
|
+
function buildRelativePath(path, params, input) {
|
|
716
|
+
let resolved = path;
|
|
717
|
+
for (const param of params) {
|
|
718
|
+
if (param.location !== "path") continue;
|
|
719
|
+
resolved = resolved.replace(`{${param.name}}`, encodeURIComponent(String(input[param.name] ?? "")));
|
|
720
|
+
}
|
|
721
|
+
const query = new URLSearchParams();
|
|
722
|
+
for (const param of params) {
|
|
723
|
+
if (param.location !== "query") continue;
|
|
724
|
+
const value = input[param.name];
|
|
725
|
+
if (value === void 0 || value === null) continue;
|
|
726
|
+
if (Array.isArray(value)) {
|
|
727
|
+
for (const entry of value) query.append(param.name, String(entry));
|
|
728
|
+
} else {
|
|
729
|
+
query.set(param.name, String(value));
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const rel = resolved.startsWith("/") ? resolved.slice(1) : resolved;
|
|
733
|
+
const qs = query.toString();
|
|
734
|
+
return qs ? `${rel}?${qs}` : rel;
|
|
735
|
+
}
|
|
736
|
+
function formatClientResult(operationId, result, source, session) {
|
|
737
|
+
if (result.error) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
`${operationId} could not be reached from the browser: ${truncate(result.error, 300)}. Report this to the user.`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
const text = truncate(redactSecrets(result.body ?? "", source, session), source.maxResponseChars);
|
|
743
|
+
if (result.status < 200 || result.status >= 300) {
|
|
744
|
+
const detail = `${operationId} failed with HTTP ${result.status}: ${truncate(text, 400)}`;
|
|
745
|
+
throw new Error(
|
|
746
|
+
result.status === 401 || result.status === 403 ? `${detail} (authentication/authorization failure). Do not retry by guessing or supplying a different resource ID, header, or credential \u2014 that will not fix an authentication failure. Report the exact error to the user.` : detail
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
return { content: [{ type: "text", text: text || "(empty response)" }], details: null };
|
|
750
|
+
}
|
|
694
751
|
function describeOperation(operation, method, path) {
|
|
695
752
|
const summary = typeof operation["summary"] === "string" ? operation["summary"] : "";
|
|
696
753
|
const description = typeof operation["description"] === "string" ? operation["description"] : "";
|
|
@@ -766,7 +823,11 @@ function asArray(value) {
|
|
|
766
823
|
function isRecord(value) {
|
|
767
824
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
768
825
|
}
|
|
769
|
-
var
|
|
826
|
+
var IDENTITY_ACTOR = "user|owner|account|customer|author|member|assignee|principal|editor|creator|created_?by|updated_?by|modified_?by";
|
|
827
|
+
var IDENTITY_FIELD_PATTERN = new RegExp(
|
|
828
|
+
`^(?:(?:${IDENTITY_ACTOR})[_-]?id|owner|author|assignee|actor|principal|creator|created_?by|on[_-]?behalf[_-]?of)$`,
|
|
829
|
+
"i"
|
|
830
|
+
);
|
|
770
831
|
function enforceServerDerivedIdentity(body, session) {
|
|
771
832
|
return enforceIdentityDeep(body, session);
|
|
772
833
|
}
|
|
@@ -775,7 +836,7 @@ function enforceIdentityDeep(value, session) {
|
|
|
775
836
|
if (!isRecord(value)) return value;
|
|
776
837
|
const next = {};
|
|
777
838
|
for (const [key, inner] of Object.entries(value)) {
|
|
778
|
-
if (IDENTITY_FIELD_PATTERN.test(key)) {
|
|
839
|
+
if (IDENTITY_FIELD_PATTERN.test(key) && !isRecord(inner) && !Array.isArray(inner)) {
|
|
779
840
|
if (session.user.anonymous) continue;
|
|
780
841
|
next[key] = session.user.id;
|
|
781
842
|
} else {
|
|
@@ -797,6 +858,39 @@ function enforceIdentityParams(params, input, session) {
|
|
|
797
858
|
}
|
|
798
859
|
return next;
|
|
799
860
|
}
|
|
861
|
+
function parseScopeRules(operation) {
|
|
862
|
+
const raw = operation["x-frankie-scope"];
|
|
863
|
+
const entries = Array.isArray(raw) ? raw : raw !== void 0 ? [raw] : [];
|
|
864
|
+
const rules = [];
|
|
865
|
+
for (const entry of entries) {
|
|
866
|
+
if (isRecord(entry) && typeof entry["param"] === "string" && typeof entry["claim"] === "string") {
|
|
867
|
+
rules.push({ param: entry["param"], claim: entry["claim"] });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return rules;
|
|
871
|
+
}
|
|
872
|
+
function enforceResourceScope(rules, input, session) {
|
|
873
|
+
for (const rule of rules) {
|
|
874
|
+
if (session.user.anonymous) {
|
|
875
|
+
throw new Error(
|
|
876
|
+
`"${rule.param}" is access-scoped, but the chat session is anonymous \u2014 Frankie will not call it for an unauthenticated visitor. Report this to the user instead of retrying.`
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
const requested = input[rule.param];
|
|
880
|
+
if (requested === void 0 || requested === null || requested === "") {
|
|
881
|
+
throw new Error(
|
|
882
|
+
`This operation requires an in-scope "${rule.param}" and none was given. Report this to the user instead of retrying.`
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
const allowed = session.user.claims[rule.claim];
|
|
886
|
+
const allowedList = Array.isArray(allowed) ? allowed.map((entry) => String(entry)) : typeof allowed === "string" ? [allowed] : [];
|
|
887
|
+
if (!allowedList.includes(String(requested))) {
|
|
888
|
+
throw new Error(
|
|
889
|
+
`Access denied: "${String(requested)}" is not in this user's "${rule.claim}" scope. Do not retry with a different id \u2014 report this to the user.`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
800
894
|
|
|
801
895
|
// src/agent/mcp.ts
|
|
802
896
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
@@ -830,7 +924,7 @@ async function connectMcpTools(options) {
|
|
|
830
924
|
if (result.server.selectedTools && !result.server.selectedTools.includes(tool.name)) continue;
|
|
831
925
|
const name = `${MCP_TOOL_PREFIX}${result.server.name}__${tool.name}`;
|
|
832
926
|
if (!evaluateToolPolicy2(name, options.policy).allowed) continue;
|
|
833
|
-
tools.push(adaptTool(name, tool, result.client, options.callTimeoutMs));
|
|
927
|
+
tools.push(adaptTool(name, tool, result.client, result.server, options.session, options.callTimeoutMs));
|
|
834
928
|
}
|
|
835
929
|
} catch (error) {
|
|
836
930
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -863,7 +957,34 @@ async function connectOne(server, options) {
|
|
|
863
957
|
}
|
|
864
958
|
return client;
|
|
865
959
|
}
|
|
866
|
-
function
|
|
960
|
+
function mcpSecrets(server, session) {
|
|
961
|
+
const secrets = [];
|
|
962
|
+
for (const value of Object.values(server.headers ?? {})) {
|
|
963
|
+
if (!value) continue;
|
|
964
|
+
secrets.push(value);
|
|
965
|
+
secrets.push(Buffer.from(value, "utf8").toString("base64"));
|
|
966
|
+
}
|
|
967
|
+
if (session?.userToken) secrets.push(session.userToken);
|
|
968
|
+
return secrets.sort((a, b) => b.length - a.length);
|
|
969
|
+
}
|
|
970
|
+
function redactString(text, secrets) {
|
|
971
|
+
let out = text;
|
|
972
|
+
for (const value of secrets) out = out.split(value).join("[redacted]");
|
|
973
|
+
return out;
|
|
974
|
+
}
|
|
975
|
+
function redactDeep(value, secrets) {
|
|
976
|
+
if (typeof value === "string") return redactString(value, secrets);
|
|
977
|
+
if (Array.isArray(value)) return value.map((entry) => redactDeep(entry, secrets));
|
|
978
|
+
if (value && typeof value === "object") {
|
|
979
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v, secrets)]));
|
|
980
|
+
}
|
|
981
|
+
return value;
|
|
982
|
+
}
|
|
983
|
+
function redactContent(content, secrets) {
|
|
984
|
+
return content.map((block) => block.type === "text" ? { ...block, text: redactString(block.text, secrets) } : block);
|
|
985
|
+
}
|
|
986
|
+
function adaptTool(name, tool, client, server, session, callTimeoutMs = 6e4) {
|
|
987
|
+
const secrets = mcpSecrets(server, session);
|
|
867
988
|
return {
|
|
868
989
|
name,
|
|
869
990
|
label: describeTool2(name, {}),
|
|
@@ -882,11 +1003,11 @@ function adaptTool(name, tool, client, callTimeoutMs = 6e4) {
|
|
|
882
1003
|
callTimeoutMs,
|
|
883
1004
|
`The ${tool.name} tool did not respond in time.`
|
|
884
1005
|
);
|
|
885
|
-
const content = toAgentContent(response.content);
|
|
1006
|
+
const content = redactContent(toAgentContent(response.content), secrets);
|
|
886
1007
|
if (response.isError) {
|
|
887
1008
|
throw new Error(textOf(content) || `The ${tool.name} tool reported an error.`);
|
|
888
1009
|
}
|
|
889
|
-
return { content, details: response.structuredContent
|
|
1010
|
+
return { content, details: response.structuredContent === void 0 ? null : redactDeep(response.structuredContent, secrets) };
|
|
890
1011
|
}
|
|
891
1012
|
};
|
|
892
1013
|
}
|
|
@@ -1134,6 +1255,7 @@ async function collectTools(options) {
|
|
|
1134
1255
|
session,
|
|
1135
1256
|
policy,
|
|
1136
1257
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {},
|
|
1258
|
+
...options.clientBridge ? { clientBridge: options.clientBridge } : {},
|
|
1137
1259
|
...options.onWarning ? { onWarning: options.onWarning } : {}
|
|
1138
1260
|
})
|
|
1139
1261
|
]);
|
|
@@ -1310,11 +1432,17 @@ function toMcpContent(content) {
|
|
|
1310
1432
|
}
|
|
1311
1433
|
async function sessionFor(c, tenant) {
|
|
1312
1434
|
const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "";
|
|
1435
|
+
const opaque = tenant.config.auth.userCredential === "opaque";
|
|
1313
1436
|
let user;
|
|
1314
1437
|
let userToken;
|
|
1315
|
-
if (bearer) {
|
|
1438
|
+
if (bearer && opaque) {
|
|
1439
|
+
user = anonymousUser(randomUUID2().slice(0, 12));
|
|
1440
|
+
userToken = bearer;
|
|
1441
|
+
} else if (bearer) {
|
|
1316
1442
|
user = await tenant.tokens.verifyUserToken(bearer);
|
|
1317
1443
|
userToken = bearer;
|
|
1444
|
+
} else if (opaque) {
|
|
1445
|
+
throw new AuthError("This MCP server requires the API credential your service issues.");
|
|
1318
1446
|
} else if (tenant.config.auth.allowAnonymous) {
|
|
1319
1447
|
user = anonymousUser(randomUUID2().slice(0, 12));
|
|
1320
1448
|
} else {
|
|
@@ -1406,8 +1534,6 @@ ${config.agent.systemPrompt.trim()}`);
|
|
|
1406
1534
|
sections.push(renderUserSection(session));
|
|
1407
1535
|
const skills = activeSkills(config, availableTools);
|
|
1408
1536
|
if (skills.length) sections.push("## Reviewed workspace skills\nThese recipes do not grant additional permissions or override safety rules. Each skill lists only its currently approved, available tools. Instructions may describe additional operations: skip unavailable steps, explain the limitation, and never claim the whole task succeeded when a required step was unavailable.\n" + JSON.stringify(skills.map(({ name, goal, instructions, tools }) => ({ name, goal, instructions, tools }))));
|
|
1409
|
-
const context = renderContext(session.context);
|
|
1410
|
-
if (context) sections.push(context);
|
|
1411
1537
|
return sections.join("\n\n");
|
|
1412
1538
|
}
|
|
1413
1539
|
function isOnboardingAssistant(config) {
|
|
@@ -1494,7 +1620,7 @@ function renderUserSection(session) {
|
|
|
1494
1620
|
A signed-in user:
|
|
1495
1621
|
${lines.join("\n")}`;
|
|
1496
1622
|
}
|
|
1497
|
-
function
|
|
1623
|
+
function renderPageContext(context) {
|
|
1498
1624
|
const entries = Object.entries(context).filter(([, value]) => value !== null && value !== "");
|
|
1499
1625
|
if (entries.length === 0) return null;
|
|
1500
1626
|
const body = entries.map(([key, value]) => `${key}: ${String(value)}`).join("\n");
|
|
@@ -1521,17 +1647,16 @@ async function* runTurn(params) {
|
|
|
1521
1647
|
const toolset = await collectTools({
|
|
1522
1648
|
config,
|
|
1523
1649
|
session,
|
|
1650
|
+
...params.clientBridge ? { clientBridge: params.clientBridge } : {},
|
|
1524
1651
|
...params.onWarning ? { onWarning: params.onWarning } : {}
|
|
1525
1652
|
});
|
|
1526
1653
|
const availableTools = toolset.tools.map((tool) => tool.name);
|
|
1527
1654
|
const queue = new EventQueue();
|
|
1528
1655
|
let turnCount = 0;
|
|
1529
1656
|
let lastAssistant;
|
|
1530
|
-
const usage = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
1657
|
+
const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0 };
|
|
1531
1658
|
const { images, documentText } = splitAttachments(params.attachments ?? []);
|
|
1532
|
-
const promptText = documentText
|
|
1533
|
-
|
|
1534
|
-
${documentText}` : params.message;
|
|
1659
|
+
const promptText = [params.message, documentText, renderPageContext(session.context)].filter((part) => Boolean(part)).join("\n\n");
|
|
1535
1660
|
const agent = new Agent({
|
|
1536
1661
|
initialState: {
|
|
1537
1662
|
systemPrompt: buildSystemPrompt(config, session, availableTools),
|
|
@@ -1545,7 +1670,13 @@ ${documentText}` : params.message;
|
|
|
1545
1670
|
streamFn: (model, context, options) => params.models.streamSimple(model, context, {
|
|
1546
1671
|
...options,
|
|
1547
1672
|
maxTokens: config.model.maxTokens,
|
|
1548
|
-
temperature: config.model.temperature
|
|
1673
|
+
temperature: config.model.temperature,
|
|
1674
|
+
// Ask for prompt caching explicitly rather than relying on the library
|
|
1675
|
+
// default (which an env var can silently turn off). With the session id
|
|
1676
|
+
// the agent already carries, providers that cache by replica affinity
|
|
1677
|
+
// route every turn of a conversation to the same warm replica, and
|
|
1678
|
+
// Anthropic-style providers get cache_control markers on the prefix.
|
|
1679
|
+
cacheRetention: "short"
|
|
1549
1680
|
}),
|
|
1550
1681
|
getApiKey: () => params.apiKey,
|
|
1551
1682
|
sessionId: session.id,
|
|
@@ -1576,10 +1707,18 @@ ${documentText}` : params.message;
|
|
|
1576
1707
|
usage.inputTokens += event.message.usage.input;
|
|
1577
1708
|
usage.outputTokens += event.message.usage.output;
|
|
1578
1709
|
usage.costUsd += event.message.usage.cost?.total ?? 0;
|
|
1710
|
+
usage.cacheReadTokens += event.message.usage.cacheRead ?? 0;
|
|
1711
|
+
usage.cacheWriteTokens += event.message.usage.cacheWrite ?? 0;
|
|
1579
1712
|
}
|
|
1580
1713
|
});
|
|
1581
1714
|
const onAbort = () => agent.abort();
|
|
1582
1715
|
params.signal.addEventListener("abort", onAbort, { once: true });
|
|
1716
|
+
let transcriptSaved = false;
|
|
1717
|
+
const saveTranscript = () => {
|
|
1718
|
+
if (transcriptSaved) return;
|
|
1719
|
+
transcriptSaved = true;
|
|
1720
|
+
params.onTranscript?.(stripReasoningFromMessages(agent.state.messages));
|
|
1721
|
+
};
|
|
1583
1722
|
const run = agent.prompt(promptText, images.length ? images : void 0).then(() => void 0).catch((error) => {
|
|
1584
1723
|
queue.push({
|
|
1585
1724
|
type: "error",
|
|
@@ -1593,13 +1732,16 @@ ${documentText}` : params.message;
|
|
|
1593
1732
|
yield { type: "tools_active", apis: toolset.apis };
|
|
1594
1733
|
for await (const event of queue) yield event;
|
|
1595
1734
|
await run;
|
|
1596
|
-
|
|
1735
|
+
saveTranscript();
|
|
1597
1736
|
yield {
|
|
1598
1737
|
type: "turn_end",
|
|
1599
1738
|
stopReason: stopReasonFor(params.signal, turnCount, config, lastAssistant),
|
|
1600
1739
|
...lastAssistant ? { usage } : {}
|
|
1601
1740
|
};
|
|
1602
1741
|
} finally {
|
|
1742
|
+
if (!params.signal.aborted) agent.abort();
|
|
1743
|
+
await run;
|
|
1744
|
+
saveTranscript();
|
|
1603
1745
|
params.signal.removeEventListener("abort", onAbort);
|
|
1604
1746
|
unsubscribe();
|
|
1605
1747
|
await toolset.close();
|
|
@@ -1810,8 +1952,9 @@ var EventQueue = class {
|
|
|
1810
1952
|
};
|
|
1811
1953
|
|
|
1812
1954
|
// src/app.ts
|
|
1813
|
-
import { randomUUID as
|
|
1955
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1814
1956
|
import { Hono as Hono3 } from "hono";
|
|
1957
|
+
import { suggestedPromptsFromSkills } from "@frankieai/core";
|
|
1815
1958
|
|
|
1816
1959
|
// src/guide.ts
|
|
1817
1960
|
import { Hono as Hono2 } from "hono";
|
|
@@ -1904,6 +2047,91 @@ function createGuideApp() {
|
|
|
1904
2047
|
return app;
|
|
1905
2048
|
}
|
|
1906
2049
|
|
|
2050
|
+
// src/agent/client-tools.ts
|
|
2051
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2052
|
+
var ClientToolChannel = class {
|
|
2053
|
+
emitters = /* @__PURE__ */ new Map();
|
|
2054
|
+
pending = /* @__PURE__ */ new Map();
|
|
2055
|
+
/** Bind the SSE emitter for a session at the start of its turn. */
|
|
2056
|
+
bindSession(sessionId, emit) {
|
|
2057
|
+
this.emitters.set(sessionId, emit);
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Drop a session's emitter at turn end and fail any calls still waiting on the
|
|
2061
|
+
* browser — the stream that would deliver their answer is gone.
|
|
2062
|
+
*/
|
|
2063
|
+
releaseSession(sessionId) {
|
|
2064
|
+
this.emitters.delete(sessionId);
|
|
2065
|
+
for (const [key, entry] of this.pending) {
|
|
2066
|
+
if (!key.startsWith(`${sessionId}::`)) continue;
|
|
2067
|
+
this.pending.delete(key);
|
|
2068
|
+
entry.timer && clearTimeout(entry.timer);
|
|
2069
|
+
entry.cleanup();
|
|
2070
|
+
entry.reject(new Error("The chat stream closed before the browser returned a tool result."));
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
/** A bridge scoped to one session and turn timeout, handed to the tool builder. */
|
|
2074
|
+
bridge(sessionId, defaultTimeoutMs) {
|
|
2075
|
+
const channel = this;
|
|
2076
|
+
return {
|
|
2077
|
+
get available() {
|
|
2078
|
+
return channel.emitters.has(sessionId);
|
|
2079
|
+
},
|
|
2080
|
+
execute(name, request, signal) {
|
|
2081
|
+
return channel.request(sessionId, name, request, defaultTimeoutMs, signal);
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
request(sessionId, name, request, timeoutMs, signal) {
|
|
2086
|
+
const emit = this.emitters.get(sessionId);
|
|
2087
|
+
if (!emit) {
|
|
2088
|
+
return Promise.reject(
|
|
2089
|
+
new Error(
|
|
2090
|
+
`"${name}" runs in the user's browser, but this session has no live browser channel (for example the hosted MCP endpoint or a headless caller). Report this to the user.`
|
|
2091
|
+
)
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
const callId = randomUUID3();
|
|
2095
|
+
const key = `${sessionId}::${callId}`;
|
|
2096
|
+
return new Promise((resolve2, reject) => {
|
|
2097
|
+
const onAbort = () => {
|
|
2098
|
+
this.pending.delete(key);
|
|
2099
|
+
clearTimeout(timer);
|
|
2100
|
+
cleanup();
|
|
2101
|
+
reject(new Error("The turn was aborted before the browser returned a tool result."));
|
|
2102
|
+
};
|
|
2103
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
2104
|
+
const timer = setTimeout(() => {
|
|
2105
|
+
this.pending.delete(key);
|
|
2106
|
+
cleanup();
|
|
2107
|
+
reject(new Error("The browser did not return a tool result in time."));
|
|
2108
|
+
}, timeoutMs);
|
|
2109
|
+
if (signal?.aborted) {
|
|
2110
|
+
clearTimeout(timer);
|
|
2111
|
+
reject(new Error("The turn was already aborted."));
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2115
|
+
this.pending.set(key, { resolve: resolve2, reject, timer, cleanup });
|
|
2116
|
+
emit({ type: "tool_call_request", callId, toolUseId: callId, name, request });
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* Deliver a browser's outcome to the waiting call. Returns false when no call
|
|
2121
|
+
* is pending for this (session, callId) — a duplicate, late, or forged result.
|
|
2122
|
+
*/
|
|
2123
|
+
resolve(sessionId, callId, result) {
|
|
2124
|
+
const key = `${sessionId}::${callId}`;
|
|
2125
|
+
const entry = this.pending.get(key);
|
|
2126
|
+
if (!entry) return false;
|
|
2127
|
+
this.pending.delete(key);
|
|
2128
|
+
clearTimeout(entry.timer);
|
|
2129
|
+
entry.cleanup();
|
|
2130
|
+
entry.resolve(result);
|
|
2131
|
+
return true;
|
|
2132
|
+
}
|
|
2133
|
+
};
|
|
2134
|
+
|
|
1907
2135
|
// src/app.ts
|
|
1908
2136
|
var HEARTBEAT_MS = 15e3;
|
|
1909
2137
|
function createFrankieApp(options) {
|
|
@@ -1913,6 +2141,7 @@ function createFrankieApp(options) {
|
|
|
1913
2141
|
const registry = options.resolved ? TenantRegistry.single(options.resolved) : options.tenants instanceof TenantRegistry ? options.tenants : new TenantRegistry(options.tenants);
|
|
1914
2142
|
const sessions = options.sessions ?? new MemorySessionStore();
|
|
1915
2143
|
const limiter = options.rateLimiter ?? new MemoryRateLimiter();
|
|
2144
|
+
const clientTools = new ClientToolChannel();
|
|
1916
2145
|
const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event })));
|
|
1917
2146
|
const app = new Hono3();
|
|
1918
2147
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
@@ -1924,6 +2153,7 @@ function createFrankieApp(options) {
|
|
|
1924
2153
|
if (origin) setCors(c, origin);
|
|
1925
2154
|
return c.body(null, 204);
|
|
1926
2155
|
}
|
|
2156
|
+
if (origin) setCors(c, origin);
|
|
1927
2157
|
const key = c.req.header("x-frankie-key") ?? "";
|
|
1928
2158
|
let tenant;
|
|
1929
2159
|
try {
|
|
@@ -1942,7 +2172,6 @@ function createFrankieApp(options) {
|
|
|
1942
2172
|
if (origin && !isOriginAllowed(origin, tenant.config.auth.allowedOrigins)) {
|
|
1943
2173
|
return jsonError(c, 403, "forbidden_origin", `Origin ${origin} is not allowed for this tenant.`);
|
|
1944
2174
|
}
|
|
1945
|
-
if (origin) setCors(c, origin);
|
|
1946
2175
|
c.set("tenant", tenant);
|
|
1947
2176
|
await next();
|
|
1948
2177
|
return;
|
|
@@ -1958,7 +2187,13 @@ function createFrankieApp(options) {
|
|
|
1958
2187
|
launcherLabel: config.branding.launcherLabel
|
|
1959
2188
|
},
|
|
1960
2189
|
greeting: config.agent.greeting,
|
|
1961
|
-
|
|
2190
|
+
// Fall back to prompts derived from the workspace's own published skills,
|
|
2191
|
+
// so an empty chat suggests what this assistant can actually do instead of
|
|
2192
|
+
// nothing. An explicitly authored list always wins.
|
|
2193
|
+
suggestedPrompts: config.agent.suggestedPrompts.length ? config.agent.suggestedPrompts : suggestedPromptsFromSkills(config),
|
|
2194
|
+
// Explicit starter cards configured in the dashboard's Appearance tab.
|
|
2195
|
+
// When set, the widget renders these instead of the suggestedPrompts.
|
|
2196
|
+
starterPrompts: config.agent.starterPrompts,
|
|
1962
2197
|
theme: config.theme,
|
|
1963
2198
|
allowAnonymous: config.auth.allowAnonymous,
|
|
1964
2199
|
showToolActivity: config.agent.showToolActivity,
|
|
@@ -1971,7 +2206,10 @@ function createFrankieApp(options) {
|
|
|
1971
2206
|
},
|
|
1972
2207
|
modelPicker: {
|
|
1973
2208
|
enabled: config.features.modelPicker.enabled,
|
|
1974
|
-
|
|
2209
|
+
// The workspace default is always selectable, so enabling the picker
|
|
2210
|
+
// shows a working selector even before any extra models are curated —
|
|
2211
|
+
// otherwise an enabled picker with an empty list renders nothing.
|
|
2212
|
+
models: publicPickerModels(config)
|
|
1975
2213
|
}
|
|
1976
2214
|
}
|
|
1977
2215
|
};
|
|
@@ -1990,7 +2228,7 @@ function createFrankieApp(options) {
|
|
|
1990
2228
|
if (body.userToken) {
|
|
1991
2229
|
user = await tokens.verifyUserToken(body.userToken);
|
|
1992
2230
|
} else if (config.auth.allowAnonymous) {
|
|
1993
|
-
user = anonymousUser(
|
|
2231
|
+
user = anonymousUser(randomUUID4().slice(0, 12));
|
|
1994
2232
|
} else {
|
|
1995
2233
|
return jsonError(c, 401, "unauthorized", "This assistant requires a signed-in user.");
|
|
1996
2234
|
}
|
|
@@ -2177,19 +2415,21 @@ function createFrankieApp(options) {
|
|
|
2177
2415
|
if (config.agent.maxBudgetUsd && session.costUsd >= config.agent.maxBudgetUsd) {
|
|
2178
2416
|
return jsonError(c, 402, "budget_exceeded", "This conversation has reached its limit.");
|
|
2179
2417
|
}
|
|
2180
|
-
|
|
2181
|
-
await sessions.update(session.id, { context: { ...session.context, ...sanitizeContext(body.context) } });
|
|
2182
|
-
}
|
|
2183
|
-
await sessions.update(session.id, {
|
|
2418
|
+
const patch = {
|
|
2184
2419
|
busy: true,
|
|
2185
2420
|
messageCount: session.messageCount + 1,
|
|
2421
|
+
...body.context ? { context: { ...session.context, ...sanitizeContext(body.context) } } : {},
|
|
2186
2422
|
...!session.title ? { title: titleFromText(body.message) } : {}
|
|
2187
|
-
}
|
|
2423
|
+
};
|
|
2424
|
+
await sessions.update(session.id, patch);
|
|
2425
|
+
const current = { ...session, ...patch };
|
|
2188
2426
|
const abort = new AbortController();
|
|
2189
2427
|
const timeout = setTimeout(() => abort.abort(), config.limits.turnTimeoutMs);
|
|
2190
2428
|
c.req.raw.signal?.addEventListener("abort", () => abort.abort());
|
|
2191
2429
|
const modelOverride = body.model && body.model !== config.model.model && config.features.modelPicker.enabled ? resolveModelChoice(models, config, body.model) ?? void 0 : void 0;
|
|
2192
|
-
|
|
2430
|
+
let finalMessages;
|
|
2431
|
+
let turnCostUsd = 0;
|
|
2432
|
+
const clientBridge = clientTools.bridge(session.id, config.limits.turnTimeoutMs);
|
|
2193
2433
|
const events = runTurn({
|
|
2194
2434
|
config,
|
|
2195
2435
|
session: current,
|
|
@@ -2197,11 +2437,12 @@ function createFrankieApp(options) {
|
|
|
2197
2437
|
apiKey: secrets.providerApiKey,
|
|
2198
2438
|
model,
|
|
2199
2439
|
models,
|
|
2440
|
+
clientBridge,
|
|
2200
2441
|
...modelOverride ? { modelOverride } : {},
|
|
2201
2442
|
...attachments.length ? { attachments } : {},
|
|
2202
2443
|
signal: abort.signal,
|
|
2203
2444
|
onTranscript: (messages) => {
|
|
2204
|
-
|
|
2445
|
+
finalMessages = messages;
|
|
2205
2446
|
},
|
|
2206
2447
|
onWarning: (event, detail) => log({ level: "warn", event, sessionId: session.id, ...detail })
|
|
2207
2448
|
});
|
|
@@ -2214,14 +2455,13 @@ function createFrankieApp(options) {
|
|
|
2214
2455
|
`));
|
|
2215
2456
|
};
|
|
2216
2457
|
const heartbeat = setInterval(() => send({ type: "ping" }), HEARTBEAT_MS);
|
|
2458
|
+
clientTools.bindSession(session.id, send);
|
|
2217
2459
|
send({ type: "session", sessionId: session.id, resumed: current.messages.length > 0 });
|
|
2218
2460
|
try {
|
|
2219
2461
|
for await (const event of events) {
|
|
2220
|
-
if (event.type === "turn_end" && event.usage
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
costUsd: (latest?.costUsd ?? current.costUsd) + event.usage.costUsd
|
|
2224
|
-
});
|
|
2462
|
+
if (event.type === "turn_end" && event.usage) {
|
|
2463
|
+
if (event.usage.costUsd !== void 0) turnCostUsd += event.usage.costUsd;
|
|
2464
|
+
log({ level: "info", event: "turn_usage", sessionId: session.id, ...event.usage });
|
|
2225
2465
|
}
|
|
2226
2466
|
send(event);
|
|
2227
2467
|
}
|
|
@@ -2236,14 +2476,18 @@ function createFrankieApp(options) {
|
|
|
2236
2476
|
} finally {
|
|
2237
2477
|
clearInterval(heartbeat);
|
|
2238
2478
|
clearTimeout(timeout);
|
|
2239
|
-
|
|
2479
|
+
clientTools.releaseSession(session.id);
|
|
2480
|
+
await sessions.update(session.id, {
|
|
2481
|
+
busy: false,
|
|
2482
|
+
...finalMessages ? { messages: finalMessages } : {},
|
|
2483
|
+
...turnCostUsd ? { costUsd: current.costUsd + turnCostUsd } : {}
|
|
2484
|
+
});
|
|
2240
2485
|
controller.close();
|
|
2241
2486
|
}
|
|
2242
2487
|
},
|
|
2243
2488
|
cancel() {
|
|
2244
2489
|
clearTimeout(timeout);
|
|
2245
2490
|
abort.abort();
|
|
2246
|
-
void sessions.update(session.id, { busy: false });
|
|
2247
2491
|
}
|
|
2248
2492
|
});
|
|
2249
2493
|
const origin = c.req.header("origin");
|
|
@@ -2257,13 +2501,46 @@ function createFrankieApp(options) {
|
|
|
2257
2501
|
}
|
|
2258
2502
|
});
|
|
2259
2503
|
});
|
|
2504
|
+
app.post("/v1/chat/tool-result", async (c) => {
|
|
2505
|
+
const { tokens } = c.get("tenant");
|
|
2506
|
+
const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
|
|
2507
|
+
let claims;
|
|
2508
|
+
try {
|
|
2509
|
+
claims = await tokens.verifySessionToken(bearer);
|
|
2510
|
+
} catch (error) {
|
|
2511
|
+
return jsonError(c, 401, "unauthorized", error instanceof AuthError ? error.message : "Unauthorized.");
|
|
2512
|
+
}
|
|
2513
|
+
const body = await readJson(c);
|
|
2514
|
+
if (!body || typeof body.callId !== "string" || !isHostToolResult(body.result)) {
|
|
2515
|
+
return jsonError(c, 400, "internal", "A tool result needs a `callId` and a `result`.");
|
|
2516
|
+
}
|
|
2517
|
+
const delivered = clientTools.resolve(claims.sessionId, body.callId, body.result);
|
|
2518
|
+
if (!delivered) {
|
|
2519
|
+
return jsonError(c, 404, "session_not_found", "No tool call is awaiting this result.");
|
|
2520
|
+
}
|
|
2521
|
+
return c.json({ ok: true });
|
|
2522
|
+
});
|
|
2260
2523
|
return app;
|
|
2261
2524
|
}
|
|
2525
|
+
function isHostToolResult(value) {
|
|
2526
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2527
|
+
const result = value;
|
|
2528
|
+
if (typeof result["status"] !== "number") return false;
|
|
2529
|
+
if (result["body"] !== void 0 && typeof result["body"] !== "string") return false;
|
|
2530
|
+
if (result["error"] !== void 0 && typeof result["error"] !== "string") return false;
|
|
2531
|
+
return true;
|
|
2532
|
+
}
|
|
2262
2533
|
function mimeAccepted(mimeType, accept) {
|
|
2263
2534
|
return accept.some(
|
|
2264
2535
|
(pattern) => pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType
|
|
2265
2536
|
);
|
|
2266
2537
|
}
|
|
2538
|
+
function publicPickerModels(config) {
|
|
2539
|
+
const curated = config.features.modelPicker.models.map((entry) => ({ id: entry.id, label: entry.label }));
|
|
2540
|
+
if (!config.features.modelPicker.enabled) return curated;
|
|
2541
|
+
if (curated.some((model) => model.id === config.model.model)) return curated;
|
|
2542
|
+
return [{ id: config.model.model, label: "Default" }, ...curated];
|
|
2543
|
+
}
|
|
2267
2544
|
function base64Bytes(data) {
|
|
2268
2545
|
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
2269
2546
|
return Math.floor(data.length * 3 / 4) - padding;
|
|
@@ -2354,10 +2631,14 @@ function titleFromClientMessages(messages) {
|
|
|
2354
2631
|
|
|
2355
2632
|
// src/control/store.ts
|
|
2356
2633
|
import { mkdir, readFile as readFile2, readdir, rename, rm, writeFile } from "fs/promises";
|
|
2357
|
-
import { randomUUID as
|
|
2634
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2358
2635
|
import { join, resolve } from "path";
|
|
2359
2636
|
import { parseFrankieConfig } from "@frankieai/core";
|
|
2360
2637
|
var MEMBERS_FILE = "__members.json";
|
|
2638
|
+
function publicVercelLink(link) {
|
|
2639
|
+
const { accessToken: _token, ...rest } = link;
|
|
2640
|
+
return rest;
|
|
2641
|
+
}
|
|
2361
2642
|
function toPublicTenant(record) {
|
|
2362
2643
|
const { config } = record;
|
|
2363
2644
|
return {
|
|
@@ -2371,6 +2652,7 @@ function toPublicTenant(record) {
|
|
|
2371
2652
|
publishableKey: config.auth.publishableKey,
|
|
2372
2653
|
allowedOrigins: config.auth.allowedOrigins,
|
|
2373
2654
|
allowAnonymous: config.auth.allowAnonymous,
|
|
2655
|
+
userCredential: config.auth.userCredential,
|
|
2374
2656
|
connection: config.apis.length > 0 ? "openapi" : config.mcpServers.length > 0 ? "mcp" : "knowledge",
|
|
2375
2657
|
mcpServers: config.mcpServers.map((s) => s.name),
|
|
2376
2658
|
apis: config.apis.map((a) => a.name),
|
|
@@ -2378,6 +2660,7 @@ function toPublicTenant(record) {
|
|
|
2378
2660
|
...config.mcpServers.map((s) => ({
|
|
2379
2661
|
kind: "mcp",
|
|
2380
2662
|
name: s.name,
|
|
2663
|
+
...s.displayName ? { displayName: s.displayName } : {},
|
|
2381
2664
|
url: s.url,
|
|
2382
2665
|
discoveredTools: s.discoveredTools,
|
|
2383
2666
|
...s.selectedTools ? { selectedTools: s.selectedTools } : {},
|
|
@@ -2388,13 +2671,16 @@ function toPublicTenant(record) {
|
|
|
2388
2671
|
...config.apis.map((a) => ({
|
|
2389
2672
|
kind: "openapi",
|
|
2390
2673
|
name: a.name,
|
|
2674
|
+
...a.displayName ? { displayName: a.displayName } : {},
|
|
2391
2675
|
spec: a.spec,
|
|
2392
2676
|
...a.baseUrl ? { baseUrl: a.baseUrl } : {},
|
|
2393
2677
|
methods: a.methods,
|
|
2394
2678
|
...a.selectedTools ? { selectedTools: a.selectedTools } : {},
|
|
2395
2679
|
exposeAsMcp: a.exposeAsMcp,
|
|
2680
|
+
execution: a.execution,
|
|
2396
2681
|
capabilities: a.capabilities.map((capability) => ({ ...capability })),
|
|
2397
2682
|
forwardsToken: Boolean(a.forwardUserTokenAs),
|
|
2683
|
+
...a.forwardUserTokenAs ? { forwardTokenAs: a.forwardUserTokenAs } : {},
|
|
2398
2684
|
securedSchemes: Object.keys(a.security ?? {}),
|
|
2399
2685
|
enabled: a.enabled && config.agent.tools.allow.some((entry) => entry === `api__${a.name}` || entry.startsWith(`api__${a.name}__`)),
|
|
2400
2686
|
status: a.capabilities.length ? "ready" : "needs_attention"
|
|
@@ -2402,8 +2688,14 @@ function toPublicTenant(record) {
|
|
|
2402
2688
|
],
|
|
2403
2689
|
locked: record.locked ?? false,
|
|
2404
2690
|
...record.repositoryInstall ? { repositoryInstall: record.repositoryInstall } : {},
|
|
2691
|
+
...record.vercel ? { vercel: publicVercelLink(record.vercel) } : {},
|
|
2405
2692
|
theme: config.theme,
|
|
2406
2693
|
features: config.features,
|
|
2694
|
+
agent: {
|
|
2695
|
+
greeting: config.agent.greeting,
|
|
2696
|
+
suggestedPrompts: config.agent.suggestedPrompts,
|
|
2697
|
+
starterPrompts: config.agent.starterPrompts
|
|
2698
|
+
},
|
|
2407
2699
|
createdAt: record.createdAt,
|
|
2408
2700
|
updatedAt: record.updatedAt
|
|
2409
2701
|
};
|
|
@@ -2436,7 +2728,7 @@ var FileTenantStore = class _FileTenantStore {
|
|
|
2436
2728
|
async putPlatformAdmin(admin) {
|
|
2437
2729
|
const path = this.adminPath(admin.ownerId);
|
|
2438
2730
|
await mkdir(join(this.dir, "platform-admins"), { recursive: true });
|
|
2439
|
-
const temporary = path + "." +
|
|
2731
|
+
const temporary = path + "." + randomUUID5() + ".tmp";
|
|
2440
2732
|
await writeFile(temporary, JSON.stringify(admin), { mode: 384 });
|
|
2441
2733
|
await rename(temporary, path);
|
|
2442
2734
|
}
|
|
@@ -2915,4 +3207,4 @@ export {
|
|
|
2915
3207
|
resolveStores,
|
|
2916
3208
|
resolveTenantStore
|
|
2917
3209
|
};
|
|
2918
|
-
//# sourceMappingURL=chunk-
|
|
3210
|
+
//# sourceMappingURL=chunk-7J5Z5MIK.js.map
|