@bothat-io/molenkopf 0.1.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/.env.example +2 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/SECURITY.md +36 -0
- package/bin/launcher.js +76 -0
- package/bin/molenkopf.js +4 -0
- package/docs/DEPLOYMENT.md +104 -0
- package/docs/MOLENKOPF_PLUGIN_API.md +113 -0
- package/docs/MOLENKOPF_PROVIDER_ENV.md +123 -0
- package/docs/MOLENKOPF_USAGE.md +195 -0
- package/docs/PRODUCT_INTENT.md +36 -0
- package/docs/THREAT_MODEL.md +94 -0
- package/molenkopf.config.example.json +68 -0
- package/package.json +98 -0
- package/packages/core/src/auth/password.ts +47 -0
- package/packages/core/src/auth/session.ts +64 -0
- package/packages/core/src/ci/ci-mode.ts +71 -0
- package/packages/core/src/compression/content-classifier.ts +25 -0
- package/packages/core/src/compression/context-compressor.ts +48 -0
- package/packages/core/src/compression/json-compressor.ts +54 -0
- package/packages/core/src/compression/log-compressor.ts +32 -0
- package/packages/core/src/compression/operational-block-compressor.ts +43 -0
- package/packages/core/src/compression/stacktrace-compressor.ts +23 -0
- package/packages/core/src/config/config-policies.ts +146 -0
- package/packages/core/src/config/molenkopf-config.ts +137 -0
- package/packages/core/src/config/provider-config.ts +139 -0
- package/packages/core/src/events/event-bus.ts +88 -0
- package/packages/core/src/identity/api-keys.ts +149 -0
- package/packages/core/src/identity/budget.ts +51 -0
- package/packages/core/src/identity/db.ts +68 -0
- package/packages/core/src/identity/identity-store.ts +175 -0
- package/packages/core/src/identity/identity-validation.ts +102 -0
- package/packages/core/src/identity/key-permissions.ts +18 -0
- package/packages/core/src/identity/pricing.ts +11 -0
- package/packages/core/src/identity/types.ts +87 -0
- package/packages/core/src/identity/usage-snapshot.ts +116 -0
- package/packages/core/src/manifest/audit-activity.ts +74 -0
- package/packages/core/src/manifest/audit-metrics.ts +7 -0
- package/packages/core/src/manifest/audit-safety.ts +113 -0
- package/packages/core/src/manifest/audit-store.ts +189 -0
- package/packages/core/src/manifest/audit-summary.ts +184 -0
- package/packages/core/src/manifest/usage-meter.ts +105 -0
- package/packages/core/src/memory/memory-extractor.ts +57 -0
- package/packages/core/src/memory/memory-graph.ts +55 -0
- package/packages/core/src/pipeline/json-string-spans.ts +143 -0
- package/packages/core/src/pipeline/openai-request-rewriter.ts +66 -0
- package/packages/core/src/plugins/builtin-plugin-descriptors.ts +10 -0
- package/packages/core/src/plugins/builtin-plugin-modules.ts +9 -0
- package/packages/core/src/plugins/plugin-api.ts +96 -0
- package/packages/core/src/plugins/plugin-catalog.ts +42 -0
- package/packages/core/src/plugins/plugin-descriptor.ts +51 -0
- package/packages/core/src/plugins/plugin-sdk.ts +47 -0
- package/packages/core/src/plugins/static-pipeline.ts +5 -0
- package/packages/core/src/profiles/profile-router.ts +45 -0
- package/packages/core/src/providers/provider-catalog.ts +186 -0
- package/packages/core/src/routing/distribution.ts +31 -0
- package/packages/core/src/security/secret-redactor.ts +139 -0
- package/packages/core/src/security/target-policy.ts +61 -0
- package/packages/core/src/storage/local-paths.ts +6 -0
- package/packages/core/src/storage/private-state.ts +30 -0
- package/packages/core/src/storage/purge-dir.ts +10 -0
- package/packages/core/src/store/retrieval-store.ts +114 -0
- package/packages/core/src/utils/hash.ts +9 -0
- package/packages/core/src/utils/text.ts +18 -0
- package/packages/core/src/utils/tokens.ts +3 -0
- package/packages/dashboard/dist/assets/index-B_aSPgHx.js +11 -0
- package/packages/dashboard/dist/assets/index-D6z2TEL2.css +1 -0
- package/packages/dashboard/dist/favicon.png +0 -0
- package/packages/dashboard/dist/index.html +15 -0
- package/packages/dashboard/dist/molenkopf-logo.png +0 -0
- package/packages/dashboard/public/favicon.png +0 -0
- package/packages/dashboard/public/molenkopf-logo.png +0 -0
- package/packages/plugins/context-compressor-plugin/descriptor.ts +19 -0
- package/packages/plugins/context-compressor-plugin/page.html +191 -0
- package/packages/plugins/context-compressor-plugin/plugin.ts +40 -0
- package/packages/plugins/obsidian-graph-plugin/descriptor.ts +19 -0
- package/packages/plugins/obsidian-graph-plugin/page.html +68 -0
- package/packages/plugins/obsidian-graph-plugin/plugin.ts +27 -0
- package/packages/plugins/shared/audit-projects.ts +32 -0
- package/packages/proxy/src/cli/args.ts +34 -0
- package/packages/proxy/src/cli/config-loader.ts +43 -0
- package/packages/proxy/src/cli/env-file.ts +43 -0
- package/packages/proxy/src/cli/main.ts +132 -0
- package/packages/proxy/src/cli/profile-server.ts +176 -0
- package/packages/proxy/src/cli/target.ts +7 -0
- package/packages/proxy/src/http/agent-drafts.ts +103 -0
- package/packages/proxy/src/http/agent-router.ts +69 -0
- package/packages/proxy/src/http/audit-view.ts +15 -0
- package/packages/proxy/src/http/auth-state.ts +44 -0
- package/packages/proxy/src/http/budget-gate.ts +45 -0
- package/packages/proxy/src/http/budget-warnings.ts +7 -0
- package/packages/proxy/src/http/cli-stream-response.ts +51 -0
- package/packages/proxy/src/http/client-identity.ts +51 -0
- package/packages/proxy/src/http/communication-graph.ts +139 -0
- package/packages/proxy/src/http/control-plane-guard.ts +56 -0
- package/packages/proxy/src/http/dashboard-assets.ts +115 -0
- package/packages/proxy/src/http/encoded-usage-meter.ts +32 -0
- package/packages/proxy/src/http/header-utils.ts +65 -0
- package/packages/proxy/src/http/identity-id.ts +11 -0
- package/packages/proxy/src/http/local-api-agent-actions.ts +17 -0
- package/packages/proxy/src/http/local-api-auth.ts +120 -0
- package/packages/proxy/src/http/local-api-consumer-actions.ts +20 -0
- package/packages/proxy/src/http/local-api-identity.ts +194 -0
- package/packages/proxy/src/http/local-api-io.ts +82 -0
- package/packages/proxy/src/http/local-api-keys.ts +126 -0
- package/packages/proxy/src/http/local-api-pipeline.ts +41 -0
- package/packages/proxy/src/http/local-api-plugin-actions.ts +31 -0
- package/packages/proxy/src/http/local-api-provider-actions.ts +181 -0
- package/packages/proxy/src/http/local-api-retention.ts +28 -0
- package/packages/proxy/src/http/local-api-runtime-auth.ts +119 -0
- package/packages/proxy/src/http/local-api-scope.ts +47 -0
- package/packages/proxy/src/http/local-api-state.ts +180 -0
- package/packages/proxy/src/http/local-api.ts +166 -0
- package/packages/proxy/src/http/password-policy.ts +5 -0
- package/packages/proxy/src/http/plugin-data.ts +38 -0
- package/packages/proxy/src/http/plugin-host.ts +87 -0
- package/packages/proxy/src/http/plugin-modules.ts +1 -0
- package/packages/proxy/src/http/plugin-page-loader.ts +24 -0
- package/packages/proxy/src/http/plugin-pipeline.ts +125 -0
- package/packages/proxy/src/http/provider-access.ts +33 -0
- package/packages/proxy/src/http/provider-http-test.ts +133 -0
- package/packages/proxy/src/http/provider-input.ts +39 -0
- package/packages/proxy/src/http/provider-routing-snapshot.ts +28 -0
- package/packages/proxy/src/http/provider-test.ts +149 -0
- package/packages/proxy/src/http/proxy-identity.ts +78 -0
- package/packages/proxy/src/http/public-bind.ts +8 -0
- package/packages/proxy/src/http/request-finish.ts +62 -0
- package/packages/proxy/src/http/request-path.ts +8 -0
- package/packages/proxy/src/http/request-policy.ts +46 -0
- package/packages/proxy/src/http/runtime-auth-proof.ts +55 -0
- package/packages/proxy/src/http/runtime-auth-registry.ts +105 -0
- package/packages/proxy/src/http/runtime-settings.ts +199 -0
- package/packages/proxy/src/http/runtime-state.ts +198 -0
- package/packages/proxy/src/http/server-io.ts +80 -0
- package/packages/proxy/src/http/server-types.ts +17 -0
- package/packages/proxy/src/http/server.ts +190 -0
- package/packages/proxy/src/http/session-secret.ts +19 -0
- package/packages/proxy/src/http/streaming-proxy.ts +88 -0
- package/packages/proxy/src/http/usage-accounting.ts +100 -0
- package/packages/proxy/src/http/usage-restore.ts +15 -0
- package/packages/proxy/src/runtime/cli-diagnostics.ts +64 -0
- package/packages/proxy/src/runtime/cli-env.ts +22 -0
- package/packages/proxy/src/runtime/cli-executor.ts +134 -0
- package/packages/proxy/src/runtime/cli-provider.ts +162 -0
- package/packages/proxy/src/runtime/cli-request.ts +79 -0
- package/packages/proxy/src/runtime/codex-runtime-config.ts +37 -0
- package/packages/proxy/src/runtime/runtime-profile.ts +170 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { hashPasswordAsync, passwordTooLong } from "../../../core/src/auth/password.ts";
|
|
3
|
+
import { normalizeBudget } from "../../../core/src/identity/budget.ts";
|
|
4
|
+
import { viewUser, type Budget, type KeyPermissions, type Role, type Team, type User } from "../../../core/src/identity/types.ts";
|
|
5
|
+
import type { RuntimeState } from "./runtime-state.ts";
|
|
6
|
+
import { readJson, writeJson } from "./local-api-io.ts";
|
|
7
|
+
import { isValidSlugId, isValidUserId } from "./identity-id.ts";
|
|
8
|
+
import { isWeakPassword } from "./password-policy.ts";
|
|
9
|
+
|
|
10
|
+
export function listIdentity(_req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
11
|
+
const id = state.identity;
|
|
12
|
+
if (!id) return writeJson(res, 200, { users: [], teams: [], pricing: {}, orgBudget: undefined });
|
|
13
|
+
writeJson(res, 200, {
|
|
14
|
+
users: id.listUsers().map(viewUser),
|
|
15
|
+
teams: id.listTeams(),
|
|
16
|
+
pricing: id.data.pricing ?? {},
|
|
17
|
+
orgBudget: id.data.orgBudget
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function putIdentityUser(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
21
|
+
const id = state.identity;
|
|
22
|
+
if (!id) return writeJson(res, 400, { error: "identity_unavailable" });
|
|
23
|
+
const body = await readJson(req);
|
|
24
|
+
const userId = typeof body.id === "string" ? body.id.trim() : "";
|
|
25
|
+
if (!isValidUserId(userId)) return writeJson(res, 400, { error: "invalid_user_id" });
|
|
26
|
+
const existing = id.getUser(userId);
|
|
27
|
+
if (isWeakPassword(body.password)) return writeJson(res, 400, { error: "weak_password" });
|
|
28
|
+
const rawPassword = typeof body.password === "string" && body.password ? body.password : "";
|
|
29
|
+
if (rawPassword && passwordTooLong(rawPassword)) return writeJson(res, 400, { error: "password_too_long" });
|
|
30
|
+
const password = rawPassword ? await hashPasswordAsync(rawPassword) : existing?.password;
|
|
31
|
+
const disabled = typeof body.disabled === "boolean" ? body.disabled : existing?.disabled;
|
|
32
|
+
const loginDisabled = typeof body.loginDisabled === "boolean" ? body.loginDisabled : (rawPassword ? false : (password ? (existing?.loginDisabled ?? false) : true));
|
|
33
|
+
if (!password && loginDisabled === false) return writeJson(res, 400, { error: "password_required" });
|
|
34
|
+
const teamIds = userTeamIds(body, existing?.teamIds ?? [], id);
|
|
35
|
+
if (teamIds === false) return writeJson(res, 400, { error: "invalid_team_id" });
|
|
36
|
+
const budget = nextBudget(body, existing?.budget);
|
|
37
|
+
if (budget === false) return writeJson(res, 400, { error: "invalid_budget" });
|
|
38
|
+
const user: User = {
|
|
39
|
+
id: userId,
|
|
40
|
+
displayName: typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : userId,
|
|
41
|
+
role: body.role === undefined ? (existing?.role ?? "member") : parseRole(body.role),
|
|
42
|
+
password,
|
|
43
|
+
loginDisabled,
|
|
44
|
+
teamIds,
|
|
45
|
+
keyPermissions: parseKeyPermissions(body.keyPermissions, existing?.keyPermissions),
|
|
46
|
+
budget,
|
|
47
|
+
disabled,
|
|
48
|
+
sessionVersion: nextSessionVersion(existing, { passwordChanged: Boolean(rawPassword), role: parseRole(body.role ?? existing?.role), disabled, loginDisabled }),
|
|
49
|
+
createdAt: existing?.createdAt ?? new Date().toISOString()
|
|
50
|
+
};
|
|
51
|
+
if (existing && isLastEnabledAdminWithPassword(id, existing) && !enabledAdminWithPassword(user)) {
|
|
52
|
+
return writeJson(res, 409, { error: "last_admin_required" });
|
|
53
|
+
}
|
|
54
|
+
const saved = await id.putUser(user);
|
|
55
|
+
writeJson(res, 200, { ok: true, user: viewUser(saved) });
|
|
56
|
+
}
|
|
57
|
+
export async function removeIdentityUser(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
58
|
+
const id = state.identity;
|
|
59
|
+
if (!id) return writeJson(res, 400, { error: "identity_unavailable" });
|
|
60
|
+
const body = await readJson(req);
|
|
61
|
+
const userId = typeof body.id === "string" ? body.id : "";
|
|
62
|
+
const existing = id.getUser(userId);
|
|
63
|
+
if (existing && isLastEnabledAdminWithPassword(id, existing)) return writeJson(res, 409, { error: "last_admin_required" });
|
|
64
|
+
const ok = await id.removeUser(userId);
|
|
65
|
+
writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { error: "unknown_user" });
|
|
66
|
+
}
|
|
67
|
+
export async function putIdentityTeam(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
68
|
+
const id = state.identity;
|
|
69
|
+
if (!id) return writeJson(res, 400, { error: "identity_unavailable" });
|
|
70
|
+
const body = await readJson(req);
|
|
71
|
+
const teamId = typeof body.id === "string" && body.id.trim() ? body.id.trim() : generatedTeamId(id, typeof body.name === "string" ? body.name : "");
|
|
72
|
+
if (!isValidSlugId(teamId)) return writeJson(res, 400, { error: "invalid_team_id" });
|
|
73
|
+
const existing = id.getTeam(teamId);
|
|
74
|
+
const allowed = body.allowedProviders;
|
|
75
|
+
const budget = nextBudget(body, existing?.budget);
|
|
76
|
+
if (budget === false) return writeJson(res, 400, { error: "invalid_budget" });
|
|
77
|
+
const allowedProviders = providerList(allowed, existing?.allowedProviders ?? "*", state);
|
|
78
|
+
if (allowedProviders === false) return writeJson(res, 400, { error: "invalid_provider" });
|
|
79
|
+
const managerIds = managerList(body.managerIds, existing?.managerIds ?? [], id);
|
|
80
|
+
if (managerIds === false) return writeJson(res, 400, { error: "invalid_manager" });
|
|
81
|
+
const team: Team = {
|
|
82
|
+
id: teamId,
|
|
83
|
+
name: typeof body.name === "string" && body.name.trim() ? body.name.trim() : teamId,
|
|
84
|
+
allowedProviders,
|
|
85
|
+
managerIds,
|
|
86
|
+
budget,
|
|
87
|
+
createdAt: existing?.createdAt ?? new Date().toISOString()
|
|
88
|
+
};
|
|
89
|
+
if (Array.isArray(body.memberIds)) {
|
|
90
|
+
const memberIds = memberList(body.memberIds, id);
|
|
91
|
+
if (memberIds === false) return writeJson(res, 400, { error: "invalid_member" });
|
|
92
|
+
const previous = structuredClone(id.data);
|
|
93
|
+
try {
|
|
94
|
+
id.data.teams[team.id] = team;
|
|
95
|
+
for (const user of id.listUsers()) {
|
|
96
|
+
const current = new Set(user.teamIds);
|
|
97
|
+
if (team.id === "everyone" || memberIds.has(user.id)) current.add(team.id);
|
|
98
|
+
else current.delete(team.id);
|
|
99
|
+
user.teamIds = [...current];
|
|
100
|
+
}
|
|
101
|
+
await id.save();
|
|
102
|
+
} catch {
|
|
103
|
+
id.data = previous;
|
|
104
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
await id.putTeam(team);
|
|
108
|
+
}
|
|
109
|
+
writeJson(res, 200, { ok: true, team });
|
|
110
|
+
}
|
|
111
|
+
export async function removeIdentityTeam(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
112
|
+
const id = state.identity;
|
|
113
|
+
if (!id) return writeJson(res, 400, { error: "identity_unavailable" });
|
|
114
|
+
const body = await readJson(req);
|
|
115
|
+
const teamId = typeof body.id === "string" ? body.id : "";
|
|
116
|
+
if (teamId === "everyone") return writeJson(res, 409, { error: "cannot_remove_default_team" });
|
|
117
|
+
const ok = await id.removeTeam(teamId);
|
|
118
|
+
writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { error: "unknown_team" });
|
|
119
|
+
}
|
|
120
|
+
function parseRole(value: unknown): Role { return value === "admin" ? "admin" : value === "manager" ? "manager" : "member"; }
|
|
121
|
+
function nextSessionVersion(existing: User | undefined, next: { passwordChanged: boolean; role: Role; disabled?: boolean; loginDisabled?: boolean }): number {
|
|
122
|
+
if (!existing) return 0;
|
|
123
|
+
const changed = next.passwordChanged || existing.role !== next.role || existing.disabled !== next.disabled || existing.loginDisabled !== next.loginDisabled;
|
|
124
|
+
return (existing.sessionVersion ?? 0) + (changed ? 1 : 0);
|
|
125
|
+
}
|
|
126
|
+
function isLastEnabledAdminWithPassword(store: { listUsers(): User[] }, user: User): boolean {
|
|
127
|
+
if (!enabledAdminWithPassword(user)) return false;
|
|
128
|
+
return !store.listUsers().some((item) => item.id !== user.id && enabledAdminWithPassword(item));
|
|
129
|
+
}
|
|
130
|
+
function enabledAdminWithPassword(user: User): boolean {
|
|
131
|
+
return user.role === "admin" && user.disabled !== true && user.loginDisabled !== true && Boolean(user.password);
|
|
132
|
+
}
|
|
133
|
+
function nextBudget(body: Record<string, unknown>, existing: Budget | undefined): Budget | undefined | false {
|
|
134
|
+
if (!Object.hasOwn(body, "budget")) return existing;
|
|
135
|
+
const parsed = normalizeBudget(body.budget);
|
|
136
|
+
return parsed.ok ? parsed.budget : false;
|
|
137
|
+
}
|
|
138
|
+
function userTeamIds(body: Record<string, unknown>, existing: string[], store: { getTeam(id: string): Team | undefined }): string[] | false {
|
|
139
|
+
if (!Array.isArray(body.teamIds)) return existing;
|
|
140
|
+
const out: string[] = [];
|
|
141
|
+
for (const value of body.teamIds) {
|
|
142
|
+
if (typeof value !== "string" || !store.getTeam(value)) return false;
|
|
143
|
+
if (!out.includes(value)) out.push(value);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
function managerList(value: unknown, existing: string[], store: { getUser(id: string): User | undefined }): string[] | false {
|
|
148
|
+
if (!Array.isArray(value)) return existing;
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
for (const id of value) {
|
|
151
|
+
if (typeof id !== "string" || !store.getUser(id)) return false;
|
|
152
|
+
if (!out.includes(id)) out.push(id);
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function memberList(value: unknown[], store: { getUser(id: string): User | undefined }): Set<string> | false {
|
|
157
|
+
const out = new Set<string>();
|
|
158
|
+
for (const id of value) {
|
|
159
|
+
if (typeof id !== "string" || !store.getUser(id)) return false;
|
|
160
|
+
out.add(id);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function providerList(value: unknown, existing: Team["allowedProviders"], state: RuntimeState): Team["allowedProviders"] | false {
|
|
165
|
+
if (value === undefined) return existing;
|
|
166
|
+
if (value === "*") return "*";
|
|
167
|
+
if (!Array.isArray(value)) return false;
|
|
168
|
+
const out: string[] = [];
|
|
169
|
+
for (const id of value) {
|
|
170
|
+
if (typeof id !== "string" || !state.providers.some((provider) => provider.id === id && provider.enabled !== false)) return false;
|
|
171
|
+
if (!out.includes(id)) out.push(id);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
function parseKeyPermissions(value: unknown, existing: KeyPermissions | undefined): KeyPermissions | undefined {
|
|
176
|
+
if (!value || typeof value !== "object") return existing;
|
|
177
|
+
const permissions = value as Record<string, unknown>;
|
|
178
|
+
return { create: permissions.create !== false, revoke: permissions.revoke !== false };
|
|
179
|
+
}
|
|
180
|
+
function generatedTeamId(store: { getTeam(id: string): Team | undefined }, name: string): string {
|
|
181
|
+
const base = slugFromName(name || "team");
|
|
182
|
+
if (!store.getTeam(base)) return base;
|
|
183
|
+
for (let index = 2; index < 100; index++) {
|
|
184
|
+
const id = `${base}-${index}`;
|
|
185
|
+
if (!store.getTeam(id)) return id;
|
|
186
|
+
}
|
|
187
|
+
return `${base}-${Date.now().toString(36)}`;
|
|
188
|
+
}
|
|
189
|
+
function slugFromName(value: string): string {
|
|
190
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
191
|
+
if (isValidSlugId(slug)) return slug;
|
|
192
|
+
const fallback = slug ? `team-${slug}` : "team";
|
|
193
|
+
return fallback.slice(0, 64);
|
|
194
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { CONTROL_PLANE_LIMITS } from "./runtime-state.ts";
|
|
3
|
+
|
|
4
|
+
export class LocalApiError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
code: string;
|
|
7
|
+
constructor(status: number, code: string) {
|
|
8
|
+
super(code);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readJson(req: IncomingMessage, maxBytes = CONTROL_PLANE_LIMITS.requestBodyBytes): Promise<Record<string, unknown>> {
|
|
15
|
+
const body = await new Promise<string>((resolve, reject) => {
|
|
16
|
+
const chunks: Buffer[] = [];
|
|
17
|
+
let size = 0;
|
|
18
|
+
let settled = false;
|
|
19
|
+
const cleanup = () => {
|
|
20
|
+
req.off("data", onData);
|
|
21
|
+
req.off("error", onError);
|
|
22
|
+
req.off("end", onEnd);
|
|
23
|
+
};
|
|
24
|
+
const fail = (error: Error) => {
|
|
25
|
+
if (settled) return;
|
|
26
|
+
settled = true;
|
|
27
|
+
cleanup();
|
|
28
|
+
req.pause();
|
|
29
|
+
reject(error);
|
|
30
|
+
};
|
|
31
|
+
const onData = (chunk: string | Buffer) => {
|
|
32
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
33
|
+
size += buffer.byteLength;
|
|
34
|
+
if (size > maxBytes) return fail(new LocalApiError(413, "json_too_large"));
|
|
35
|
+
chunks.push(buffer);
|
|
36
|
+
};
|
|
37
|
+
const onError = (error: Error) => fail(error);
|
|
38
|
+
const onEnd = () => {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
settled = true;
|
|
41
|
+
cleanup();
|
|
42
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
43
|
+
};
|
|
44
|
+
req.on("data", onData);
|
|
45
|
+
req.on("error", onError);
|
|
46
|
+
req.on("end", onEnd);
|
|
47
|
+
});
|
|
48
|
+
if (!body) return {};
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(body);
|
|
51
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("invalid");
|
|
52
|
+
return parsed as Record<string, unknown>;
|
|
53
|
+
} catch {
|
|
54
|
+
throw new LocalApiError(400, "invalid_json");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function writeJson(res: ServerResponse, status: number, data: unknown): void {
|
|
59
|
+
res.writeHead(status, jsonHeaders());
|
|
60
|
+
res.end(JSON.stringify(data));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function jsonHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
|
64
|
+
return securityHeaders({ "content-type": "application/json", "cache-control": "no-store", pragma: "no-cache", expires: "0", ...headers });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeHtml(res: ServerResponse, html: string): void {
|
|
68
|
+
res.writeHead(200, securityHeaders({
|
|
69
|
+
"content-type": "text/html; charset=utf-8",
|
|
70
|
+
"cache-control": "no-store",
|
|
71
|
+
"content-security-policy": "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'",
|
|
72
|
+
}));
|
|
73
|
+
res.end(html);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function securityHeaders(headers: Record<string, string>): Record<string, string> {
|
|
77
|
+
return {
|
|
78
|
+
...headers,
|
|
79
|
+
"referrer-policy": "no-referrer",
|
|
80
|
+
"x-content-type-options": "nosniff"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { cleanKeyLabel, cleanKeyProject, issueApiKey, listKeys, revokeKey } from "../../../core/src/identity/api-keys.ts";
|
|
3
|
+
import { normalizeBudget } from "../../../core/src/identity/budget.ts";
|
|
4
|
+
import { canCreateOwnKey, canRevokeOwnKey } from "../../../core/src/identity/key-permissions.ts";
|
|
5
|
+
import { viewKey, viewUser, type User } from "../../../core/src/identity/types.ts";
|
|
6
|
+
import { emptyUsage, orgCostUsed, orgTokensUsed, usageForPeriod, userUsageKey, type RuntimeState } from "./runtime-state.ts";
|
|
7
|
+
import { canManage, type AuthUser } from "./auth-state.ts";
|
|
8
|
+
import { readJson, writeJson } from "./local-api-io.ts";
|
|
9
|
+
|
|
10
|
+
// Key management + scoped usage. Admin/open mode manages any owner; a logged-in
|
|
11
|
+
// member manages only their own keys and sees only their own scope.
|
|
12
|
+
|
|
13
|
+
function ownerScope(state: RuntimeState, user: AuthUser | undefined): string | undefined {
|
|
14
|
+
return canManage(state, user) ? undefined : user?.id; // undefined => all
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listKeysHandler(_req: IncomingMessage, res: ServerResponse, state: RuntimeState, user: AuthUser | undefined) {
|
|
18
|
+
if (!state.identity) return writeJson(res, 200, { items: [] });
|
|
19
|
+
writeJson(res, 200, { items: listKeys(state.identity, ownerScope(state, user)) });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function issueKeyHandler(req: IncomingMessage, res: ServerResponse, state: RuntimeState, user: AuthUser | undefined) {
|
|
23
|
+
if (!state.identity) return writeJson(res, 400, { error: "identity_unavailable" });
|
|
24
|
+
const body = await readJson(req);
|
|
25
|
+
const scope = ownerScope(state, user);
|
|
26
|
+
const explicitOwner = typeof body.owner === "string" ? body.owner.trim() : "";
|
|
27
|
+
const owner = scope ?? (explicitOwner || user?.id || "");
|
|
28
|
+
if (!owner) return writeJson(res, 400, { error: "owner_required" });
|
|
29
|
+
if (scope && owner !== scope) return writeJson(res, 403, { error: "forbidden" });
|
|
30
|
+
if (scope && !canCreateOwnKey(user)) return writeJson(res, 403, { error: "key_create_forbidden" });
|
|
31
|
+
const project = typeof body.project === "string" ? cleanKeyProject(body.project) : undefined;
|
|
32
|
+
if (!project) return writeJson(res, 400, { error: "project_required" });
|
|
33
|
+
const teamId = validKeyTeam(state, owner, body.teamId);
|
|
34
|
+
if (teamId === false) return writeJson(res, 400, { error: "invalid_key_team" });
|
|
35
|
+
if (teamId === undefined && requiresExplicitTeam(state, owner)) return writeJson(res, 400, { error: "team_required" });
|
|
36
|
+
const budget = normalizeBudget(body.budget);
|
|
37
|
+
if (!budget.ok) return writeJson(res, 400, { error: "invalid_budget" });
|
|
38
|
+
const issued = await issueApiKey(state.identity, owner, {
|
|
39
|
+
agentLabel: typeof body.agentLabel === "string" ? body.agentLabel : undefined,
|
|
40
|
+
project,
|
|
41
|
+
teamId,
|
|
42
|
+
scopes: Array.isArray(body.scopes) ? body.scopes : undefined,
|
|
43
|
+
budget: budget.budget
|
|
44
|
+
});
|
|
45
|
+
if (!issued) return writeJson(res, 404, { error: "unknown_owner" });
|
|
46
|
+
writeJson(res, 200, { ok: true, secret: issued.secret, key: issued.view });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function revokeKeyHandler(req: IncomingMessage, res: ServerResponse, state: RuntimeState, user: AuthUser | undefined) {
|
|
50
|
+
if (!state.identity) return writeJson(res, 400, { error: "identity_unavailable" });
|
|
51
|
+
const body = await readJson(req);
|
|
52
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
53
|
+
const key = state.identity.data.keys[id];
|
|
54
|
+
if (!key) return writeJson(res, 404, { error: "unknown_key" });
|
|
55
|
+
const scope = ownerScope(state, user);
|
|
56
|
+
if (scope && key.ownerUserId !== scope) return writeJson(res, 403, { error: "forbidden" });
|
|
57
|
+
if (scope && !canRevokeOwnKey(user)) return writeJson(res, 403, { error: "key_revoke_forbidden" });
|
|
58
|
+
await revokeKey(state.identity, id);
|
|
59
|
+
writeJson(res, 200, { ok: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function updateKeyHandler(req: IncomingMessage, res: ServerResponse, state: RuntimeState, user: AuthUser | undefined) {
|
|
63
|
+
if (!state.identity) return writeJson(res, 400, { error: "identity_unavailable" });
|
|
64
|
+
const body = await readJson(req);
|
|
65
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
66
|
+
const key = state.identity.data.keys[id];
|
|
67
|
+
if (!key) return writeJson(res, 404, { error: "unknown_key" });
|
|
68
|
+
const scope = ownerScope(state, user);
|
|
69
|
+
if (scope && key.ownerUserId !== scope) return writeJson(res, 403, { error: "forbidden" });
|
|
70
|
+
const project = typeof body.project === "string" ? cleanKeyProject(body.project) : undefined;
|
|
71
|
+
if (!project) return writeJson(res, 400, { error: "project_required" });
|
|
72
|
+
const previous = { ...key };
|
|
73
|
+
if (typeof body.agentLabel === "string") key.agentLabel = cleanKeyLabel(body.agentLabel);
|
|
74
|
+
key.project = project;
|
|
75
|
+
if ("teamId" in body) {
|
|
76
|
+
const teamId = validKeyTeam(state, key.ownerUserId, body.teamId);
|
|
77
|
+
if (teamId === false) return writeJson(res, 400, { error: "invalid_key_team" });
|
|
78
|
+
if (teamId === undefined && requiresExplicitTeam(state, key.ownerUserId)) return writeJson(res, 400, { error: "team_required" });
|
|
79
|
+
key.teamId = teamId;
|
|
80
|
+
}
|
|
81
|
+
try { await state.identity.save(); } catch {
|
|
82
|
+
state.identity.data.keys[id] = previous;
|
|
83
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
84
|
+
}
|
|
85
|
+
writeJson(res, 200, { ok: true, key: viewKey(key) });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function usageHandler(_req: IncomingMessage, res: ServerResponse, state: RuntimeState, user: AuthUser | undefined) {
|
|
89
|
+
if (!state.identity) return writeJson(res, 200, { org: emptyUsage(), users: [], teams: [], keys: [] });
|
|
90
|
+
const id = state.identity;
|
|
91
|
+
const scope = ownerScope(state, user);
|
|
92
|
+
const scopedUser = scope ? id.getUser(scope) : undefined;
|
|
93
|
+
const users = id.listUsers()
|
|
94
|
+
.filter((u) => !scope || u.id === scope)
|
|
95
|
+
.map((u) => ({ ...viewUser(u), usage: usageForPeriod(state.usageByUser[userUsageKey(u.id)], u.budget?.period) }));
|
|
96
|
+
const teams = id.listTeams()
|
|
97
|
+
.filter((t) => !scope || readableTeam(scopedUser, t.id, t.managerIds))
|
|
98
|
+
.map((t) => ({ id: t.id, name: t.name, budget: t.budget, usage: usageForPeriod(state.usageByTeam[t.id], t.budget?.period), members: id.usersInTeam(t.id).length }));
|
|
99
|
+
const keys = listKeys(id, scope).map((k) => ({ ...k, usage: usageForPeriod(state.usageByKey[k.id], id.data.keys[k.id]?.budget?.period) }));
|
|
100
|
+
const org = scope ? undefined : { tokens: orgTokensUsed(state, id.data.orgBudget?.period), costEur: orgCostUsed(state, id.data.orgBudget?.period) };
|
|
101
|
+
writeJson(res, 200, { scope: scope ?? "all", org, users, teams, keys });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validKeyTeam(state: RuntimeState, owner: string, value: unknown): string | undefined | false {
|
|
105
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
106
|
+
if (typeof value !== "string") return false;
|
|
107
|
+
const team = state.identity?.getTeam(value.trim());
|
|
108
|
+
const user = state.identity?.getUser(owner);
|
|
109
|
+
if (team?.id === "everyone" && nonDefaultTeamIds(user?.teamIds ?? []).length) return false;
|
|
110
|
+
return team && user?.teamIds.includes(team.id) ? team.id : false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function requiresExplicitTeam(state: RuntimeState, owner: string): boolean {
|
|
114
|
+
const teamIds = state.identity?.getUser(owner)?.teamIds ?? [];
|
|
115
|
+
return nonDefaultTeamIds(teamIds).length > 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function nonDefaultTeamIds(teamIds: string[]): string[] {
|
|
119
|
+
return teamIds.filter((id) => id !== "everyone");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readableTeam(user: User | undefined, teamId: string, managerIds: string[]): boolean {
|
|
123
|
+
if (!user) return false;
|
|
124
|
+
if (user.teamIds.includes(teamId)) return true;
|
|
125
|
+
return user.role === "manager" && managerIds.includes(user.id);
|
|
126
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { staticPluginPipeline } from "../../../core/src/plugins/static-pipeline.ts";
|
|
3
|
+
import type { RuntimeState } from "./runtime-state.ts";
|
|
4
|
+
import { readJson, writeJson } from "./local-api-io.ts";
|
|
5
|
+
import { persistRuntimeSettings } from "./runtime-settings.ts";
|
|
6
|
+
|
|
7
|
+
// Optional plugin middleware order. Core request safety is not represented here.
|
|
8
|
+
export function pluginOrder(state: RuntimeState): string[] {
|
|
9
|
+
const order = state.pluginOrder?.filter((id) => (staticPluginPipeline as readonly string[]).includes(id)) ?? [];
|
|
10
|
+
for (const id of staticPluginPipeline) if (!order.includes(id)) order.push(id);
|
|
11
|
+
return order;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function orderIndex(state: RuntimeState, id: string): number {
|
|
15
|
+
const i = pluginOrder(state).indexOf(id);
|
|
16
|
+
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function redactionBeforeCompression(_state: RuntimeState): boolean {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function reorderPlugin(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
24
|
+
const body = await readJson(req);
|
|
25
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
26
|
+
const dir = body.direction === "up" ? -1 : body.direction === "down" ? 1 : 0;
|
|
27
|
+
const order = pluginOrder(state);
|
|
28
|
+
const i = order.indexOf(id);
|
|
29
|
+
if (i < 0 || dir === 0) return writeJson(res, 400, { error: "bad_reorder" });
|
|
30
|
+
const j = i + dir;
|
|
31
|
+
if (j < 0 || j >= order.length) return writeJson(res, 200, { ok: true, order });
|
|
32
|
+
const next = [...order];
|
|
33
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
34
|
+
const previous = state.pluginOrder;
|
|
35
|
+
state.pluginOrder = next;
|
|
36
|
+
try { await persistRuntimeSettings(state); } catch {
|
|
37
|
+
state.pluginOrder = previous;
|
|
38
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
39
|
+
}
|
|
40
|
+
writeJson(res, 200, { ok: true, order: next });
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { findPlugin } from "../../../core/src/plugins/plugin-catalog.ts";
|
|
3
|
+
import { pluginView } from "./local-api-state.ts";
|
|
4
|
+
import { readJson, writeJson } from "./local-api-io.ts";
|
|
5
|
+
import { persistRuntimeSettings } from "./runtime-settings.ts";
|
|
6
|
+
import type { PluginHost } from "./plugin-host.ts";
|
|
7
|
+
import type { RuntimeState } from "./runtime-state.ts";
|
|
8
|
+
|
|
9
|
+
export async function togglePlugin(req: IncomingMessage, res: ServerResponse, state: RuntimeState, pluginHost?: PluginHost) {
|
|
10
|
+
const body = await readJson(req);
|
|
11
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
12
|
+
const plugin = findPlugin(id);
|
|
13
|
+
if (!plugin) return writeJson(res, 404, { error: "unknown_plugin" });
|
|
14
|
+
if (typeof body.enabled !== "boolean") return writeJson(res, 400, { error: "invalid_enabled" });
|
|
15
|
+
const previousEnabled = state.pluginEnabled[id], previousUpdated = state.pluginUpdatedAt[id];
|
|
16
|
+
state.pluginEnabled[id] = body.enabled;
|
|
17
|
+
state.pluginUpdatedAt[id] = new Date().toISOString();
|
|
18
|
+
try { await persistRuntimeSettings(state); } catch {
|
|
19
|
+
restorePluginState(state, id, previousEnabled, previousUpdated);
|
|
20
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
21
|
+
}
|
|
22
|
+
if (pluginHost) await (body.enabled ? pluginHost.enable(id) : pluginHost.disable(id));
|
|
23
|
+
writeJson(res, 200, pluginView(plugin, state));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function restorePluginState(state: RuntimeState, id: string, enabled: boolean | undefined, updatedAt: string | undefined): void {
|
|
27
|
+
if (enabled === undefined) delete state.pluginEnabled[id];
|
|
28
|
+
else state.pluginEnabled[id] = enabled;
|
|
29
|
+
if (updatedAt === undefined) delete state.pluginUpdatedAt[id];
|
|
30
|
+
else state.pluginUpdatedAt[id] = updatedAt;
|
|
31
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { validateProviderTarget } from "../../../core/src/security/target-policy.ts";
|
|
3
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
4
|
+
import { distributionEligible, providerWeight, repairActiveProvider, type RuntimeState } from "./runtime-state.ts";
|
|
5
|
+
import { buildProviderStatus } from "./local-api-state.ts";
|
|
6
|
+
import { readJson, writeJson } from "./local-api-io.ts";
|
|
7
|
+
import { persistRuntimeAuthProvider, persistRuntimeAuthSelection, removeRuntimeAuthProvider } from "./runtime-auth-registry.ts";
|
|
8
|
+
import { persistRuntimeSettings } from "./runtime-settings.ts";
|
|
9
|
+
import { restoreProviderRouting, snapshotProviderRouting } from "./provider-routing-snapshot.ts";
|
|
10
|
+
import { buildProviderFromInput, validEnv } from "./provider-input.ts";
|
|
11
|
+
|
|
12
|
+
export async function selectProvider(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
13
|
+
const body = await readJson(req);
|
|
14
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
15
|
+
const provider = state.providers.find((item) => item.id === id);
|
|
16
|
+
if (!provider) return writeJson(res, 404, { error: "unknown_provider" });
|
|
17
|
+
if (provider.enabled === false) return writeJson(res, 409, { error: "provider_disabled" });
|
|
18
|
+
const previous = snapshotProviderRouting(state);
|
|
19
|
+
state.activeProviderId = provider.id;
|
|
20
|
+
state.providerSelectedAt = new Date().toISOString();
|
|
21
|
+
try { await persistProviderRouting(state); } catch {
|
|
22
|
+
restoreProviderRouting(state, previous);
|
|
23
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
24
|
+
}
|
|
25
|
+
writeJson(res, 200, buildProviderStatus(state));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function setProviderWeight(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
29
|
+
const body = await readJson(req);
|
|
30
|
+
const id = typeof body.id === "string" ? body.id : "", provider = state.providers.find((item) => item.id === id);
|
|
31
|
+
if (!provider) return writeJson(res, 404, { error: "unknown_provider" });
|
|
32
|
+
if (typeof body.weight !== "number" || !Number.isFinite(body.weight) || body.weight < 0 || body.weight > 1000) return writeJson(res, 400, { error: "invalid_weight" });
|
|
33
|
+
const weights = { ...state.providerWeights, [id]: body.weight };
|
|
34
|
+
if (distributionEligible(provider) && !hasPositiveDistributionWeight(state, weights)) return writeJson(res, 409, { error: "last_provider_weight" });
|
|
35
|
+
const previous = snapshotProviderRouting(state);
|
|
36
|
+
state.providerWeights[id] = body.weight;
|
|
37
|
+
try { await persistRuntimeSettings(state); } catch {
|
|
38
|
+
restoreProviderRouting(state, previous);
|
|
39
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
40
|
+
}
|
|
41
|
+
writeJson(res, 200, buildProviderStatus(state));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function setProviderWeights(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
45
|
+
const body = await readJson(req), source = body.weights && typeof body.weights === "object" && !Array.isArray(body.weights) ? body.weights as Record<string, unknown> : undefined;
|
|
46
|
+
if (!source) return writeJson(res, 400, { error: "invalid_weights" });
|
|
47
|
+
const next: Record<string, number> = {};
|
|
48
|
+
for (const [id, weight] of Object.entries(source)) {
|
|
49
|
+
if (!state.providers.some((item) => item.id === id)) return writeJson(res, 404, { error: "unknown_provider" });
|
|
50
|
+
if (typeof weight !== "number" || !Number.isFinite(weight) || weight < 0 || weight > 1000) return writeJson(res, 400, { error: "invalid_weight" });
|
|
51
|
+
next[id] = weight;
|
|
52
|
+
}
|
|
53
|
+
const merged = { ...state.providerWeights, ...next };
|
|
54
|
+
if (body.mode === "distribute" && !hasPositiveDistributionWeight(state, merged)) return writeJson(res, 409, { error: "no_weighted_provider" });
|
|
55
|
+
if (state.routingMode === "distribute" && !hasPositiveDistributionWeight(state, merged)) return writeJson(res, 409, { error: "last_provider_weight" });
|
|
56
|
+
const previous = snapshotProviderRouting(state);
|
|
57
|
+
Object.assign(state.providerWeights, next);
|
|
58
|
+
if (body.mode === "manual" || body.mode === "distribute") state.routingMode = body.mode;
|
|
59
|
+
try { await persistProviderRouting(state); } catch {
|
|
60
|
+
restoreProviderRouting(state, previous);
|
|
61
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
62
|
+
}
|
|
63
|
+
writeJson(res, 200, { routingMode: state.routingMode, providers: buildProviderStatus(state) });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function addProvider(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
67
|
+
const body = await readJson(req);
|
|
68
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
69
|
+
if (!/^[a-z0-9][a-z0-9._:-]{0,63}$/i.test(id)) return writeJson(res, 400, { error: "invalid_provider_id" });
|
|
70
|
+
if (id.toLowerCase() === "default") return writeJson(res, 400, { error: "reserved_provider_id" });
|
|
71
|
+
if (state.providers.some((item) => item.id === id)) return writeJson(res, 409, { error: "provider_exists" });
|
|
72
|
+
const name = typeof body.name === "string" && body.name.trim() ? body.name.trim().slice(0, 80) : id;
|
|
73
|
+
const built = buildProviderFromInput(id, name, body);
|
|
74
|
+
if ("error" in built) return writeJson(res, 400, { error: built.error });
|
|
75
|
+
const previousProviders = state.providers.slice(), previousWeights = { ...state.providerWeights };
|
|
76
|
+
state.providers.push(built.provider);
|
|
77
|
+
state.providerWeights[id] = 1;
|
|
78
|
+
try { await persistRuntimeSettings(state); } catch {
|
|
79
|
+
state.providers = previousProviders;
|
|
80
|
+
state.providerWeights = previousWeights;
|
|
81
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
82
|
+
}
|
|
83
|
+
writeJson(res, 200, buildProviderStatus(state));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function updateProvider(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
87
|
+
const body = await readJson(req);
|
|
88
|
+
const provider = state.providers.find((item) => item.id === body.id);
|
|
89
|
+
if (!provider) return writeJson(res, 404, { error: "unknown_provider" });
|
|
90
|
+
const before = { ...provider };
|
|
91
|
+
const beforeActive = state.activeProviderId, beforeSelected = state.providerSelectedAt;
|
|
92
|
+
if (typeof body.name === "string" && body.name.trim()) provider.name = body.name.trim().slice(0, 80);
|
|
93
|
+
if (typeof body.target === "string" && body.target.trim()) {
|
|
94
|
+
let nextTarget: string;
|
|
95
|
+
try { nextTarget = validateProviderTarget(body.target.trim(), { path: "provider target", allowPrivate: provider.kind === "local" }); } catch { return writeJson(res, 400, { error: "invalid_target" }); }
|
|
96
|
+
if (originOf(nextTarget) !== originOf(provider.target)) {
|
|
97
|
+
if (hasCredential(provider) && body.clearCredential !== true && body.credential === undefined && body.credentialEnv === undefined) return writeJson(res, 409, { error: "credential_origin_change" });
|
|
98
|
+
provider.credentialValue = undefined;
|
|
99
|
+
provider.credentialEnv = undefined;
|
|
100
|
+
provider.credentialRef = "none";
|
|
101
|
+
provider.authScheme = provider.kind === "local" ? "none" : provider.authScheme;
|
|
102
|
+
}
|
|
103
|
+
provider.target = nextTarget;
|
|
104
|
+
}
|
|
105
|
+
if (typeof body.credentialEnv === "string") {
|
|
106
|
+
const env = body.credentialEnv.trim();
|
|
107
|
+
if (env && !validEnv(env)) return writeJson(res, 400, { error: "invalid_credential_env" });
|
|
108
|
+
provider.credentialEnv = env || undefined; provider.credentialValue = undefined; provider.credentialRef = provider.credentialEnv ? `env:${provider.credentialEnv}` : "none";
|
|
109
|
+
}
|
|
110
|
+
if (typeof body.credential === "string" && body.credential.trim()) { provider.credentialValue = body.credential.trim(); provider.credentialEnv = undefined; provider.credentialRef = "inline"; }
|
|
111
|
+
if (body.clearCredential === true) { provider.credentialValue = undefined; provider.credentialEnv = undefined; provider.credentialRef = "none"; }
|
|
112
|
+
if (typeof body.enabled === "boolean") provider.enabled = body.enabled;
|
|
113
|
+
if (typeof body.allowDistribution === "boolean") provider.allowDistribution = body.allowDistribution;
|
|
114
|
+
repairActiveProvider(state);
|
|
115
|
+
try {
|
|
116
|
+
await persistProviderRouting(state);
|
|
117
|
+
await persistRuntimeAuthProvider(state.dataDir, provider, state.activeProviderId === provider.id, state.routingMode);
|
|
118
|
+
} catch {
|
|
119
|
+
Object.assign(provider, before);
|
|
120
|
+
state.activeProviderId = beforeActive;
|
|
121
|
+
state.providerSelectedAt = beforeSelected;
|
|
122
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
123
|
+
}
|
|
124
|
+
writeJson(res, 200, buildProviderStatus(state));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function removeProvider(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
128
|
+
const body = await readJson(req);
|
|
129
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
130
|
+
if (id === "default") return writeJson(res, 409, { error: "cannot_remove_default" });
|
|
131
|
+
const index = state.providers.findIndex((item) => item.id === id);
|
|
132
|
+
if (index < 0) return writeJson(res, 404, { error: "unknown_provider" });
|
|
133
|
+
const previous = snapshotProviderRouting(state);
|
|
134
|
+
const [removed] = state.providers.splice(index, 1);
|
|
135
|
+
delete state.providerWeights[id];
|
|
136
|
+
repairActiveProvider(state);
|
|
137
|
+
try { await persistProviderRouting(state); } catch {
|
|
138
|
+
restoreProviderRouting(state, previous);
|
|
139
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
140
|
+
}
|
|
141
|
+
try { await removeRuntimeAuthProvider(removed); } catch {
|
|
142
|
+
return writeJson(res, 500, { error: "remove_failed" });
|
|
143
|
+
}
|
|
144
|
+
writeJson(res, 200, buildProviderStatus(state));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function setRoutingMode(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
148
|
+
const body = await readJson(req);
|
|
149
|
+
if (body.mode !== "manual" && body.mode !== "distribute") return writeJson(res, 400, { error: "invalid_routing_mode" });
|
|
150
|
+
if (body.mode === "distribute" && !hasPositiveDistributionWeight(state)) return writeJson(res, 409, { error: "no_weighted_provider" });
|
|
151
|
+
const previous = snapshotProviderRouting(state);
|
|
152
|
+
state.routingMode = body.mode;
|
|
153
|
+
try { await persistProviderRouting(state); } catch {
|
|
154
|
+
restoreProviderRouting(state, previous);
|
|
155
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
156
|
+
}
|
|
157
|
+
writeJson(res, 200, { routingMode: state.routingMode, providers: buildProviderStatus(state) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function persistProviderRouting(state: RuntimeState): Promise<void> {
|
|
161
|
+
await Promise.all([
|
|
162
|
+
persistRuntimeSettings(state),
|
|
163
|
+
persistRuntimeAuthSelection(state.dataDir, state.activeProviderId, state.routingMode)
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function originOf(target: string): string {
|
|
168
|
+
try { const url = new URL(target); return `${url.protocol}//${url.host}`.toLowerCase(); } catch { return ""; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hasCredential(provider: ProviderConfig): boolean {
|
|
172
|
+
return Boolean(provider.credentialValue || provider.credentialEnv || (provider.credentialRef && provider.credentialRef !== "none"));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hasPositiveDistributionWeight(state: RuntimeState, weights = state.providerWeights): boolean {
|
|
176
|
+
return state.providers.some((provider) => {
|
|
177
|
+
if (!distributionEligible(provider)) return false;
|
|
178
|
+
const weight = typeof weights[provider.id] === "number" ? weights[provider.id] : providerWeight(state, provider.id);
|
|
179
|
+
return weight > 0;
|
|
180
|
+
});
|
|
181
|
+
}
|