@crewhaus/tenancy 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +74 -0
- package/dist/index.js +87 -0
- package/package.json +9 -6
- package/src/index.test.ts +0 -127
- package/src/index.ts +0 -135
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R17 `tenancy` — per-tenant storage rebase + cross-tenant guard.
|
|
3
|
+
*
|
|
4
|
+
* The managed daemon (Section 20) routes every request through
|
|
5
|
+
* `withTenant(tenantId, fn)` which:
|
|
6
|
+
*
|
|
7
|
+
* 1. Validates `tenantId` against `TENANT_ID_RE` so a malformed claim
|
|
8
|
+
* cannot become a path-traversal vector.
|
|
9
|
+
* 2. Resolves a per-tenant root: `<tenantsRoot>/<tenantId>/`.
|
|
10
|
+
* 3. Exposes that root via the AsyncLocalStorage-backed
|
|
11
|
+
* `currentTenantContext()` so downstream code (session-store,
|
|
12
|
+
* checkpoint-store, audit-log, tool-result-store) can rebase
|
|
13
|
+
* its own `rootDir`.
|
|
14
|
+
* 4. Fences cross-tenant reads — `assertSamePath(absPath)` throws
|
|
15
|
+
* `TenancyError` if `absPath` is not under the active tenant's
|
|
16
|
+
* root. Storage adapters call this on every public method.
|
|
17
|
+
*
|
|
18
|
+
* The runtime carries the active tenant id through `RunContext.tenantId`;
|
|
19
|
+
* tenancy itself stays loosely coupled to the wider runtime so it can
|
|
20
|
+
* be unit-tested in isolation.
|
|
21
|
+
*
|
|
22
|
+
* Layer R17. Pairs with `gateway-server` (R16) and `audit-log` (R17).
|
|
23
|
+
*/
|
|
24
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
25
|
+
export declare class TenancyError extends CrewhausError {
|
|
26
|
+
readonly name = "TenancyError";
|
|
27
|
+
constructor(message: string, cause?: unknown);
|
|
28
|
+
}
|
|
29
|
+
export type TenantBudget = {
|
|
30
|
+
/** Cumulative tokens granted before runs.create returns 429. */
|
|
31
|
+
readonly maxInputTokens: number;
|
|
32
|
+
readonly maxOutputTokens: number;
|
|
33
|
+
};
|
|
34
|
+
export type Tenant = {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
/** Path-rebased session-store root. */
|
|
37
|
+
readonly sessionRoot: string;
|
|
38
|
+
/** Path-rebased eval-store root. */
|
|
39
|
+
readonly evalRoot: string;
|
|
40
|
+
/** Path-rebased tool-result-store root. */
|
|
41
|
+
readonly toolResultRoot: string;
|
|
42
|
+
/** Per-tenant audit-log root. */
|
|
43
|
+
readonly auditRoot: string;
|
|
44
|
+
/** Per-tenant policy overrides (extends the global policy ruleset). */
|
|
45
|
+
readonly policyOverrides: ReadonlyArray<{
|
|
46
|
+
readonly toolName: string;
|
|
47
|
+
readonly action: "allow" | "deny" | "audit";
|
|
48
|
+
}>;
|
|
49
|
+
readonly budget: TenantBudget;
|
|
50
|
+
};
|
|
51
|
+
export type TenantContext = {
|
|
52
|
+
readonly tenant: Tenant;
|
|
53
|
+
};
|
|
54
|
+
export declare function validateTenantId(id: string): void;
|
|
55
|
+
export type TenantOptions = {
|
|
56
|
+
/** Filesystem root under which per-tenant subtrees live. */
|
|
57
|
+
readonly tenantsRoot?: string;
|
|
58
|
+
/** Optional overrides keyed by tenantId. */
|
|
59
|
+
readonly overrides?: Readonly<Record<string, Partial<Tenant>>>;
|
|
60
|
+
};
|
|
61
|
+
export declare function buildTenant(id: string, opts?: TenantOptions): Tenant;
|
|
62
|
+
/**
|
|
63
|
+
* Run `fn` with `tenant` as the active context. Nested calls override
|
|
64
|
+
* outer; the outer context is restored on return.
|
|
65
|
+
*/
|
|
66
|
+
export declare function withTenant<T>(tenant: Tenant, fn: () => Promise<T> | T): Promise<T> | T;
|
|
67
|
+
export declare function currentTenantContext(): TenantContext | undefined;
|
|
68
|
+
export declare function requireTenant(): Tenant;
|
|
69
|
+
/**
|
|
70
|
+
* Throw `TenancyError` if `absPath` is not contained within `expectedRoot`.
|
|
71
|
+
* Used by storage adapters before every public read/write to fail-closed
|
|
72
|
+
* on a cross-tenant path leak.
|
|
73
|
+
*/
|
|
74
|
+
export declare function assertSamePath(absPath: string, expectedRoot: string): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R17 `tenancy` — per-tenant storage rebase + cross-tenant guard.
|
|
3
|
+
*
|
|
4
|
+
* The managed daemon (Section 20) routes every request through
|
|
5
|
+
* `withTenant(tenantId, fn)` which:
|
|
6
|
+
*
|
|
7
|
+
* 1. Validates `tenantId` against `TENANT_ID_RE` so a malformed claim
|
|
8
|
+
* cannot become a path-traversal vector.
|
|
9
|
+
* 2. Resolves a per-tenant root: `<tenantsRoot>/<tenantId>/`.
|
|
10
|
+
* 3. Exposes that root via the AsyncLocalStorage-backed
|
|
11
|
+
* `currentTenantContext()` so downstream code (session-store,
|
|
12
|
+
* checkpoint-store, audit-log, tool-result-store) can rebase
|
|
13
|
+
* its own `rootDir`.
|
|
14
|
+
* 4. Fences cross-tenant reads — `assertSamePath(absPath)` throws
|
|
15
|
+
* `TenancyError` if `absPath` is not under the active tenant's
|
|
16
|
+
* root. Storage adapters call this on every public method.
|
|
17
|
+
*
|
|
18
|
+
* The runtime carries the active tenant id through `RunContext.tenantId`;
|
|
19
|
+
* tenancy itself stays loosely coupled to the wider runtime so it can
|
|
20
|
+
* be unit-tested in isolation.
|
|
21
|
+
*
|
|
22
|
+
* Layer R17. Pairs with `gateway-server` (R16) and `audit-log` (R17).
|
|
23
|
+
*/
|
|
24
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
25
|
+
import { resolve as resolvePath } from "node:path";
|
|
26
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
27
|
+
const TENANT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$/;
|
|
28
|
+
export class TenancyError extends CrewhausError {
|
|
29
|
+
name = "TenancyError";
|
|
30
|
+
constructor(message, cause) {
|
|
31
|
+
super("config", message, cause);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const storage = new AsyncLocalStorage();
|
|
35
|
+
export function validateTenantId(id) {
|
|
36
|
+
if (!TENANT_ID_RE.test(id)) {
|
|
37
|
+
throw new TenancyError(`invalid tenantId "${id}" — expected alphanumeric / hyphen / underscore, max 64 chars, no leading separator`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const DEFAULT_BUDGET = {
|
|
41
|
+
maxInputTokens: 1_000_000,
|
|
42
|
+
maxOutputTokens: 200_000,
|
|
43
|
+
};
|
|
44
|
+
const DEFAULT_TENANTS_ROOT = ".crewhaus/tenants";
|
|
45
|
+
export function buildTenant(id, opts = {}) {
|
|
46
|
+
validateTenantId(id);
|
|
47
|
+
const root = resolvePath(opts.tenantsRoot ?? DEFAULT_TENANTS_ROOT, id);
|
|
48
|
+
const override = opts.overrides?.[id] ?? {};
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
sessionRoot: override.sessionRoot ?? resolvePath(root, "sessions"),
|
|
52
|
+
evalRoot: override.evalRoot ?? resolvePath(root, "evals"),
|
|
53
|
+
toolResultRoot: override.toolResultRoot ?? resolvePath(root, "tool-results"),
|
|
54
|
+
auditRoot: override.auditRoot ?? resolvePath(root, "audit"),
|
|
55
|
+
policyOverrides: override.policyOverrides ?? [],
|
|
56
|
+
budget: override.budget ?? DEFAULT_BUDGET,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Run `fn` with `tenant` as the active context. Nested calls override
|
|
61
|
+
* outer; the outer context is restored on return.
|
|
62
|
+
*/
|
|
63
|
+
export function withTenant(tenant, fn) {
|
|
64
|
+
return storage.run({ tenant }, fn);
|
|
65
|
+
}
|
|
66
|
+
export function currentTenantContext() {
|
|
67
|
+
return storage.getStore();
|
|
68
|
+
}
|
|
69
|
+
export function requireTenant() {
|
|
70
|
+
const ctx = storage.getStore();
|
|
71
|
+
if (ctx === undefined) {
|
|
72
|
+
throw new TenancyError("no active tenant — wrap the call in withTenant(tenant, fn)");
|
|
73
|
+
}
|
|
74
|
+
return ctx.tenant;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Throw `TenancyError` if `absPath` is not contained within `expectedRoot`.
|
|
78
|
+
* Used by storage adapters before every public read/write to fail-closed
|
|
79
|
+
* on a cross-tenant path leak.
|
|
80
|
+
*/
|
|
81
|
+
export function assertSamePath(absPath, expectedRoot) {
|
|
82
|
+
const resolved = resolvePath(absPath);
|
|
83
|
+
const root = resolvePath(expectedRoot);
|
|
84
|
+
if (resolved !== root && !resolved.startsWith(`${root}/`)) {
|
|
85
|
+
throw new TenancyError(`cross-tenant access denied: "${resolved}" is not under "${root}"`);
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/tenancy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Per-tenant storage rebase + cross-tenant guard for the managed-daemon target",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.6"
|
|
16
19
|
},
|
|
17
20
|
"license": "Apache-2.0",
|
|
18
21
|
"author": {
|
|
@@ -32,5 +35,5 @@
|
|
|
32
35
|
"publishConfig": {
|
|
33
36
|
"access": "public"
|
|
34
37
|
},
|
|
35
|
-
"files": ["
|
|
38
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
36
39
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
TenancyError,
|
|
7
|
-
assertSamePath,
|
|
8
|
-
buildTenant,
|
|
9
|
-
currentTenantContext,
|
|
10
|
-
requireTenant,
|
|
11
|
-
validateTenantId,
|
|
12
|
-
withTenant,
|
|
13
|
-
} from "./index";
|
|
14
|
-
|
|
15
|
-
let tmp: string;
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
tmp = mkdtempSync(join(tmpdir(), "tenancy-"));
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe("validateTenantId", () => {
|
|
26
|
-
test("accepts alphanumeric ids", () => {
|
|
27
|
-
expect(() => validateTenantId("tenant-a")).not.toThrow();
|
|
28
|
-
expect(() => validateTenantId("Acme_42")).not.toThrow();
|
|
29
|
-
expect(() => validateTenantId("a")).not.toThrow();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("rejects leading hyphen / underscore", () => {
|
|
33
|
-
expect(() => validateTenantId("-bad")).toThrow(TenancyError);
|
|
34
|
-
expect(() => validateTenantId("_bad")).toThrow(TenancyError);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("rejects path-traversal characters", () => {
|
|
38
|
-
expect(() => validateTenantId("../etc")).toThrow(TenancyError);
|
|
39
|
-
expect(() => validateTenantId("a/b")).toThrow(TenancyError);
|
|
40
|
-
expect(() => validateTenantId("a\\b")).toThrow(TenancyError);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("rejects whitespace / nulls / overlong ids", () => {
|
|
44
|
-
expect(() => validateTenantId("a b")).toThrow(TenancyError);
|
|
45
|
-
expect(() => validateTenantId("a".repeat(70))).toThrow(TenancyError);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe("buildTenant", () => {
|
|
50
|
-
test("rebases all per-tenant roots under the tenants root", () => {
|
|
51
|
-
const t = buildTenant("acme", { tenantsRoot: tmp });
|
|
52
|
-
expect(t.id).toBe("acme");
|
|
53
|
-
expect(t.sessionRoot).toBe(join(tmp, "acme", "sessions"));
|
|
54
|
-
expect(t.evalRoot).toBe(join(tmp, "acme", "evals"));
|
|
55
|
-
expect(t.toolResultRoot).toBe(join(tmp, "acme", "tool-results"));
|
|
56
|
-
expect(t.auditRoot).toBe(join(tmp, "acme", "audit"));
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("applies overrides", () => {
|
|
60
|
-
const t = buildTenant("acme", {
|
|
61
|
-
tenantsRoot: tmp,
|
|
62
|
-
overrides: { acme: { sessionRoot: "/srv/custom" } },
|
|
63
|
-
});
|
|
64
|
-
expect(t.sessionRoot).toBe("/srv/custom");
|
|
65
|
-
// Other roots still default.
|
|
66
|
-
expect(t.evalRoot).toBe(join(tmp, "acme", "evals"));
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("withTenant + AsyncLocalStorage", () => {
|
|
71
|
-
test("requireTenant returns the active tenant inside withTenant", async () => {
|
|
72
|
-
const t = buildTenant("acme", { tenantsRoot: tmp });
|
|
73
|
-
await withTenant(t, async () => {
|
|
74
|
-
expect(requireTenant().id).toBe("acme");
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("requireTenant throws outside of withTenant", () => {
|
|
79
|
-
expect(() => requireTenant()).toThrow(TenancyError);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("currentTenantContext returns undefined outside", () => {
|
|
83
|
-
expect(currentTenantContext()).toBeUndefined();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("nested withTenant overrides the outer", async () => {
|
|
87
|
-
const a = buildTenant("acme", { tenantsRoot: tmp });
|
|
88
|
-
const b = buildTenant("bravo", { tenantsRoot: tmp });
|
|
89
|
-
await withTenant(a, async () => {
|
|
90
|
-
await withTenant(b, () => {
|
|
91
|
-
expect(requireTenant().id).toBe("bravo");
|
|
92
|
-
});
|
|
93
|
-
expect(requireTenant().id).toBe("acme");
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("async work inside withTenant retains the context across awaits", async () => {
|
|
98
|
-
const t = buildTenant("acme", { tenantsRoot: tmp });
|
|
99
|
-
await withTenant(t, async () => {
|
|
100
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
101
|
-
expect(requireTenant().id).toBe("acme");
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe("assertSamePath (T8)", () => {
|
|
107
|
-
test("accepts exact root match", () => {
|
|
108
|
-
expect(() => assertSamePath("/srv/acme", "/srv/acme")).not.toThrow();
|
|
109
|
-
});
|
|
110
|
-
test("accepts a child path", () => {
|
|
111
|
-
expect(() => assertSamePath("/srv/acme/sessions/abc", "/srv/acme")).not.toThrow();
|
|
112
|
-
});
|
|
113
|
-
test("rejects a sibling path", () => {
|
|
114
|
-
expect(() => assertSamePath("/srv/bravo/sessions/abc", "/srv/acme")).toThrow(
|
|
115
|
-
/cross-tenant access denied/,
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
test("rejects a parent path", () => {
|
|
119
|
-
expect(() => assertSamePath("/srv", "/srv/acme")).toThrow(TenancyError);
|
|
120
|
-
});
|
|
121
|
-
test("rejects an unrelated path", () => {
|
|
122
|
-
expect(() => assertSamePath("/etc/passwd", "/srv/acme")).toThrow(TenancyError);
|
|
123
|
-
});
|
|
124
|
-
test("normalises ../ traversal before checking", () => {
|
|
125
|
-
expect(() => assertSamePath("/srv/acme/../bravo/data", "/srv/acme")).toThrow(TenancyError);
|
|
126
|
-
});
|
|
127
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catalog R17 `tenancy` — per-tenant storage rebase + cross-tenant guard.
|
|
3
|
-
*
|
|
4
|
-
* The managed daemon (Section 20) routes every request through
|
|
5
|
-
* `withTenant(tenantId, fn)` which:
|
|
6
|
-
*
|
|
7
|
-
* 1. Validates `tenantId` against `TENANT_ID_RE` so a malformed claim
|
|
8
|
-
* cannot become a path-traversal vector.
|
|
9
|
-
* 2. Resolves a per-tenant root: `<tenantsRoot>/<tenantId>/`.
|
|
10
|
-
* 3. Exposes that root via the AsyncLocalStorage-backed
|
|
11
|
-
* `currentTenantContext()` so downstream code (session-store,
|
|
12
|
-
* checkpoint-store, audit-log, tool-result-store) can rebase
|
|
13
|
-
* its own `rootDir`.
|
|
14
|
-
* 4. Fences cross-tenant reads — `assertSamePath(absPath)` throws
|
|
15
|
-
* `TenancyError` if `absPath` is not under the active tenant's
|
|
16
|
-
* root. Storage adapters call this on every public method.
|
|
17
|
-
*
|
|
18
|
-
* The runtime carries the active tenant id through `RunContext.tenantId`;
|
|
19
|
-
* tenancy itself stays loosely coupled to the wider runtime so it can
|
|
20
|
-
* be unit-tested in isolation.
|
|
21
|
-
*
|
|
22
|
-
* Layer R17. Pairs with `gateway-server` (R16) and `audit-log` (R17).
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
26
|
-
import { resolve as resolvePath } from "node:path";
|
|
27
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
28
|
-
|
|
29
|
-
const TENANT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$/;
|
|
30
|
-
|
|
31
|
-
export class TenancyError extends CrewhausError {
|
|
32
|
-
override readonly name = "TenancyError";
|
|
33
|
-
constructor(message: string, cause?: unknown) {
|
|
34
|
-
super("config", message, cause);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export type TenantBudget = {
|
|
39
|
-
/** Cumulative tokens granted before runs.create returns 429. */
|
|
40
|
-
readonly maxInputTokens: number;
|
|
41
|
-
readonly maxOutputTokens: number;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export type Tenant = {
|
|
45
|
-
readonly id: string;
|
|
46
|
-
/** Path-rebased session-store root. */
|
|
47
|
-
readonly sessionRoot: string;
|
|
48
|
-
/** Path-rebased eval-store root. */
|
|
49
|
-
readonly evalRoot: string;
|
|
50
|
-
/** Path-rebased tool-result-store root. */
|
|
51
|
-
readonly toolResultRoot: string;
|
|
52
|
-
/** Per-tenant audit-log root. */
|
|
53
|
-
readonly auditRoot: string;
|
|
54
|
-
/** Per-tenant policy overrides (extends the global policy ruleset). */
|
|
55
|
-
readonly policyOverrides: ReadonlyArray<{
|
|
56
|
-
readonly toolName: string;
|
|
57
|
-
readonly action: "allow" | "deny" | "audit";
|
|
58
|
-
}>;
|
|
59
|
-
readonly budget: TenantBudget;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
export type TenantContext = {
|
|
63
|
-
readonly tenant: Tenant;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const storage = new AsyncLocalStorage<TenantContext>();
|
|
67
|
-
|
|
68
|
-
export function validateTenantId(id: string): void {
|
|
69
|
-
if (!TENANT_ID_RE.test(id)) {
|
|
70
|
-
throw new TenancyError(
|
|
71
|
-
`invalid tenantId "${id}" — expected alphanumeric / hyphen / underscore, max 64 chars, no leading separator`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export type TenantOptions = {
|
|
77
|
-
/** Filesystem root under which per-tenant subtrees live. */
|
|
78
|
-
readonly tenantsRoot?: string;
|
|
79
|
-
/** Optional overrides keyed by tenantId. */
|
|
80
|
-
readonly overrides?: Readonly<Record<string, Partial<Tenant>>>;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
const DEFAULT_BUDGET: TenantBudget = {
|
|
84
|
-
maxInputTokens: 1_000_000,
|
|
85
|
-
maxOutputTokens: 200_000,
|
|
86
|
-
};
|
|
87
|
-
const DEFAULT_TENANTS_ROOT = ".crewhaus/tenants";
|
|
88
|
-
|
|
89
|
-
export function buildTenant(id: string, opts: TenantOptions = {}): Tenant {
|
|
90
|
-
validateTenantId(id);
|
|
91
|
-
const root = resolvePath(opts.tenantsRoot ?? DEFAULT_TENANTS_ROOT, id);
|
|
92
|
-
const override = opts.overrides?.[id] ?? {};
|
|
93
|
-
return {
|
|
94
|
-
id,
|
|
95
|
-
sessionRoot: override.sessionRoot ?? resolvePath(root, "sessions"),
|
|
96
|
-
evalRoot: override.evalRoot ?? resolvePath(root, "evals"),
|
|
97
|
-
toolResultRoot: override.toolResultRoot ?? resolvePath(root, "tool-results"),
|
|
98
|
-
auditRoot: override.auditRoot ?? resolvePath(root, "audit"),
|
|
99
|
-
policyOverrides: override.policyOverrides ?? [],
|
|
100
|
-
budget: override.budget ?? DEFAULT_BUDGET,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Run `fn` with `tenant` as the active context. Nested calls override
|
|
106
|
-
* outer; the outer context is restored on return.
|
|
107
|
-
*/
|
|
108
|
-
export function withTenant<T>(tenant: Tenant, fn: () => Promise<T> | T): Promise<T> | T {
|
|
109
|
-
return storage.run({ tenant }, fn);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function currentTenantContext(): TenantContext | undefined {
|
|
113
|
-
return storage.getStore();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function requireTenant(): Tenant {
|
|
117
|
-
const ctx = storage.getStore();
|
|
118
|
-
if (ctx === undefined) {
|
|
119
|
-
throw new TenancyError("no active tenant — wrap the call in withTenant(tenant, fn)");
|
|
120
|
-
}
|
|
121
|
-
return ctx.tenant;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Throw `TenancyError` if `absPath` is not contained within `expectedRoot`.
|
|
126
|
-
* Used by storage adapters before every public read/write to fail-closed
|
|
127
|
-
* on a cross-tenant path leak.
|
|
128
|
-
*/
|
|
129
|
-
export function assertSamePath(absPath: string, expectedRoot: string): void {
|
|
130
|
-
const resolved = resolvePath(absPath);
|
|
131
|
-
const root = resolvePath(expectedRoot);
|
|
132
|
-
if (resolved !== root && !resolved.startsWith(`${root}/`)) {
|
|
133
|
-
throw new TenancyError(`cross-tenant access denied: "${resolved}" is not under "${root}"`);
|
|
134
|
-
}
|
|
135
|
-
}
|