@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,51 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ProviderAllowlist } from "./provider-access.ts";
|
|
3
|
+
|
|
4
|
+
export type ClientIdentity = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
source: "user" | "agent" | "api_key" | "unattributed";
|
|
8
|
+
userId?: string;
|
|
9
|
+
agentId?: string;
|
|
10
|
+
keyAgentLabel?: string;
|
|
11
|
+
teamIds?: string[];
|
|
12
|
+
keyId?: string;
|
|
13
|
+
project?: string;
|
|
14
|
+
allowedProviderIds?: ProviderAllowlist;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function deriveClientIdentity(headers: Headers): ClientIdentity {
|
|
18
|
+
const agent = agentIdFromHeaders(headers);
|
|
19
|
+
if (agent) return { id: clientIdForAgent(agent), label: `agent:${safeSubjectId(agent)}`, source: "agent", agentId: agent };
|
|
20
|
+
const credential = headers.get("authorization") || headers.get("x-api-key");
|
|
21
|
+
if (credential) {
|
|
22
|
+
const hash = createHash("sha256").update(credential).digest("hex").slice(0, 12);
|
|
23
|
+
return { id: `api-key:${hash}`, label: `api-key sha256:${hash}`, source: "api_key" };
|
|
24
|
+
}
|
|
25
|
+
return { id: "unattributed", label: "unattributed client", source: "unattributed" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clientIdForAgent(agentId: string): string {
|
|
29
|
+
return `agent:${safeSubjectId(agentId)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function agentIdFromHeaders(headers: Headers): string | undefined {
|
|
33
|
+
return clean(headers.get("x-molenkopf-agent"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clean(value: string | null): string | undefined {
|
|
37
|
+
const normalized = value?.replace(/[^\w .@-]/g, "").trim().slice(0, 48);
|
|
38
|
+
return normalized || undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function safeSubjectId(value: string): string {
|
|
42
|
+
return looksSensitive(value) ? createHash("sha256").update(value).digest("hex").slice(0, 12) : slug(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function looksSensitive(value: string): boolean {
|
|
46
|
+
return /@|\s/.test(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function slug(value: string): string {
|
|
50
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "unknown";
|
|
51
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { AuditManifest } from "../../../core/src/manifest/audit-store.ts";
|
|
2
|
+
|
|
3
|
+
export type CommunicationNode = { id: string; label: string; kind: string; detail: string; count: number };
|
|
4
|
+
export type CommunicationEdge = { from: string; to: string; label: string; count: number };
|
|
5
|
+
export type CommunicationGraph = { nodes: CommunicationNode[]; edges: CommunicationEdge[]; updatedAt?: string };
|
|
6
|
+
|
|
7
|
+
const MAX_GRAPH_NODES = 80;
|
|
8
|
+
const MAX_GRAPH_EDGES = 180;
|
|
9
|
+
const MAX_ID = 140;
|
|
10
|
+
const MAX_TEXT = 96;
|
|
11
|
+
const MAX_COUNT = 99999;
|
|
12
|
+
|
|
13
|
+
export function createCommunicationGraph(): CommunicationGraph {
|
|
14
|
+
return { nodes: [], edges: [] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildCommunicationGraph(manifests: AuditManifest[]): CommunicationGraph {
|
|
18
|
+
const graph = createCommunicationGraph();
|
|
19
|
+
for (const manifest of manifests) recordCommunicationGraph(graph, manifest);
|
|
20
|
+
return graph;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function recordCommunicationGraph(graph: CommunicationGraph, manifest: AuditManifest) {
|
|
24
|
+
const path = safePath(manifest.path);
|
|
25
|
+
if (!path) return;
|
|
26
|
+
const source = safeClient(manifest.client);
|
|
27
|
+
const sourceId = `source:${source.source}:${source.id}`;
|
|
28
|
+
const host = safeHost(manifest.targetHost);
|
|
29
|
+
const method = safeMethod(manifest.method);
|
|
30
|
+
const status = safeStatus(manifest.statusCode);
|
|
31
|
+
const providerId = `provider:${host}`;
|
|
32
|
+
const endpointId = `endpoint:${method}:${path}`;
|
|
33
|
+
const statusId = `status:${status.label}`;
|
|
34
|
+
|
|
35
|
+
upsert(graph, sourceId, source.label, source.kind, "captured from proxied traffic");
|
|
36
|
+
upsert(graph, providerId, host, "provider", "active upstream");
|
|
37
|
+
upsert(graph, endpointId, `${method} ${path}`, "request", "path and status only");
|
|
38
|
+
upsert(graph, statusId, `${status.label} responses`, "status", status.detail);
|
|
39
|
+
link(graph, sourceId, endpointId, "sends");
|
|
40
|
+
link(graph, endpointId, providerId, "routes to");
|
|
41
|
+
link(graph, endpointId, statusId, "returns");
|
|
42
|
+
|
|
43
|
+
if (manifest.compressedItems > 0 || manifest.retrievalIds.length > 0) {
|
|
44
|
+
upsert(graph, "plugin:compression", "Context compression", "plugin", `${manifest.compressedItems} items`);
|
|
45
|
+
link(graph, endpointId, "plugin:compression", "compresses");
|
|
46
|
+
}
|
|
47
|
+
if (manifest.retrievalIds.length > 0) {
|
|
48
|
+
upsert(graph, "store:retrieval", "Local retrieval store", "store", "retrieval IDs only");
|
|
49
|
+
link(graph, "plugin:compression", "store:retrieval", "stores");
|
|
50
|
+
}
|
|
51
|
+
graph.updatedAt = manifest.timestamp;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeClient(client: AuditManifest["client"]): { id: string; label: string; source: string; kind: string } {
|
|
55
|
+
if (!client) return { id: "unattributed", label: "unattributed client", source: "unattributed", kind: "agent" };
|
|
56
|
+
const source = client.source === "user" || client.source === "agent" || client.source === "api_key" ? client.source : "unattributed";
|
|
57
|
+
return {
|
|
58
|
+
id: safeGraphId(client.id || client.label || source),
|
|
59
|
+
label: trim(client.label || client.id || source, 80),
|
|
60
|
+
source,
|
|
61
|
+
kind: source === "api_key" ? "metadata" : "agent"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function safeGraphId(value: string): string {
|
|
66
|
+
const clean = String(value ?? "unattributed").toLowerCase().replace(/[^a-z0-9._:-]+/g, "-");
|
|
67
|
+
return trim(clean || "unattributed", 80);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function safePath(path: string): string | undefined {
|
|
71
|
+
let pathname: string;
|
|
72
|
+
try {
|
|
73
|
+
pathname = new URL(String(path ?? ""), "http://local").pathname;
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
if (pathname === "/" || pathname === "/favicon.ico" || pathname === "/src/App.jsx") return undefined;
|
|
78
|
+
if (pathname.startsWith("/.well-known/") || pathname.startsWith("/__molenkopf/")) return undefined;
|
|
79
|
+
const safe = pathname.split("/").map(safePathSegment).join("/").replace(/\/+/g, "/");
|
|
80
|
+
return safe === "/" ? undefined : trim(safe, MAX_TEXT);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safePathSegment(segment: string): string {
|
|
84
|
+
if (!segment) return "";
|
|
85
|
+
let decoded = segment;
|
|
86
|
+
try {
|
|
87
|
+
decoded = decodeURIComponent(segment);
|
|
88
|
+
} catch {
|
|
89
|
+
return ":value";
|
|
90
|
+
}
|
|
91
|
+
if (/\s/.test(decoded) || /^(?:token|secret|password|credential|prompt|api[-_]?key|key)$/i.test(decoded)) return ":value";
|
|
92
|
+
const clean = decoded.replace(/[^A-Za-z0-9._:-]/g, "-");
|
|
93
|
+
if (!clean || clean.length > 28 || /^[A-Za-z0-9_-]{24,}$/.test(clean)) return ":value";
|
|
94
|
+
return clean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function safeHost(value: string): string {
|
|
98
|
+
try {
|
|
99
|
+
const text = String(value ?? "");
|
|
100
|
+
const host = new URL(text.includes("://") ? text : `http://${text}`).hostname;
|
|
101
|
+
return trim(host || "unknown-host", 80);
|
|
102
|
+
} catch {
|
|
103
|
+
return "unknown-host";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function safeMethod(value: string): string {
|
|
108
|
+
const method = String(value ?? "").toUpperCase().replace(/[^A-Z]/g, "").slice(0, 12);
|
|
109
|
+
return method || "REQUEST";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function safeStatus(statusCode: AuditManifest["statusCode"]): { label: string; detail: string } {
|
|
113
|
+
const code = Number(statusCode);
|
|
114
|
+
if (!Number.isInteger(code) || code < 100 || code > 599) return { label: "unknown", detail: "not captured" };
|
|
115
|
+
return { label: `${Math.floor(code / 100)}xx`, detail: String(code) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function upsert(graph: CommunicationGraph, id: string, label: string, kind: string, detail: string) {
|
|
119
|
+
const found = graph.nodes.find((node) => node.id === id);
|
|
120
|
+
if (found) {
|
|
121
|
+
found.count = Math.min(MAX_COUNT, found.count + 1);
|
|
122
|
+
found.detail = trim(detail, MAX_TEXT);
|
|
123
|
+
} else {
|
|
124
|
+
if (graph.nodes.length >= MAX_GRAPH_NODES) return;
|
|
125
|
+
graph.nodes.push({ id: trim(id, MAX_ID), label: trim(label, 80), kind: trim(kind, 24), detail: trim(detail, MAX_TEXT), count: 1 });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function link(graph: CommunicationGraph, from: string, to: string, label: string) {
|
|
130
|
+
if (!graph.nodes.some((node) => node.id === from) || !graph.nodes.some((node) => node.id === to)) return;
|
|
131
|
+
const found = graph.edges.find((edge) => edge.from === from && edge.to === to && edge.label === label);
|
|
132
|
+
if (found) found.count = Math.min(MAX_COUNT, found.count + 1);
|
|
133
|
+
else if (graph.edges.length < MAX_GRAPH_EDGES) graph.edges.push({ from, to, label: trim(label, 48), count: 1 });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function trim(value: string, max: number): string {
|
|
137
|
+
const text = String(value ?? "");
|
|
138
|
+
return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
|
|
139
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import type { RuntimeState } from "./runtime-state.ts";
|
|
3
|
+
import { isLoopbackBindHost } from "./public-bind.ts";
|
|
4
|
+
|
|
5
|
+
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
6
|
+
|
|
7
|
+
export type ControlPlaneGuardResult = { ok: true } | { ok: false; status: number; error: string };
|
|
8
|
+
|
|
9
|
+
export function checkControlPlaneWrite(req: IncomingMessage, path: string, state: RuntimeState): ControlPlaneGuardResult {
|
|
10
|
+
if (!WRITE_METHODS.has(req.method ?? "GET")) return { ok: true };
|
|
11
|
+
if (!originAllowed(req.headers.origin, req.headers.host, process.env.MOLENKOPF_DASHBOARD_DEV_ORIGIN)) {
|
|
12
|
+
return { ok: false, status: 403, error: "bad_origin" };
|
|
13
|
+
}
|
|
14
|
+
if (!req.headers.origin && !isLoopbackBindHost(state.host) && hasSessionCookie(req.headers.cookie)) {
|
|
15
|
+
return { ok: false, status: 403, error: "bad_origin" };
|
|
16
|
+
}
|
|
17
|
+
if (!isJsonContentType(req.headers["content-type"])) {
|
|
18
|
+
return { ok: false, status: 415, error: "json_required" };
|
|
19
|
+
}
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasSessionCookie(value: string | string[] | undefined): boolean {
|
|
24
|
+
return typeof value === "string" && /(?:^|;\s*)molenkopf_session=/.test(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function originAllowed(origin: string | string[] | undefined, host: string | string[] | undefined, dashboardDevOrigin?: string): boolean {
|
|
28
|
+
if (!origin) return true;
|
|
29
|
+
if (Array.isArray(origin) || Array.isArray(host) || !host) return false;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(origin);
|
|
32
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
33
|
+
if (parsed.host.toLowerCase() === host.toLowerCase()) return true;
|
|
34
|
+
if (!dashboardDevOrigin) return false;
|
|
35
|
+
const allowedDev = new URL(dashboardDevOrigin);
|
|
36
|
+
return parsed.protocol === allowedDev.protocol && sameOriginHost(parsed, allowedDev);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sameOriginHost(actual: URL, expected: URL): boolean {
|
|
43
|
+
if (actual.host.toLowerCase() === expected.host.toLowerCase()) return true;
|
|
44
|
+
return actual.port === expected.port && isLoopbackName(actual.hostname) && isLoopbackName(expected.hostname);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isLoopbackName(hostname: string): boolean {
|
|
48
|
+
const value = hostname.toLowerCase();
|
|
49
|
+
return value === "localhost" || value === "127.0.0.1" || value === "[::1]";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isJsonContentType(value: string | string[] | undefined): boolean {
|
|
53
|
+
if (Array.isArray(value) || !value) return false;
|
|
54
|
+
const mediaType = value.split(";")[0].trim().toLowerCase();
|
|
55
|
+
return mediaType === "application/json" || mediaType.endsWith("+json");
|
|
56
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { dirname, join, normalize, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const dashboardRoot = resolve(here, "..", "..", "..", "dashboard");
|
|
9
|
+
const defaultDist = join(dashboardRoot, "dist");
|
|
10
|
+
const routePrefix = "/__molenkopf/dashboard";
|
|
11
|
+
|
|
12
|
+
export function isDashboardRequest(url: string | undefined): boolean {
|
|
13
|
+
const path = routePath(url);
|
|
14
|
+
return path === routePrefix || path.startsWith(`${routePrefix}/`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function handleDashboardRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
18
|
+
const path = parsePath(req.url);
|
|
19
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
20
|
+
writeText(res, 405, "method not allowed");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const devOrigin = process.env.MOLENKOPF_DASHBOARD_DEV_ORIGIN;
|
|
24
|
+
if (devOrigin) {
|
|
25
|
+
await proxyDevDashboard(req, res, devOrigin);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!path) return writeText(res, 400, "bad request");
|
|
29
|
+
if (path.startsWith(`${routePrefix}/assets/`)) return serveDistPath(path.slice(routePrefix.length + 1), true, res);
|
|
30
|
+
if (path === `${routePrefix}/favicon.png` || path === `${routePrefix}/molenkopf-logo.png`) return serveDistPath(path.slice(routePrefix.length + 1), true, res);
|
|
31
|
+
if (hasExtension(path)) return writeText(res, 404, "not found");
|
|
32
|
+
return serveIndex(res);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function serveIndex(res: ServerResponse) {
|
|
36
|
+
const file = join(distDir(), "index.html");
|
|
37
|
+
if (!existsSync(file)) return writeText(res, 503, missingBuildHtml(), "text/html; charset=utf-8");
|
|
38
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
39
|
+
res.end(await readFile(file));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function serveDistPath(routePath: string, immutable: boolean, res: ServerResponse) {
|
|
43
|
+
const safe = safeRelative(routePath);
|
|
44
|
+
if (!safe) return writeText(res, 400, "bad request");
|
|
45
|
+
const file = normalize(join(distDir(), safe));
|
|
46
|
+
const root = distDir();
|
|
47
|
+
if (!(file.startsWith(`${root}${sep}`)) || !existsSync(file)) return writeText(res, 404, "not found");
|
|
48
|
+
res.writeHead(200, { "content-type": contentType(file), "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-store" });
|
|
49
|
+
createReadStream(file).pipe(res);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function proxyDevDashboard(req: IncomingMessage, res: ServerResponse, origin: string) {
|
|
53
|
+
try {
|
|
54
|
+
const incoming = parseIncoming(req.url);
|
|
55
|
+
if (!incoming) return writeText(res, 400, "bad request");
|
|
56
|
+
const target = new URL(`${incoming.pathname}${incoming.search}`, origin);
|
|
57
|
+
if (target.pathname === routePrefix) target.pathname = `${routePrefix}/`;
|
|
58
|
+
const upstream = await fetch(target, { method: req.method });
|
|
59
|
+
const headers: Record<string, string> = {};
|
|
60
|
+
upstream.headers.forEach((value, key) => { headers[key] = value; });
|
|
61
|
+
res.writeHead(upstream.status, headers);
|
|
62
|
+
if (req.method === "HEAD") return res.end();
|
|
63
|
+
res.end(Buffer.from(await upstream.arrayBuffer()));
|
|
64
|
+
} catch {
|
|
65
|
+
writeText(res, 503, "dashboard dev server is starting");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseIncoming(url: string | undefined): URL | undefined {
|
|
70
|
+
const raw = url ?? "/";
|
|
71
|
+
if (!raw.startsWith("/") || raw.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return undefined;
|
|
72
|
+
try { return new URL(raw, "http://local"); } catch { return undefined; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parsePath(url: string | undefined): string {
|
|
76
|
+
return parseIncoming(url)?.pathname ?? "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function routePath(url: string | undefined): string {
|
|
80
|
+
try { return new URL(url ?? "/", "http://local").pathname; } catch { return ""; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeRelative(routePath: string): string | undefined {
|
|
84
|
+
let decoded: string;
|
|
85
|
+
try { decoded = decodeURIComponent(routePath); } catch { return undefined; }
|
|
86
|
+
if (!decoded || decoded.includes("\\") || decoded.split("/").some((part) => !part || part === "." || part === "..")) return undefined;
|
|
87
|
+
return decoded;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasExtension(path: string): boolean {
|
|
91
|
+
return /\/[^/]+\.[a-z0-9]+$/i.test(path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function distDir() {
|
|
95
|
+
return process.env.MOLENKOPF_DASHBOARD_DIST || defaultDist;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function contentType(file: string): string {
|
|
99
|
+
if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
100
|
+
if (file.endsWith(".css")) return "text/css; charset=utf-8";
|
|
101
|
+
if (file.endsWith(".svg")) return "image/svg+xml";
|
|
102
|
+
if (file.endsWith(".png")) return "image/png";
|
|
103
|
+
if (file.endsWith(".ico")) return "image/x-icon";
|
|
104
|
+
if (file.endsWith(".woff2")) return "font/woff2";
|
|
105
|
+
return "application/octet-stream";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeText(res: ServerResponse, status: number, body: string, type = "text/plain; charset=utf-8") {
|
|
109
|
+
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
110
|
+
res.end(body);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function missingBuildHtml() {
|
|
114
|
+
return "<!doctype html><html><body><div id=\"root\">Dashboard build missing. Run npm --prefix packages/dashboard run build.</div></body></html>";
|
|
115
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createBrotliDecompress, createGunzip, createInflate, type BrotliDecompress, type Gunzip, type Inflate } from "node:zlib";
|
|
2
|
+
import { createUsageMeter, type UsageTotals as MeterTotals } from "../../../core/src/manifest/usage-meter.ts";
|
|
3
|
+
|
|
4
|
+
type DecodeStream = BrotliDecompress | Gunzip | Inflate;
|
|
5
|
+
|
|
6
|
+
export type ResponseUsageScanner = {
|
|
7
|
+
feed(chunk: Buffer): void;
|
|
8
|
+
finish(): Promise<MeterTotals>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function createResponseUsageScanner(encoding: string | undefined): ResponseUsageScanner {
|
|
12
|
+
const meter = createUsageMeter();
|
|
13
|
+
const decoder = decoderFor(encoding);
|
|
14
|
+
if (!decoder) return { feed: (chunk) => meter.feed(chunk), finish: async () => meter.result() };
|
|
15
|
+
const done = new Promise<void>((resolve) => {
|
|
16
|
+
decoder.on("data", (chunk: Buffer) => meter.feed(chunk));
|
|
17
|
+
decoder.on("end", resolve);
|
|
18
|
+
decoder.on("error", resolve);
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
feed(chunk) { decoder.write(chunk); },
|
|
22
|
+
async finish() { decoder.end(); await done; return meter.result(); }
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function decoderFor(encoding: string | undefined): DecodeStream | undefined {
|
|
27
|
+
const name = encoding?.split(",")[0]?.trim().toLowerCase();
|
|
28
|
+
if (name === "gzip") return createGunzip();
|
|
29
|
+
if (name === "br") return createBrotliDecompress();
|
|
30
|
+
if (name === "deflate") return createInflate();
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
2
|
+
|
|
3
|
+
const hopByHop = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host"]);
|
|
4
|
+
const sensitive = new Set(["authorization", "cookie", "set-cookie", "x-api-key"]);
|
|
5
|
+
const providerAuth = new Set(["authorization", "x-api-key"]);
|
|
6
|
+
const blockedRequest = new Set(["set-cookie", "forwarded", "proxy-connection", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"]);
|
|
7
|
+
|
|
8
|
+
export function buildForwardHeaders(headers: Headers, provider?: ProviderConfig, env: Record<string, string | undefined> = process.env): Headers {
|
|
9
|
+
const out = new Headers();
|
|
10
|
+
const usesProviderCredential = hasProviderCredentialRef(provider);
|
|
11
|
+
const forwardsClientCredential = forwardsClientAuth(provider);
|
|
12
|
+
const credential = usesProviderCredential ? providerCredential(provider, env) : undefined;
|
|
13
|
+
headers.forEach((value, key) => {
|
|
14
|
+
const lower = key.toLowerCase();
|
|
15
|
+
if (blockedRequest.has(lower)) return;
|
|
16
|
+
if (hopByHop.has(lower) || lower.startsWith("x-molenkopf-")) return;
|
|
17
|
+
if (lower === "cookie") return;
|
|
18
|
+
if (providerAuth.has(lower) && usesProviderCredential) return;
|
|
19
|
+
if (providerAuth.has(lower) && stripsClientAuth(provider) && !provider?.allowClientCredentialForwarding) return;
|
|
20
|
+
if (providerAuth.has(lower) && !forwardsClientCredential) return;
|
|
21
|
+
out.set(key, value);
|
|
22
|
+
});
|
|
23
|
+
if (credential) setProviderCredential(out, provider?.authScheme, credential);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function missingProviderCredential(provider: ProviderConfig | undefined, env: Record<string, string | undefined> = process.env): boolean {
|
|
28
|
+
return Boolean(hasProviderCredentialRef(provider) && provider?.authScheme !== "none" && !providerCredential(provider, env));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasProviderCredentialRef(provider: ProviderConfig | undefined): boolean {
|
|
32
|
+
if (!provider) return false;
|
|
33
|
+
if (provider.credentialValue) return true;
|
|
34
|
+
if (provider.credentialEnv) return true;
|
|
35
|
+
return Boolean(provider.credentialRef && provider.credentialRef !== "none");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stripsClientAuth(provider: ProviderConfig | undefined): boolean {
|
|
39
|
+
if (!provider || provider.id === "default") return false;
|
|
40
|
+
return provider.authScheme === "none" || provider.kind === "local";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function forwardsClientAuth(provider: ProviderConfig | undefined): boolean {
|
|
44
|
+
if (!provider) return false;
|
|
45
|
+
if (provider.allowClientCredentialForwarding) return true;
|
|
46
|
+
return provider.id === "default" && provider.kind === "api" && provider.authScheme === "none" && !hasProviderCredentialRef(provider);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function providerCredential(provider: ProviderConfig | undefined, env: Record<string, string | undefined>): string | undefined {
|
|
50
|
+
return provider?.credentialValue ?? (provider?.credentialEnv ? env[provider.credentialEnv] : undefined);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setProviderCredential(headers: Headers, scheme: ProviderConfig["authScheme"], credential: string) {
|
|
54
|
+
if (scheme === "none") return;
|
|
55
|
+
if (scheme === "x-api-key") headers.set("x-api-key", credential);
|
|
56
|
+
else headers.set("authorization", credential.toLowerCase().startsWith("bearer ") ? credential : `Bearer ${credential}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sanitizeHeadersForAudit(headers: Headers): Record<string, string> {
|
|
60
|
+
const out: Record<string, string> = {};
|
|
61
|
+
headers.forEach((value, key) => {
|
|
62
|
+
if (!sensitive.has(key.toLowerCase())) out[key] = value;
|
|
63
|
+
});
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const SLUG_ID_RE = /^[a-z0-9][a-z0-9._-]{1,63}$/i;
|
|
2
|
+
const EMAIL_ID_RE = /^[a-z0-9._%+-]{1,64}@[a-z0-9.-]{1,190}\.[a-z]{2,24}$/i;
|
|
3
|
+
|
|
4
|
+
export function isValidUserId(value: string): boolean {
|
|
5
|
+
const id = value.trim();
|
|
6
|
+
return SLUG_ID_RE.test(id) || (id.length <= 254 && EMAIL_ID_RE.test(id));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isValidSlugId(value: string): boolean {
|
|
10
|
+
return SLUG_ID_RE.test(value.trim());
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { upsertAgentDraft } from "./agent-drafts.ts";
|
|
3
|
+
import { readJson, writeJson } from "./local-api-io.ts";
|
|
4
|
+
import { persistRuntimeSettings } from "./runtime-settings.ts";
|
|
5
|
+
import type { RuntimeState } from "./runtime-state.ts";
|
|
6
|
+
|
|
7
|
+
export async function saveAgentDraft(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
8
|
+
const body = await readJson(req);
|
|
9
|
+
const previous = state.agentDrafts.map((draft) => ({ ...draft, enabledPluginIds: [...draft.enabledPluginIds] }));
|
|
10
|
+
const result = upsertAgentDraft(state, body);
|
|
11
|
+
if (result.ok === false) return writeJson(res, result.status, { error: result.error, reason: result.reason });
|
|
12
|
+
try { await persistRuntimeSettings(state); } catch {
|
|
13
|
+
state.agentDrafts = previous;
|
|
14
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
15
|
+
}
|
|
16
|
+
writeJson(res, 200, { item: result.value, tokenPolicy: "hash-only; raw token values rejected" });
|
|
17
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { verifyPasswordAsync, hashPasswordAsync, passwordTooLong } from "../../../core/src/auth/password.ts";
|
|
3
|
+
import { signSession } from "../../../core/src/auth/session.ts";
|
|
4
|
+
import type { User } from "../../../core/src/identity/types.ts";
|
|
5
|
+
import { authRequired, canManage, currentUser } from "./auth-state.ts";
|
|
6
|
+
import { type RuntimeState } from "./runtime-state.ts";
|
|
7
|
+
import { jsonHeaders, readJson, writeJson } from "./local-api-io.ts";
|
|
8
|
+
import { isValidUserId } from "./identity-id.ts";
|
|
9
|
+
import { MIN_PASSWORD_LENGTH } from "./password-policy.ts";
|
|
10
|
+
|
|
11
|
+
// Session login backed by the Identity store. Passwords are scrypt-hashed; the
|
|
12
|
+
// session is a signed httpOnly cookie. canManage/teams come from the user's role.
|
|
13
|
+
const COOKIE = "molenkopf_session";
|
|
14
|
+
const MAX_AUTH_FAILURES = 5;
|
|
15
|
+
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export async function login(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
18
|
+
const attemptId = attemptKey("login", req, "");
|
|
19
|
+
if (rateLimited(state, attemptId)) return writeJson(res, 429, { error: "too_many_attempts" });
|
|
20
|
+
const body = await readJson(req);
|
|
21
|
+
const username = typeof body.username === "string" ? body.username.trim() : "";
|
|
22
|
+
const password = typeof body.password === "string" ? body.password : "";
|
|
23
|
+
const user = state.identity?.getUser(username);
|
|
24
|
+
if (passwordTooLong(password) || !user || user.disabled || user.loginDisabled || !await verifyPasswordAsync(password, user.password)) {
|
|
25
|
+
recordFailure(state, attemptId);
|
|
26
|
+
recordFailure(state, attemptKey("login", req, username));
|
|
27
|
+
return writeJson(res, 401, { error: "invalid_login" });
|
|
28
|
+
}
|
|
29
|
+
clearFailures(state, attemptId);
|
|
30
|
+
clearFailures(state, attemptKey("login", req, username));
|
|
31
|
+
const token = signSession(user.id, state.sessionSecret, undefined, undefined, user.sessionVersion ?? 0);
|
|
32
|
+
res.writeHead(200, authHeaders(req, `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200`));
|
|
33
|
+
res.end(JSON.stringify({ ok: true, user: meView(state, user) }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function logout(req: IncomingMessage, res: ServerResponse) {
|
|
37
|
+
res.writeHead(200, authHeaders(req, `${COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`));
|
|
38
|
+
res.end(JSON.stringify({ ok: true }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function me(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
42
|
+
if (!authRequired(state)) return writeJson(res, 200, { open: true, canManage: true, needsSetup: true });
|
|
43
|
+
const user = currentUser(state, req.headers.cookie ?? null);
|
|
44
|
+
if (!user) return writeJson(res, 200, {});
|
|
45
|
+
writeJson(res, 200, { user: meView(state, user) });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// First-run: claim the admin account from the UI when none exists yet. This is
|
|
49
|
+
// the one bootstrap that's allowed in open mode; afterwards login is required.
|
|
50
|
+
export async function setupAdmin(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
51
|
+
if (!state.identity) return writeJson(res, 400, { error: "identity_unavailable" });
|
|
52
|
+
if (state.bootstrapSetup) {
|
|
53
|
+
await state.bootstrapSetup;
|
|
54
|
+
return writeJson(res, 403, { error: "already_initialized" });
|
|
55
|
+
}
|
|
56
|
+
if (authRequired(state)) return writeJson(res, 403, { error: "already_initialized" });
|
|
57
|
+
let release = () => {};
|
|
58
|
+
state.bootstrapSetup = new Promise<void>((resolve) => { release = resolve; });
|
|
59
|
+
try {
|
|
60
|
+
const setupAttempt = attemptKey("setup", req, "");
|
|
61
|
+
if (rateLimited(state, setupAttempt)) return writeJson(res, 429, { error: "too_many_attempts" });
|
|
62
|
+
const body = await readJson(req);
|
|
63
|
+
const id = typeof body.username === "string" ? body.username.trim() : "";
|
|
64
|
+
if (!isValidUserId(id)) return writeJson(res, 400, { error: "invalid_user_id" });
|
|
65
|
+
const password = typeof body.password === "string" ? body.password : "";
|
|
66
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
67
|
+
recordFailure(state, setupAttempt);
|
|
68
|
+
return writeJson(res, 400, { error: "weak_password" });
|
|
69
|
+
}
|
|
70
|
+
if (passwordTooLong(password)) return writeJson(res, 400, { error: "password_too_long" });
|
|
71
|
+
const createdEveryone = !state.identity.getTeam("everyone");
|
|
72
|
+
if (createdEveryone) await state.identity.putTeam({ id: "everyone", name: "Everyone", allowedProviders: "*", managerIds: [], createdAt: new Date().toISOString() });
|
|
73
|
+
const user: User = { id, displayName: typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : id, role: "admin", password: await hashPasswordAsync(password), teamIds: ["everyone"], sessionVersion: 0, createdAt: new Date().toISOString() };
|
|
74
|
+
await state.identity.putUser(user);
|
|
75
|
+
if (createdEveryone) await state.identity.putTeam({ ...state.identity.getTeam("everyone")!, managerIds: [id] });
|
|
76
|
+
clearFailures(state, setupAttempt);
|
|
77
|
+
const token = signSession(user.id, state.sessionSecret, undefined, undefined, user.sessionVersion ?? 0);
|
|
78
|
+
res.writeHead(200, authHeaders(req, `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200`));
|
|
79
|
+
res.end(JSON.stringify({ ok: true, user: meView(state, user) }));
|
|
80
|
+
} finally {
|
|
81
|
+
release();
|
|
82
|
+
state.bootstrapSetup = undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function attemptKey(kind: string, req: IncomingMessage, userId: string): string {
|
|
87
|
+
return `${kind}:${clientAddress(req)}:${userId.trim().toLowerCase()}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function clientAddress(req: IncomingMessage): string {
|
|
91
|
+
return (req.socket.remoteAddress ?? "unknown").replace(/^::ffff:/, "").toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function rateLimited(state: RuntimeState, key: string): boolean {
|
|
95
|
+
const entry = state.authAttempts[key];
|
|
96
|
+
if (!entry || entry.resetAt <= Date.now()) return false;
|
|
97
|
+
return entry.count >= MAX_AUTH_FAILURES;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function recordFailure(state: RuntimeState, key: string): void {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const current = state.authAttempts[key];
|
|
103
|
+
state.authAttempts[key] = current && current.resetAt > now ? { count: current.count + 1, resetAt: current.resetAt } : { count: 1, resetAt: now + AUTH_WINDOW_MS };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function clearFailures(state: RuntimeState, key: string): void {
|
|
107
|
+
delete state.authAttempts[key];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function meView(state: RuntimeState, user: User) {
|
|
111
|
+
return { id: user.id, displayName: user.displayName, role: user.role, teamIds: user.teamIds, keyPermissions: user.keyPermissions, canManage: canManage(state, user) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function authHeaders(req: IncomingMessage, cookie: string): Record<string, string> {
|
|
115
|
+
return jsonHeaders({ "set-cookie": secureCookie(req, cookie) });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function secureCookie(_req: IncomingMessage, cookie: string): string {
|
|
119
|
+
return process.env.MOLENKOPF_EXTERNAL_SCHEME === "https" ? `${cookie}; Secure` : cookie;
|
|
120
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { buildConsumers } from "./local-api-state.ts";
|
|
3
|
+
import { readJson, writeJson } from "./local-api-io.ts";
|
|
4
|
+
import { persistRuntimeSettings } from "./runtime-settings.ts";
|
|
5
|
+
import type { RuntimeState } from "./runtime-state.ts";
|
|
6
|
+
|
|
7
|
+
export async function setConsumerBudget(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
8
|
+
const body = await readJson(req);
|
|
9
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
10
|
+
if (!id) return writeJson(res, 400, { error: "invalid_consumer" });
|
|
11
|
+
const previous = { ...state.consumerBudgets };
|
|
12
|
+
if (body.limit === null || body.limit === 0) delete state.consumerBudgets[id];
|
|
13
|
+
else if (typeof body.limit === "number" && Number.isInteger(body.limit) && body.limit > 0) state.consumerBudgets[id] = body.limit;
|
|
14
|
+
else return writeJson(res, 400, { error: "invalid_limit" });
|
|
15
|
+
try { await persistRuntimeSettings(state); } catch {
|
|
16
|
+
state.consumerBudgets = previous;
|
|
17
|
+
return writeJson(res, 500, { error: "persist_failed" });
|
|
18
|
+
}
|
|
19
|
+
writeJson(res, 200, buildConsumers(state));
|
|
20
|
+
}
|