@hachej/boring-governance 0.1.71 → 0.1.73

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/README.md CHANGED
@@ -30,4 +30,57 @@ const governanceCompanyAdmin = createGovernanceCompanyAdmin()
30
30
  <CoreWorkspaceAgentFront companyAdmin={governanceCompanyAdmin} />
31
31
  ```
32
32
 
33
- Policy source is configured with `BORING_GOVERNANCE_POLICY_PATH`. Company context source roots use `BORING_GOVERNANCE_COMPANY_CONTEXT_ROOT` or the default workspace root resolver outside sandbox mode.
33
+ Policy source is configured with `BORING_GOVERNANCE_POLICY_PATH`. Company context source roots use `BORING_GOVERNANCE_COMPANY_CONTEXT_ROOT` or the default workspace root resolver outside sandbox mode. When an explicit, governance-owned company-context root is configured, verified tenant admins receive a tenant-wide read/write `company_context` binding; ordinary policy users receive only their regex-filtered readonly projection. Custom resolvers must opt into admin mutations with `allowAdminMutations: true` only when no other actor can mutate the root outside the governance store.
34
+
35
+ ## Policy budgets
36
+
37
+ Model grants remain the model picker allowlist. Optional user budgets cap aggregate monthly spend across all allowed models; optional per-model budgets cap individual models.
38
+
39
+ ```yaml
40
+ users:
41
+ - email: readonly@example.com
42
+ role: user
43
+ budgets:
44
+ monthlyEur: 10
45
+ models:
46
+ - provider: infomaniak
47
+ id: Qwen/Qwen3.5-122B-A10B-FP8
48
+ monthlyBudgetEur: 2
49
+ ```
50
+
51
+ A run is admitted only when the user is allowed to use the selected model, the aggregate user budget has remaining monthly capacity, and the selected model budget has remaining monthly capacity.
52
+
53
+ ## Governance access matrix smoke
54
+
55
+ The package includes a reusable HTTP matrix runner for deployment smoke tests:
56
+
57
+ ```bash
58
+ boring-governance-access-matrix
59
+ # or from the source package:
60
+ pnpm --filter @hachej/boring-governance smoke:governance-matrix
61
+ ```
62
+
63
+ It signs in two environment-provided users and verifies the expected read/write behavior for:
64
+
65
+ - company-context public paths
66
+ - company-context Adam-private paths
67
+ - each user's own `user` workspace filesystem
68
+ - cross-user workspace access denial
69
+
70
+ All deployment-specific values must be supplied explicitly; the reusable package intentionally does not ship real credentials or workspace IDs.
71
+
72
+ | Var | Required value |
73
+ | --- | --- |
74
+ | `MATRIX_BASE_URL` / `DEPLOY_URL` | Deployment base URL |
75
+ | `MATRIX_ADAM_EMAIL` | Admin fixture email |
76
+ | `MATRIX_ADAM_PASSWORD` | Admin fixture password |
77
+ | `MATRIX_ADAM_WORKSPACE_ID` | Admin fixture workspace ID |
78
+ | `MATRIX_READONLY_EMAIL` | Readonly fixture email |
79
+ | `MATRIX_READONLY_PASSWORD` | Readonly fixture password |
80
+ | `MATRIX_READONLY_WORKSPACE_ID` | Readonly fixture workspace ID |
81
+ | `MATRIX_COMPANY_PUBLIC_READ_PATH` | Existing public company-context file readable by both users |
82
+ | `MATRIX_COMPANY_PRIVATE_READ_PATH` | Existing private company-context file readable by admin and denied to readonly |
83
+ | `MATRIX_COMPANY_PUBLIC_WRITE_DIR` | Company-context directory used for admin public write probes |
84
+ | `MATRIX_COMPANY_PRIVATE_WRITE_DIR` | Company-context directory used for admin private write probes |
85
+ | `MATRIX_ADMIN_COMPANY_WRITE_EXPECTED` | Optional admin company-context write status, defaults to `403` for the base readonly plugin |
86
+ | `MATRIX_READONLY_COMPANY_WRITE_EXPECTED` | Optional readonly company-context write status, defaults to `403` |
@@ -23,6 +23,7 @@ interface GovernanceMeResponse {
23
23
  users?: Array<{
24
24
  email: string;
25
25
  role: 'admin' | 'user';
26
+ monthlyBudgetEur: number | null;
26
27
  modelCount: number;
27
28
  contextRuleCount: number;
28
29
  }>;
@@ -85,15 +85,17 @@ function GovernanceAdminView({ status }) {
85
85
  /* @__PURE__ */ jsx("thead", { className: "bg-muted/60 text-xs uppercase tracking-[0.12em] text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
86
86
  /* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "User" }),
87
87
  /* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "Role" }),
88
+ /* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "Monthly budget" }),
88
89
  /* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "Models" }),
89
90
  /* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "Context rules" })
90
91
  ] }) }),
91
92
  /* @__PURE__ */ jsx("tbody", { children: (status.users ?? []).length > 0 ? status.users.map((user) => /* @__PURE__ */ jsxs("tr", { className: "border-t border-border", children: [
92
93
  /* @__PURE__ */ jsx("td", { className: "px-3 py-2 font-medium", children: user.email }),
93
94
  /* @__PURE__ */ jsx("td", { className: "px-3 py-2", children: user.role }),
95
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-2", children: user.monthlyBudgetEur == null ? "\u2014" : `\u20AC${user.monthlyBudgetEur}` }),
94
96
  /* @__PURE__ */ jsx("td", { className: "px-3 py-2", children: user.modelCount }),
95
97
  /* @__PURE__ */ jsx("td", { className: "px-3 py-2", children: user.contextRuleCount })
96
- ] }, user.email)) : /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: 4, className: "px-3 py-4 text-muted-foreground", children: "No users in policy." }) }) })
98
+ ] }, user.email)) : /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: 5, className: "px-3 py-4 text-muted-foreground", children: "No users in policy." }) }) })
97
99
  ] }) })
98
100
  ] }) })
99
101
  ] }) })
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/scripts/governance-access-matrix.ts
4
+ import { randomUUID } from "crypto";
5
+ var baseUrl = normalizeBaseUrl(readRequiredEnv("MATRIX_BASE_URL", process.env.MATRIX_BASE_URL ?? process.env.DEPLOY_URL));
6
+ var requestTimeoutMs = readPositiveIntEnv("MATRIX_REQUEST_TIMEOUT_MS", 1e4);
7
+ var publicReadPath = readRequiredEnv("MATRIX_COMPANY_PUBLIC_READ_PATH");
8
+ var privateReadPath = readRequiredEnv("MATRIX_COMPANY_PRIVATE_READ_PATH");
9
+ var publicWriteDir = normalizeCompanyDir(readRequiredEnv("MATRIX_COMPANY_PUBLIC_WRITE_DIR"));
10
+ var privateWriteDir = normalizeCompanyDir(readRequiredEnv("MATRIX_COMPANY_PRIVATE_WRITE_DIR"));
11
+ var adminCompanyWriteExpected = readStatusEnv("MATRIX_ADMIN_COMPANY_WRITE_EXPECTED", 403);
12
+ var readonlyCompanyWriteExpected = readStatusEnv("MATRIX_READONLY_COMPANY_WRITE_EXPECTED", 403);
13
+ var runId = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`;
14
+ var users = {
15
+ adam: {
16
+ key: "adam",
17
+ label: "Adam/admin",
18
+ email: readRequiredEnv("MATRIX_ADAM_EMAIL"),
19
+ password: readRequiredSecretEnv("MATRIX_ADAM_PASSWORD"),
20
+ workspaceId: readRequiredEnv("MATRIX_ADAM_WORKSPACE_ID")
21
+ },
22
+ readonly: {
23
+ key: "readonly",
24
+ label: "Readonly",
25
+ email: readRequiredEnv("MATRIX_READONLY_EMAIL"),
26
+ password: readRequiredSecretEnv("MATRIX_READONLY_PASSWORD"),
27
+ workspaceId: readRequiredEnv("MATRIX_READONLY_WORKSPACE_ID")
28
+ }
29
+ };
30
+ function readRequiredEnv(name, value = process.env[name]) {
31
+ const trimmed = value?.trim();
32
+ if (!trimmed) throw new Error(`${name} is required`);
33
+ return trimmed;
34
+ }
35
+ function readRequiredSecretEnv(name) {
36
+ const value = process.env[name];
37
+ if (value === void 0 || value === "") throw new Error(`${name} is required`);
38
+ return value;
39
+ }
40
+ function readPositiveIntEnv(name, fallback) {
41
+ const raw = process.env[name]?.trim();
42
+ if (!raw) return fallback;
43
+ const parsed = Number(raw);
44
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
45
+ return parsed;
46
+ }
47
+ function readStatusEnv(name, fallback) {
48
+ const raw = process.env[name]?.trim();
49
+ if (!raw) return fallback;
50
+ const parsed = Number(raw);
51
+ if (!Number.isInteger(parsed) || parsed < 100 || parsed > 599) throw new Error(`${name} must be an HTTP status code`);
52
+ return parsed;
53
+ }
54
+ function normalizeCompanyDir(value) {
55
+ const raw = value.trim();
56
+ if (raw === "/") return "/";
57
+ const trimmed = raw.replace(/\/+$/, "");
58
+ if (!trimmed.startsWith("/")) throw new Error("company-context fixture directories must be absolute policy paths");
59
+ return trimmed;
60
+ }
61
+ function joinCompanyPath(dir, basename) {
62
+ return dir === "/" ? `/${basename}` : `${dir}/${basename}`;
63
+ }
64
+ async function fetchWithTimeout(input, init = {}) {
65
+ return fetch(input, { ...init, signal: AbortSignal.timeout(requestTimeoutMs) });
66
+ }
67
+ function normalizeBaseUrl(raw) {
68
+ const parsed = new URL(raw);
69
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
70
+ const loopback = ["localhost", "127.0.0.1", "::1"].includes(hostname);
71
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && (loopback || process.env.MATRIX_ALLOW_INSECURE_HTTP === "1"))) {
72
+ throw new Error("MATRIX_BASE_URL/DEPLOY_URL must be HTTPS unless targeting loopback or MATRIX_ALLOW_INSECURE_HTTP=1 is set");
73
+ }
74
+ return parsed.toString().replace(/\/$/, "");
75
+ }
76
+ function workspaceFor(testUser, scope) {
77
+ if (scope === "own") return testUser.workspaceId;
78
+ return users[scope].workspaceId;
79
+ }
80
+ function cookieFrom(headers) {
81
+ const setCookie = headers.get("set-cookie");
82
+ if (!setCookie) throw new Error("sign-in response did not set a cookie");
83
+ return setCookie.split(/,(?=\s*[^;,\s]+=)/).map((part) => part.split(";")[0]?.trim()).filter(Boolean).join("; ");
84
+ }
85
+ async function signIn(user) {
86
+ const response = await fetchWithTimeout(`${baseUrl}/auth/sign-in/email`, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json", origin: baseUrl },
89
+ body: JSON.stringify({ email: user.email, password: user.password })
90
+ });
91
+ if (!response.ok) throw new Error(`${user.label} sign-in failed: HTTP ${response.status} ${await response.text()}`);
92
+ return cookieFrom(response.headers);
93
+ }
94
+ async function requestCase(testCase, cookies, cleanupTargets) {
95
+ const cookie = cookies[testCase.user];
96
+ const user = users[testCase.user];
97
+ const workspaceId = workspaceFor(user, testCase.workspace);
98
+ let response;
99
+ if (testCase.operation === "read") {
100
+ const url = new URL(`${baseUrl}/api/v1/files`);
101
+ url.searchParams.set("workspaceId", workspaceId);
102
+ url.searchParams.set("filesystem", testCase.filesystem);
103
+ url.searchParams.set("path", testCase.path);
104
+ response = await fetchWithTimeout(url, { headers: { cookie } });
105
+ } else {
106
+ const content = testCase.content ?? `${testCase.id} ${runId}`;
107
+ cleanupTargets.push({ testCase, cookie: cleanupCookieFor(testCase, cookies) });
108
+ const url = new URL(`${baseUrl}/api/v1/files`);
109
+ url.searchParams.set("workspaceId", workspaceId);
110
+ response = await fetchWithTimeout(url, {
111
+ method: "POST",
112
+ headers: { cookie, "content-type": "application/json" },
113
+ body: JSON.stringify({
114
+ filesystem: testCase.filesystem,
115
+ path: testCase.path,
116
+ content
117
+ })
118
+ });
119
+ const body2 = await response.text();
120
+ if (response.ok && !(testCase.expected >= 200 && testCase.expected < 300)) {
121
+ cleanupTargets.push({ testCase, cookie: cleanupCookieFor(testCase, cookies) });
122
+ }
123
+ const statusMatches = response.status === testCase.expected;
124
+ if (statusMatches && response.ok && testCase.expected >= 200 && testCase.expected < 300) {
125
+ const verified = await verifyWrite(testCase, cookie, workspaceId, content);
126
+ return { ...testCase, actual: response.status, ok: verified, body: verified ? body2 : "write verification failed" };
127
+ }
128
+ if (statusMatches && testCase.expected >= 400) {
129
+ const noSideEffect = await verifyDeniedWriteNoSideEffect(testCase, cleanupCookieFor(testCase, cookies));
130
+ if (!noSideEffect) cleanupTargets.push({ testCase, cookie: cleanupCookieFor(testCase, cookies) });
131
+ return { ...testCase, actual: response.status, ok: noSideEffect, body: noSideEffect ? body2 : "denied write had side effects" };
132
+ }
133
+ return { ...testCase, actual: response.status, ok: statusMatches, body: body2 };
134
+ }
135
+ const body = await response.text();
136
+ return { ...testCase, actual: response.status, ok: response.status === testCase.expected, body };
137
+ }
138
+ function cleanupWorkspaceId(testCase) {
139
+ return testCase.filesystem === "company_context" ? users.adam.workspaceId : workspaceFor(users[testCase.user], testCase.workspace);
140
+ }
141
+ async function verifyWrite(testCase, cookie, workspaceId, content) {
142
+ const url = new URL(`${baseUrl}/api/v1/files`);
143
+ url.searchParams.set("workspaceId", workspaceId);
144
+ url.searchParams.set("filesystem", testCase.filesystem);
145
+ url.searchParams.set("path", testCase.path);
146
+ const response = await fetchWithTimeout(url, { headers: { cookie } });
147
+ if (!response.ok) return false;
148
+ try {
149
+ const parsed = await response.json();
150
+ return parsed.content === content;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+ async function verifyDeniedWriteNoSideEffect(testCase, cookie) {
156
+ const url = new URL(`${baseUrl}/api/v1/files`);
157
+ url.searchParams.set("workspaceId", cleanupWorkspaceId(testCase));
158
+ url.searchParams.set("filesystem", testCase.filesystem);
159
+ url.searchParams.set("path", testCase.path);
160
+ const response = await fetchWithTimeout(url, { headers: { cookie } });
161
+ return response.status === 404;
162
+ }
163
+ function cleanupCookieFor(testCase, cookies) {
164
+ if (testCase.filesystem !== "user") return cookies.adam;
165
+ if (testCase.workspace === "adam") return cookies.adam;
166
+ if (testCase.workspace === "readonly") return cookies.readonly;
167
+ return cookies[testCase.user];
168
+ }
169
+ async function signOut(cookie) {
170
+ const response = await fetchWithTimeout(`${baseUrl}/auth/sign-out`, {
171
+ method: "POST",
172
+ headers: { cookie, origin: baseUrl, "content-type": "application/json" },
173
+ body: "{}"
174
+ });
175
+ if (!response.ok) throw new Error(`sign-out failed: HTTP ${response.status}`);
176
+ }
177
+ async function cleanupWrite(target) {
178
+ const workspaceId = cleanupWorkspaceId(target.testCase);
179
+ const url = new URL(`${baseUrl}/api/v1/files`);
180
+ url.searchParams.set("workspaceId", workspaceId);
181
+ url.searchParams.set("filesystem", target.testCase.filesystem);
182
+ url.searchParams.set("path", target.testCase.path);
183
+ const response = await fetchWithTimeout(url, { method: "DELETE", headers: { cookie: target.cookie } });
184
+ if (!response.ok && response.status !== 404) {
185
+ if (response.status === 403 && await verifyDeniedWriteNoSideEffect(target.testCase, target.cookie)) return;
186
+ throw new Error(`${target.testCase.id} cleanup failed: HTTP ${response.status}`);
187
+ }
188
+ }
189
+ function buildCases() {
190
+ const adamUserPath = `adam-user-matrix-${runId}.txt`;
191
+ const readonlyUserPath = `readonly-user-matrix-${runId}.txt`;
192
+ const cases = [];
193
+ for (const user of ["adam", "readonly"]) {
194
+ const isAdmin = user === "adam";
195
+ cases.push(
196
+ {
197
+ id: `${user}-read-company-public`,
198
+ user,
199
+ operation: "read",
200
+ filesystem: "company_context",
201
+ location: "company public",
202
+ workspace: "own",
203
+ path: publicReadPath,
204
+ expected: 200,
205
+ note: "public company context is readable by both policy users"
206
+ },
207
+ {
208
+ id: `${user}-read-company-adam-private`,
209
+ user,
210
+ operation: "read",
211
+ filesystem: "company_context",
212
+ location: "company adam-private",
213
+ workspace: "own",
214
+ path: privateReadPath,
215
+ expected: isAdmin ? 200 : 404,
216
+ note: "adam-private company context is restricted to Adam/admin"
217
+ },
218
+ {
219
+ id: `${user}-write-company-public`,
220
+ user,
221
+ operation: "write",
222
+ filesystem: "company_context",
223
+ location: "company public",
224
+ workspace: "own",
225
+ path: joinCompanyPath(publicWriteDir, `${user}-company-public-write-${runId}.txt`),
226
+ expected: isAdmin ? adminCompanyWriteExpected : readonlyCompanyWriteExpected,
227
+ note: "company context writes require admin"
228
+ },
229
+ {
230
+ id: `${user}-write-company-adam-private`,
231
+ user,
232
+ operation: "write",
233
+ filesystem: "company_context",
234
+ location: "company adam-private",
235
+ workspace: "own",
236
+ path: joinCompanyPath(privateWriteDir, `${user}-company-private-write-${runId}.txt`),
237
+ expected: isAdmin ? adminCompanyWriteExpected : readonlyCompanyWriteExpected,
238
+ note: "company context writes require admin, including private area"
239
+ },
240
+ {
241
+ id: `${user}-write-own-user-workspace`,
242
+ user,
243
+ operation: "write",
244
+ filesystem: "user",
245
+ location: "own user workspace",
246
+ workspace: "own",
247
+ path: user === "adam" ? adamUserPath : readonlyUserPath,
248
+ expected: 200,
249
+ note: "each user can write their own workspace"
250
+ },
251
+ {
252
+ id: `${user}-read-own-user-workspace`,
253
+ user,
254
+ operation: "read",
255
+ filesystem: "user",
256
+ location: "own user workspace",
257
+ workspace: "own",
258
+ path: user === "adam" ? adamUserPath : readonlyUserPath,
259
+ expected: 200,
260
+ note: "each user can read their own workspace"
261
+ },
262
+ {
263
+ id: `${user}-write-other-user-workspace`,
264
+ user,
265
+ operation: "write",
266
+ filesystem: "user",
267
+ location: "other user workspace",
268
+ workspace: user === "adam" ? "readonly" : "adam",
269
+ path: `${user}-cross-workspace-write-${runId}.txt`,
270
+ expected: 403,
271
+ note: "workspace membership blocks writes to the other user workspace"
272
+ },
273
+ {
274
+ id: `${user}-read-other-user-workspace`,
275
+ user,
276
+ operation: "read",
277
+ filesystem: "user",
278
+ location: "other user workspace",
279
+ workspace: user === "adam" ? "readonly" : "adam",
280
+ path: user === "adam" ? readonlyUserPath : adamUserPath,
281
+ expected: 403,
282
+ note: "workspace membership blocks reads from the other user workspace"
283
+ }
284
+ );
285
+ }
286
+ return cases;
287
+ }
288
+ function compactBody(body, actual) {
289
+ if (body === "write verification failed" || body === "denied write had side effects") return body;
290
+ if (actual >= 200 && actual < 300) return "[success body redacted]";
291
+ try {
292
+ const parsed = JSON.parse(body);
293
+ const code = typeof parsed.error?.code === "string" ? parsed.error.code : typeof parsed.code === "string" ? parsed.code : "error";
294
+ const message = typeof parsed.error?.message === "string" ? parsed.error.message : typeof parsed.message === "string" ? parsed.message : "";
295
+ return [code, message].filter(Boolean).join(": ").slice(0, 100);
296
+ } catch {
297
+ return "[error body redacted]";
298
+ }
299
+ }
300
+ function printMarkdown(results) {
301
+ console.log(`
302
+ Governance access matrix (${baseUrl})
303
+ `);
304
+ console.log("| User | Operation | Filesystem | Location | Workspace | Path | Expected | Actual | Result |");
305
+ console.log("|---|---:|---|---|---|---|---:|---:|---|");
306
+ for (const result of results) {
307
+ console.log(`| ${users[result.user].label} | ${result.operation} | \`${result.filesystem}\` | ${result.location} | ${result.workspace} | \`${result.path}\` | ${result.expected} | ${result.actual} | ${result.ok ? "\u2705" : `\u274C ${compactBody(result.body, result.actual)}`} |`);
308
+ }
309
+ }
310
+ async function main() {
311
+ const cookies = {};
312
+ const cleanupTargets = [];
313
+ const results = [];
314
+ const cleanupFailures = [];
315
+ let primaryError = null;
316
+ try {
317
+ cookies.adam = await signIn(users.adam);
318
+ cookies.readonly = await signIn(users.readonly);
319
+ const completeCookies = cookies;
320
+ for (const testCase of buildCases()) {
321
+ results.push(await requestCase(testCase, completeCookies, cleanupTargets));
322
+ }
323
+ } catch (error) {
324
+ primaryError = error;
325
+ } finally {
326
+ for (const target of cleanupTargets.reverse()) {
327
+ await cleanupWrite(target).catch((error) => {
328
+ cleanupFailures.push(error instanceof Error ? error.message : String(error));
329
+ });
330
+ }
331
+ await Promise.all(Object.values(cookies).filter((cookie) => Boolean(cookie)).map((cookie) => signOut(cookie).catch((error) => {
332
+ cleanupFailures.push(error instanceof Error ? error.message : String(error));
333
+ })));
334
+ }
335
+ printMarkdown(results);
336
+ const failures = results.filter((result) => !result.ok);
337
+ if (primaryError || failures.length > 0 || cleanupFailures.length > 0) {
338
+ if (primaryError) console.error(primaryError instanceof Error ? primaryError.stack ?? primaryError.message : String(primaryError));
339
+ for (const failure of cleanupFailures) console.error(`cleanup failed: ${failure}`);
340
+ if (failures.length > 0) console.error(`
341
+ ${failures.length} governance access matrix case(s) failed.`);
342
+ if (cleanupFailures.length > 0) console.error(`${cleanupFailures.length} cleanup operation(s) failed.`);
343
+ process.exit(1);
344
+ }
345
+ console.log("\nAll governance access matrix cases passed.");
346
+ }
347
+ main().catch((error) => {
348
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
349
+ process.exit(1);
350
+ });
@@ -2,6 +2,7 @@ import * as _hachej_boring_agent_server from '@hachej/boring-agent/server';
2
2
  import { AgentMeteringSink, RegisterAgentRoutesOptions } from '@hachej/boring-agent/server';
3
3
  import { CoreConfig } from '@hachej/boring-core/shared';
4
4
  import { CoreWorkspaceAgentServerPlugin } from '@hachej/boring-core/app/server';
5
+ import { PostgresBudgetReservationStore } from '@hachej/boring-core/server';
5
6
  import { FastifyRequest } from 'fastify';
6
7
 
7
8
  type TenantRole = 'admin' | 'user';
@@ -11,9 +12,14 @@ interface GovernanceModelGrant {
11
12
  monthlyBudgetEur: number;
12
13
  monthlyBudgetMicros: number;
13
14
  }
15
+ interface GovernanceUserBudgets {
16
+ monthlyEur: number | null;
17
+ monthlyMicros: number | null;
18
+ }
14
19
  interface GovernanceUserPolicy {
15
20
  email: string;
16
21
  role: TenantRole;
22
+ budgets: GovernanceUserBudgets;
17
23
  models: GovernanceModelGrant[];
18
24
  companyContext: {
19
25
  allow: string[];
@@ -69,6 +75,7 @@ interface LoadGovernancePolicyOptions {
69
75
  declare function loadGovernancePolicy({ env, nodeEnv, config, }?: LoadGovernancePolicyOptions): Promise<GovernanceLoadResult>;
70
76
 
71
77
  type GovernancePolicyErrorCode = 'disabled' | 'invalid' | 'denied' | 'not_allowed';
78
+ type CompanyContextAccess = 'none' | 'readonly' | 'readwrite';
72
79
  declare class GovernancePolicyError extends Error {
73
80
  readonly code: GovernancePolicyErrorCode;
74
81
  constructor(message: string, code: GovernancePolicyErrorCode);
@@ -88,6 +95,7 @@ interface GovernanceMeResponse {
88
95
  email: string;
89
96
  role: TenantRole;
90
97
  modelCount: number;
98
+ monthlyBudgetEur: number | null;
91
99
  contextRuleCount: number;
92
100
  }>;
93
101
  models?: Array<GovernanceModelGrant & {
@@ -111,22 +119,17 @@ declare class GovernanceService {
111
119
  allowedModelsForUser<TModel extends ServedModelLike>(user: GovernanceUserLike, servedModels: readonly TModel[]): TModel[];
112
120
  assertModelAllowed(user: GovernanceUserLike, model: ServedModelLike): void;
113
121
  monthlyBudgetMicros(user: GovernanceUserLike, model: ServedModelLike): number | null;
122
+ userMonthlyBudgetMicros(user: GovernanceUserLike): number | null;
114
123
  companyContextRules(user: GovernanceUserLike): string[];
124
+ companyContextAccessForUser(user: GovernanceUserLike | null | undefined): CompanyContextAccess;
115
125
  companyContextWorkspaceId(): string | null;
116
126
  me(user: GovernanceUserLike | null | undefined): GovernanceMeResponse;
117
127
  }
118
128
 
119
- declare const PostgresModelBudgetStore: new (db: unknown) => {
120
- reserve(input: unknown): Promise<{
121
- reservationId: string;
122
- }>;
123
- settle(input: unknown): Promise<void>;
124
- release(input: unknown): Promise<void>;
125
- };
126
- type ModelBudgetDb = ConstructorParameters<typeof PostgresModelBudgetStore>[0];
129
+ type BudgetDb = ConstructorParameters<typeof PostgresBudgetReservationStore>[0];
127
130
  declare function createGovernanceMeteringSink(options: {
128
131
  service: GovernanceService;
129
- getDb: () => ModelBudgetDb;
132
+ getDb: () => BudgetDb;
130
133
  delegate: AgentMeteringSink;
131
134
  holdTtlSeconds?: number;
132
135
  }): AgentMeteringSink;
@@ -145,6 +148,8 @@ type CompanyContextRootResolver = (ctx: GovernanceFilesystemBindingContext, comp
145
148
  interface CreateGovernanceFilesystemBindingsOptions {
146
149
  /** Explicit source root resolver for the tenant company-context workspace. */
147
150
  resolveCompanyContextRoot?: CompanyContextRootResolver;
151
+ /** Enable admin mutations only when the resolved root is exclusively managed by the governance service. */
152
+ allowAdminMutations?: boolean;
148
153
  projectionRootParent?: string;
149
154
  }
150
155
  declare function createDefaultCompanyContextRootResolver(env?: NodeJS.ProcessEnv): CompanyContextRootResolver | undefined;