@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.
Files changed (147) hide show
  1. package/.env.example +2 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/SECURITY.md +36 -0
  5. package/bin/launcher.js +76 -0
  6. package/bin/molenkopf.js +4 -0
  7. package/docs/DEPLOYMENT.md +104 -0
  8. package/docs/MOLENKOPF_PLUGIN_API.md +113 -0
  9. package/docs/MOLENKOPF_PROVIDER_ENV.md +123 -0
  10. package/docs/MOLENKOPF_USAGE.md +195 -0
  11. package/docs/PRODUCT_INTENT.md +36 -0
  12. package/docs/THREAT_MODEL.md +94 -0
  13. package/molenkopf.config.example.json +68 -0
  14. package/package.json +98 -0
  15. package/packages/core/src/auth/password.ts +47 -0
  16. package/packages/core/src/auth/session.ts +64 -0
  17. package/packages/core/src/ci/ci-mode.ts +71 -0
  18. package/packages/core/src/compression/content-classifier.ts +25 -0
  19. package/packages/core/src/compression/context-compressor.ts +48 -0
  20. package/packages/core/src/compression/json-compressor.ts +54 -0
  21. package/packages/core/src/compression/log-compressor.ts +32 -0
  22. package/packages/core/src/compression/operational-block-compressor.ts +43 -0
  23. package/packages/core/src/compression/stacktrace-compressor.ts +23 -0
  24. package/packages/core/src/config/config-policies.ts +146 -0
  25. package/packages/core/src/config/molenkopf-config.ts +137 -0
  26. package/packages/core/src/config/provider-config.ts +139 -0
  27. package/packages/core/src/events/event-bus.ts +88 -0
  28. package/packages/core/src/identity/api-keys.ts +149 -0
  29. package/packages/core/src/identity/budget.ts +51 -0
  30. package/packages/core/src/identity/db.ts +68 -0
  31. package/packages/core/src/identity/identity-store.ts +175 -0
  32. package/packages/core/src/identity/identity-validation.ts +102 -0
  33. package/packages/core/src/identity/key-permissions.ts +18 -0
  34. package/packages/core/src/identity/pricing.ts +11 -0
  35. package/packages/core/src/identity/types.ts +87 -0
  36. package/packages/core/src/identity/usage-snapshot.ts +116 -0
  37. package/packages/core/src/manifest/audit-activity.ts +74 -0
  38. package/packages/core/src/manifest/audit-metrics.ts +7 -0
  39. package/packages/core/src/manifest/audit-safety.ts +113 -0
  40. package/packages/core/src/manifest/audit-store.ts +189 -0
  41. package/packages/core/src/manifest/audit-summary.ts +184 -0
  42. package/packages/core/src/manifest/usage-meter.ts +105 -0
  43. package/packages/core/src/memory/memory-extractor.ts +57 -0
  44. package/packages/core/src/memory/memory-graph.ts +55 -0
  45. package/packages/core/src/pipeline/json-string-spans.ts +143 -0
  46. package/packages/core/src/pipeline/openai-request-rewriter.ts +66 -0
  47. package/packages/core/src/plugins/builtin-plugin-descriptors.ts +10 -0
  48. package/packages/core/src/plugins/builtin-plugin-modules.ts +9 -0
  49. package/packages/core/src/plugins/plugin-api.ts +96 -0
  50. package/packages/core/src/plugins/plugin-catalog.ts +42 -0
  51. package/packages/core/src/plugins/plugin-descriptor.ts +51 -0
  52. package/packages/core/src/plugins/plugin-sdk.ts +47 -0
  53. package/packages/core/src/plugins/static-pipeline.ts +5 -0
  54. package/packages/core/src/profiles/profile-router.ts +45 -0
  55. package/packages/core/src/providers/provider-catalog.ts +186 -0
  56. package/packages/core/src/routing/distribution.ts +31 -0
  57. package/packages/core/src/security/secret-redactor.ts +139 -0
  58. package/packages/core/src/security/target-policy.ts +61 -0
  59. package/packages/core/src/storage/local-paths.ts +6 -0
  60. package/packages/core/src/storage/private-state.ts +30 -0
  61. package/packages/core/src/storage/purge-dir.ts +10 -0
  62. package/packages/core/src/store/retrieval-store.ts +114 -0
  63. package/packages/core/src/utils/hash.ts +9 -0
  64. package/packages/core/src/utils/text.ts +18 -0
  65. package/packages/core/src/utils/tokens.ts +3 -0
  66. package/packages/dashboard/dist/assets/index-B_aSPgHx.js +11 -0
  67. package/packages/dashboard/dist/assets/index-D6z2TEL2.css +1 -0
  68. package/packages/dashboard/dist/favicon.png +0 -0
  69. package/packages/dashboard/dist/index.html +15 -0
  70. package/packages/dashboard/dist/molenkopf-logo.png +0 -0
  71. package/packages/dashboard/public/favicon.png +0 -0
  72. package/packages/dashboard/public/molenkopf-logo.png +0 -0
  73. package/packages/plugins/context-compressor-plugin/descriptor.ts +19 -0
  74. package/packages/plugins/context-compressor-plugin/page.html +191 -0
  75. package/packages/plugins/context-compressor-plugin/plugin.ts +40 -0
  76. package/packages/plugins/obsidian-graph-plugin/descriptor.ts +19 -0
  77. package/packages/plugins/obsidian-graph-plugin/page.html +68 -0
  78. package/packages/plugins/obsidian-graph-plugin/plugin.ts +27 -0
  79. package/packages/plugins/shared/audit-projects.ts +32 -0
  80. package/packages/proxy/src/cli/args.ts +34 -0
  81. package/packages/proxy/src/cli/config-loader.ts +43 -0
  82. package/packages/proxy/src/cli/env-file.ts +43 -0
  83. package/packages/proxy/src/cli/main.ts +132 -0
  84. package/packages/proxy/src/cli/profile-server.ts +176 -0
  85. package/packages/proxy/src/cli/target.ts +7 -0
  86. package/packages/proxy/src/http/agent-drafts.ts +103 -0
  87. package/packages/proxy/src/http/agent-router.ts +69 -0
  88. package/packages/proxy/src/http/audit-view.ts +15 -0
  89. package/packages/proxy/src/http/auth-state.ts +44 -0
  90. package/packages/proxy/src/http/budget-gate.ts +45 -0
  91. package/packages/proxy/src/http/budget-warnings.ts +7 -0
  92. package/packages/proxy/src/http/cli-stream-response.ts +51 -0
  93. package/packages/proxy/src/http/client-identity.ts +51 -0
  94. package/packages/proxy/src/http/communication-graph.ts +139 -0
  95. package/packages/proxy/src/http/control-plane-guard.ts +56 -0
  96. package/packages/proxy/src/http/dashboard-assets.ts +115 -0
  97. package/packages/proxy/src/http/encoded-usage-meter.ts +32 -0
  98. package/packages/proxy/src/http/header-utils.ts +65 -0
  99. package/packages/proxy/src/http/identity-id.ts +11 -0
  100. package/packages/proxy/src/http/local-api-agent-actions.ts +17 -0
  101. package/packages/proxy/src/http/local-api-auth.ts +120 -0
  102. package/packages/proxy/src/http/local-api-consumer-actions.ts +20 -0
  103. package/packages/proxy/src/http/local-api-identity.ts +194 -0
  104. package/packages/proxy/src/http/local-api-io.ts +82 -0
  105. package/packages/proxy/src/http/local-api-keys.ts +126 -0
  106. package/packages/proxy/src/http/local-api-pipeline.ts +41 -0
  107. package/packages/proxy/src/http/local-api-plugin-actions.ts +31 -0
  108. package/packages/proxy/src/http/local-api-provider-actions.ts +181 -0
  109. package/packages/proxy/src/http/local-api-retention.ts +28 -0
  110. package/packages/proxy/src/http/local-api-runtime-auth.ts +119 -0
  111. package/packages/proxy/src/http/local-api-scope.ts +47 -0
  112. package/packages/proxy/src/http/local-api-state.ts +180 -0
  113. package/packages/proxy/src/http/local-api.ts +166 -0
  114. package/packages/proxy/src/http/password-policy.ts +5 -0
  115. package/packages/proxy/src/http/plugin-data.ts +38 -0
  116. package/packages/proxy/src/http/plugin-host.ts +87 -0
  117. package/packages/proxy/src/http/plugin-modules.ts +1 -0
  118. package/packages/proxy/src/http/plugin-page-loader.ts +24 -0
  119. package/packages/proxy/src/http/plugin-pipeline.ts +125 -0
  120. package/packages/proxy/src/http/provider-access.ts +33 -0
  121. package/packages/proxy/src/http/provider-http-test.ts +133 -0
  122. package/packages/proxy/src/http/provider-input.ts +39 -0
  123. package/packages/proxy/src/http/provider-routing-snapshot.ts +28 -0
  124. package/packages/proxy/src/http/provider-test.ts +149 -0
  125. package/packages/proxy/src/http/proxy-identity.ts +78 -0
  126. package/packages/proxy/src/http/public-bind.ts +8 -0
  127. package/packages/proxy/src/http/request-finish.ts +62 -0
  128. package/packages/proxy/src/http/request-path.ts +8 -0
  129. package/packages/proxy/src/http/request-policy.ts +46 -0
  130. package/packages/proxy/src/http/runtime-auth-proof.ts +55 -0
  131. package/packages/proxy/src/http/runtime-auth-registry.ts +105 -0
  132. package/packages/proxy/src/http/runtime-settings.ts +199 -0
  133. package/packages/proxy/src/http/runtime-state.ts +198 -0
  134. package/packages/proxy/src/http/server-io.ts +80 -0
  135. package/packages/proxy/src/http/server-types.ts +17 -0
  136. package/packages/proxy/src/http/server.ts +190 -0
  137. package/packages/proxy/src/http/session-secret.ts +19 -0
  138. package/packages/proxy/src/http/streaming-proxy.ts +88 -0
  139. package/packages/proxy/src/http/usage-accounting.ts +100 -0
  140. package/packages/proxy/src/http/usage-restore.ts +15 -0
  141. package/packages/proxy/src/runtime/cli-diagnostics.ts +64 -0
  142. package/packages/proxy/src/runtime/cli-env.ts +22 -0
  143. package/packages/proxy/src/runtime/cli-executor.ts +134 -0
  144. package/packages/proxy/src/runtime/cli-provider.ts +162 -0
  145. package/packages/proxy/src/runtime/cli-request.ts +79 -0
  146. package/packages/proxy/src/runtime/codex-runtime-config.ts +37 -0
  147. package/packages/proxy/src/runtime/runtime-profile.ts +170 -0
@@ -0,0 +1,139 @@
1
+ import type { ProviderConfig } from "../providers/provider-catalog.ts";
2
+ import { validateProviderTarget } from "../security/target-policy.ts";
3
+
4
+ type JsonRecord = Record<string, unknown>;
5
+
6
+ const ID_RE = /^[a-z0-9][a-z0-9._:-]{0,63}$/i;
7
+ const ENV_REF_RE = /^env:([A-Z_][A-Z0-9_]*)$/i;
8
+ const SECRET_REF_RE = /^secret:[a-z0-9][a-z0-9._:-]{0,63}$/i;
9
+
10
+ export function normalizeProvider(input: unknown, index: number): ProviderConfig {
11
+ const item = record(input, `$.providers[${index}]`);
12
+ if (item.credentialEnv !== undefined) throw new Error("use auth.credentialRef instead of credentialEnv in JSON config");
13
+ const id = idValue(item.id, `$.providers[${index}].id`);
14
+ const runtime = runtimeKind(item.kind);
15
+ if (runtime) return cliProvider(item, id, runtime);
16
+ return apiProvider(item, index, id);
17
+ }
18
+
19
+ function apiProvider(item: JsonRecord, index: number, id: string): ProviderConfig {
20
+ const kind = providerKind(item.kind);
21
+ if (item.credentialRef !== undefined) throw new Error("use auth.credentialRef instead of top-level credentialRef in JSON config");
22
+ if (item.authScheme !== undefined) throw new Error("use auth.scheme instead of top-level authScheme in JSON config");
23
+ const target = stringOrUndefined(item.baseUrl) ?? stringOrUndefined(item.target) ?? defaultTarget(item.kind);
24
+ if (!target) throw new Error(`missing provider baseUrl: ${id}`);
25
+ validateProviderTarget(target, { path: `$.providers[${index}].baseUrl`, allowPrivate: kind === "local" });
26
+ const auth = item.auth === undefined ? {} : record(item.auth, `$.providers[${index}].auth`);
27
+ if (auth.credential !== undefined) throw new Error("inline credentials are not allowed in file config; use auth.credentialRef");
28
+ const credentialValue = undefined;
29
+ const credentialRef = stringOrUndefined(auth.credentialRef) ?? "none";
30
+ const credentialEnv = credentialEnvFromRef(credentialRef, id);
31
+ return {
32
+ id,
33
+ name: stringOrUndefined(item.name) ?? id,
34
+ kind,
35
+ target,
36
+ credentialEnv,
37
+ credentialRef,
38
+ credentialValue,
39
+ authScheme: authSchemeValue(auth.scheme, target, credentialEnv || credentialValue, `$.providers[${index}].auth.scheme`),
40
+ protocol: protocolValue(item.protocol, item.kind, kind, target, `$.providers[${index}].protocol`),
41
+ enabled: item.enabled === undefined ? true : boolean(item.enabled, `$.providers[${index}].enabled`)
42
+ };
43
+ }
44
+
45
+ function cliProvider(item: JsonRecord, id: string, runtime: "claude" | "codex"): ProviderConfig {
46
+ if (item.inputMode === "argument" && item.allowUnsafeArgumentInput !== true) throw new Error(`unsafe CLI inputMode for provider: ${id}`);
47
+ return {
48
+ id,
49
+ name: stringOrUndefined(item.name) ?? id,
50
+ kind: "cli",
51
+ target: `cli://${id}`,
52
+ runtime,
53
+ cliCommand: stringOrUndefined(item.command) ?? runtime,
54
+ cliArgs: stringArray(item.args ?? (runtime === "codex" ? ["exec"] : ["--print"]), `$.providers.${id}.args`),
55
+ cliInputMode: item.inputMode === "argument" ? "argument" : "stdin",
56
+ cliTimeoutMs: positiveNumber(item.timeoutMs, 120000),
57
+ authScheme: "none",
58
+ credentialRef: "none",
59
+ enabled: item.enabled === undefined ? true : boolean(item.enabled, `$.providers.${id}.enabled`)
60
+ };
61
+ }
62
+
63
+ function runtimeKind(value: unknown): ProviderConfig["runtime"] | undefined {
64
+ if (value === "cli-claude") return "claude";
65
+ if (value === "cli-codex") return "codex";
66
+ return undefined;
67
+ }
68
+
69
+ function credentialEnvFromRef(ref: string, id: string): string | undefined {
70
+ if (ref === "none") return undefined;
71
+ if (ref === "json:inline") throw new Error(`inline credentials are not allowed for provider: ${id}`);
72
+ if (SECRET_REF_RE.test(ref)) throw new Error(`unsupported credentialRef for provider: ${id}`);
73
+ const match = ref.match(ENV_REF_RE);
74
+ if (!match) throw new Error(`invalid credentialRef for provider: ${id}`);
75
+ return match[1];
76
+ }
77
+
78
+ function authSchemeValue(value: unknown, target: string, credential: string | undefined, path: string): ProviderConfig["authScheme"] {
79
+ if (value === "bearer" || value === "x-api-key" || value === "none") return value;
80
+ if (value !== undefined) throw new Error(`invalid provider auth.scheme: ${path}`);
81
+ if (!credential) return "none";
82
+ return target.includes("anthropic") ? "x-api-key" : "bearer";
83
+ }
84
+
85
+ function providerKind(value: unknown): ProviderConfig["kind"] {
86
+ if (value === undefined || value === "api" || value === "openai-compatible" || value === "anthropic") return "api";
87
+ if (value === "local" || value === "local-openai" || value === "ollama") return "local";
88
+ throw new Error("invalid provider kind");
89
+ }
90
+
91
+ function defaultTarget(value: unknown): string | undefined {
92
+ return value === "ollama" ? "http://127.0.0.1:11434/v1" : undefined;
93
+ }
94
+
95
+ function protocolValue(value: unknown, rawKind: unknown, kind: ProviderConfig["kind"], target: string, path: string): ProviderConfig["protocol"] {
96
+ if (value === "openai-responses" || value === "anthropic-messages" || value === "openai-chat" || value === "ollama-tags") return value;
97
+ if (value !== undefined) throw new Error(`invalid provider protocol: ${path}`);
98
+ if (rawKind === "ollama" || target.includes("11434")) return "ollama-tags";
99
+ if (kind === "local") return "openai-chat";
100
+ return target.includes("anthropic") || rawKind === "anthropic" ? "anthropic-messages" : "openai-responses";
101
+ }
102
+
103
+ function record(value: unknown, path: string): JsonRecord {
104
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`expected object: ${path}`);
105
+ return value as JsonRecord;
106
+ }
107
+
108
+ function idValue(value: unknown, path: string): string {
109
+ const id = string(value, path);
110
+ if (!ID_RE.test(id)) throw new Error(`invalid id: ${path}`);
111
+ if (id.toLowerCase() === "default") throw new Error(`reserved provider id: ${path}`);
112
+ return id;
113
+ }
114
+
115
+ function string(value: unknown, path: string): string {
116
+ if (typeof value !== "string" || !value.trim()) throw new Error(`expected string: ${path}`);
117
+ return value.trim();
118
+ }
119
+
120
+ function stringOrUndefined(value: unknown): string | undefined {
121
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
122
+ }
123
+
124
+ function stringArray(value: unknown, path: string): string[] {
125
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`expected string array: ${path}`);
126
+ return value as string[];
127
+ }
128
+
129
+ function boolean(value: unknown, path: string): boolean {
130
+ if (typeof value !== "boolean") throw new Error(`expected boolean: ${path}`);
131
+ return value;
132
+ }
133
+
134
+ function positiveNumber(value: unknown, fallback: number): number {
135
+ if (value === undefined) return fallback;
136
+ const numberValue = Number(value);
137
+ if (!Number.isInteger(numberValue) || numberValue <= 0) throw new Error("invalid cli timeout");
138
+ return numberValue;
139
+ }
@@ -0,0 +1,88 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { redactSecrets } from "../security/secret-redactor.ts";
3
+ import { shortHash } from "../utils/hash.ts";
4
+
5
+ export type MolenkopfEvent = {
6
+ id: string;
7
+ type: "request_started" | "request_compressed" | "request_forwarded" | "request_finished" | "request_failed" | "request_warning" | "plugin_event" | "warning";
8
+ timestamp: string;
9
+ requestId?: string;
10
+ data?: Record<string, unknown>;
11
+ };
12
+
13
+ export class EventBus {
14
+ private clients = new Set<(event: MolenkopfEvent) => void>();
15
+ private history: MolenkopfEvent[] = [];
16
+
17
+ emit(type: MolenkopfEvent["type"], data: Omit<MolenkopfEvent, "id" | "type" | "timestamp"> = {}): MolenkopfEvent {
18
+ const event = deepFreeze({ id: randomUUID(), type, timestamp: new Date().toISOString(), requestId: safeString(data.requestId), data: safeData(data.data) });
19
+ this.history.push(event);
20
+ this.history = this.history.slice(-100);
21
+ for (const client of [...this.clients]) this.deliver(client, event);
22
+ return event;
23
+ }
24
+
25
+ subscribe(client: (event: MolenkopfEvent) => void): () => void {
26
+ this.clients.add(client);
27
+ for (const event of this.history) if (!this.deliver(client, event)) break;
28
+ return () => this.clients.delete(client);
29
+ }
30
+
31
+ private deliver(client: (event: MolenkopfEvent) => void, event: MolenkopfEvent): boolean {
32
+ try {
33
+ client(event);
34
+ return true;
35
+ } catch {
36
+ this.clients.delete(client);
37
+ return false;
38
+ }
39
+ }
40
+ }
41
+
42
+ function safeData(value: unknown, seen = new WeakSet<object>()): Record<string, unknown> | undefined {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
44
+ return sanitize(value, seen) as Record<string, unknown>;
45
+ }
46
+
47
+ function sanitize(value: unknown, seen: WeakSet<object>, key?: string): unknown {
48
+ if (key && isSensitiveKey(key)) return sensitiveMarker(key, value);
49
+ if (typeof value === "string") return safeString(value);
50
+ if (typeof value === "number") return Number.isFinite(value) ? value : 0;
51
+ if (typeof value === "boolean" || value === null) return value;
52
+ if (!value || typeof value !== "object") return undefined;
53
+ if (seen.has(value)) return "[circular]";
54
+ seen.add(value);
55
+ if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitize(item, seen));
56
+ return Object.fromEntries(Object.entries(value).slice(0, 50).map(([itemKey, item]) => [safeString(itemKey), sanitize(item, seen, itemKey)]));
57
+ }
58
+
59
+ function safeString(value: unknown): string | undefined {
60
+ if (typeof value !== "string") return undefined;
61
+ return redactSecrets(value).text.slice(0, 512);
62
+ }
63
+
64
+ function deepFreeze<T>(value: T): T {
65
+ if (value && typeof value === "object") {
66
+ Object.freeze(value);
67
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function isSensitiveKey(key: string): boolean {
73
+ const normalized = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2");
74
+ return /(?:^|[_-])(?:password|passwd|pwd|token|authorization|auth|cookie|secret|api[_-]?key|credential|private[_-]?key)(?:$|[_-])/i.test(normalized);
75
+ }
76
+
77
+ function sensitiveMarker(key: string, value: unknown): string {
78
+ const kind = key.toLowerCase().replace(/[^a-z0-9]+/g, "_") || "secret";
79
+ const text = typeof value === "string" ? value : sensitiveValueLabel(value);
80
+ return `[REDACTED_SECRET:event_${kind}:sha256:${shortHash(text ?? "")}]`;
81
+ }
82
+
83
+ function sensitiveValueLabel(value: unknown): string {
84
+ if (value === null) return "null";
85
+ if (Array.isArray(value)) return "[array]";
86
+ if (typeof value === "object") return "[object]";
87
+ return String(value ?? "");
88
+ }
@@ -0,0 +1,149 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import type { IdentityStore } from "./identity-store.ts";
3
+ import { viewKey, type ApiKey, type ApiKeyView, type Budget } from "./types.ts";
4
+
5
+ // Molenkopf-issued API keys. The secret is shown EXACTLY ONCE at creation; only
6
+ // its sha256 hash is stored — never the plaintext. Node built-ins only.
7
+
8
+ export type IssueOptions = { agentLabel?: string; project: string; teamId?: string; scopes?: string[]; budget?: Budget };
9
+ export type IssuedKey = { view: ApiKeyView; secret: string };
10
+
11
+ export function hashSecret(secret: string): string {
12
+ return createHash("sha256").update(secret).digest("hex");
13
+ }
14
+
15
+ function newSecret(): string {
16
+ return `mk_${randomBytes(24).toString("base64url")}`;
17
+ }
18
+
19
+ export async function issueApiKey(store: IdentityStore, ownerUserId: string, opts: IssueOptions): Promise<IssuedKey | undefined> {
20
+ const owner = store.getUser(ownerUserId);
21
+ if (!owner || owner.disabled) return undefined;
22
+ const project = cleanKeyProject(opts.project);
23
+ if (!project) return undefined;
24
+ const teamId = resolveIssueTeam(store, owner, opts.teamId);
25
+ if (teamId === false) return undefined;
26
+ const scopes = cleanScopes(opts.scopes);
27
+ if (scopes === false) return undefined;
28
+ const secret = newSecret();
29
+ const key: ApiKey = {
30
+ id: newKeyId(store),
31
+ hash: hashSecret(secret),
32
+ prefix: secret.slice(0, 8),
33
+ ownerUserId,
34
+ teamId,
35
+ agentLabel: cleanKeyLabel(opts.agentLabel),
36
+ project,
37
+ scopes,
38
+ budget: opts.budget,
39
+ createdAt: new Date().toISOString()
40
+ };
41
+ store.data.keys[key.id] = key;
42
+ try {
43
+ await store.save();
44
+ } catch (error) {
45
+ delete store.data.keys[key.id];
46
+ throw error;
47
+ }
48
+ return { view: viewKey(key), secret };
49
+ }
50
+
51
+ // Looks up a presented secret. Constant-time hash compare; ignores disabled keys.
52
+ export function authenticateKey(store: IdentityStore, secret: string | undefined): ApiKey | undefined {
53
+ if (!secret) return undefined;
54
+ const candidate = hashSecret(secret);
55
+ for (const key of Object.values(store.data.keys)) {
56
+ if (key.disabled) continue;
57
+ if (equalHex(candidate, key.hash) && keyUsable(store, key)) return key;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ export function listKeys(store: IdentityStore, ownerUserId?: string): ApiKeyView[] {
63
+ return Object.values(store.data.keys)
64
+ .filter((k) => !ownerUserId || k.ownerUserId === ownerUserId)
65
+ .map(viewKey);
66
+ }
67
+
68
+ export async function revokeKey(store: IdentityStore, id: string): Promise<boolean> {
69
+ const key = store.data.keys[id];
70
+ if (!key || key.disabled) return false;
71
+ const previous = key.disabled;
72
+ key.disabled = true;
73
+ try {
74
+ await store.save();
75
+ } catch (error) {
76
+ key.disabled = previous;
77
+ throw error;
78
+ }
79
+ return true;
80
+ }
81
+
82
+ // Records last-used without forcing a disk write on every request (caller flushes).
83
+ export function touchKey(store: IdentityStore, key: ApiKey, at = new Date().toISOString()): void {
84
+ key.lastUsedAt = at;
85
+ }
86
+
87
+ function equalHex(a: string, b: string): boolean {
88
+ if (!isSha256Hex(a) || !isSha256Hex(b)) return false;
89
+ if (a.length !== b.length) return false;
90
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
91
+ }
92
+
93
+ function isSha256Hex(value: string): boolean {
94
+ return /^[a-f0-9]{64}$/.test(value);
95
+ }
96
+
97
+ function newKeyId(store: IdentityStore): string {
98
+ for (let attempt = 0; attempt < 10; attempt++) {
99
+ const id = `key_${randomBytes(5).toString("hex")}`;
100
+ if (!store.data.keys[id]) return id;
101
+ }
102
+ throw new Error("api_key_id_collision");
103
+ }
104
+
105
+ function cleanScopes(scopes: string[] | undefined): string[] | undefined | false {
106
+ if (scopes === undefined) return undefined;
107
+ if (!Array.isArray(scopes)) return false;
108
+ const out: string[] = [];
109
+ for (const scope of scopes) {
110
+ if (typeof scope !== "string" || !/^[a-z0-9][a-z0-9._:-]{0,63}$/i.test(scope)) return false;
111
+ if (!out.includes(scope)) out.push(scope);
112
+ }
113
+ return out.length ? out : false;
114
+ }
115
+
116
+ function resolveIssueTeam(store: IdentityStore, owner: { teamIds: string[] }, value: string | undefined): string | undefined | false {
117
+ const teamId = value?.trim();
118
+ if (teamId) {
119
+ if (!store.getTeam(teamId) || !owner.teamIds.includes(teamId)) return false;
120
+ if (teamId === "everyone" && billableTeamIds(owner.teamIds).length) return false;
121
+ return teamId;
122
+ }
123
+ const billingTeams = billableTeamIds(owner.teamIds);
124
+ if (billingTeams.length === 1) return billingTeams[0];
125
+ if (billingTeams.length > 1) return false;
126
+ return owner.teamIds.includes("everyone") ? "everyone" : undefined;
127
+ }
128
+
129
+ function keyUsable(store: IdentityStore, key: ApiKey): boolean {
130
+ const owner = store.getUser(key.ownerUserId);
131
+ if (!owner || owner.disabled) return false;
132
+ if (!cleanKeyProject(key.project)) return false;
133
+ if (key.teamId) return Boolean(store.getTeam(key.teamId) && owner.teamIds.includes(key.teamId));
134
+ return billableTeamIds(owner.teamIds).length <= 1;
135
+ }
136
+
137
+ function billableTeamIds(teamIds: string[]): string[] {
138
+ return teamIds.filter((id) => id !== "everyone");
139
+ }
140
+
141
+ export function cleanKeyLabel(value: string | undefined): string | undefined {
142
+ const normalized = value?.replace(/[^\w .@:-]/g, "").trim().slice(0, 64);
143
+ return normalized || undefined;
144
+ }
145
+
146
+ export function cleanKeyProject(value: string | undefined): string | undefined {
147
+ const normalized = value?.replace(/[^\w .@/-]/g, "").trim().slice(0, 80);
148
+ return normalized || undefined;
149
+ }
@@ -0,0 +1,51 @@
1
+ import type { Budget, BudgetAction, BudgetPeriod } from "./types.ts";
2
+
3
+ export const BUDGET_PERIODS: BudgetPeriod[] = ["day", "week", "month", "total"];
4
+ export const BUDGET_ACTIONS: BudgetAction[] = ["block", "warn"];
5
+ export const DEFAULT_BUDGET_PERIOD: BudgetPeriod = "month";
6
+ export const DEFAULT_BUDGET_ACTION: BudgetAction = "block";
7
+
8
+ export type BudgetParseResult = { ok: true; budget?: Budget } | { ok: false; error: "invalid_budget" };
9
+
10
+ export function normalizeBudget(value: unknown): BudgetParseResult {
11
+ if (value === undefined || value === null) return { ok: true };
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "invalid_budget" };
13
+ const input = value as Record<string, unknown>;
14
+ const tokenLimit = limit(input.tokenLimit);
15
+ const costLimitEur = limit(input.costLimitEur);
16
+ if (tokenLimit === false || costLimitEur === false) return { ok: false, error: "invalid_budget" };
17
+ if (tokenLimit === undefined && costLimitEur === undefined) return { ok: true };
18
+ const period = input.period === undefined ? DEFAULT_BUDGET_PERIOD : input.period;
19
+ const onExceed = input.onExceed === undefined ? DEFAULT_BUDGET_ACTION : input.onExceed;
20
+ if (!BUDGET_PERIODS.includes(period as BudgetPeriod) || !BUDGET_ACTIONS.includes(onExceed as BudgetAction)) return { ok: false, error: "invalid_budget" };
21
+ return { ok: true, budget: { tokenLimit, costLimitEur, period: period as BudgetPeriod, onExceed: onExceed as BudgetAction } };
22
+ }
23
+
24
+ export function isBudget(value: unknown): value is Budget {
25
+ const parsed = normalizeBudget(value);
26
+ return parsed.ok && parsed.budget !== undefined;
27
+ }
28
+
29
+ export function budgetPeriodKey(period: BudgetPeriod, at: Date): string {
30
+ if (period === "total") return "total";
31
+ const year = at.getUTCFullYear();
32
+ const month = String(at.getUTCMonth() + 1).padStart(2, "0");
33
+ const day = String(at.getUTCDate()).padStart(2, "0");
34
+ if (period === "day") return `day:${year}-${month}-${day}`;
35
+ if (period === "month") return `month:${year}-${month}`;
36
+ const { weekYear, week } = isoWeek(at);
37
+ return `week:${weekYear}-W${String(week).padStart(2, "0")}`;
38
+ }
39
+
40
+ function limit(value: unknown): number | undefined | false {
41
+ if (value === undefined || value === null || value === "") return undefined;
42
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : false;
43
+ }
44
+
45
+ function isoWeek(value: Date): { weekYear: number; week: number } {
46
+ const date = new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
47
+ date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7));
48
+ const weekYear = date.getUTCFullYear();
49
+ const first = new Date(Date.UTC(weekYear, 0, 1));
50
+ return { weekYear, week: Math.ceil((((date.getTime() - first.getTime()) / 86400000) + 1) / 7) };
51
+ }
@@ -0,0 +1,68 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { existsSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { defaultDataDir } from "../storage/local-paths.ts";
5
+ import { chmodPrivateSync, ensurePrivateDirSync, PRIVATE_FILE_MODE } from "../storage/private-state.ts";
6
+
7
+ // Real SQLite persistence via Node's BUILT-IN node:sqlite (no npm dependency —
8
+ // honors the "core/proxy: built-ins only" invariant). One database file holds
9
+ // identity (users/teams/api_keys/meta) and usage. Run with --experimental-sqlite.
10
+
11
+ export type Db = DatabaseSync;
12
+
13
+ export function openDb(root = defaultDataDir()): Db {
14
+ ensurePrivateDirSync(root);
15
+ const path = join(root, "molenkopf.db");
16
+ const marker = join(root, "molenkopf.db.quarantined");
17
+ if (existsSync(marker)) throw new Error("identity database unavailable: quarantined database marker exists");
18
+ let db: DatabaseSync | undefined;
19
+ try {
20
+ db = new DatabaseSync(path);
21
+ db.exec("PRAGMA busy_timeout = 4000; PRAGMA journal_mode = WAL;");
22
+ ensureSchema(db);
23
+ repairSqlitePermissions(root);
24
+ return db;
25
+ } catch (error) {
26
+ try { db?.close(); } catch { /* ignore */ }
27
+ if (isOperationalSqliteError(error)) throw new Error("identity database unavailable: busy");
28
+ try { if (existsSync(path)) renameSync(path, `${path}.corrupt.${Date.now()}`); } catch { /* best effort */ }
29
+ try { writeFileSync(marker, `${new Date().toISOString()} ${error instanceof Error ? error.message : String(error)}\n`, privateWriteOptions()); chmodPrivateSync(marker, PRIVATE_FILE_MODE); } catch { /* best effort */ }
30
+ throw new Error("identity database unavailable");
31
+ }
32
+ }
33
+
34
+ function isOperationalSqliteError(error: unknown): boolean {
35
+ const item = error as { code?: unknown; message?: unknown };
36
+ return /SQLITE_(BUSY|LOCKED)/.test(String(item.code ?? "")) || /database is (locked|busy)/i.test(String(item.message ?? ""));
37
+ }
38
+
39
+ export function markIdentityDbUnavailable(root = defaultDataDir(), reason: string): void {
40
+ ensurePrivateDirSync(root);
41
+ const marker = join(root, "molenkopf.db.quarantined");
42
+ try { writeFileSync(marker, `${new Date().toISOString()} ${reason}\n`, privateWriteOptions()); chmodPrivateSync(marker, PRIVATE_FILE_MODE); } catch { /* best effort */ }
43
+ }
44
+
45
+ export function repairSqlitePermissions(root = defaultDataDir()): void {
46
+ for (const name of ["molenkopf.db", "molenkopf.db-wal", "molenkopf.db-shm"]) {
47
+ const file = join(root, name);
48
+ if (existsSync(file)) chmodPrivateSync(file, PRIVATE_FILE_MODE);
49
+ }
50
+ }
51
+
52
+ function privateWriteOptions(): { mode: number } | undefined {
53
+ return process.platform === "win32" ? undefined : { mode: PRIVATE_FILE_MODE };
54
+ }
55
+
56
+ function ensureSchema(db: DatabaseSync): void {
57
+ db.exec(`
58
+ CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, json TEXT NOT NULL);
59
+ CREATE TABLE IF NOT EXISTS teams (id TEXT PRIMARY KEY, json TEXT NOT NULL);
60
+ CREATE TABLE IF NOT EXISTS api_keys (
61
+ id TEXT PRIMARY KEY, hash TEXT, owner_user_id TEXT, disabled INTEGER DEFAULT 0, json TEXT NOT NULL
62
+ );
63
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(hash);
64
+ CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_user_id);
65
+ CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, json TEXT NOT NULL);
66
+ CREATE TABLE IF NOT EXISTS usage (scope TEXT NOT NULL, id TEXT NOT NULL, json TEXT NOT NULL, PRIMARY KEY (scope, id));
67
+ `);
68
+ }
@@ -0,0 +1,175 @@
1
+ import { IDENTITY_SCHEMA_VERSION, emptyIdentity, type ApiKey, type IdentityData, type Team, type User } from "./types.ts";
2
+ import { markIdentityDbUnavailable, openDb, type Db } from "./db.ts";
3
+ import { isIdentityApiKey, isIdentityTeam, isIdentityUser, loadIdentityMeta, parseIdentityRow, validateIdentityData, type IdentityMetaRow, type IdentityRow } from "./identity-validation.ts";
4
+ import { defaultDataDir } from "../storage/local-paths.ts";
5
+
6
+ // Identity store backed by real SQLite (node:sqlite, a built-in — no dependency).
7
+ // Keeps an in-memory `data` mirror for fast logic; `save()` syncs it to the DB in
8
+ // one transaction. Same public interface as before, so all callers are unchanged.
9
+
10
+ export class IdentityStore {
11
+ private root: string;
12
+ private db?: Db;
13
+ private closed = false;
14
+ private ephemeralUserIds = new Set<string>();
15
+ data: IdentityData = emptyIdentity();
16
+
17
+ constructor(root = defaultDataDir()) {
18
+ this.root = root;
19
+ }
20
+
21
+ private handle(): Db {
22
+ if (!this.db) this.db = openDb(this.root);
23
+ return this.db;
24
+ }
25
+
26
+ async load(): Promise<IdentityData> {
27
+ const db = this.handle();
28
+ const data = emptyIdentity();
29
+ try {
30
+ for (const row of db.prepare("SELECT id, json FROM users").all() as IdentityRow[]) data.users[row.id] = parseIdentityRow(row, isIdentityUser, "user");
31
+ for (const row of db.prepare("SELECT id, json FROM teams").all() as IdentityRow[]) data.teams[row.id] = parseIdentityRow(row, isIdentityTeam, "team");
32
+ for (const row of db.prepare("SELECT id, json FROM api_keys").all() as IdentityRow[]) data.keys[row.id] = parseIdentityRow(row, isIdentityApiKey, "api_key");
33
+ for (const row of db.prepare("SELECT k, json FROM meta").all() as IdentityMetaRow[]) loadIdentityMeta(data, row);
34
+ normalizeDefaultTeam(data);
35
+ data.schemaVersion = IDENTITY_SCHEMA_VERSION;
36
+ validateIdentityData(data);
37
+ this.data = data;
38
+ return this.data;
39
+ } catch (error) {
40
+ try { this.db?.close(); } catch { /* ignore */ }
41
+ this.db = undefined;
42
+ markIdentityDbUnavailable(this.root, error instanceof Error ? error.message : "invalid identity data");
43
+ throw new Error("identity database unavailable: invalid identity data");
44
+ }
45
+ }
46
+
47
+ async save(): Promise<void> {
48
+ if (this.closed) throw new Error("identity_store_closed");
49
+ const persisted = persistedIdentity(this.data, this.ephemeralUserIds);
50
+ validateIdentityData(persisted);
51
+ const db = this.handle();
52
+ db.exec("BEGIN");
53
+ try {
54
+ db.exec("DELETE FROM users; DELETE FROM teams; DELETE FROM api_keys; DELETE FROM meta;");
55
+ const u = db.prepare("INSERT INTO users(id, json) VALUES(?, ?)");
56
+ for (const user of Object.values(persisted.users)) u.run(user.id, JSON.stringify(user));
57
+ const t = db.prepare("INSERT INTO teams(id, json) VALUES(?, ?)");
58
+ for (const team of Object.values(persisted.teams)) t.run(team.id, JSON.stringify(team));
59
+ const k = db.prepare("INSERT INTO api_keys(id, hash, owner_user_id, disabled, json) VALUES(?, ?, ?, ?, ?)");
60
+ for (const key of Object.values(persisted.keys)) k.run(key.id, key.hash, key.ownerUserId, key.disabled ? 1 : 0, JSON.stringify(key));
61
+ const m = db.prepare("INSERT INTO meta(k, json) VALUES(?, ?)");
62
+ if (persisted.orgBudget) m.run("orgBudget", JSON.stringify(persisted.orgBudget));
63
+ if (persisted.pricing) m.run("pricing", JSON.stringify(persisted.pricing));
64
+ db.exec("COMMIT");
65
+ } catch (error) {
66
+ db.exec("ROLLBACK");
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ // ---- users ----
72
+ listUsers(): User[] { return Object.values(this.data.users); }
73
+ getUser(id: string): User | undefined { return this.data.users[id]; }
74
+ markEphemeralUser(id: string): void { this.ephemeralUserIds.add(id); }
75
+ async putUser(user: User): Promise<User> {
76
+ const previous = this.data;
77
+ this.data = cloneIdentity(previous);
78
+ try {
79
+ this.data.users[user.id] = normalizeUser({ ...user, teamIds: [...(user.teamIds ?? [])] }, this.data);
80
+ await this.save();
81
+ return this.data.users[user.id];
82
+ } catch (error) {
83
+ this.data = previous;
84
+ throw error;
85
+ }
86
+ }
87
+ async removeUser(id: string): Promise<boolean> {
88
+ if (!this.data.users[id]) return false;
89
+ const previous = this.data;
90
+ this.data = cloneIdentity(previous);
91
+ try {
92
+ delete this.data.users[id];
93
+ for (const key of Object.values(this.data.keys)) if (key.ownerUserId === id) delete this.data.keys[key.id];
94
+ for (const team of Object.values(this.data.teams)) team.managerIds = team.managerIds.filter((managerId) => managerId !== id);
95
+ await this.save();
96
+ return true;
97
+ } catch (error) {
98
+ this.data = previous;
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ // ---- teams ----
104
+ listTeams(): Team[] { return Object.values(this.data.teams); }
105
+ getTeam(id: string): Team | undefined { return this.data.teams[id]; }
106
+ async putTeam(team: Team): Promise<Team> {
107
+ const previous = this.data;
108
+ this.data = cloneIdentity(previous);
109
+ try {
110
+ this.data.teams[team.id] = { ...team, managerIds: [...team.managerIds] };
111
+ if (team.id === "everyone") normalizeDefaultTeam(this.data);
112
+ await this.save();
113
+ return this.data.teams[team.id];
114
+ } catch (error) {
115
+ this.data = previous;
116
+ throw error;
117
+ }
118
+ }
119
+ async removeTeam(id: string): Promise<boolean> {
120
+ if (id === "everyone") return false;
121
+ if (!this.data.teams[id]) return false;
122
+ const previous = this.data;
123
+ this.data = cloneIdentity(previous);
124
+ try {
125
+ delete this.data.teams[id];
126
+ for (const user of Object.values(this.data.users)) user.teamIds = user.teamIds.filter((t) => t !== id);
127
+ for (const key of Object.values(this.data.keys)) if (key.teamId === id) {
128
+ key.disabled = true;
129
+ delete key.teamId;
130
+ }
131
+ await this.save();
132
+ return true;
133
+ } catch (error) {
134
+ this.data = previous;
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ usersInTeam(teamId: string): User[] { return teamId === "everyone" && this.data.teams.everyone ? this.listUsers() : this.listUsers().filter((u) => u.teamIds.includes(teamId)); }
140
+
141
+ close(): void {
142
+ this.closed = true;
143
+ try { this.db?.close(); } catch { /* ignore */ }
144
+ this.db = undefined;
145
+ }
146
+ }
147
+
148
+ function normalizeDefaultTeam(data: IdentityData): void {
149
+ if (!data.teams.everyone) return;
150
+ for (const user of Object.values(data.users)) normalizeUser(user, data);
151
+ }
152
+
153
+ function normalizeUser(user: User, data: IdentityData): User {
154
+ user.teamIds = Array.isArray(user.teamIds) ? user.teamIds.filter((id) => Boolean(data.teams[id])) : [];
155
+ if (data.teams.everyone && !user.teamIds.includes("everyone")) user.teamIds = ["everyone", ...user.teamIds];
156
+ return user;
157
+ }
158
+
159
+ function persistedTeam(team: Team, ephemeralUserIds: Set<string>): Team {
160
+ return { ...team, managerIds: team.managerIds.filter((id) => !ephemeralUserIds.has(id)) };
161
+ }
162
+
163
+ function persistedIdentity(data: IdentityData, ephemeralUserIds: Set<string>): IdentityData {
164
+ const persisted = cloneIdentity(data);
165
+ for (const id of ephemeralUserIds) delete persisted.users[id];
166
+ for (const key of Object.values(persisted.keys)) if (ephemeralUserIds.has(key.ownerUserId)) delete persisted.keys[key.id];
167
+ for (const team of Object.values(persisted.teams)) persisted.teams[team.id] = persistedTeam(team, ephemeralUserIds);
168
+ return persisted;
169
+ }
170
+
171
+ function cloneIdentity(data: IdentityData): IdentityData {
172
+ return JSON.parse(JSON.stringify(data)) as IdentityData;
173
+ }
174
+
175
+ export type { ApiKey };