@suveren/gateway 0.16.1 → 0.18.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/content/guides/1-interview.md +31 -0
- package/content/guides/2-package.md +30 -0
- package/content/guides/3-mandates.md +34 -0
- package/content/guides/4-brief.md +30 -0
- package/content/guides/5-agent-process.md +39 -0
- package/content/guides/6-risks.md +14 -0
- package/dist/control-plane/index.mjs +272 -17
- package/dist/mcp-server/http.mjs +250 -23
- package/dist/ui/assets/index-BKgX8_Zh.js +111 -0
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/server.js +3 -0
- package/dist/ui/assets/index-tdGbzBko.js +0 -110
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Interview
|
|
2
|
+
|
|
3
|
+
Learn the company, the team and the work, and collect real cases. Nothing is loaded or proposed in this step.
|
|
4
|
+
|
|
5
|
+
You ask; the person answers and checks. Ask one topic at a time and write down what you learn.
|
|
6
|
+
|
|
7
|
+
## What to ask, in this order
|
|
8
|
+
|
|
9
|
+
1. **Company:** what it sells, to whom, in which currency, and which systems it uses. The systems connected to this gateway are listed in the header above; ask about the ones that matter for the work.
|
|
10
|
+
2. **Department and team:** who does the work today, what a normal day looks like, what must never happen.
|
|
11
|
+
3. **The work to test:** which requests come in (for example quote requests, orders, questions), how many, how fast they are answered today, and what a good answer looks like.
|
|
12
|
+
4. **Order of systems:** whether the team follows a fixed sequence across systems — for example: always look up the customer in the CRM before quoting in the ERP, and log the reply in the CRM after sending. Which step must come first, which may be skipped. This becomes the agent's process and a rule in its brief.
|
|
13
|
+
5. **Real cases:** 5–10 recent examples, each with the request as it arrived and the reply that was actually sent. The person pastes or forwards them.
|
|
14
|
+
6. **Rules that are not written down:** discounts, credit limits, who may promise delivery dates, when to escalate and to whom.
|
|
15
|
+
|
|
16
|
+
## Renaming
|
|
17
|
+
|
|
18
|
+
Before anything else happens, propose new names for every person, company and email domain in the cases. Keep amounts, products and dates realistic. Use `.example` domains (for example `einkauf@huber.example`). The person checks the renamed cases; change them until they agree.
|
|
19
|
+
|
|
20
|
+
## Result of this step
|
|
21
|
+
|
|
22
|
+
- A short written summary: company, team, the work, the order of systems, the rules.
|
|
23
|
+
- The renamed cases.
|
|
24
|
+
|
|
25
|
+
Both confirmed by the person. The summary feeds the agent brief and the mandates; the cases feed the test data.
|
|
26
|
+
|
|
27
|
+
## Ask, don't guess
|
|
28
|
+
|
|
29
|
+
If a rule or a step is unclear, ask the person. Never invent a rule, a limit or a step to fill a gap.
|
|
30
|
+
|
|
31
|
+
Next: **package**.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Package
|
|
2
|
+
|
|
3
|
+
Turn the confirmed cases into test data and load it into the simulated systems.
|
|
4
|
+
|
|
5
|
+
## Build the package
|
|
6
|
+
|
|
7
|
+
One package feeds every simulated system. Its format, and how to build a good one, is described in the `load_simulation` tool of each simulated system — read it there; it is the authority. In short: the company's `name` and `currency`, the `customers`, `products` and `contacts` the cases need, and the `cases` themselves (each request as it arrived and the reply that was actually sent).
|
|
8
|
+
|
|
9
|
+
- Include every customer, product and contact a case mentions, with stock, prices and credit limits that make each case answerable the way it really was.
|
|
10
|
+
- Use only the renamed cases.
|
|
11
|
+
- Show the person the package before loading it; load only after they agree.
|
|
12
|
+
|
|
13
|
+
## Load
|
|
14
|
+
|
|
15
|
+
- Loading needs a setup mandate for each system (see **mandates**). The working AI's mandates must never allow loading.
|
|
16
|
+
- Call `load_simulation` in each simulated system with the same package.
|
|
17
|
+
- A load only works into an empty system. Incoming requests are dated within the hour before the load, in case order.
|
|
18
|
+
|
|
19
|
+
## Run again
|
|
20
|
+
|
|
21
|
+
To run the same cases again under a different setup, or other cases under the same setup:
|
|
22
|
+
|
|
23
|
+
1. `clear_simulation` in each system — this deletes all test data, including the record of what happened.
|
|
24
|
+
2. `load_simulation` again.
|
|
25
|
+
|
|
26
|
+
Clear and load each count against the setup mandate's daily limit, so one re-run costs two per system. If the limit is too small, propose a new setup mandate with a higher limit.
|
|
27
|
+
|
|
28
|
+
The real replies stay inside the simulated email system. They are never shown to the working AI; they exist for comparing afterwards.
|
|
29
|
+
|
|
30
|
+
Next: **mandates**.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Mandates
|
|
2
|
+
|
|
3
|
+
Propose the mandates the setup and the working AI need. You propose with `create_mandate`; the person approves each one on its own; only then is it created.
|
|
4
|
+
|
|
5
|
+
## Ask first
|
|
6
|
+
|
|
7
|
+
Before proposing any mandate, ask the person which steps they want to approve themselves. Their answer wins over everything below.
|
|
8
|
+
|
|
9
|
+
## The standard (when the person has no preference)
|
|
10
|
+
|
|
11
|
+
- **Steps the customer would see** — replies, sent quotes, orders — start in **review** mode: each one waits for the person's approval.
|
|
12
|
+
- **Internal steps** — looking up and noting things in the CRM, drafting quotes, loading test data — run **automatically** within limits.
|
|
13
|
+
|
|
14
|
+
Apply this to whatever systems are connected: the header above lists each system with its action types and tools. Judge from the action type and its tools whether the customer would see the result. When unsure, ask.
|
|
15
|
+
|
|
16
|
+
## Rules
|
|
17
|
+
|
|
18
|
+
- **One mandate per system.** Split a system into several mandates only when the team has different rules for different tasks. A mode applies to a whole mandate, so different modes for different steps of one system mean separate mandates (for example: drafting quotes automatically, sending quotes in review).
|
|
19
|
+
- **Setup and work are separate.** Setup mandates allow loading and clearing test data and nothing else. The working AI's mandates allow no loading or clearing (setup limit 0).
|
|
20
|
+
- **Start strict.** Where the team has not said otherwise, propose review mode or low caps. The person can loosen a mandate later by replacing it.
|
|
21
|
+
- **Limits come from the interview:** amounts, discounts, daily counts, allowed recipients. Do not invent them; ask.
|
|
22
|
+
- **Intent:** write why, goal and watch-outs in the person's words, in the language the team uses. It is signed with the mandate and the working AI follows it.
|
|
23
|
+
- **Teams:** in a team, the person must be an approver for the profile. `create_mandate` refuses at once if not; tell the person who can fix it (a team admin).
|
|
24
|
+
- **Reporting:** never propose a `reporting` mandate for the working AI during a run (see **risks**).
|
|
25
|
+
|
|
26
|
+
## Example (only an example)
|
|
27
|
+
|
|
28
|
+
- Setup, per system: automatic, setup limit 4–6 per day, everything else 0.
|
|
29
|
+
- CRM: automatic, with daily limits.
|
|
30
|
+
- Sales, drafting quotes: automatic, within value and discount caps.
|
|
31
|
+
- Sales, sending quotes and orders: review.
|
|
32
|
+
- Email replies: review, recipients limited to the customers' domains.
|
|
33
|
+
|
|
34
|
+
Next: **brief**.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Brief
|
|
2
|
+
|
|
3
|
+
Propose the agent brief — the standing instructions the working AI starts every session with. You propose with `set_agent_brief`; the person approves; it applies from the working AI's next session.
|
|
4
|
+
|
|
5
|
+
## What goes in
|
|
6
|
+
|
|
7
|
+
From the interview:
|
|
8
|
+
|
|
9
|
+
- **Who the AI works for:** the company, the team, the person.
|
|
10
|
+
- **The job:** which requests it handles and what a good outcome is.
|
|
11
|
+
- **The process:** the steps in order, including the order of systems from the interview, written as a rule (for example: "Always look up the customer in the CRM before preparing a quote.").
|
|
12
|
+
- **House rules:** tone, language, discounts, credit, what to escalate and to whom.
|
|
13
|
+
- **Hard lines:** what it must never do, even where a mandate would allow it.
|
|
14
|
+
- **When unsure, ask:** a missing rule or an unclear step goes to the person, never to a guess.
|
|
15
|
+
|
|
16
|
+
Write the brief in the language the team uses.
|
|
17
|
+
|
|
18
|
+
## What stays out
|
|
19
|
+
|
|
20
|
+
- Anything that reveals a test: no "simulation", "test case", "expected reply" or similar words.
|
|
21
|
+
- The real replies from the cases.
|
|
22
|
+
- Secrets, passwords, keys.
|
|
23
|
+
|
|
24
|
+
## Limits
|
|
25
|
+
|
|
26
|
+
- At most 16 KB.
|
|
27
|
+
- It replaces the whole brief each time — always write the complete brief, not a change.
|
|
28
|
+
- The person approves every version.
|
|
29
|
+
|
|
30
|
+
Next: **agent-process**.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Agent process
|
|
2
|
+
|
|
3
|
+
Help the person set up the working AI's process in its own client. The process is the working AI's work, not the gateway's: the gateway only grants and checks what it may do.
|
|
4
|
+
|
|
5
|
+
## Write the process as steps
|
|
6
|
+
|
|
7
|
+
From the interview, for example:
|
|
8
|
+
|
|
9
|
+
1. Read new messages in the inbox.
|
|
10
|
+
2. Look up the customer in the CRM.
|
|
11
|
+
3. Check stock and price in the ERP.
|
|
12
|
+
4. Prepare the quote.
|
|
13
|
+
5. Reply to the customer.
|
|
14
|
+
|
|
15
|
+
Keep a fixed order of systems from the interview exactly as given; it is also a rule in the brief. The person confirms the steps.
|
|
16
|
+
|
|
17
|
+
## Where it lives
|
|
18
|
+
|
|
19
|
+
- In the working AI's client, as its standing instructions for this work, connected to this gateway.
|
|
20
|
+
- In a separate conversation or project from this setup conversation.
|
|
21
|
+
|
|
22
|
+
## What starts a run
|
|
23
|
+
|
|
24
|
+
- The person asks (for example: "Work the new requests.").
|
|
25
|
+
- Or a scheduled or triggered run, if the client offers one. Check what the person's client offers today; do not assume.
|
|
26
|
+
|
|
27
|
+
## When a step is unclear
|
|
28
|
+
|
|
29
|
+
The working AI stops and asks the person. It never fills a gap by guessing.
|
|
30
|
+
|
|
31
|
+
## Claude Desktop
|
|
32
|
+
|
|
33
|
+
Help the person connect the client to this gateway (the gateway's MCP address) and put the steps into the client's standing instructions for this work. Check the client's current settings with the person; menus change between versions.
|
|
34
|
+
|
|
35
|
+
## ChatGPT
|
|
36
|
+
|
|
37
|
+
Same as above: connect the gateway if the person's plan and client support custom MCP connectors, and add the steps as instructions. If connecting is not possible, say so — the working AI cannot be tested without the gateway.
|
|
38
|
+
|
|
39
|
+
Next: **risks**, then start a run.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Risks
|
|
2
|
+
|
|
3
|
+
What can make a test say more than it should, and what to do about each. Read this once at the start and again before every run.
|
|
4
|
+
|
|
5
|
+
| Risk | Why it matters | What to do |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| The client remembers earlier runs | A re-run of the same cases is no longer a fresh test | Start every run in a new conversation; turn the client's memory off for the working AI |
|
|
8
|
+
| The working AI learns it is a test | It may behave differently | Never give it a `reporting` mandate during a run — its tools list the cases. Grant `reporting` only after the run, or to a separate AI session that only writes the report. Keep test words out of the brief |
|
|
9
|
+
| Setup and work in the same session | Setup tools and the real replies stay in the working AI's context | Use separate conversations for setup and for work |
|
|
10
|
+
| Mandates too loose | A wrong result can look like success | Start in review mode or with low caps; loosen by replacing the mandate |
|
|
11
|
+
| Proposals are stored readable on the Authority Server | The content of every proposal, including intents, can be read there | Test data and renamed cases only — never real customer data in a setup |
|
|
12
|
+
| Setup limit too low | A clear without the following load leaves empty systems | Use a setup limit of 4–6 per day; clear and load in one go |
|
|
13
|
+
|
|
14
|
+
Next: start a run — the person asks the working AI to work the new requests.
|
|
@@ -40,6 +40,8 @@ var Vault = class {
|
|
|
40
40
|
* disk, gone the moment the process restarts or the vault locks.
|
|
41
41
|
*/
|
|
42
42
|
spSessionExpiresAt = null;
|
|
43
|
+
/** Who the current AS session belongs to (from the login response). Never persisted. */
|
|
44
|
+
sessionUser = null;
|
|
43
45
|
/** Set by lockExpired(); cleared by any fresh unseal (a new login). */
|
|
44
46
|
lockedReason = null;
|
|
45
47
|
constructor(dataDir) {
|
|
@@ -97,6 +99,7 @@ var Vault = class {
|
|
|
97
99
|
this.spSessionCookie = null;
|
|
98
100
|
this.unsealedBy = null;
|
|
99
101
|
this.spSessionExpiresAt = null;
|
|
102
|
+
this.sessionUser = null;
|
|
100
103
|
this.lockedReason = null;
|
|
101
104
|
}
|
|
102
105
|
/**
|
|
@@ -149,6 +152,13 @@ var Vault = class {
|
|
|
149
152
|
getSessionExpiresAt() {
|
|
150
153
|
return this.spSessionExpiresAt;
|
|
151
154
|
}
|
|
155
|
+
/** The signed-in person, set at login from the AS session response; cleared with the session. */
|
|
156
|
+
setSessionUser(user) {
|
|
157
|
+
this.sessionUser = user;
|
|
158
|
+
}
|
|
159
|
+
getSessionUser() {
|
|
160
|
+
return this.sessionUser;
|
|
161
|
+
}
|
|
152
162
|
/**
|
|
153
163
|
* Check if the vault was created by a different API key.
|
|
154
164
|
* Tries to decrypt the first credential — if it fails, keys don't match.
|
|
@@ -414,6 +424,11 @@ async function runCommittedProposals() {
|
|
|
414
424
|
headers: internalHeaders()
|
|
415
425
|
});
|
|
416
426
|
}
|
|
427
|
+
async function getToolDisplay() {
|
|
428
|
+
const res = await fetch(`${MCP_BASE}/internal/tool-display`, { headers: internalHeaders() });
|
|
429
|
+
if (!res.ok) throw new Error(`MCP tool-display failed: ${res.status}`);
|
|
430
|
+
return res.json();
|
|
431
|
+
}
|
|
417
432
|
async function getIntegrations() {
|
|
418
433
|
const res = await fetch(`${MCP_BASE}/internal/integrations`, {
|
|
419
434
|
headers: internalHeaders()
|
|
@@ -1726,6 +1741,10 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
|
|
|
1726
1741
|
}
|
|
1727
1742
|
const sessionExpiresAt = typeof data.sessionExpiresAt === "number" ? data.sessionExpiresAt : null;
|
|
1728
1743
|
vault2.setSessionExpiresAt(sessionExpiresAt);
|
|
1744
|
+
const sessionUser = data.user;
|
|
1745
|
+
vault2.setSessionUser(
|
|
1746
|
+
typeof sessionUser?.id === "string" && typeof sessionUser?.did === "string" ? { id: sessionUser.id, did: sessionUser.did } : null
|
|
1747
|
+
);
|
|
1729
1748
|
onSessionEstablished?.();
|
|
1730
1749
|
if (sessionCookie) {
|
|
1731
1750
|
try {
|
|
@@ -3087,7 +3106,26 @@ function notify(title, message, platform = process.platform, url) {
|
|
|
3087
3106
|
|
|
3088
3107
|
// src/routes/encrypt-intent.ts
|
|
3089
3108
|
import { Router as Router7 } from "express";
|
|
3109
|
+
|
|
3110
|
+
// src/lib/intent-encryption.ts
|
|
3090
3111
|
import { computeIntentDisclosureHash } from "@hap/core";
|
|
3112
|
+
async function encryptIntentForRecipients(intent, recipients) {
|
|
3113
|
+
const parsed = recipients.map(({ userId, publicKey }) => {
|
|
3114
|
+
const bytes = new Uint8Array(Buffer.from(publicKey, "base64"));
|
|
3115
|
+
if (bytes.length !== 32) throw new Error(`publicKey for userId "${userId}" must be 32 bytes (X25519); got ${bytes.length}`);
|
|
3116
|
+
return { userId, publicKey: bytes };
|
|
3117
|
+
});
|
|
3118
|
+
const encrypted = await encryptForRecipients(intent, parsed);
|
|
3119
|
+
const encryptedKeys = {};
|
|
3120
|
+
for (const [userId, wrap] of Object.entries(encrypted.encryptedKeys)) {
|
|
3121
|
+
encryptedKeys[userId] = { ct: Buffer.from(wrap.ct).toString("base64"), enc: Buffer.from(wrap.enc).toString("base64") };
|
|
3122
|
+
}
|
|
3123
|
+
const intentCiphertext = Buffer.from(encrypted.intentCiphertext).toString("base64");
|
|
3124
|
+
const approversFrozen = parsed.map((r) => r.userId);
|
|
3125
|
+
return { intentCiphertext, encryptedKeys, approversFrozen, intentDisclosureHash: computeIntentDisclosureHash(intentCiphertext, approversFrozen) };
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// src/routes/encrypt-intent.ts
|
|
3091
3129
|
function createEncryptIntentRouter() {
|
|
3092
3130
|
const router = Router7();
|
|
3093
3131
|
router.post("/", async (req, res) => {
|
|
@@ -3125,23 +3163,10 @@ function createEncryptIntentRouter() {
|
|
|
3125
3163
|
parsed.push({ userId, publicKey: pubBytes });
|
|
3126
3164
|
}
|
|
3127
3165
|
try {
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
ct: Buffer.from(wrap.ct).toString("base64"),
|
|
3133
|
-
enc: Buffer.from(wrap.enc).toString("base64")
|
|
3134
|
-
};
|
|
3135
|
-
}
|
|
3136
|
-
const intentCiphertextB64 = Buffer.from(encrypted.intentCiphertext).toString("base64");
|
|
3137
|
-
const approversFrozen = parsed.map((r) => r.userId);
|
|
3138
|
-
const intentDisclosureHash = computeIntentDisclosureHash(intentCiphertextB64, approversFrozen);
|
|
3139
|
-
res.json({
|
|
3140
|
-
intentCiphertext: intentCiphertextB64,
|
|
3141
|
-
encryptedKeys,
|
|
3142
|
-
approversFrozen,
|
|
3143
|
-
intentDisclosureHash
|
|
3144
|
-
});
|
|
3166
|
+
res.json(await encryptIntentForRecipients(
|
|
3167
|
+
intent,
|
|
3168
|
+
parsed.map((r) => ({ userId: r.userId, publicKey: Buffer.from(r.publicKey).toString("base64") }))
|
|
3169
|
+
));
|
|
3145
3170
|
} catch (err) {
|
|
3146
3171
|
console.error("[Control Plane] encrypt-intent error:", err);
|
|
3147
3172
|
res.status(500).json({ error: err instanceof Error ? err.message : "Encryption failed" });
|
|
@@ -3757,6 +3782,201 @@ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch
|
|
|
3757
3782
|
return router;
|
|
3758
3783
|
}
|
|
3759
3784
|
|
|
3785
|
+
// src/routes/internal-mandate.ts
|
|
3786
|
+
import { Router as Router15 } from "express";
|
|
3787
|
+
import { timingSafeEqual as timingSafeEqual3, randomUUID } from "crypto";
|
|
3788
|
+
|
|
3789
|
+
// src/lib/mandate-ceremony.ts
|
|
3790
|
+
import {
|
|
3791
|
+
allowedCommitmentModes,
|
|
3792
|
+
computeBoundsHash,
|
|
3793
|
+
computeContextHash,
|
|
3794
|
+
computeIntentHash,
|
|
3795
|
+
isCommitmentModeAllowed,
|
|
3796
|
+
validateBoundsParams,
|
|
3797
|
+
validateContextParams
|
|
3798
|
+
} from "@hap/core";
|
|
3799
|
+
var MandateRefused = class extends Error {
|
|
3800
|
+
};
|
|
3801
|
+
var INTENT_MAX = 2e3;
|
|
3802
|
+
var shortOf = (id) => id.replace(/@.*$/, "").split("/").pop() ?? id;
|
|
3803
|
+
var versionOf = (id) => id.split("@")[1] ?? "";
|
|
3804
|
+
function refuse(msg) {
|
|
3805
|
+
throw new MandateRefused(msg);
|
|
3806
|
+
}
|
|
3807
|
+
async function resolveProfile(as, requested) {
|
|
3808
|
+
let id = requested.trim();
|
|
3809
|
+
if (!id.includes("@")) {
|
|
3810
|
+
const list = await as("GET", "/api/profiles");
|
|
3811
|
+
const matches = (list.body?.profiles ?? []).filter((p) => shortOf(p.id) === id);
|
|
3812
|
+
if (matches.length === 0) refuse(`Unknown profile "${requested}".`);
|
|
3813
|
+
matches.sort((a, b) => versionOf(b.id).localeCompare(versionOf(a.id), void 0, { numeric: true }));
|
|
3814
|
+
id = matches[0].id;
|
|
3815
|
+
}
|
|
3816
|
+
const res = await as("GET", `/api/profiles/${encodeURIComponent(id)}`);
|
|
3817
|
+
if (res.status !== 200 || !res.body?.id) refuse(`Unknown profile "${requested}".`);
|
|
3818
|
+
return res.body;
|
|
3819
|
+
}
|
|
3820
|
+
function scalarRecord(label, value) {
|
|
3821
|
+
if (value === void 0 || value === null) return {};
|
|
3822
|
+
if (typeof value !== "object" || Array.isArray(value)) refuse(`\`${label}\` must be an object.`);
|
|
3823
|
+
const out = {};
|
|
3824
|
+
for (const [k, v] of Object.entries(value)) {
|
|
3825
|
+
if (typeof v !== "string" && typeof v !== "number") refuse(`\`${label}.${k}\` must be a string or a number.`);
|
|
3826
|
+
out[k] = v;
|
|
3827
|
+
}
|
|
3828
|
+
return out;
|
|
3829
|
+
}
|
|
3830
|
+
function approverPubkeys(body) {
|
|
3831
|
+
const map = body?.pubkeys;
|
|
3832
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return [];
|
|
3833
|
+
return Object.entries(map).filter((e) => typeof e[1] === "string" && e[1].length > 0).map(([userId, publicKey]) => ({ userId, publicKey }));
|
|
3834
|
+
}
|
|
3835
|
+
async function planMandate(req, deps) {
|
|
3836
|
+
const user = deps.user;
|
|
3837
|
+
if (!user) refuse("The gateway is not signed in.");
|
|
3838
|
+
const groupsRes = await deps.as("GET", "/api/groups");
|
|
3839
|
+
const groups = groupsRes.body?.groups ?? [];
|
|
3840
|
+
const group = req.team ? groups.find((g) => !g.isPersonal && (g.id === req.team || g.name === req.team)) : groups.find((g) => g.isPersonal);
|
|
3841
|
+
if (!group) refuse(req.team ? `You are not a member of a team "${req.team}".` : "No personal workspace found.");
|
|
3842
|
+
const isPersonal = !!group.isPersonal;
|
|
3843
|
+
const profile = await resolveProfile(deps.as, req.profile);
|
|
3844
|
+
const name = profile.name ?? shortOf(profile.id);
|
|
3845
|
+
let approvers = [];
|
|
3846
|
+
if (!isPersonal) {
|
|
3847
|
+
const cfg = await deps.as("GET", `/api/groups/${encodeURIComponent(group.id)}/profile-config/${encodeURIComponent(profile.id)}`);
|
|
3848
|
+
approvers = (cfg.status === 200 ? cfg.body?.config?.approvers : void 0) ?? [];
|
|
3849
|
+
if (approvers.length === 0) refuse(`${name} is not enabled in "${group.name}": a team admin must name who may give ${name} mandates.`);
|
|
3850
|
+
if (!approvers.includes(user.id)) refuse(`Only ${name}'s approvers in "${group.name}" can give this mandate, and the signed-in person is not one of them.`);
|
|
3851
|
+
}
|
|
3852
|
+
const bounds = { ...scalarRecord("limits", req.limits), profile: profile.id };
|
|
3853
|
+
const bv = validateBoundsParams(bounds, profile);
|
|
3854
|
+
const errors = [...bv.errors];
|
|
3855
|
+
for (const [field, value] of Object.entries(bounds)) {
|
|
3856
|
+
const bt = profile.boundsSchema?.fields[field]?.boundType;
|
|
3857
|
+
if (bt?.kind === "enum" && Array.isArray(bt.values) && !bt.values.includes(String(value))) {
|
|
3858
|
+
errors.push(`Field "${field}" must be one of ${bt.values.join(", ")}.`);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
const context = scalarRecord("scope", req.scope);
|
|
3862
|
+
errors.push(...validateContextParams(context, profile).errors);
|
|
3863
|
+
if (errors.length) refuse(`The mandate does not fit profile ${profile.id}: ${errors.join(" ")}`);
|
|
3864
|
+
if (!isCommitmentModeAllowed(profile, req.mode)) {
|
|
3865
|
+
refuse(`Profile ${profile.id} allows mode ${allowedCommitmentModes(profile).join(" or ") || "none"}, not "${req.mode}".`);
|
|
3866
|
+
}
|
|
3867
|
+
const ttlSeconds = req.durationHours === void 0 ? profile.ttl.default : Math.round(req.durationHours * 3600);
|
|
3868
|
+
if (!(ttlSeconds > 0)) refuse("`duration_hours` must be positive.");
|
|
3869
|
+
if (ttlSeconds > profile.ttl.max) refuse(`At most ${profile.ttl.max / 3600} hours for profile ${profile.id}.`);
|
|
3870
|
+
const intent = typeof req.intent === "string" ? req.intent.trim() : "";
|
|
3871
|
+
if (!intent) refuse("`intent` is required \u2014 the why, goal and watch-outs, in the person's words.");
|
|
3872
|
+
if (intent.length > INTENT_MAX) refuse(`\`intent\` is ${intent.length} characters; the limit is ${INTENT_MAX}.`);
|
|
3873
|
+
return {
|
|
3874
|
+
groupId: group.id,
|
|
3875
|
+
groupName: group.name,
|
|
3876
|
+
isPersonal,
|
|
3877
|
+
domain: isPersonal ? "owner" : user.id,
|
|
3878
|
+
profile,
|
|
3879
|
+
bounds,
|
|
3880
|
+
context,
|
|
3881
|
+
intent,
|
|
3882
|
+
mode: req.mode,
|
|
3883
|
+
ttlSeconds,
|
|
3884
|
+
title: (req.title ?? "").trim() || `${name} (proposed by AI)`,
|
|
3885
|
+
approvers
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3888
|
+
async function createMandate(req, deps) {
|
|
3889
|
+
const plan = await planMandate(req, deps);
|
|
3890
|
+
const user = deps.user;
|
|
3891
|
+
const boundsHash = computeBoundsHash(plan.bounds, plan.profile);
|
|
3892
|
+
const contextHash = computeContextHash(plan.context, plan.profile);
|
|
3893
|
+
const intentHash = computeIntentHash(plan.intent);
|
|
3894
|
+
const ecHash = computeIntentHash(JSON.stringify({ profile: plan.profile.id, domain: plan.domain, group: plan.groupId }));
|
|
3895
|
+
let enc;
|
|
3896
|
+
if (!plan.isPersonal) {
|
|
3897
|
+
const keys = await deps.as(
|
|
3898
|
+
"GET",
|
|
3899
|
+
`/api/groups/${encodeURIComponent(plan.groupId)}/profile-config/${encodeURIComponent(plan.profile.id)}/approvers/pubkeys`
|
|
3900
|
+
);
|
|
3901
|
+
const recipients = approverPubkeys(keys.body);
|
|
3902
|
+
if (recipients.length > 0) enc = await deps.encrypt(plan.intent, recipients);
|
|
3903
|
+
}
|
|
3904
|
+
const authorizationId = deps.newAuthorizationId();
|
|
3905
|
+
const res = await deps.as("POST", "/api/as/attest", {
|
|
3906
|
+
authorization_id: authorizationId,
|
|
3907
|
+
profile_id: plan.profile.id,
|
|
3908
|
+
bounds: plan.bounds,
|
|
3909
|
+
bounds_hash: boundsHash,
|
|
3910
|
+
context_hash: contextHash,
|
|
3911
|
+
domain: plan.domain,
|
|
3912
|
+
did: user.did,
|
|
3913
|
+
gate_content_hashes: { intent: intentHash },
|
|
3914
|
+
execution_context_hash: ecHash,
|
|
3915
|
+
group_id: plan.groupId,
|
|
3916
|
+
ttl: plan.ttlSeconds,
|
|
3917
|
+
commitment_mode: plan.mode,
|
|
3918
|
+
title: plan.title,
|
|
3919
|
+
intent_ciphertext: enc?.intentCiphertext,
|
|
3920
|
+
encrypted_keys: enc?.encryptedKeys,
|
|
3921
|
+
approvers_frozen: enc?.approversFrozen,
|
|
3922
|
+
intent_disclosure_hash: enc?.intentDisclosureHash
|
|
3923
|
+
});
|
|
3924
|
+
if (res.status >= 300) {
|
|
3925
|
+
const why = res.body?.message ?? res.body?.detail ?? res.body?.error ?? `status ${res.status}`;
|
|
3926
|
+
refuse(`The Authority Server refused the mandate: ${why}`);
|
|
3927
|
+
}
|
|
3928
|
+
try {
|
|
3929
|
+
await deps.deliverGateContent({ authorizationId, boundsHash, contextHash, context: plan.context, gateContent: { intent: plan.intent } });
|
|
3930
|
+
} catch (err) {
|
|
3931
|
+
await deps.as("POST", `/api/authorizations/${encodeURIComponent(authorizationId)}/revoke`, {
|
|
3932
|
+
reason: "Auto-revoked: gate content delivery failed"
|
|
3933
|
+
}).catch(() => {
|
|
3934
|
+
});
|
|
3935
|
+
refuse(`The mandate was signed but its intent could not be delivered to the gateway, so it was revoked. (${err instanceof Error ? err.message : String(err)})`);
|
|
3936
|
+
}
|
|
3937
|
+
return { authorizationId, plan };
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
// src/routes/internal-mandate.ts
|
|
3941
|
+
function secretMatches2(provided, expected) {
|
|
3942
|
+
if (!expected || !provided) return false;
|
|
3943
|
+
const a = Buffer.from(provided);
|
|
3944
|
+
const b = Buffer.from(expected);
|
|
3945
|
+
return a.length === b.length && timingSafeEqual3(a, b);
|
|
3946
|
+
}
|
|
3947
|
+
function createInternalMandateRouter(getSecret, deps) {
|
|
3948
|
+
const router = Router15();
|
|
3949
|
+
router.post("/mandate", async (req, res) => {
|
|
3950
|
+
if (!secretMatches2(req.header("X-Internal-Secret") ?? void 0, getSecret())) {
|
|
3951
|
+
res.status(401).json({ error: "unauthorized" });
|
|
3952
|
+
return;
|
|
3953
|
+
}
|
|
3954
|
+
const { dryRun, request } = req.body ?? {};
|
|
3955
|
+
if (!request || typeof request !== "object") {
|
|
3956
|
+
res.status(400).json({ error: "missing request" });
|
|
3957
|
+
return;
|
|
3958
|
+
}
|
|
3959
|
+
const d = { ...deps(), newAuthorizationId: () => `authz_${randomUUID()}` };
|
|
3960
|
+
try {
|
|
3961
|
+
if (dryRun === true) {
|
|
3962
|
+
const plan2 = await planMandate(request, d);
|
|
3963
|
+
res.json({ ok: true, groupName: plan2.groupName, profileId: plan2.profile.id, mode: plan2.mode, ttlSeconds: plan2.ttlSeconds });
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
const { authorizationId, plan } = await createMandate(request, d);
|
|
3967
|
+
res.json({ ok: true, authorizationId, profileId: plan.profile.id, groupName: plan.groupName, mode: plan.mode, ttlSeconds: plan.ttlSeconds });
|
|
3968
|
+
} catch (err) {
|
|
3969
|
+
if (err instanceof MandateRefused) {
|
|
3970
|
+
res.status(422).json({ error: "mandate_refused", message: err.message });
|
|
3971
|
+
return;
|
|
3972
|
+
}
|
|
3973
|
+
console.error("[Control Plane] /internal/mandate failed:", err);
|
|
3974
|
+
res.status(500).json({ error: "internal", message: err instanceof Error ? err.message : String(err) });
|
|
3975
|
+
}
|
|
3976
|
+
});
|
|
3977
|
+
return router;
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3760
3980
|
// src/lib/notification-dispatcher.ts
|
|
3761
3981
|
var TRIGGER_EVENTS = ["proposal-added", "action-approval-needed"];
|
|
3762
3982
|
var TITLE = "Suveren";
|
|
@@ -4273,6 +4493,33 @@ app.get("/auth/oauth/:integrationId/health", authGuard, async (req, res) => {
|
|
|
4273
4493
|
});
|
|
4274
4494
|
app.get("/events", requireAllowedHost, requireAuthQueryOrHeader(vault), createEventsHandler());
|
|
4275
4495
|
app.use("/internal", jsonParser, createInternalEventsRouter(() => internalSecret2, lockExpiredSession, lockAsKeyMismatch, lockAsTlsMismatch));
|
|
4496
|
+
app.use("/internal", jsonParser, createInternalMandateRouter(() => internalSecret2, () => ({
|
|
4497
|
+
user: vault.getSessionUser(),
|
|
4498
|
+
as: async (method, path, body) => {
|
|
4499
|
+
const cookie = vault.getSpCookie();
|
|
4500
|
+
if (!cookie) return { status: 401, body: { error: "not signed in" } };
|
|
4501
|
+
const pin = readPairing(DATA_DIR);
|
|
4502
|
+
try {
|
|
4503
|
+
const { res } = await fetchAs(
|
|
4504
|
+
`${SP_URL}${path}`,
|
|
4505
|
+
{
|
|
4506
|
+
method,
|
|
4507
|
+
headers: { Cookie: cookie, ...body !== void 0 ? { "Content-Type": "application/json" } : {} },
|
|
4508
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
4509
|
+
signal: AbortSignal.timeout(15e3)
|
|
4510
|
+
},
|
|
4511
|
+
{ enforce: resolvePinTls(DATA_DIR), pinnedSpkiHex: pin && pin.asUrl === SP_URL ? pin.tlsSpkiPinHex : void 0 }
|
|
4512
|
+
);
|
|
4513
|
+
if (res.status === 401 && vault.isUnlocked()) lockExpiredSession();
|
|
4514
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
4515
|
+
} catch (err) {
|
|
4516
|
+
if (err instanceof AsTlsMismatchError) lockAsTlsMismatch();
|
|
4517
|
+
throw err;
|
|
4518
|
+
}
|
|
4519
|
+
},
|
|
4520
|
+
encrypt: encryptIntentForRecipients,
|
|
4521
|
+
deliverGateContent: (args) => pushGateContent(args)
|
|
4522
|
+
})));
|
|
4276
4523
|
app.use("/vault", jsonParser, authGuard, createVaultRouter(vault));
|
|
4277
4524
|
app.use("/ai", jsonParser, authGuard, createAIRouter(vault));
|
|
4278
4525
|
app.use("/ai-prompts", jsonParser, authGuard, createAIPromptsRouter());
|
|
@@ -4466,6 +4713,14 @@ app.post("/gate-content", jsonParser, authGuard, async (req, res) => {
|
|
|
4466
4713
|
res.status(500).json({ error: "Failed to forward gate content to MCP server" });
|
|
4467
4714
|
}
|
|
4468
4715
|
});
|
|
4716
|
+
app.get("/tool-display", authGuard, async (_req, res) => {
|
|
4717
|
+
try {
|
|
4718
|
+
res.json(await getToolDisplay());
|
|
4719
|
+
} catch (err) {
|
|
4720
|
+
console.error("[Control Plane] tool-display error:", err);
|
|
4721
|
+
res.status(502).json({ error: "MCP server unavailable" });
|
|
4722
|
+
}
|
|
4723
|
+
});
|
|
4469
4724
|
app.post("/resync-gates", authGuard, async (_req, res) => {
|
|
4470
4725
|
try {
|
|
4471
4726
|
const data = await resyncGates();
|