mandat 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +293 -3
- package/dist/approvals/approvals.d.ts +136 -0
- package/dist/approvals/approvals.js +262 -0
- package/dist/bridge.d.ts +38 -0
- package/dist/bridge.js +117 -0
- package/dist/cli/args.d.ts +9 -0
- package/dist/cli/args.js +39 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +3 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +649 -0
- package/dist/cli/templates.d.ts +91 -0
- package/dist/cli/templates.js +57 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +88 -0
- package/dist/controlplane/config.d.ts +48 -0
- package/dist/controlplane/config.js +33 -0
- package/dist/controlplane/console/.claude-flow/neural/patterns.json +402 -0
- package/dist/controlplane/console/.claude-flow/neural/stats.json +6 -0
- package/dist/controlplane/console/.claude-flow/policy/ledger-anchors.json +3 -0
- package/dist/controlplane/console/.claude-flow/policy/state.json +54 -0
- package/dist/controlplane/console/app.css +93 -0
- package/dist/controlplane/console/app.js +330 -0
- package/dist/controlplane/console/index.html +70 -0
- package/dist/controlplane/server.d.ts +17 -0
- package/dist/controlplane/server.js +427 -0
- package/dist/guard/guard.d.ts +154 -0
- package/dist/guard/guard.js +290 -0
- package/dist/guard/pinning.d.ts +46 -0
- package/dist/guard/pinning.js +49 -0
- package/dist/guard/remote.d.ts +78 -0
- package/dist/guard/remote.js +185 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +67 -0
- package/dist/ledger/export.d.ts +27 -0
- package/dist/ledger/export.js +169 -0
- package/dist/ledger/ledger.d.ts +117 -0
- package/dist/ledger/ledger.js +139 -0
- package/dist/ledger/redact.d.ts +13 -0
- package/dist/ledger/redact.js +73 -0
- package/dist/ledger/report.d.ts +71 -0
- package/dist/ledger/report.js +128 -0
- package/dist/mcp/protect.d.ts +8 -0
- package/dist/mcp/protect.js +68 -0
- package/dist/mcp/server.d.ts +49 -0
- package/dist/mcp/server.js +211 -0
- package/dist/oidc.d.ts +52 -0
- package/dist/oidc.js +194 -0
- package/dist/policy/engine.d.ts +135 -0
- package/dist/policy/engine.js +243 -0
- package/dist/policy/expr.d.ts +56 -0
- package/dist/policy/expr.js +506 -0
- package/dist/scan/findings.d.ts +32 -0
- package/dist/scan/findings.js +105 -0
- package/dist/scan/remote.d.ts +7 -0
- package/dist/scan/remote.js +225 -0
- package/dist/scan/static.d.ts +15 -0
- package/dist/scan/static.js +245 -0
- package/dist/store.d.ts +14 -0
- package/dist/store.js +40 -0
- package/dist/sts/jwt.d.ts +64 -0
- package/dist/sts/jwt.js +168 -0
- package/dist/sts/revocation.d.ts +38 -0
- package/dist/sts/revocation.js +52 -0
- package/dist/sts/sts.d.ts +83 -0
- package/dist/sts/sts.js +239 -0
- package/dist/util.d.ts +28 -0
- package/dist/util.js +137 -0
- package/package.json +47 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { PolicyDocument } from "../policy/engine.ts";
|
|
2
|
+
export declare const STARTER_POLICY: PolicyDocument;
|
|
3
|
+
export declare const STARTER_TESTS: ({
|
|
4
|
+
name: string;
|
|
5
|
+
input: {
|
|
6
|
+
tool: string;
|
|
7
|
+
args: {
|
|
8
|
+
amount: number;
|
|
9
|
+
currency: string;
|
|
10
|
+
order_id: string;
|
|
11
|
+
to?: undefined;
|
|
12
|
+
subject?: undefined;
|
|
13
|
+
body?: undefined;
|
|
14
|
+
};
|
|
15
|
+
session?: undefined;
|
|
16
|
+
task?: undefined;
|
|
17
|
+
};
|
|
18
|
+
expect: string;
|
|
19
|
+
} | {
|
|
20
|
+
name: string;
|
|
21
|
+
input: {
|
|
22
|
+
tool: string;
|
|
23
|
+
args: {
|
|
24
|
+
to: string[];
|
|
25
|
+
subject: string;
|
|
26
|
+
body: string;
|
|
27
|
+
amount?: undefined;
|
|
28
|
+
currency?: undefined;
|
|
29
|
+
order_id?: undefined;
|
|
30
|
+
};
|
|
31
|
+
session?: undefined;
|
|
32
|
+
task?: undefined;
|
|
33
|
+
};
|
|
34
|
+
expect: string;
|
|
35
|
+
} | {
|
|
36
|
+
name: string;
|
|
37
|
+
input: {
|
|
38
|
+
tool: string;
|
|
39
|
+
args: {
|
|
40
|
+
to: string[];
|
|
41
|
+
subject: string;
|
|
42
|
+
body: string;
|
|
43
|
+
amount?: undefined;
|
|
44
|
+
currency?: undefined;
|
|
45
|
+
order_id?: undefined;
|
|
46
|
+
};
|
|
47
|
+
session: {
|
|
48
|
+
tainted: boolean;
|
|
49
|
+
};
|
|
50
|
+
task?: undefined;
|
|
51
|
+
};
|
|
52
|
+
expect: string;
|
|
53
|
+
} | {
|
|
54
|
+
name: string;
|
|
55
|
+
input: {
|
|
56
|
+
tool: string;
|
|
57
|
+
args: {
|
|
58
|
+
amount?: undefined;
|
|
59
|
+
currency?: undefined;
|
|
60
|
+
order_id?: undefined;
|
|
61
|
+
to?: undefined;
|
|
62
|
+
subject?: undefined;
|
|
63
|
+
body?: undefined;
|
|
64
|
+
};
|
|
65
|
+
session?: undefined;
|
|
66
|
+
task?: undefined;
|
|
67
|
+
};
|
|
68
|
+
expect: string;
|
|
69
|
+
} | {
|
|
70
|
+
name: string;
|
|
71
|
+
input: {
|
|
72
|
+
tool: string;
|
|
73
|
+
args: {
|
|
74
|
+
amount: number;
|
|
75
|
+
currency: string;
|
|
76
|
+
order_id?: undefined;
|
|
77
|
+
to?: undefined;
|
|
78
|
+
subject?: undefined;
|
|
79
|
+
body?: undefined;
|
|
80
|
+
};
|
|
81
|
+
task: {
|
|
82
|
+
authorization_details: {
|
|
83
|
+
type: string;
|
|
84
|
+
tools: string[];
|
|
85
|
+
}[];
|
|
86
|
+
};
|
|
87
|
+
session?: undefined;
|
|
88
|
+
};
|
|
89
|
+
expect: string;
|
|
90
|
+
})[];
|
|
91
|
+
export declare function readmeSnippet(resource: string, url: string): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Files written by `mandat init`.
|
|
2
|
+
export const STARTER_POLICY = {
|
|
3
|
+
version: 1,
|
|
4
|
+
default: "deny",
|
|
5
|
+
tools: {
|
|
6
|
+
"lookup_*": { risk: "read" },
|
|
7
|
+
"list_*": { risk: "read" },
|
|
8
|
+
"get_*": { risk: "read" },
|
|
9
|
+
"read_inbox": { risk: "read", taints: true, description: "Returns untrusted e-mail content: taints the session" },
|
|
10
|
+
"fetch_url": { risk: "read", taints: true },
|
|
11
|
+
"send_email": { risk: "external_comm", redact: ["body"] },
|
|
12
|
+
"refund": { risk: "financial" },
|
|
13
|
+
"delete_*": { risk: "destructive" },
|
|
14
|
+
},
|
|
15
|
+
rules: [
|
|
16
|
+
{ id: "reads", description: "Read-only tools are allowed", tool: ["lookup_*", "list_*", "get_*", "read_inbox", "fetch_url"], effect: "allow" },
|
|
17
|
+
{ id: "refund-auto", description: "Small refunds are automatic", tool: "refund", when: "args.amount <= 50 && args.currency == 'EUR'", effect: "allow" },
|
|
18
|
+
{ id: "refund-approval", description: "€50–500 needs a human", tool: "refund", when: "args.amount > 50 && args.amount <= 500 && args.currency == 'EUR'", effect: "require_approval", approval: { approvers: 1, timeout: "15m" } },
|
|
19
|
+
{ id: "refund-limit", tool: "refund", when: "args.amount > 500 || args.currency != 'EUR'", effect: "deny", reason: "Refunds above €500 (or not in EUR) must be done by finance, not by an agent" },
|
|
20
|
+
{ id: "email-internal", tool: "send_email", when: "args.to.all(r, r.endsWith('@example.com'))", effect: "allow_with_obligations", obligations: [{ type: "annotate", tags: ["internal-email"] }] },
|
|
21
|
+
{ id: "email-external", tool: "send_email", when: "!args.to.all(r, r.endsWith('@example.com'))", effect: "require_approval", reason: "External recipients need approval" },
|
|
22
|
+
{ id: "no-exfil-after-untrusted", description: "Lethal trifecta guard: after reading untrusted content, never write to external channels", tool: "send_email", when: "session.tainted && !args.to.all(r, r.endsWith('@example.com'))", effect: "deny", reason: "Session processed untrusted content; external e-mail is blocked (possible prompt injection)" },
|
|
23
|
+
{ id: "destructive-four-eyes", description: "Deletes need two humans", tool: "delete_*", effect: "require_approval", approval: { approvers: 2, timeout: "30m" } },
|
|
24
|
+
{ id: "no-prod-delete-at-night", tool: "delete_*", when: "has(args.environment) && args.environment == 'production' && (env.hour_utc < 6 || env.hour_utc >= 22)", effect: "deny", reason: "No destructive production changes outside working hours" },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
export const STARTER_TESTS = [
|
|
28
|
+
{ name: "small refund is automatic", input: { tool: "refund", args: { amount: 20, currency: "EUR", order_id: "A1" } }, expect: "allow" },
|
|
29
|
+
{ name: "medium refund needs approval", input: { tool: "refund", args: { amount: 120, currency: "EUR", order_id: "A1" } }, expect: "require_approval" },
|
|
30
|
+
{ name: "large refund is denied", input: { tool: "refund", args: { amount: 900, currency: "EUR", order_id: "A1" } }, expect: "deny" },
|
|
31
|
+
{ name: "internal e-mail allowed", input: { tool: "send_email", args: { to: ["ops@example.com"], subject: "hi", body: "x" } }, expect: "allow*" },
|
|
32
|
+
{ name: "external e-mail after untrusted input is denied", input: { tool: "send_email", args: { to: ["x@evil.test"], subject: "hi", body: "x" }, session: { tainted: true } }, expect: "deny" },
|
|
33
|
+
{ name: "unknown tool is denied by default", input: { tool: "drop_database", args: {} }, expect: "deny" },
|
|
34
|
+
{ name: "capability from task token is enforced", input: { tool: "refund", args: { amount: 10, currency: "EUR" }, task: { authorization_details: [{ type: "mandat_tool_call", tools: ["lookup_order"] }] } }, expect: "deny" },
|
|
35
|
+
];
|
|
36
|
+
export function readmeSnippet(resource, url) {
|
|
37
|
+
return `
|
|
38
|
+
Protect an MCP server built with the official TypeScript SDK:
|
|
39
|
+
|
|
40
|
+
import { createGuard, protect } from "mandat";
|
|
41
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
42
|
+
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
43
|
+
|
|
44
|
+
const guard = await createGuard({
|
|
45
|
+
resource: "${resource}",
|
|
46
|
+
controlPlane: "${url}",
|
|
47
|
+
apiKey: process.env.MANDAT_SERVICE_KEY,
|
|
48
|
+
pinning: { lockfile: "mandat.lock.json", mode: "block" },
|
|
49
|
+
});
|
|
50
|
+
const server = protect(new McpServer({ name: "my-server", version: "1.0.0" }), guard);
|
|
51
|
+
app.get("/.well-known/oauth-protected-resource/mcp", (_q, r) => r.json(guard.protectedResourceMetadata()));
|
|
52
|
+
app.use("/mcp", requireBearerAuth({ verifier: guard.sdkTokenVerifier({ InvalidTokenError }),
|
|
53
|
+
resourceMetadataUrl: "${new URL(resource).origin}/.well-known/oauth-protected-resource/mcp" }));
|
|
54
|
+
|
|
55
|
+
Or with the built-in zero-dependency server: new McpHttpServer({ name, version, guard, tools }).listen(7401)
|
|
56
|
+
`;
|
|
57
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AuthorizationDetail } from "./policy/engine.ts";
|
|
2
|
+
export interface ExchangeOptions {
|
|
3
|
+
/** Mandat control plane base URL. */
|
|
4
|
+
mandatUrl: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret: string;
|
|
7
|
+
/** The user's token from the IdP (ID token / access token), or a parent Mandat token for sub-agents. */
|
|
8
|
+
subjectToken: string;
|
|
9
|
+
subjectTokenType?: string;
|
|
10
|
+
/** MCP server URL the token is for (RFC 8707). */
|
|
11
|
+
resource: string;
|
|
12
|
+
/** Narrow the capability: tools (and optional limits) for this task only. */
|
|
13
|
+
authorizationDetails?: AuthorizationDetail[];
|
|
14
|
+
agentVersion?: string;
|
|
15
|
+
/** System prompt (hashed client-side; only the hash is sent and logged). */
|
|
16
|
+
systemPrompt?: string;
|
|
17
|
+
taskId?: string;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
expiresIn?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface TaskToken {
|
|
22
|
+
access_token: string;
|
|
23
|
+
expires_in: number;
|
|
24
|
+
authorization_details: AuthorizationDetail[];
|
|
25
|
+
}
|
|
26
|
+
export declare function exchangeToken(o: ExchangeOptions): Promise<TaskToken>;
|
|
27
|
+
/** Dev IdP login (only when the control plane runs with `devIdp: true`). */
|
|
28
|
+
export declare function devLogin(mandatUrl: string, user: {
|
|
29
|
+
sub: string;
|
|
30
|
+
email?: string;
|
|
31
|
+
tenant?: string;
|
|
32
|
+
}): Promise<string>;
|
|
33
|
+
export declare class McpHttpClient {
|
|
34
|
+
private url;
|
|
35
|
+
token: string;
|
|
36
|
+
private sessionId;
|
|
37
|
+
private id;
|
|
38
|
+
constructor(url: string, token: string);
|
|
39
|
+
request(method: string, params?: Record<string, unknown>): Promise<any>;
|
|
40
|
+
initialize(): Promise<any>;
|
|
41
|
+
listTools(): Promise<Array<{
|
|
42
|
+
name: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
}>>;
|
|
45
|
+
callTool(name: string, args: Record<string, unknown>): Promise<{
|
|
46
|
+
content: Array<{
|
|
47
|
+
type: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
}>;
|
|
50
|
+
isError?: boolean;
|
|
51
|
+
_meta?: Record<string, any>;
|
|
52
|
+
structuredContent?: unknown;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Agent-side helpers: obtain a per-task token (RFC 8693) and call an MCP server over Streamable HTTP.
|
|
2
|
+
import { GRANT_TOKEN_EXCHANGE, TT_ID_TOKEN } from "./sts/sts.js";
|
|
3
|
+
import { sha256 } from "./util.js";
|
|
4
|
+
export async function exchangeToken(o) {
|
|
5
|
+
const form = new URLSearchParams({
|
|
6
|
+
grant_type: GRANT_TOKEN_EXCHANGE,
|
|
7
|
+
subject_token: o.subjectToken,
|
|
8
|
+
subject_token_type: o.subjectTokenType ?? TT_ID_TOKEN,
|
|
9
|
+
resource: o.resource,
|
|
10
|
+
});
|
|
11
|
+
if (o.authorizationDetails)
|
|
12
|
+
form.set("authorization_details", JSON.stringify(o.authorizationDetails));
|
|
13
|
+
if (o.agentVersion)
|
|
14
|
+
form.set("mandat_agent_version", o.agentVersion);
|
|
15
|
+
if (o.systemPrompt)
|
|
16
|
+
form.set("mandat_prompt_hash", "sha256:" + sha256(o.systemPrompt).slice(0, 32));
|
|
17
|
+
if (o.taskId)
|
|
18
|
+
form.set("mandat_task_id", o.taskId);
|
|
19
|
+
if (o.sessionId)
|
|
20
|
+
form.set("mandat_session_id", o.sessionId);
|
|
21
|
+
if (o.expiresIn)
|
|
22
|
+
form.set("expires_in", String(o.expiresIn));
|
|
23
|
+
const res = await fetch(`${o.mandatUrl.replace(/\/$/, "")}/oauth/token`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
27
|
+
authorization: "Basic " + Buffer.from(`${encodeURIComponent(o.clientId)}:${encodeURIComponent(o.clientSecret)}`).toString("base64"),
|
|
28
|
+
},
|
|
29
|
+
body: form,
|
|
30
|
+
});
|
|
31
|
+
const body = (await res.json());
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw new Error(`Token exchange failed: ${body.error}: ${body.error_description}`);
|
|
34
|
+
return body;
|
|
35
|
+
}
|
|
36
|
+
/** Dev IdP login (only when the control plane runs with `devIdp: true`). */
|
|
37
|
+
export async function devLogin(mandatUrl, user) {
|
|
38
|
+
const res = await fetch(`${mandatUrl.replace(/\/$/, "")}/dev/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(user) });
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
throw new Error(`dev login failed: ${res.status}`);
|
|
41
|
+
return (await res.json()).id_token;
|
|
42
|
+
}
|
|
43
|
+
export class McpHttpClient {
|
|
44
|
+
url;
|
|
45
|
+
token;
|
|
46
|
+
sessionId = null;
|
|
47
|
+
id = 0;
|
|
48
|
+
constructor(url, token) {
|
|
49
|
+
this.url = url;
|
|
50
|
+
this.token = token;
|
|
51
|
+
}
|
|
52
|
+
async request(method, params = {}) {
|
|
53
|
+
const res = await fetch(this.url, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
accept: "application/json, text/event-stream",
|
|
58
|
+
authorization: `Bearer ${this.token}`,
|
|
59
|
+
"mcp-protocol-version": "2026-07-28",
|
|
60
|
+
...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++this.id, method, params }),
|
|
63
|
+
});
|
|
64
|
+
if (res.status === 401 || res.status === 403) {
|
|
65
|
+
const body = (await res.json().catch(() => ({})));
|
|
66
|
+
const err = new Error(`HTTP ${res.status}: ${body.error_description ?? "unauthorized"}`);
|
|
67
|
+
err.status = res.status;
|
|
68
|
+
err.challenge = res.headers.get("www-authenticate");
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
const sid = res.headers.get("mcp-session-id");
|
|
72
|
+
if (sid)
|
|
73
|
+
this.sessionId = sid;
|
|
74
|
+
const msg = (await res.json());
|
|
75
|
+
if (msg.error)
|
|
76
|
+
throw new Error(`MCP error: ${msg.error.message}`);
|
|
77
|
+
return msg.result;
|
|
78
|
+
}
|
|
79
|
+
initialize() {
|
|
80
|
+
return this.request("initialize", { protocolVersion: "2026-07-28", capabilities: {}, clientInfo: { name: "mandat-client", version: "0.1.0" } });
|
|
81
|
+
}
|
|
82
|
+
async listTools() {
|
|
83
|
+
return (await this.request("tools/list")).tools;
|
|
84
|
+
}
|
|
85
|
+
callTool(name, args) {
|
|
86
|
+
return this.request("tools/call", { name, arguments: args });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { TrustedIssuer } from "../sts/sts.ts";
|
|
2
|
+
import type { RedactionOptions } from "../ledger/redact.ts";
|
|
3
|
+
export type Role = "admin" | "approver" | "auditor" | "service";
|
|
4
|
+
export declare const ROLES: Role[];
|
|
5
|
+
export interface ApiKeyEntry {
|
|
6
|
+
/** Identity recorded in the ledger (e.g. "alice", "refund-mcp"). */
|
|
7
|
+
id: string;
|
|
8
|
+
/** "sha256:<hex>" of the key. */
|
|
9
|
+
hash: string;
|
|
10
|
+
roles: Role[];
|
|
11
|
+
}
|
|
12
|
+
export interface ControlPlaneConfig {
|
|
13
|
+
/** Public base URL; also the token issuer. */
|
|
14
|
+
issuer: string;
|
|
15
|
+
port: number;
|
|
16
|
+
host?: string;
|
|
17
|
+
dataDir: string;
|
|
18
|
+
policyFile: string;
|
|
19
|
+
devIdp?: boolean;
|
|
20
|
+
tokenTtlSeconds?: number;
|
|
21
|
+
maxTokenTtlSeconds?: number;
|
|
22
|
+
trustedIssuers?: TrustedIssuer[];
|
|
23
|
+
apiKeys: ApiKeyEntry[];
|
|
24
|
+
approvals?: {
|
|
25
|
+
defaultTimeout?: string;
|
|
26
|
+
log?: boolean;
|
|
27
|
+
slack?: {
|
|
28
|
+
webhookUrl: string;
|
|
29
|
+
signingSecret?: string;
|
|
30
|
+
};
|
|
31
|
+
webhooks?: Array<{
|
|
32
|
+
name?: string;
|
|
33
|
+
url: string;
|
|
34
|
+
secret: string;
|
|
35
|
+
}>;
|
|
36
|
+
};
|
|
37
|
+
ledger?: {
|
|
38
|
+
retentionDays?: number;
|
|
39
|
+
otlp?: {
|
|
40
|
+
endpoint: string;
|
|
41
|
+
headers?: Record<string, string>;
|
|
42
|
+
};
|
|
43
|
+
redaction?: RedactionOptions;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export declare function hashApiKey(key: string): string;
|
|
47
|
+
/** Loads a config file, expands ${env:VAR}, resolves relative paths against the file's directory. */
|
|
48
|
+
export declare function loadConfig(path: string): ControlPlaneConfig;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
2
|
+
import { interpolateEnv, readJson, sha256 } from "../util.js";
|
|
3
|
+
export const ROLES = ["admin", "approver", "auditor", "service"];
|
|
4
|
+
export function hashApiKey(key) {
|
|
5
|
+
return "sha256:" + sha256(key);
|
|
6
|
+
}
|
|
7
|
+
/** Loads a config file, expands ${env:VAR}, resolves relative paths against the file's directory. */
|
|
8
|
+
export function loadConfig(path) {
|
|
9
|
+
const raw = interpolateEnv(readJson(path));
|
|
10
|
+
const base = dirname(resolve(path));
|
|
11
|
+
const abs = (p) => (isAbsolute(p) ? p : resolve(base, p));
|
|
12
|
+
const cfg = {
|
|
13
|
+
host: "127.0.0.1",
|
|
14
|
+
tokenTtlSeconds: 300,
|
|
15
|
+
maxTokenTtlSeconds: 900,
|
|
16
|
+
trustedIssuers: [],
|
|
17
|
+
...raw,
|
|
18
|
+
dataDir: abs(raw.dataDir ?? ".mandat"),
|
|
19
|
+
policyFile: abs(raw.policyFile ?? "mandat.policy.json"),
|
|
20
|
+
};
|
|
21
|
+
if (!cfg.issuer)
|
|
22
|
+
throw new Error("config: `issuer` is required");
|
|
23
|
+
if (!cfg.apiKeys?.length)
|
|
24
|
+
throw new Error("config: at least one entry in `apiKeys` is required");
|
|
25
|
+
for (const k of cfg.apiKeys) {
|
|
26
|
+
if (!k.hash?.startsWith("sha256:"))
|
|
27
|
+
throw new Error(`config: apiKeys[${k.id}].hash must be "sha256:<hex>"`);
|
|
28
|
+
for (const r of k.roles)
|
|
29
|
+
if (!ROLES.includes(r))
|
|
30
|
+
throw new Error(`config: apiKeys[${k.id}] unknown role '${r}'`);
|
|
31
|
+
}
|
|
32
|
+
return cfg;
|
|
33
|
+
}
|