trellis 3.2.3 → 3.2.4
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/CHANGELOG.md +10 -0
- package/README.md +4 -0
- package/dist/better-sqlite-backend-CDYOAJDF.js +209 -0
- package/dist/boot-middleware-LBJX4N2S.js +9 -0
- package/dist/boot-middleware-PATBLD52.js +9 -0
- package/dist/bridge-G5DPW6TY.js +2361 -0
- package/dist/bridge-JSSPQPRH.js +2389 -0
- package/dist/bridge-LNXE4HAW.js +2389 -0
- package/dist/bridge-OAAXJWSA.js +2389 -0
- package/dist/bridge-SFS63RHC.js +2367 -0
- package/dist/bridge-U5TOUBVM.js +2389 -0
- package/dist/browser/index.js +12 -12
- package/dist/{chunk-KSU2GW22.js → chunk-276W52FK.js} +8 -0
- package/dist/{chunk-KFQGP6VL.js → chunk-2ESYSVXG.js} +15 -0
- package/dist/{chunk-L5N5PR2S.js → chunk-2WW2X2BR.js} +313 -77
- package/dist/chunk-3XCOAP6G.js +48 -0
- package/dist/chunk-53PDCTJ3.js +1856 -0
- package/dist/{chunk-N6MYZT4O.js → chunk-547FZFKO.js} +1 -1
- package/dist/{chunk-7DUMWO5L.js → chunk-7WVOPXJV.js} +3 -3
- package/dist/chunk-CMSFD6OV.js +11801 -0
- package/dist/{chunk-RSWUFTO2.js → chunk-CPUYCCLT.js} +2 -2
- package/dist/{chunk-4HYPLFJD.js → chunk-FA5TFVA5.js} +1 -1
- package/dist/{chunk-4XIVMVHC.js → chunk-G3XIHPSQ.js} +1 -1
- package/dist/{chunk-JHHEXEDM.js → chunk-GCQF75T4.js} +3 -3
- package/dist/{chunk-FKQO5A3H.js → chunk-GHG4H3AS.js} +2 -2
- package/dist/{chunk-GLM4HGN4.js → chunk-GRWQPKYK.js} +1 -1
- package/dist/{chunk-PZ4XYZ4J.js → chunk-GXA73CNC.js} +33 -8
- package/dist/{chunk-6GEZ6DN5.js → chunk-H6VTC5SS.js} +3 -3
- package/dist/{chunk-S3XLFZ27.js → chunk-IHHSOBJS.js} +1 -1
- package/dist/chunk-IJNC25UD.js +2243 -0
- package/dist/{chunk-VDAKEOML.js → chunk-J6OUIION.js} +1 -1
- package/dist/chunk-JAVAYMMS.js +6 -0
- package/dist/{chunk-S6GBJ2ZB.js → chunk-JCQQFLO5.js} +1 -1
- package/dist/{chunk-FWRS4CSC.js → chunk-KQG6ZYXP.js} +3 -3
- package/dist/chunk-KXAISKZT.js +119 -0
- package/dist/chunk-N3R74ZSR.js +7949 -0
- package/dist/{chunk-GU374KWZ.js → chunk-NLFRRGX6.js} +3 -3
- package/dist/{chunk-XMQ7G77X.js → chunk-OB4CMMX6.js} +1 -1
- package/dist/chunk-OKNASCZY.js +182 -0
- package/dist/chunk-ONOPPX5E.js +394 -0
- package/dist/chunk-QMSU2RAH.js +98 -0
- package/dist/{chunk-TCKXSOBP.js → chunk-RARKFVWU.js} +8 -1
- package/dist/{chunk-2AACZRII.js → chunk-SJWVDS5M.js} +751 -162
- package/dist/{chunk-SPSPV5BC.js → chunk-SZ3VAB5P.js} +159 -2
- package/dist/chunk-V2ASD6RQ.js +147 -0
- package/dist/{chunk-KQ4A2D2P.js → chunk-V4YCJDYL.js} +58 -38
- package/dist/{chunk-LNUIGRDZ.js → chunk-VWKLKLYB.js} +1 -1
- package/dist/chunk-W5PADCDF.js +1856 -0
- package/dist/{chunk-MLC5BUYO.js → chunk-WMERMSWI.js} +1 -1
- package/dist/chunk-WXAURJUD.js +1948 -0
- package/dist/chunk-YC5I32PS.js +147 -0
- package/dist/{chunk-VBYUTRXX.js → chunk-YKVB4LUQ.js} +3 -3
- package/dist/{chunk-NWSW5YNS.js → chunk-YPT6XMS4.js} +20 -50
- package/dist/cli/deploy-cli.d.ts.map +1 -1
- package/dist/cli/deploy-cli.js +43 -4
- package/dist/cli/gateway-deploy-cli.d.ts +19 -0
- package/dist/cli/gateway-deploy-cli.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +1360 -881
- package/dist/cli/lane.d.ts.map +1 -1
- package/dist/cli/protocol.d.ts +6 -0
- package/dist/cli/protocol.d.ts.map +1 -0
- package/dist/client/index.browser.js +2 -2
- package/dist/client/index.js +11 -11
- package/dist/client/sdk.browser.js +1 -1
- package/dist/client/sdk.js +2 -2
- package/dist/cms/index.js +1 -1
- package/dist/config-KMY6RRK3.js +19 -0
- package/dist/core/index.js +13 -13
- package/dist/core/kernel/boot-middleware.d.ts +1 -1
- package/dist/core/kernel/boot-middleware.d.ts.map +1 -1
- package/dist/core/kernel/schema-middleware.d.ts +2 -1
- package/dist/core/kernel/schema-middleware.d.ts.map +1 -1
- package/dist/core/kernel/trellis-kernel.d.ts +10 -10
- package/dist/core/kernel/trellis-kernel.d.ts.map +1 -1
- package/dist/core/persist/factory.js +2 -2
- package/dist/core/persist/sqljs-backend.js +2 -2
- package/dist/db/index.js +30 -25
- package/dist/decisions/index.js +4 -4
- package/dist/embeddings/index.js +2 -2
- package/dist/engine.d.ts +24 -6
- package/dist/engine.d.ts.map +1 -1
- package/dist/gateway-deploy-cli-4TU4V3QI.js +312 -0
- package/dist/gateway-deploy-cli-A4ISTDHJ.js +298 -0
- package/dist/gateway-deploy-cli-D3XTKJQJ.js +312 -0
- package/dist/gateway-deploy-cli-ECV3OQCN.js +305 -0
- package/dist/gateway-deploy-cli-JUHKVIJE.js +296 -0
- package/dist/gateway-deploy-cli-NSDW2JLT.js +310 -0
- package/dist/gateway-serve-CM5EAZ52.js +113 -0
- package/dist/import-AJLYHFIA.js +10 -0
- package/dist/index.js +11 -11
- package/dist/launch-explorer-FVX6ZABW.js +95 -0
- package/dist/links/index.js +2 -2
- package/dist/mcp/bridge.d.ts +45 -0
- package/dist/mcp/bridge.d.ts.map +1 -0
- package/dist/mcp/discovery.d.ts +15 -0
- package/dist/mcp/discovery.d.ts.map +1 -0
- package/dist/mcp/gateway-config.d.ts +20 -0
- package/dist/mcp/gateway-config.d.ts.map +1 -0
- package/dist/mcp/gateway-serve.d.ts +19 -0
- package/dist/mcp/gateway-serve.d.ts.map +1 -0
- package/dist/mcp/gateway-serve.js +119 -0
- package/dist/mcp/graph-summary.d.ts +47 -0
- package/dist/mcp/graph-summary.d.ts.map +1 -0
- package/dist/mcp/room-audit.d.ts +14 -0
- package/dist/mcp/room-audit.d.ts.map +1 -0
- package/dist/mcp/room-helpers.d.ts +43 -0
- package/dist/mcp/room-helpers.d.ts.map +1 -0
- package/dist/mcp/room-registry.d.ts +32 -0
- package/dist/mcp/room-registry.d.ts.map +1 -0
- package/dist/mcp/room.d.ts +28 -0
- package/dist/mcp/room.d.ts.map +1 -0
- package/dist/plugins/agent-memory/index.js +1 -1
- package/dist/plugins/idea-garden/index.js +1 -1
- package/dist/plugins/plan-approval/index.js +1 -1
- package/dist/plugins/proactive-watcher/index.js +1 -1
- package/dist/protocol/envelope.d.ts +30 -0
- package/dist/protocol/envelope.d.ts.map +1 -0
- package/dist/protocol/index.d.ts +3 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/whereami.d.ts +33 -0
- package/dist/protocol/whereami.d.ts.map +1 -0
- package/dist/query-KHDDIR5G.js +31 -0
- package/dist/react/index.js +1 -1
- package/dist/react/realtime.js +1 -1
- package/dist/react/schema-hooks.js +5 -5
- package/dist/realtime/index.js +3 -3
- package/dist/realtime/relay-server.d.ts.map +1 -1
- package/dist/realtime/room.d.ts.map +1 -1
- package/dist/realtime.bundle.js +2 -0
- package/dist/relay-server-BT6DCJ7F.js +12 -0
- package/dist/relay-server-MPQVGNQU.js +12 -0
- package/dist/relay-server-PAPW2EWC.js +12 -0
- package/dist/remote-manager-6ERQUBSL.js +224 -0
- package/dist/remote-manager-G665WWYY.js +224 -0
- package/dist/remote-manager-HJTEZ4OF.js +224 -0
- package/dist/remote-manager-NYP2Y5UR.js +224 -0
- package/dist/remote-manager-WGLCAMMC.js +224 -0
- package/dist/scaffold/write.d.ts.map +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/server/cors.d.ts.map +1 -1
- package/dist/server/deploy-gateway.d.ts +27 -0
- package/dist/server/deploy-gateway.d.ts.map +1 -0
- package/dist/server/deploy.d.ts.map +1 -1
- package/dist/server/deploy.js +3 -2
- package/dist/server/discovery-mcp-gateway.d.ts +13 -0
- package/dist/server/discovery-mcp-gateway.d.ts.map +1 -0
- package/dist/server/index.js +38 -31
- package/dist/server/mcp-gateway.d.ts +27 -0
- package/dist/server/mcp-gateway.d.ts.map +1 -0
- package/dist/server/mcp-oauth-metadata.d.ts +31 -0
- package/dist/server/mcp-oauth-metadata.d.ts.map +1 -0
- package/dist/server/node-adapter.d.ts +7 -0
- package/dist/server/node-adapter.d.ts.map +1 -1
- package/dist/server/node-adapter.js +2 -2
- package/dist/server/realtime.d.ts.map +1 -1
- package/dist/server/server.d.ts +8 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/streamable-mcp-gateway.d.ts +12 -0
- package/dist/server/streamable-mcp-gateway.d.ts.map +1 -0
- package/dist/server/usage-meter.d.ts +11 -0
- package/dist/server/usage-meter.d.ts.map +1 -1
- package/dist/server-4VEZHXXS.js +23 -0
- package/dist/{server-OGVWNDNK.js → server-556BMK4C.js} +1 -1
- package/dist/server-FQT2VYRE.js +23 -0
- package/dist/server-HGZE4ECC.js +23 -0
- package/dist/server-J4JKEAMH.js +23 -0
- package/dist/server-LXPM5TFR.js +23 -0
- package/dist/server-POB4NEUX.js +18 -0
- package/dist/server-VJKAPGPG.js +23 -0
- package/dist/server-YHS3XIIG.js +23 -0
- package/dist/server-ZHTPWPXS.js +23 -0
- package/dist/sprites-NC6D7YZS.js +27 -0
- package/dist/svelte/index.js +3 -3
- package/dist/svelte/schema-hooks.js +4 -4
- package/dist/sync/index.js +4 -4
- package/dist/tenancy-2SZTP4UD.js +16 -0
- package/dist/tenancy-7MTSESMJ.js +16 -0
- package/dist/tenancy-U4E7ZEAG.js +16 -0
- package/dist/vcs/decompose.d.ts.map +1 -1
- package/dist/vcs/index.js +5 -5
- package/dist/vcs/lane-disk-materialize.d.ts +11 -0
- package/dist/vcs/lane-disk-materialize.d.ts.map +1 -0
- package/dist/vcs/lane-worktree.d.ts +21 -0
- package/dist/vcs/lane-worktree.d.ts.map +1 -0
- package/dist/vcs/store.d.ts +28 -0
- package/dist/vcs/store.d.ts.map +1 -0
- package/dist/vcs/types.d.ts +5 -0
- package/dist/vcs/types.d.ts.map +1 -1
- package/dist/vm-config-YZRJ3KUB.js +21 -0
- package/dist/vue/index.js +3 -3
- package/dist/vue/schema-hooks.js +4 -4
- package/package.json +4 -3
- package/dist/chunk-GFZCJ4EH.js +0 -108
|
@@ -0,0 +1,1948 @@
|
|
|
1
|
+
import {
|
|
2
|
+
McpServer,
|
|
3
|
+
StreamableMcpGateway,
|
|
4
|
+
corsEnabledForConfig,
|
|
5
|
+
corsPreflightResponse,
|
|
6
|
+
discoveryMcpGateway,
|
|
7
|
+
mcpServiceDocument,
|
|
8
|
+
oauthAuthorizationServerMetadata,
|
|
9
|
+
oauthProtectedResourceMetadata,
|
|
10
|
+
withCors
|
|
11
|
+
} from "./chunk-W5PADCDF.js";
|
|
12
|
+
import {
|
|
13
|
+
McpRateLimitError,
|
|
14
|
+
UsageMeter,
|
|
15
|
+
assertMcpBudget,
|
|
16
|
+
collectionSlugFromMetaId,
|
|
17
|
+
resolveCollectionId,
|
|
18
|
+
resolveMcpTenantId,
|
|
19
|
+
resolveUsageTenantId,
|
|
20
|
+
verifyAdminKey,
|
|
21
|
+
writeContext
|
|
22
|
+
} from "./chunk-CMSFD6OV.js";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_TENANT
|
|
25
|
+
} from "./chunk-GCQF75T4.js";
|
|
26
|
+
import {
|
|
27
|
+
rel
|
|
28
|
+
} from "./chunk-44FFQKQX.js";
|
|
29
|
+
import {
|
|
30
|
+
entityRecordToPlain,
|
|
31
|
+
hydrateBindings,
|
|
32
|
+
resolveRelations
|
|
33
|
+
} from "./chunk-JUEMDWLU.js";
|
|
34
|
+
import {
|
|
35
|
+
parseSimple
|
|
36
|
+
} from "./chunk-X5FLTRUB.js";
|
|
37
|
+
|
|
38
|
+
// src/server/server.ts
|
|
39
|
+
import { existsSync, readFileSync } from "fs";
|
|
40
|
+
import { dirname, join } from "path";
|
|
41
|
+
import { fileURLToPath } from "url";
|
|
42
|
+
|
|
43
|
+
// src/server/auth.ts
|
|
44
|
+
var ANONYMOUS = {
|
|
45
|
+
userId: null,
|
|
46
|
+
tenantId: null,
|
|
47
|
+
roles: [],
|
|
48
|
+
claims: {},
|
|
49
|
+
authenticated: false
|
|
50
|
+
};
|
|
51
|
+
function base64UrlEncode(input) {
|
|
52
|
+
return btoa(String.fromCharCode(...input)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
53
|
+
}
|
|
54
|
+
function base64UrlDecode(input) {
|
|
55
|
+
const padded = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
56
|
+
const padLen = (4 - padded.length % 4) % 4;
|
|
57
|
+
const base64 = padded + "=".repeat(padLen);
|
|
58
|
+
const binary = atob(base64);
|
|
59
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
60
|
+
}
|
|
61
|
+
async function hmacKey(secret) {
|
|
62
|
+
return crypto.subtle.importKey(
|
|
63
|
+
"raw",
|
|
64
|
+
new TextEncoder().encode(secret),
|
|
65
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
66
|
+
false,
|
|
67
|
+
["sign", "verify"]
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
async function signJwt(payload, secret, expiresInSeconds = 86400) {
|
|
71
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
72
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
73
|
+
const claims = { iat: now, exp: now + expiresInSeconds, ...payload };
|
|
74
|
+
const enc = new TextEncoder();
|
|
75
|
+
const headerB64 = base64UrlEncode(enc.encode(JSON.stringify(header)));
|
|
76
|
+
const payloadB64 = base64UrlEncode(enc.encode(JSON.stringify(claims)));
|
|
77
|
+
const signing = `${headerB64}.${payloadB64}`;
|
|
78
|
+
const key = await hmacKey(secret);
|
|
79
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(signing));
|
|
80
|
+
const sigB64 = base64UrlEncode(new Uint8Array(sig));
|
|
81
|
+
return `${signing}.${sigB64}`;
|
|
82
|
+
}
|
|
83
|
+
async function verifyJwt(token, secret) {
|
|
84
|
+
const parts = token.split(".");
|
|
85
|
+
if (parts.length !== 3) return null;
|
|
86
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
87
|
+
const enc = new TextEncoder();
|
|
88
|
+
const signing = `${headerB64}.${payloadB64}`;
|
|
89
|
+
try {
|
|
90
|
+
const key = await hmacKey(secret);
|
|
91
|
+
const sigRaw = base64UrlDecode(sigB64);
|
|
92
|
+
const sigBuf = sigRaw.buffer.slice(
|
|
93
|
+
sigRaw.byteOffset,
|
|
94
|
+
sigRaw.byteOffset + sigRaw.byteLength
|
|
95
|
+
);
|
|
96
|
+
const valid = await crypto.subtle.verify(
|
|
97
|
+
"HMAC",
|
|
98
|
+
key,
|
|
99
|
+
sigBuf,
|
|
100
|
+
enc.encode(signing)
|
|
101
|
+
);
|
|
102
|
+
if (!valid) return null;
|
|
103
|
+
const claims = JSON.parse(
|
|
104
|
+
new TextDecoder().decode(base64UrlDecode(payloadB64))
|
|
105
|
+
);
|
|
106
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
107
|
+
if (typeof claims.exp === "number" && claims.exp < now) return null;
|
|
108
|
+
return claims;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function resolveAuth(authHeader, config) {
|
|
114
|
+
if (!authHeader) {
|
|
115
|
+
return config.allowPublic === false ? { ...ANONYMOUS, authenticated: false } : ANONYMOUS;
|
|
116
|
+
}
|
|
117
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
|
|
118
|
+
if (config.apiKey && token === config.apiKey) {
|
|
119
|
+
return {
|
|
120
|
+
userId: "service",
|
|
121
|
+
tenantId: null,
|
|
122
|
+
roles: ["admin"],
|
|
123
|
+
claims: { sub: "service" },
|
|
124
|
+
authenticated: true
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (config.jwtSecret) {
|
|
128
|
+
const claims = await verifyJwt(token, config.jwtSecret);
|
|
129
|
+
if (claims) {
|
|
130
|
+
return {
|
|
131
|
+
userId: claims.sub ?? null,
|
|
132
|
+
tenantId: claims.tenantId ?? null,
|
|
133
|
+
roles: Array.isArray(claims.roles) ? claims.roles : typeof claims.role === "string" ? [claims.role] : [],
|
|
134
|
+
claims,
|
|
135
|
+
authenticated: true
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return ANONYMOUS;
|
|
140
|
+
}
|
|
141
|
+
var GOOGLE_PROVIDER = {
|
|
142
|
+
name: "google",
|
|
143
|
+
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
144
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
145
|
+
userInfoUrl: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
146
|
+
scopes: ["openid", "email", "profile"]
|
|
147
|
+
};
|
|
148
|
+
var GITHUB_PROVIDER = {
|
|
149
|
+
name: "github",
|
|
150
|
+
authUrl: "https://github.com/login/oauth/authorize",
|
|
151
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
152
|
+
userInfoUrl: "https://api.github.com/user",
|
|
153
|
+
scopes: ["read:user", "user:email"]
|
|
154
|
+
};
|
|
155
|
+
function buildOAuthUrl(provider, redirectUri, state) {
|
|
156
|
+
const params = new URLSearchParams({
|
|
157
|
+
client_id: provider.clientId,
|
|
158
|
+
redirect_uri: redirectUri,
|
|
159
|
+
response_type: "code",
|
|
160
|
+
scope: provider.scopes.join(" "),
|
|
161
|
+
state
|
|
162
|
+
});
|
|
163
|
+
return `${provider.authUrl}?${params}`;
|
|
164
|
+
}
|
|
165
|
+
async function exchangeOAuthCode(provider, code, redirectUri) {
|
|
166
|
+
const tokenRes = await fetch(provider.tokenUrl, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
170
|
+
Accept: "application/json"
|
|
171
|
+
},
|
|
172
|
+
body: new URLSearchParams({
|
|
173
|
+
client_id: provider.clientId,
|
|
174
|
+
client_secret: provider.clientSecret,
|
|
175
|
+
code,
|
|
176
|
+
redirect_uri: redirectUri,
|
|
177
|
+
grant_type: "authorization_code"
|
|
178
|
+
})
|
|
179
|
+
});
|
|
180
|
+
if (!tokenRes.ok) {
|
|
181
|
+
throw new Error(`OAuth token exchange failed: ${tokenRes.status}`);
|
|
182
|
+
}
|
|
183
|
+
const tokenData = await tokenRes.json();
|
|
184
|
+
const accessToken = tokenData.access_token;
|
|
185
|
+
const userRes = await fetch(provider.userInfoUrl, {
|
|
186
|
+
headers: {
|
|
187
|
+
Authorization: `Bearer ${accessToken}`,
|
|
188
|
+
Accept: "application/json"
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
if (!userRes.ok) {
|
|
192
|
+
throw new Error(`OAuth user info fetch failed: ${userRes.status}`);
|
|
193
|
+
}
|
|
194
|
+
const user = await userRes.json();
|
|
195
|
+
return {
|
|
196
|
+
id: String(user.id ?? user.sub ?? ""),
|
|
197
|
+
email: String(user.email ?? ""),
|
|
198
|
+
name: String(user.name ?? user.login ?? ""),
|
|
199
|
+
avatarUrl: user.picture ?? user.avatar_url ?? void 0
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/server/permissions.ts
|
|
204
|
+
var PermissionRegistry = class {
|
|
205
|
+
rules = /* @__PURE__ */ new Map();
|
|
206
|
+
defaultRule = "authenticated";
|
|
207
|
+
/**
|
|
208
|
+
* Register permissions for an entity type.
|
|
209
|
+
*/
|
|
210
|
+
register(entityType, permissions) {
|
|
211
|
+
this.rules.set(entityType, permissions);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Set the fallback rule used when an entity type has no declared permissions.
|
|
215
|
+
*/
|
|
216
|
+
setDefault(rule) {
|
|
217
|
+
this.defaultRule = rule;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Get the permission rule for a specific operation on an entity type.
|
|
221
|
+
* Falls back to the default rule if not declared.
|
|
222
|
+
*/
|
|
223
|
+
getRule(entityType, op) {
|
|
224
|
+
const def = this.rules.get(entityType);
|
|
225
|
+
return def?.[op] ?? this.defaultRule;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Check whether an auth context is allowed to perform an operation.
|
|
229
|
+
*/
|
|
230
|
+
check(auth, entityType, op, entity = null) {
|
|
231
|
+
const rule = this.getRule(entityType, op);
|
|
232
|
+
return evaluateRule(rule, auth, entity);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Assert access — throws a PermissionError if denied.
|
|
236
|
+
*/
|
|
237
|
+
assert(auth, entityType, op, entity = null) {
|
|
238
|
+
if (!this.check(auth, entityType, op, entity)) {
|
|
239
|
+
throw new PermissionError(auth, entityType, op);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
function evaluateRule(rule, auth, entity) {
|
|
244
|
+
if (rule === "public") {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (rule === "authenticated") {
|
|
248
|
+
return auth.authenticated;
|
|
249
|
+
}
|
|
250
|
+
if (rule === "own") {
|
|
251
|
+
if (!auth.authenticated || !auth.userId) return false;
|
|
252
|
+
const ownerFact = entity?.facts.find(
|
|
253
|
+
(f) => f.a === "ownerId" || f.a === "createdBy"
|
|
254
|
+
);
|
|
255
|
+
return ownerFact?.v === auth.userId;
|
|
256
|
+
}
|
|
257
|
+
if (typeof rule === "object") {
|
|
258
|
+
if ("role" in rule) {
|
|
259
|
+
return auth.roles.includes(rule.role);
|
|
260
|
+
}
|
|
261
|
+
if ("roles" in rule) {
|
|
262
|
+
return rule.roles.some((r) => auth.roles.includes(r));
|
|
263
|
+
}
|
|
264
|
+
if ("fn" in rule) {
|
|
265
|
+
try {
|
|
266
|
+
return rule.fn(auth, entity);
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
var PermissionError = class extends Error {
|
|
275
|
+
constructor(auth, entityType, op) {
|
|
276
|
+
const who = auth.authenticated ? `user:${auth.userId}` : "anonymous";
|
|
277
|
+
super(`Permission denied: ${who} cannot ${op} ${entityType}`);
|
|
278
|
+
this.auth = auth;
|
|
279
|
+
this.entityType = entityType;
|
|
280
|
+
this.op = op;
|
|
281
|
+
this.name = "PermissionError";
|
|
282
|
+
}
|
|
283
|
+
toResponse() {
|
|
284
|
+
return {
|
|
285
|
+
error: "Forbidden",
|
|
286
|
+
message: this.message,
|
|
287
|
+
code: 403
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
var PUBLIC_READ = {
|
|
292
|
+
read: "public",
|
|
293
|
+
create: "authenticated",
|
|
294
|
+
update: "authenticated",
|
|
295
|
+
delete: "authenticated"
|
|
296
|
+
};
|
|
297
|
+
var FULLY_PUBLIC = {
|
|
298
|
+
read: "public",
|
|
299
|
+
create: "public",
|
|
300
|
+
update: "public",
|
|
301
|
+
delete: "public"
|
|
302
|
+
};
|
|
303
|
+
var OWNER_ONLY = {
|
|
304
|
+
read: "own",
|
|
305
|
+
create: "authenticated",
|
|
306
|
+
update: "own",
|
|
307
|
+
delete: "own"
|
|
308
|
+
};
|
|
309
|
+
var ADMIN_ONLY = {
|
|
310
|
+
read: { role: "admin" },
|
|
311
|
+
create: { role: "admin" },
|
|
312
|
+
update: { role: "admin" },
|
|
313
|
+
delete: { role: "admin" }
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// src/schema/kernel-resolve.ts
|
|
317
|
+
import { z } from "zod";
|
|
318
|
+
function ontologyTypeName(id) {
|
|
319
|
+
return id.includes(":") ? id.split(":").pop() : id;
|
|
320
|
+
}
|
|
321
|
+
function findOntologyByTypeName(kernel, typeName) {
|
|
322
|
+
return kernel.listOntologies().find((s) => {
|
|
323
|
+
const short = ontologyTypeName(s["@id"]);
|
|
324
|
+
return short === typeName || short.toLowerCase() === typeName.toLowerCase() || s.label === typeName;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function schemaHandleFromOntology(def, typeName) {
|
|
328
|
+
const name = typeName ?? ontologyTypeName(def["@id"]);
|
|
329
|
+
const relations = {};
|
|
330
|
+
for (const field of def.fields) {
|
|
331
|
+
if (field.valueType === "relation" && field.relation?.targetSchema) {
|
|
332
|
+
const target = ontologyTypeName(field.relation.targetSchema);
|
|
333
|
+
relations[field.name] = rel(
|
|
334
|
+
target,
|
|
335
|
+
field.relation.cardinality ?? "one"
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
type: name,
|
|
341
|
+
zod: z.object({}),
|
|
342
|
+
relations,
|
|
343
|
+
computed: {},
|
|
344
|
+
definition: def,
|
|
345
|
+
toOntologySchema: () => {
|
|
346
|
+
throw new Error("schemaHandleFromOntology: legacy adapter not available");
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function createSchemaLookup(kernel) {
|
|
351
|
+
const cache = /* @__PURE__ */ new Map();
|
|
352
|
+
return (typeName) => {
|
|
353
|
+
if (cache.has(typeName)) return cache.get(typeName);
|
|
354
|
+
const def = findOntologyByTypeName(kernel, typeName);
|
|
355
|
+
if (!def) return null;
|
|
356
|
+
const handle = schemaHandleFromOntology(def, typeName);
|
|
357
|
+
cache.set(typeName, handle);
|
|
358
|
+
return handle;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function createKernelResolveClient(kernel) {
|
|
362
|
+
return {
|
|
363
|
+
read: async (id) => {
|
|
364
|
+
const entity = kernel.getEntity(id);
|
|
365
|
+
return entity ? entityRecordToPlain(entity) : null;
|
|
366
|
+
},
|
|
367
|
+
query: async (q) => {
|
|
368
|
+
const parsed = parseSimple(q);
|
|
369
|
+
const qr = await kernel.query(parsed);
|
|
370
|
+
return {
|
|
371
|
+
bindings: hydrateBindings(
|
|
372
|
+
kernel,
|
|
373
|
+
qr.bindings
|
|
374
|
+
),
|
|
375
|
+
executionTime: qr.executionTime
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
async function hydrateAndResolve(kernel, bindings, entityType, resolve) {
|
|
381
|
+
let entities = hydrateBindings(kernel, bindings);
|
|
382
|
+
if (!entityType || !resolve || Object.keys(resolve).length === 0) {
|
|
383
|
+
return entities;
|
|
384
|
+
}
|
|
385
|
+
const def = findOntologyByTypeName(kernel, entityType);
|
|
386
|
+
if (!def) return entities;
|
|
387
|
+
const schema = schemaHandleFromOntology(def, entityType);
|
|
388
|
+
const client = createKernelResolveClient(kernel);
|
|
389
|
+
const lookup = createSchemaLookup(kernel);
|
|
390
|
+
return resolveRelations(client, schema, entities, resolve, {
|
|
391
|
+
schemaLookup: lookup
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/server/realtime.ts
|
|
396
|
+
var SubscriptionManager = class {
|
|
397
|
+
clients = /* @__PURE__ */ new Map();
|
|
398
|
+
pool;
|
|
399
|
+
permissions;
|
|
400
|
+
meter;
|
|
401
|
+
constructor(pool, permissions = null, meter = null) {
|
|
402
|
+
this.pool = pool;
|
|
403
|
+
this.permissions = permissions;
|
|
404
|
+
this.meter = meter;
|
|
405
|
+
}
|
|
406
|
+
// -------------------------------------------------------------------------
|
|
407
|
+
// Client lifecycle
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
addClient(clientId, ws, auth, tenantId) {
|
|
410
|
+
this.clients.set(clientId, {
|
|
411
|
+
id: clientId,
|
|
412
|
+
ws,
|
|
413
|
+
subscriptions: /* @__PURE__ */ new Map(),
|
|
414
|
+
auth,
|
|
415
|
+
tenantId
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
removeClient(clientId) {
|
|
419
|
+
this.clients.delete(clientId);
|
|
420
|
+
}
|
|
421
|
+
// -------------------------------------------------------------------------
|
|
422
|
+
// Message handling
|
|
423
|
+
// -------------------------------------------------------------------------
|
|
424
|
+
async handleMessage(clientId, raw) {
|
|
425
|
+
const client = this.clients.get(clientId);
|
|
426
|
+
if (!client) return;
|
|
427
|
+
let msg;
|
|
428
|
+
try {
|
|
429
|
+
msg = JSON.parse(raw);
|
|
430
|
+
} catch {
|
|
431
|
+
this._send(client, { type: "error", id: "", message: "Invalid JSON" });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (msg.type === "ping") {
|
|
435
|
+
this._send(client, { type: "pong" });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (msg.type === "subscribe") {
|
|
439
|
+
await this._handleSubscribe(client, msg.id, msg.query, {
|
|
440
|
+
tenantId: msg.tenantId,
|
|
441
|
+
entityType: msg.entityType,
|
|
442
|
+
resolve: msg.resolve
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (msg.type === "unsubscribe") {
|
|
447
|
+
client.subscriptions.delete(msg.id);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// -------------------------------------------------------------------------
|
|
452
|
+
// Notify — called after every mutation
|
|
453
|
+
// -------------------------------------------------------------------------
|
|
454
|
+
/**
|
|
455
|
+
* Re-evaluate all subscriptions for a given tenant and push diffs.
|
|
456
|
+
* Called after every write op lands.
|
|
457
|
+
*/
|
|
458
|
+
async notify(tenantId) {
|
|
459
|
+
const tid = tenantId ?? null;
|
|
460
|
+
const dead = [];
|
|
461
|
+
for (const [clientId, client] of this.clients) {
|
|
462
|
+
if (client.ws.readyState !== 1) {
|
|
463
|
+
dead.push(clientId);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
for (const [subId, sub] of client.subscriptions) {
|
|
467
|
+
if (sub.tenantId !== tid) continue;
|
|
468
|
+
await this._pushUpdate(client, sub);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const id of dead) this.clients.delete(id);
|
|
472
|
+
}
|
|
473
|
+
get clientCount() {
|
|
474
|
+
return this.clients.size;
|
|
475
|
+
}
|
|
476
|
+
// -------------------------------------------------------------------------
|
|
477
|
+
// Private
|
|
478
|
+
// -------------------------------------------------------------------------
|
|
479
|
+
async _handleSubscribe(client, subId, queryStr, opts = {}) {
|
|
480
|
+
const tid = opts.tenantId ?? client.tenantId ?? null;
|
|
481
|
+
let parsedQuery;
|
|
482
|
+
try {
|
|
483
|
+
parsedQuery = parseSimple(queryStr);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
this._send(client, {
|
|
486
|
+
type: "error",
|
|
487
|
+
id: subId,
|
|
488
|
+
message: `Invalid query: ${err instanceof Error ? err.message : String(err)}`
|
|
489
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const kernel = await this.pool.preload(tid);
|
|
493
|
+
let result;
|
|
494
|
+
try {
|
|
495
|
+
const qr = await kernel.query(parsedQuery);
|
|
496
|
+
this._recordGraphIo(tid);
|
|
497
|
+
result = await hydrateAndResolve(
|
|
498
|
+
kernel,
|
|
499
|
+
qr.bindings,
|
|
500
|
+
opts.entityType,
|
|
501
|
+
opts.resolve
|
|
502
|
+
);
|
|
503
|
+
} catch (err) {
|
|
504
|
+
this._send(client, {
|
|
505
|
+
type: "error",
|
|
506
|
+
id: subId,
|
|
507
|
+
message: `Query failed: ${err instanceof Error ? err.message : String(err)}`
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const resolved = Boolean(opts.entityType) && Boolean(opts.resolve && Object.keys(opts.resolve).length > 0);
|
|
512
|
+
const sub = {
|
|
513
|
+
id: subId,
|
|
514
|
+
query: queryStr,
|
|
515
|
+
tenantId: tid,
|
|
516
|
+
auth: client.auth,
|
|
517
|
+
lastResult: result,
|
|
518
|
+
entityType: opts.entityType,
|
|
519
|
+
resolve: opts.resolve
|
|
520
|
+
};
|
|
521
|
+
client.subscriptions.set(subId, sub);
|
|
522
|
+
if (tid !== null && client.tenantId !== tid) {
|
|
523
|
+
client.tenantId = tid;
|
|
524
|
+
}
|
|
525
|
+
this._send(client, { type: "subscribed", id: subId });
|
|
526
|
+
this._send(client, {
|
|
527
|
+
type: "data",
|
|
528
|
+
id: subId,
|
|
529
|
+
result,
|
|
530
|
+
diff: { added: result, updated: [], removed: [] },
|
|
531
|
+
...resolved ? { resolved: true } : {}
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
async _pushUpdate(client, sub) {
|
|
535
|
+
const kernel = await this.pool.preload(sub.tenantId);
|
|
536
|
+
let newResult;
|
|
537
|
+
try {
|
|
538
|
+
const parsed = parseSimple(sub.query);
|
|
539
|
+
const qr = await kernel.query(parsed);
|
|
540
|
+
this._recordGraphIo(sub.tenantId);
|
|
541
|
+
newResult = await hydrateAndResolve(
|
|
542
|
+
kernel,
|
|
543
|
+
qr.bindings,
|
|
544
|
+
sub.entityType,
|
|
545
|
+
sub.resolve
|
|
546
|
+
);
|
|
547
|
+
} catch {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const diff = computeDiff(sub.lastResult, newResult);
|
|
551
|
+
if (diff.added.length === 0 && diff.updated.length === 0 && diff.removed.length === 0) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
sub.lastResult = newResult;
|
|
555
|
+
const resolved = Boolean(sub.entityType) && Boolean(sub.resolve && Object.keys(sub.resolve).length > 0);
|
|
556
|
+
this._send(client, {
|
|
557
|
+
type: "data",
|
|
558
|
+
id: sub.id,
|
|
559
|
+
result: newResult,
|
|
560
|
+
diff,
|
|
561
|
+
...resolved ? { resolved: true } : {}
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
_recordGraphIo(tenantId) {
|
|
565
|
+
this.meter?.recordGraphIo(resolveUsageTenantId(tenantId));
|
|
566
|
+
}
|
|
567
|
+
_send(client, payload) {
|
|
568
|
+
const data = JSON.stringify(payload);
|
|
569
|
+
if (this.meter) {
|
|
570
|
+
const tid = resolveUsageTenantId(client.tenantId ?? DEFAULT_TENANT);
|
|
571
|
+
this.meter.recordEgress(tid, new TextEncoder().encode(data).length);
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
client.ws.send(data);
|
|
575
|
+
} catch {
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
function entityId(row) {
|
|
580
|
+
return String(row["?e"] ?? row.id ?? row.e ?? JSON.stringify(row));
|
|
581
|
+
}
|
|
582
|
+
function computeDiff(prev, next) {
|
|
583
|
+
const prevMap = new Map(prev.map((r) => [entityId(r), r]));
|
|
584
|
+
const nextMap = new Map(next.map((r) => [entityId(r), r]));
|
|
585
|
+
const added = [];
|
|
586
|
+
const updated = [];
|
|
587
|
+
const removed = [];
|
|
588
|
+
for (const [id, row] of nextMap) {
|
|
589
|
+
if (!prevMap.has(id)) {
|
|
590
|
+
added.push(row);
|
|
591
|
+
} else if (JSON.stringify(prevMap.get(id)) !== JSON.stringify(row)) {
|
|
592
|
+
updated.push(row);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
for (const [id, row] of prevMap) {
|
|
596
|
+
if (!nextMap.has(id)) removed.push(row);
|
|
597
|
+
}
|
|
598
|
+
return { added, updated, removed };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/mcp/room.ts
|
|
602
|
+
import { z as z2 } from "zod";
|
|
603
|
+
|
|
604
|
+
// src/mcp/graph-summary.ts
|
|
605
|
+
var SKIP_ATTRS = /* @__PURE__ */ new Set(["@id", "@type", "id", "type"]);
|
|
606
|
+
function buildRoomGraphSummary(kernel, tenantId, opts = {}) {
|
|
607
|
+
const limit = opts.limit ?? 10;
|
|
608
|
+
const store = kernel.getStore();
|
|
609
|
+
let factCount = 0;
|
|
610
|
+
for (const _ of store.getAllFacts()) factCount++;
|
|
611
|
+
let linkCount = 0;
|
|
612
|
+
for (const _ of store.getAllLinks()) linkCount++;
|
|
613
|
+
const typeCounts = {};
|
|
614
|
+
const entityIds = /* @__PURE__ */ new Set();
|
|
615
|
+
for (const fact of store.getAllFacts()) {
|
|
616
|
+
if (fact.a === "type") {
|
|
617
|
+
const type = String(fact.v);
|
|
618
|
+
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
|
|
619
|
+
entityIds.add(fact.e);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const entityTypes = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([type, count]) => ({ type, count }));
|
|
623
|
+
const systemOntologies = [];
|
|
624
|
+
const userOntologies = [];
|
|
625
|
+
for (const schema of kernel.listOntologies()) {
|
|
626
|
+
const shortId = schema["@id"].replace(/^(trellis:schema\/|core:)/, "");
|
|
627
|
+
if (schema.tier === "user") {
|
|
628
|
+
userOntologies.push(shortId);
|
|
629
|
+
} else if (schema.tier !== "core") {
|
|
630
|
+
systemOntologies.push(shortId);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const topAttributes = store.getCatalog().filter((c) => !SKIP_ATTRS.has(c.attribute)).sort((a, b) => b.distinctCount - a.distinctCount).slice(0, limit).map((c) => ({
|
|
634
|
+
attribute: c.attribute,
|
|
635
|
+
distinctCount: c.distinctCount,
|
|
636
|
+
cardinality: c.cardinality
|
|
637
|
+
}));
|
|
638
|
+
const relations = /* @__PURE__ */ new Set();
|
|
639
|
+
for (const link of store.getAllLinks()) relations.add(link.a);
|
|
640
|
+
const recentMutations = kernel.readAllOps().slice(-limit).reverse().map((op) => ({
|
|
641
|
+
kind: op.kind,
|
|
642
|
+
agentId: op.agentId,
|
|
643
|
+
timestamp: op.timestamp,
|
|
644
|
+
entityId: inferEntityIdFromOp(op)
|
|
645
|
+
}));
|
|
646
|
+
return {
|
|
647
|
+
health: {
|
|
648
|
+
status: "ok",
|
|
649
|
+
factCount,
|
|
650
|
+
linkCount,
|
|
651
|
+
entityCount: entityIds.size,
|
|
652
|
+
ops: kernel.readAllOps().length,
|
|
653
|
+
tenantId
|
|
654
|
+
},
|
|
655
|
+
entityTypes,
|
|
656
|
+
ontologies: {
|
|
657
|
+
total: kernel.listOntologies().length,
|
|
658
|
+
system: systemOntologies.slice(0, limit),
|
|
659
|
+
user: userOntologies.slice(0, limit)
|
|
660
|
+
},
|
|
661
|
+
topAttributes,
|
|
662
|
+
links: { total: linkCount, relations: [...relations].sort().slice(0, limit) },
|
|
663
|
+
recentMutations
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
function inferEntityIdFromOp(op) {
|
|
667
|
+
const facts = op.facts ?? op.deleteFacts;
|
|
668
|
+
if (!facts?.length) return void 0;
|
|
669
|
+
const typeFact = facts.find((f) => "a" in f && f.a === "type");
|
|
670
|
+
return typeFact?.e ?? facts[0]?.e;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/mcp/room-audit.ts
|
|
674
|
+
function sanitizeInput(params) {
|
|
675
|
+
const out = {};
|
|
676
|
+
for (const [key, value] of Object.entries(params)) {
|
|
677
|
+
if (typeof value === "string" && value.length > 2e3) {
|
|
678
|
+
out[key] = value.slice(0, 2e3) + "\u2026";
|
|
679
|
+
} else {
|
|
680
|
+
out[key] = value;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
function summarizeText(text2, max = 500) {
|
|
686
|
+
return text2.length > max ? text2.slice(0, max) + "\u2026" : text2;
|
|
687
|
+
}
|
|
688
|
+
async function recordRoomMcpAudit(kernel, agentId, toolName, params, resultText, wctx, relatedEntities = []) {
|
|
689
|
+
try {
|
|
690
|
+
const id = `decision:mcp-${crypto.randomUUID().slice(0, 8)}`;
|
|
691
|
+
const links = relatedEntities.map((targetEntityId) => ({
|
|
692
|
+
attribute: "relatedTo",
|
|
693
|
+
targetEntityId
|
|
694
|
+
}));
|
|
695
|
+
await kernel.createEntity(
|
|
696
|
+
id,
|
|
697
|
+
"Decision",
|
|
698
|
+
{
|
|
699
|
+
title: `MCP ${toolName}`,
|
|
700
|
+
toolName,
|
|
701
|
+
input: JSON.stringify(sanitizeInput(params)),
|
|
702
|
+
outputSummary: summarizeText(resultText),
|
|
703
|
+
createdBy: agentId,
|
|
704
|
+
source: "room-mcp"
|
|
705
|
+
},
|
|
706
|
+
links.length > 0 ? links : void 0,
|
|
707
|
+
wctx
|
|
708
|
+
);
|
|
709
|
+
return id;
|
|
710
|
+
} catch {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function scheduleRoomMcpAudit(kernelPromise, agentId, toolName, params, resultText, wctx, relatedEntities) {
|
|
715
|
+
void kernelPromise.then(
|
|
716
|
+
(kernel) => recordRoomMcpAudit(
|
|
717
|
+
kernel,
|
|
718
|
+
agentId,
|
|
719
|
+
toolName,
|
|
720
|
+
params,
|
|
721
|
+
resultText,
|
|
722
|
+
wctx,
|
|
723
|
+
relatedEntities
|
|
724
|
+
)
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/mcp/room.ts
|
|
729
|
+
var laneSchema = z2.string().optional().describe(
|
|
730
|
+
"Draft lane for op attribution (e.g. agent:cursor). Defaults to authenticated user or agent:room-mcp."
|
|
731
|
+
);
|
|
732
|
+
var tenantIdSchema = z2.string().optional().describe(
|
|
733
|
+
"Tenant id (e.g. embed-design-review). Overrides session default. Use for Playground multi-tenant rooms."
|
|
734
|
+
);
|
|
735
|
+
var roomSchema = z2.string().optional().describe(
|
|
736
|
+
"Playground ?room= slug \u2014 resolves to tenant embed-{slug} (same as playground.trellis.computer session rooms)."
|
|
737
|
+
);
|
|
738
|
+
function resolveToolTenant(ctx, args) {
|
|
739
|
+
return resolveMcpTenantId({
|
|
740
|
+
defaultTenantId: ctx.tenantId,
|
|
741
|
+
headerTenant: ctx.headerTenant,
|
|
742
|
+
toolTenantId: args.tenantId,
|
|
743
|
+
roomSlug: args.room
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
function text(content) {
|
|
747
|
+
return { content: [{ type: "text", text: content }] };
|
|
748
|
+
}
|
|
749
|
+
function jsonText(data) {
|
|
750
|
+
return text(JSON.stringify(data, null, 2));
|
|
751
|
+
}
|
|
752
|
+
function toolError(err) {
|
|
753
|
+
if (err instanceof PermissionError || err instanceof McpRateLimitError) {
|
|
754
|
+
return text(err.message);
|
|
755
|
+
}
|
|
756
|
+
return text(err instanceof Error ? err.message : String(err));
|
|
757
|
+
}
|
|
758
|
+
async function withMcpIo(ctx, tenantId, fn) {
|
|
759
|
+
assertMcpBudget(ctx.meter, tenantId);
|
|
760
|
+
const result = await fn();
|
|
761
|
+
ctx.meter?.recordGraphIo(resolveUsageTenantId(tenantId));
|
|
762
|
+
return result;
|
|
763
|
+
}
|
|
764
|
+
function auditWrite(kernel, wctx, toolName, params, payload, relatedEntities = []) {
|
|
765
|
+
const agentId = wctx.agentId ?? "agent:room-mcp";
|
|
766
|
+
const resultText = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
767
|
+
scheduleRoomMcpAudit(
|
|
768
|
+
Promise.resolve(kernel),
|
|
769
|
+
agentId,
|
|
770
|
+
toolName,
|
|
771
|
+
params,
|
|
772
|
+
resultText,
|
|
773
|
+
wctx,
|
|
774
|
+
relatedEntities
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
function createRoomMcpServer(ctx) {
|
|
778
|
+
const server = new McpServer({
|
|
779
|
+
name: "trellis-room",
|
|
780
|
+
version: "0.2.0"
|
|
781
|
+
});
|
|
782
|
+
server.registerTool(
|
|
783
|
+
"get_graph_summary",
|
|
784
|
+
{
|
|
785
|
+
description: "Compact graph overview \u2014 health, entity types, ontologies, top attributes, links, recent ops. Call first.",
|
|
786
|
+
inputSchema: {
|
|
787
|
+
limit: z2.number().optional().describe("Max items per section (default: 10)"),
|
|
788
|
+
tenantId: tenantIdSchema,
|
|
789
|
+
room: roomSchema
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
async ({ limit, tenantId, room }) => {
|
|
793
|
+
try {
|
|
794
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
795
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
796
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
797
|
+
return jsonText(
|
|
798
|
+
buildRoomGraphSummary(kernel, effectiveTenant, { limit })
|
|
799
|
+
);
|
|
800
|
+
});
|
|
801
|
+
} catch (err) {
|
|
802
|
+
return toolError(err);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
server.registerTool(
|
|
807
|
+
"graph_health",
|
|
808
|
+
{
|
|
809
|
+
description: "Quick liveness check for the Trellis room graph (ops + entity counts).",
|
|
810
|
+
inputSchema: {
|
|
811
|
+
tenantId: tenantIdSchema,
|
|
812
|
+
room: roomSchema
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
async ({ tenantId, room }) => {
|
|
816
|
+
try {
|
|
817
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
818
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
819
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
820
|
+
const entities = kernel.listEntities();
|
|
821
|
+
const byType = {};
|
|
822
|
+
for (const e of entities) {
|
|
823
|
+
byType[e.type] = (byType[e.type] ?? 0) + 1;
|
|
824
|
+
}
|
|
825
|
+
return jsonText({
|
|
826
|
+
status: "ok",
|
|
827
|
+
ops: kernel.readAllOps().length,
|
|
828
|
+
entities: entities.length,
|
|
829
|
+
byType,
|
|
830
|
+
tenantId: effectiveTenant
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
} catch (err) {
|
|
834
|
+
return toolError(err);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
);
|
|
838
|
+
server.registerTool(
|
|
839
|
+
"query_graph",
|
|
840
|
+
{
|
|
841
|
+
description: "Run an EQL-S query against the room graph.",
|
|
842
|
+
inputSchema: {
|
|
843
|
+
query: z2.string().describe("EQL-S query string"),
|
|
844
|
+
tenantId: tenantIdSchema,
|
|
845
|
+
room: roomSchema
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
async ({ query, tenantId, room }) => {
|
|
849
|
+
try {
|
|
850
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
851
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
852
|
+
const parsed = parseSimple(query);
|
|
853
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
854
|
+
const result = await kernel.query(parsed);
|
|
855
|
+
const bindings = hydrateBindings(
|
|
856
|
+
kernel,
|
|
857
|
+
result.bindings
|
|
858
|
+
);
|
|
859
|
+
return jsonText({
|
|
860
|
+
bindings,
|
|
861
|
+
executionTime: result.executionTime
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
} catch (err) {
|
|
865
|
+
return toolError(err);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
);
|
|
869
|
+
server.registerTool(
|
|
870
|
+
"get_node",
|
|
871
|
+
{
|
|
872
|
+
description: "Read a single entity by ID.",
|
|
873
|
+
inputSchema: {
|
|
874
|
+
id: z2.string().describe("Entity ID"),
|
|
875
|
+
tenantId: tenantIdSchema,
|
|
876
|
+
room: roomSchema
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
async ({ id, tenantId, room }) => {
|
|
880
|
+
try {
|
|
881
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
882
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
883
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
884
|
+
const entity = kernel.getEntity(id);
|
|
885
|
+
if (!entity) return text(`Not found: ${id}`);
|
|
886
|
+
ctx.permissions?.assert(ctx.auth, entity.type, "read", entity);
|
|
887
|
+
return jsonText(entityRecordToPlain(entity));
|
|
888
|
+
});
|
|
889
|
+
} catch (err) {
|
|
890
|
+
return toolError(err);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
);
|
|
894
|
+
server.registerTool(
|
|
895
|
+
"create_node",
|
|
896
|
+
{
|
|
897
|
+
description: "Create a new entity in the room graph. Optionally pass links for relations.",
|
|
898
|
+
inputSchema: {
|
|
899
|
+
type: z2.string().describe("Entity type name"),
|
|
900
|
+
id: z2.string().optional().describe("Optional explicit entity ID"),
|
|
901
|
+
attributes: z2.record(z2.string(), z2.unknown()).optional().describe("Entity attributes"),
|
|
902
|
+
links: z2.array(
|
|
903
|
+
z2.object({
|
|
904
|
+
attribute: z2.string(),
|
|
905
|
+
targetEntityId: z2.string()
|
|
906
|
+
})
|
|
907
|
+
).optional().describe("Relation links to create with the entity"),
|
|
908
|
+
lane: laneSchema,
|
|
909
|
+
tenantId: tenantIdSchema,
|
|
910
|
+
room: roomSchema
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
async ({ type, id, attributes, links, lane, tenantId, room }) => {
|
|
914
|
+
try {
|
|
915
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
916
|
+
ctx.permissions?.assert(ctx.auth, type, "create");
|
|
917
|
+
const wctx = writeContext(lane, ctx.auth, ctx.headerLane);
|
|
918
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
919
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
920
|
+
const entityId2 = id ?? `${type.toLowerCase()}:${crypto.randomUUID()}`;
|
|
921
|
+
const attrs = { ...attributes ?? {} };
|
|
922
|
+
if (ctx.auth.userId) attrs.createdBy = ctx.auth.userId;
|
|
923
|
+
if (effectiveTenant) attrs.tenantId = effectiveTenant;
|
|
924
|
+
attrs.laneId = wctx.agentId;
|
|
925
|
+
const result = await kernel.createEntity(
|
|
926
|
+
entityId2,
|
|
927
|
+
type,
|
|
928
|
+
attrs,
|
|
929
|
+
links,
|
|
930
|
+
wctx
|
|
931
|
+
);
|
|
932
|
+
await ctx.subs.notify(effectiveTenant);
|
|
933
|
+
const payload = {
|
|
934
|
+
id: entityId2,
|
|
935
|
+
op: result.op.hash,
|
|
936
|
+
lane: wctx.agentId,
|
|
937
|
+
tenantId: effectiveTenant
|
|
938
|
+
};
|
|
939
|
+
auditWrite(
|
|
940
|
+
kernel,
|
|
941
|
+
wctx,
|
|
942
|
+
"create_node",
|
|
943
|
+
{ type, id, attributes, links, lane, tenantId, room },
|
|
944
|
+
payload,
|
|
945
|
+
[entityId2]
|
|
946
|
+
);
|
|
947
|
+
return jsonText(payload);
|
|
948
|
+
});
|
|
949
|
+
} catch (err) {
|
|
950
|
+
return toolError(err);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
);
|
|
954
|
+
server.registerTool(
|
|
955
|
+
"update_node",
|
|
956
|
+
{
|
|
957
|
+
description: "Partial update of an entity attributes.",
|
|
958
|
+
inputSchema: {
|
|
959
|
+
id: z2.string().describe("Entity ID"),
|
|
960
|
+
attributes: z2.record(z2.string(), z2.unknown()).describe("Attributes to merge"),
|
|
961
|
+
lane: laneSchema,
|
|
962
|
+
tenantId: tenantIdSchema,
|
|
963
|
+
room: roomSchema
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
async ({ id, attributes, lane, tenantId, room }) => {
|
|
967
|
+
try {
|
|
968
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
969
|
+
const wctx = writeContext(lane, ctx.auth, ctx.headerLane);
|
|
970
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
971
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
972
|
+
const entity = kernel.getEntity(id);
|
|
973
|
+
if (!entity) return text(`Not found: ${id}`);
|
|
974
|
+
ctx.permissions?.assert(ctx.auth, entity.type, "update", entity);
|
|
975
|
+
await kernel.updateEntity(id, attributes, wctx);
|
|
976
|
+
await ctx.subs.notify(effectiveTenant);
|
|
977
|
+
const payload = {
|
|
978
|
+
id,
|
|
979
|
+
updated: true,
|
|
980
|
+
lane: wctx.agentId,
|
|
981
|
+
tenantId: effectiveTenant
|
|
982
|
+
};
|
|
983
|
+
auditWrite(
|
|
984
|
+
kernel,
|
|
985
|
+
wctx,
|
|
986
|
+
"update_node",
|
|
987
|
+
{ id, attributes, lane, tenantId, room },
|
|
988
|
+
payload,
|
|
989
|
+
[id]
|
|
990
|
+
);
|
|
991
|
+
return jsonText(payload);
|
|
992
|
+
});
|
|
993
|
+
} catch (err) {
|
|
994
|
+
return toolError(err);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
);
|
|
998
|
+
server.registerTool(
|
|
999
|
+
"delete_node",
|
|
1000
|
+
{
|
|
1001
|
+
description: "Delete an entity by ID.",
|
|
1002
|
+
inputSchema: {
|
|
1003
|
+
id: z2.string().describe("Entity ID"),
|
|
1004
|
+
lane: laneSchema,
|
|
1005
|
+
tenantId: tenantIdSchema,
|
|
1006
|
+
room: roomSchema
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
async ({ id, lane, tenantId, room }) => {
|
|
1010
|
+
try {
|
|
1011
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
1012
|
+
const wctx = writeContext(lane, ctx.auth, ctx.headerLane);
|
|
1013
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
1014
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
1015
|
+
const entity = kernel.getEntity(id);
|
|
1016
|
+
if (!entity) return text(`Not found: ${id}`);
|
|
1017
|
+
ctx.permissions?.assert(ctx.auth, entity.type, "delete", entity);
|
|
1018
|
+
await kernel.deleteEntity(id, wctx);
|
|
1019
|
+
await ctx.subs.notify(effectiveTenant);
|
|
1020
|
+
const payload = {
|
|
1021
|
+
id,
|
|
1022
|
+
deleted: true,
|
|
1023
|
+
lane: wctx.agentId,
|
|
1024
|
+
tenantId: effectiveTenant
|
|
1025
|
+
};
|
|
1026
|
+
auditWrite(
|
|
1027
|
+
kernel,
|
|
1028
|
+
wctx,
|
|
1029
|
+
"delete_node",
|
|
1030
|
+
{ id, lane, tenantId, room },
|
|
1031
|
+
payload,
|
|
1032
|
+
[id]
|
|
1033
|
+
);
|
|
1034
|
+
return jsonText(payload);
|
|
1035
|
+
});
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
return toolError(err);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1041
|
+
server.registerTool(
|
|
1042
|
+
"create_collection_record",
|
|
1043
|
+
{
|
|
1044
|
+
description: "Create a Playground CollectionRecord row (shows in Collections UI). Sets collectionId to collectionMeta:<slug> so recordBelongsToCollection matches. Optionally ensureCollection to create CollectionMeta when the collection is missing.",
|
|
1045
|
+
inputSchema: {
|
|
1046
|
+
collectionSlug: z2.string().optional().describe("Collection slug (e.g. people, ideas). Required unless collectionId is set."),
|
|
1047
|
+
collectionId: z2.string().optional().describe("Explicit collection id (e.g. collectionMeta:people). Overrides slug."),
|
|
1048
|
+
title: z2.string().min(1).describe("Record title shown in the collection"),
|
|
1049
|
+
body: z2.string().optional().describe("Optional record body / notes"),
|
|
1050
|
+
sortOrder: z2.number().int().optional().describe("Row sort order within the collection"),
|
|
1051
|
+
attributes: z2.record(z2.string(), z2.unknown()).optional().describe("Extra typed fields (status, tags, etc.) merged into the record"),
|
|
1052
|
+
ensureCollection: z2.boolean().optional().describe(
|
|
1053
|
+
"When true, create CollectionMeta with stable id collectionMeta:<slug> if missing."
|
|
1054
|
+
),
|
|
1055
|
+
collectionTitle: z2.string().optional().describe("Title for CollectionMeta when ensureCollection creates it"),
|
|
1056
|
+
lane: laneSchema,
|
|
1057
|
+
tenantId: tenantIdSchema,
|
|
1058
|
+
room: roomSchema
|
|
1059
|
+
}
|
|
1060
|
+
},
|
|
1061
|
+
async ({
|
|
1062
|
+
collectionSlug,
|
|
1063
|
+
collectionId,
|
|
1064
|
+
title,
|
|
1065
|
+
body,
|
|
1066
|
+
sortOrder,
|
|
1067
|
+
attributes,
|
|
1068
|
+
ensureCollection,
|
|
1069
|
+
collectionTitle,
|
|
1070
|
+
lane,
|
|
1071
|
+
tenantId,
|
|
1072
|
+
room
|
|
1073
|
+
}) => {
|
|
1074
|
+
try {
|
|
1075
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
1076
|
+
ctx.permissions?.assert(ctx.auth, "CollectionRecord", "create");
|
|
1077
|
+
const wctx = writeContext(lane, ctx.auth, ctx.headerLane);
|
|
1078
|
+
const resolvedCollectionId = resolveCollectionId({
|
|
1079
|
+
collectionSlug,
|
|
1080
|
+
collectionId
|
|
1081
|
+
});
|
|
1082
|
+
const slug = collectionSlug?.trim() || collectionSlugFromMetaId(resolvedCollectionId) || resolvedCollectionId;
|
|
1083
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
1084
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
1085
|
+
let collectionCreated = false;
|
|
1086
|
+
if (ensureCollection) {
|
|
1087
|
+
const existingMeta = kernel.getEntity(resolvedCollectionId);
|
|
1088
|
+
if (!existingMeta) {
|
|
1089
|
+
ctx.permissions?.assert(ctx.auth, "CollectionMeta", "create");
|
|
1090
|
+
const metaAttrs = {
|
|
1091
|
+
title: collectionTitle?.trim() || slug,
|
|
1092
|
+
slug,
|
|
1093
|
+
sortOrder: 0
|
|
1094
|
+
};
|
|
1095
|
+
if (ctx.auth.userId) metaAttrs.createdBy = ctx.auth.userId;
|
|
1096
|
+
if (effectiveTenant) metaAttrs.tenantId = effectiveTenant;
|
|
1097
|
+
metaAttrs.laneId = wctx.agentId;
|
|
1098
|
+
await kernel.createEntity(
|
|
1099
|
+
resolvedCollectionId,
|
|
1100
|
+
"CollectionMeta",
|
|
1101
|
+
metaAttrs,
|
|
1102
|
+
void 0,
|
|
1103
|
+
wctx
|
|
1104
|
+
);
|
|
1105
|
+
collectionCreated = true;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const recordId = `collectionRecord:${crypto.randomUUID()}`;
|
|
1109
|
+
const recordAttrs = {
|
|
1110
|
+
...attributes ?? {},
|
|
1111
|
+
collectionId: resolvedCollectionId,
|
|
1112
|
+
title
|
|
1113
|
+
};
|
|
1114
|
+
if (body !== void 0) recordAttrs.body = body;
|
|
1115
|
+
if (sortOrder !== void 0) recordAttrs.sortOrder = sortOrder;
|
|
1116
|
+
if (ctx.auth.userId) recordAttrs.createdBy = ctx.auth.userId;
|
|
1117
|
+
if (effectiveTenant) recordAttrs.tenantId = effectiveTenant;
|
|
1118
|
+
recordAttrs.laneId = wctx.agentId;
|
|
1119
|
+
const result = await kernel.createEntity(
|
|
1120
|
+
recordId,
|
|
1121
|
+
"CollectionRecord",
|
|
1122
|
+
recordAttrs,
|
|
1123
|
+
void 0,
|
|
1124
|
+
wctx
|
|
1125
|
+
);
|
|
1126
|
+
await ctx.subs.notify(effectiveTenant);
|
|
1127
|
+
const payload = {
|
|
1128
|
+
id: recordId,
|
|
1129
|
+
collectionId: resolvedCollectionId,
|
|
1130
|
+
collectionCreated,
|
|
1131
|
+
op: result.op.hash,
|
|
1132
|
+
lane: wctx.agentId,
|
|
1133
|
+
tenantId: effectiveTenant
|
|
1134
|
+
};
|
|
1135
|
+
auditWrite(
|
|
1136
|
+
kernel,
|
|
1137
|
+
wctx,
|
|
1138
|
+
"create_collection_record",
|
|
1139
|
+
{
|
|
1140
|
+
collectionSlug,
|
|
1141
|
+
collectionId,
|
|
1142
|
+
title,
|
|
1143
|
+
body,
|
|
1144
|
+
sortOrder,
|
|
1145
|
+
attributes,
|
|
1146
|
+
ensureCollection,
|
|
1147
|
+
collectionTitle,
|
|
1148
|
+
lane,
|
|
1149
|
+
tenantId,
|
|
1150
|
+
room
|
|
1151
|
+
},
|
|
1152
|
+
payload,
|
|
1153
|
+
collectionCreated ? [recordId, resolvedCollectionId] : [recordId]
|
|
1154
|
+
);
|
|
1155
|
+
return jsonText(payload);
|
|
1156
|
+
});
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
return toolError(err);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
);
|
|
1162
|
+
server.registerTool(
|
|
1163
|
+
"link_nodes",
|
|
1164
|
+
{
|
|
1165
|
+
description: "Create a semantic link between two entities (assignedTo, belongsTo, references, dependsOn, \u2026).",
|
|
1166
|
+
inputSchema: {
|
|
1167
|
+
e1: z2.string().describe("Source entity ID"),
|
|
1168
|
+
relation: z2.string().describe("Relation attribute name"),
|
|
1169
|
+
e2: z2.string().describe("Target entity ID"),
|
|
1170
|
+
lane: laneSchema,
|
|
1171
|
+
tenantId: tenantIdSchema,
|
|
1172
|
+
room: roomSchema
|
|
1173
|
+
}
|
|
1174
|
+
},
|
|
1175
|
+
async ({ e1, relation, e2, lane, tenantId, room }) => {
|
|
1176
|
+
try {
|
|
1177
|
+
const effectiveTenant = resolveToolTenant(ctx, { tenantId, room });
|
|
1178
|
+
const wctx = writeContext(lane, ctx.auth, ctx.headerLane);
|
|
1179
|
+
return await withMcpIo(ctx, effectiveTenant, async () => {
|
|
1180
|
+
const kernel = await ctx.pool.preload(effectiveTenant);
|
|
1181
|
+
const source = kernel.getEntity(e1);
|
|
1182
|
+
const target = kernel.getEntity(e2);
|
|
1183
|
+
if (!source) return text(`Not found: ${e1}`);
|
|
1184
|
+
if (!target) return text(`Not found: ${e2}`);
|
|
1185
|
+
ctx.permissions?.assert(ctx.auth, source.type, "update", source);
|
|
1186
|
+
const result = await kernel.addLink(e1, relation, e2, wctx);
|
|
1187
|
+
await ctx.subs.notify(effectiveTenant);
|
|
1188
|
+
const payload = {
|
|
1189
|
+
e1,
|
|
1190
|
+
relation,
|
|
1191
|
+
e2,
|
|
1192
|
+
op: result.op.hash,
|
|
1193
|
+
lane: wctx.agentId,
|
|
1194
|
+
tenantId: effectiveTenant
|
|
1195
|
+
};
|
|
1196
|
+
auditWrite(
|
|
1197
|
+
kernel,
|
|
1198
|
+
wctx,
|
|
1199
|
+
"link_nodes",
|
|
1200
|
+
{ e1, relation, e2, lane, tenantId, room },
|
|
1201
|
+
payload,
|
|
1202
|
+
[e1, e2]
|
|
1203
|
+
);
|
|
1204
|
+
return jsonText(payload);
|
|
1205
|
+
});
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
return toolError(err);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
);
|
|
1211
|
+
return server;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// src/server/mcp-gateway.ts
|
|
1215
|
+
var RoomMcpGateway = class {
|
|
1216
|
+
gateway = new StreamableMcpGateway();
|
|
1217
|
+
async handle(req, ctx, auth, tenantId) {
|
|
1218
|
+
const roomCtx = {
|
|
1219
|
+
pool: ctx.pool,
|
|
1220
|
+
permissions: ctx.permissions,
|
|
1221
|
+
subs: ctx.subs,
|
|
1222
|
+
meter: ctx.meter,
|
|
1223
|
+
auth,
|
|
1224
|
+
tenantId,
|
|
1225
|
+
headerLane: req.headers.get("x-trellis-lane"),
|
|
1226
|
+
headerTenant: req.headers.get("x-trellis-tenant")
|
|
1227
|
+
};
|
|
1228
|
+
return this.gateway.handle(req, () => createRoomMcpServer(roomCtx));
|
|
1229
|
+
}
|
|
1230
|
+
async close() {
|
|
1231
|
+
await this.gateway.close();
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
var roomMcpGateway = new RoomMcpGateway();
|
|
1235
|
+
|
|
1236
|
+
// src/server/server.ts
|
|
1237
|
+
var __moduleDir = import.meta.dir ?? dirname(fileURLToPath(import.meta.url));
|
|
1238
|
+
async function startServer(opts) {
|
|
1239
|
+
return startServerNode(opts);
|
|
1240
|
+
}
|
|
1241
|
+
var startServerCrossRuntime = startServer;
|
|
1242
|
+
function buildServerContext(opts) {
|
|
1243
|
+
const { pool, permissions, config } = opts;
|
|
1244
|
+
const port = opts.port ?? config.port ?? 3e3;
|
|
1245
|
+
const authConfig = {
|
|
1246
|
+
jwtSecret: config.jwtSecret,
|
|
1247
|
+
apiKey: config.apiKey,
|
|
1248
|
+
allowPublic: true
|
|
1249
|
+
};
|
|
1250
|
+
const meter = new UsageMeter();
|
|
1251
|
+
const subs = new SubscriptionManager(pool, permissions ?? null, meter);
|
|
1252
|
+
const enableCors = corsEnabledForConfig(config.apiKey);
|
|
1253
|
+
const handleHttpInner = async (req) => {
|
|
1254
|
+
const url = new URL(req.url);
|
|
1255
|
+
const path = url.pathname;
|
|
1256
|
+
const auth = await resolveAuth(
|
|
1257
|
+
req.headers.get("authorization"),
|
|
1258
|
+
authConfig
|
|
1259
|
+
);
|
|
1260
|
+
const tenantId = auth.tenantId ?? url.searchParams.get("tenantId") ?? null;
|
|
1261
|
+
try {
|
|
1262
|
+
return await route(req, url, path, auth, tenantId, {
|
|
1263
|
+
pool,
|
|
1264
|
+
permissions: permissions ?? null,
|
|
1265
|
+
subs,
|
|
1266
|
+
meter,
|
|
1267
|
+
authConfig,
|
|
1268
|
+
config,
|
|
1269
|
+
oauthProviders: opts.oauthProviders ?? {}
|
|
1270
|
+
});
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
if (err instanceof PermissionError) {
|
|
1273
|
+
return json(err.toResponse(), 403);
|
|
1274
|
+
}
|
|
1275
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1276
|
+
if (process.env.TRELLIS_DEBUG) {
|
|
1277
|
+
console.error(
|
|
1278
|
+
`[trellis] ${req.method} ${path} \u2192 500:`,
|
|
1279
|
+
msg
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
return json(
|
|
1283
|
+
{
|
|
1284
|
+
error: "Internal Server Error",
|
|
1285
|
+
message: msg,
|
|
1286
|
+
method: req.method,
|
|
1287
|
+
path
|
|
1288
|
+
},
|
|
1289
|
+
500
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
const handleHttp = async (req) => {
|
|
1294
|
+
if (enableCors && req.method === "OPTIONS") {
|
|
1295
|
+
return corsPreflightResponse(req);
|
|
1296
|
+
}
|
|
1297
|
+
const res = await handleHttpInner(req);
|
|
1298
|
+
return enableCors ? withCors(req, res) : res;
|
|
1299
|
+
};
|
|
1300
|
+
return { port, authConfig, subs, handleHttp };
|
|
1301
|
+
}
|
|
1302
|
+
async function startServerNode(opts) {
|
|
1303
|
+
const { port, subs, handleHttp } = buildServerContext(opts);
|
|
1304
|
+
const { startNodeServer } = await import("./server/node-adapter.js");
|
|
1305
|
+
return startNodeServer({
|
|
1306
|
+
port,
|
|
1307
|
+
fetch: handleHttp,
|
|
1308
|
+
attachPresenceRelay: opts.presenceRelay,
|
|
1309
|
+
websocket: {
|
|
1310
|
+
open(ws) {
|
|
1311
|
+
const id = crypto.randomUUID();
|
|
1312
|
+
ws.__clientId = id;
|
|
1313
|
+
subs.addClient(
|
|
1314
|
+
id,
|
|
1315
|
+
ws,
|
|
1316
|
+
{
|
|
1317
|
+
userId: null,
|
|
1318
|
+
tenantId: null,
|
|
1319
|
+
roles: [],
|
|
1320
|
+
claims: {},
|
|
1321
|
+
authenticated: false
|
|
1322
|
+
},
|
|
1323
|
+
null
|
|
1324
|
+
);
|
|
1325
|
+
},
|
|
1326
|
+
async message(ws, raw) {
|
|
1327
|
+
const id = ws.__clientId;
|
|
1328
|
+
await subs.handleMessage(
|
|
1329
|
+
id,
|
|
1330
|
+
typeof raw === "string" ? raw : raw.toString()
|
|
1331
|
+
);
|
|
1332
|
+
},
|
|
1333
|
+
close(ws) {
|
|
1334
|
+
const id = ws.__clientId;
|
|
1335
|
+
subs.removeClient(id);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
function recordGraphIo(ctx, tenantId) {
|
|
1341
|
+
ctx.meter.recordGraphIo(resolveUsageTenantId(tenantId));
|
|
1342
|
+
}
|
|
1343
|
+
async function route(req, url, path, auth, tenantId, ctx) {
|
|
1344
|
+
const method = req.method.toUpperCase();
|
|
1345
|
+
if (method === "GET" && path === "/__trellis/inspector.js") {
|
|
1346
|
+
const candidates = [
|
|
1347
|
+
join(__moduleDir, "db", "inspector.js"),
|
|
1348
|
+
// chunk lives in dist/, inspector in dist/db/
|
|
1349
|
+
join(__moduleDir, "inspector.js")
|
|
1350
|
+
// chunk lives in dist/db/, inspector alongside
|
|
1351
|
+
];
|
|
1352
|
+
for (const p of candidates) {
|
|
1353
|
+
if (existsSync(p)) {
|
|
1354
|
+
return new Response(readFileSync(p, "utf-8"), {
|
|
1355
|
+
headers: { "Content-Type": "application/javascript; charset=utf-8" }
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return new Response(
|
|
1360
|
+
"/* Trellis DB Inspector: run `bun run build:inspector` first */",
|
|
1361
|
+
{
|
|
1362
|
+
headers: { "Content-Type": "application/javascript" }
|
|
1363
|
+
}
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
if (method === "GET" && path === "/__trellis/trellis.css") {
|
|
1367
|
+
const candidates = [
|
|
1368
|
+
join(__moduleDir, "db", "trellis.css"),
|
|
1369
|
+
join(__moduleDir, "trellis.css")
|
|
1370
|
+
];
|
|
1371
|
+
for (const p of candidates) {
|
|
1372
|
+
if (existsSync(p)) {
|
|
1373
|
+
return new Response(readFileSync(p, "utf-8"), {
|
|
1374
|
+
headers: { "Content-Type": "text/css; charset=utf-8" }
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
return new Response("/* Trellis CSS not found */", {
|
|
1379
|
+
headers: { "Content-Type": "text/css" }
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
if (method === "GET" && (path === "/" || path === "/inspector")) {
|
|
1383
|
+
const html = `<!DOCTYPE html>
|
|
1384
|
+
<html lang="en">
|
|
1385
|
+
<head>
|
|
1386
|
+
<meta charset="UTF-8">
|
|
1387
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1388
|
+
<title>Trellis DB Inspector</title>
|
|
1389
|
+
<link rel="stylesheet" href="/__trellis/trellis.css">
|
|
1390
|
+
</head>
|
|
1391
|
+
<body>
|
|
1392
|
+
<script src="/__trellis/inspector.js"></script>
|
|
1393
|
+
</body>
|
|
1394
|
+
</html>`;
|
|
1395
|
+
return new Response(html, {
|
|
1396
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
if (path === "/mcp" || path === "/mcp/" || path === "/trellis/mcp" || path === "/trellis/mcp/") {
|
|
1400
|
+
return roomMcpGateway.handle(req, ctx, auth, tenantId);
|
|
1401
|
+
}
|
|
1402
|
+
if (path === "/gateway/mcp" || path === "/gateway/mcp/") {
|
|
1403
|
+
return discoveryMcpGateway.handle(req, {
|
|
1404
|
+
configDir: ".",
|
|
1405
|
+
origin: url.origin
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
if (method === "GET" && path === "/.well-known/oauth-protected-resource") {
|
|
1409
|
+
return json(oauthProtectedResourceMetadata(url.origin));
|
|
1410
|
+
}
|
|
1411
|
+
if (method === "GET" && path === "/.well-known/oauth-authorization-server") {
|
|
1412
|
+
return json(oauthAuthorizationServerMetadata(url.origin));
|
|
1413
|
+
}
|
|
1414
|
+
if (method === "GET" && path === "/.well-known/trellis-mcp") {
|
|
1415
|
+
return json(mcpServiceDocument(url.origin, ctx.authConfig));
|
|
1416
|
+
}
|
|
1417
|
+
if (method === "GET" && path === "/health") {
|
|
1418
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1419
|
+
const ops = kernel.readAllOps().length;
|
|
1420
|
+
return json({
|
|
1421
|
+
status: "ok",
|
|
1422
|
+
ops,
|
|
1423
|
+
tenants: ctx.pool.activeTenants().length,
|
|
1424
|
+
mcp: "/mcp",
|
|
1425
|
+
mcpSprites: "/trellis/mcp",
|
|
1426
|
+
mcpGateway: "/gateway/mcp",
|
|
1427
|
+
oauth: {
|
|
1428
|
+
protectedResource: "/.well-known/oauth-protected-resource",
|
|
1429
|
+
authorizationServer: "/.well-known/oauth-authorization-server"
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
if (method === "GET" && path === "/admin/usage") {
|
|
1434
|
+
return handleAdminUsage(req, url, ctx);
|
|
1435
|
+
}
|
|
1436
|
+
if (path === "/entities" || path === "/entities/") {
|
|
1437
|
+
if (method === "POST") return handleCreate(req, auth, tenantId, ctx);
|
|
1438
|
+
if (method === "GET") return handleList(url, auth, tenantId, ctx);
|
|
1439
|
+
}
|
|
1440
|
+
const entityMatch = path.match(/^\/entities\/([^/]+)$/);
|
|
1441
|
+
if (entityMatch) {
|
|
1442
|
+
const id = decodeURIComponent(entityMatch[1]);
|
|
1443
|
+
if (method === "GET") return handleRead(id, auth, tenantId, ctx);
|
|
1444
|
+
if (method === "PUT" || method === "PATCH")
|
|
1445
|
+
return handleUpdate(req, id, auth, tenantId, ctx);
|
|
1446
|
+
if (method === "DELETE") return handleDelete(id, auth, tenantId, ctx);
|
|
1447
|
+
}
|
|
1448
|
+
if (method === "GET" && (path === "/ops" || path === "/ops/")) {
|
|
1449
|
+
return handleListOps(url, tenantId, ctx);
|
|
1450
|
+
}
|
|
1451
|
+
if (method === "POST" && path === "/query") {
|
|
1452
|
+
return handleQuery(req, auth, tenantId, ctx);
|
|
1453
|
+
}
|
|
1454
|
+
if (method === "POST" && (path === "/ontologies" || path === "/ontologies/")) {
|
|
1455
|
+
return handleCreateOntology(req, auth, tenantId, ctx);
|
|
1456
|
+
}
|
|
1457
|
+
const ontologyMatch = path.match(/^\/ontologies\/(.+)$/);
|
|
1458
|
+
if (ontologyMatch && (method === "PATCH" || method === "PUT")) {
|
|
1459
|
+
const id = decodeURIComponent(ontologyMatch[1]);
|
|
1460
|
+
return handleUpdateOntology(req, id, auth, tenantId, ctx);
|
|
1461
|
+
}
|
|
1462
|
+
if (method === "POST" && path === "/upload") {
|
|
1463
|
+
return handleUpload(req, auth, tenantId, ctx);
|
|
1464
|
+
}
|
|
1465
|
+
const fileMatch = path.match(/^\/files\/([^/]+)$/);
|
|
1466
|
+
if (method === "GET" && fileMatch) {
|
|
1467
|
+
return handleFileDownload(fileMatch[1], ctx, tenantId);
|
|
1468
|
+
}
|
|
1469
|
+
if (method === "POST" && path === "/auth/register") {
|
|
1470
|
+
return handleRegister(req, tenantId, ctx);
|
|
1471
|
+
}
|
|
1472
|
+
if (method === "POST" && path === "/auth/login") {
|
|
1473
|
+
return handleLogin(req, tenantId, ctx);
|
|
1474
|
+
}
|
|
1475
|
+
const oauthMatch = path.match(/^\/auth\/oauth\/([^/]+)(\/callback)?$/);
|
|
1476
|
+
if (oauthMatch) {
|
|
1477
|
+
const providerName = oauthMatch[1];
|
|
1478
|
+
const isCallback = !!oauthMatch[2];
|
|
1479
|
+
if (isCallback)
|
|
1480
|
+
return handleOAuthCallback(url, providerName, tenantId, ctx);
|
|
1481
|
+
return handleOAuthRedirect(providerName, url, ctx);
|
|
1482
|
+
}
|
|
1483
|
+
return json({ error: "Not Found" }, 404);
|
|
1484
|
+
}
|
|
1485
|
+
async function handleCreate(req, auth, tenantId, ctx) {
|
|
1486
|
+
const body = await req.json();
|
|
1487
|
+
if (!body.type) return json({ error: "type is required" }, 400);
|
|
1488
|
+
ctx.permissions?.assert(auth, body.type, "create");
|
|
1489
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1490
|
+
const entityId2 = `${body.type.toLowerCase()}:${crypto.randomUUID()}`;
|
|
1491
|
+
const attrs = {
|
|
1492
|
+
...body.attributes
|
|
1493
|
+
};
|
|
1494
|
+
if (auth.userId) attrs.createdBy = auth.userId;
|
|
1495
|
+
if (auth.tenantId) attrs.tenantId = auth.tenantId;
|
|
1496
|
+
const result = await kernel.createEntity(
|
|
1497
|
+
entityId2,
|
|
1498
|
+
body.type,
|
|
1499
|
+
attrs,
|
|
1500
|
+
body.links
|
|
1501
|
+
);
|
|
1502
|
+
recordGraphIo(ctx, tenantId);
|
|
1503
|
+
await ctx.subs.notify(tenantId);
|
|
1504
|
+
return json({ id: entityId2, op: result.op.hash }, 201);
|
|
1505
|
+
}
|
|
1506
|
+
async function handleRead(id, auth, tenantId, ctx) {
|
|
1507
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1508
|
+
const entity = kernel.getEntity(id);
|
|
1509
|
+
if (!entity) return json({ error: "Not Found" }, 404);
|
|
1510
|
+
ctx.permissions?.assert(auth, entity.type, "read", entity);
|
|
1511
|
+
return json(entityToJson(entity));
|
|
1512
|
+
}
|
|
1513
|
+
async function handleUpdate(req, id, auth, tenantId, ctx) {
|
|
1514
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1515
|
+
const entity = kernel.getEntity(id);
|
|
1516
|
+
if (!entity) return json({ error: "Not Found" }, 404);
|
|
1517
|
+
ctx.permissions?.assert(auth, entity.type, "update", entity);
|
|
1518
|
+
const updates = await req.json();
|
|
1519
|
+
await kernel.updateEntity(id, updates);
|
|
1520
|
+
recordGraphIo(ctx, tenantId);
|
|
1521
|
+
await ctx.subs.notify(tenantId);
|
|
1522
|
+
return json({ id, updated: true });
|
|
1523
|
+
}
|
|
1524
|
+
async function handleDelete(id, auth, tenantId, ctx) {
|
|
1525
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1526
|
+
const entity = kernel.getEntity(id);
|
|
1527
|
+
if (!entity) return json({ error: "Not Found" }, 404);
|
|
1528
|
+
ctx.permissions?.assert(auth, entity.type, "delete", entity);
|
|
1529
|
+
await kernel.deleteEntity(id);
|
|
1530
|
+
recordGraphIo(ctx, tenantId);
|
|
1531
|
+
await ctx.subs.notify(tenantId);
|
|
1532
|
+
return json({ id, deleted: true });
|
|
1533
|
+
}
|
|
1534
|
+
async function handleListOps(url, tenantId, ctx) {
|
|
1535
|
+
const kind = url.searchParams.get("kind") ?? void 0;
|
|
1536
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "100", 10), 500);
|
|
1537
|
+
const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
|
|
1538
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1539
|
+
let ops = kernel.readAllOps();
|
|
1540
|
+
if (kind) {
|
|
1541
|
+
ops = ops.filter((op) => op.kind === kind);
|
|
1542
|
+
}
|
|
1543
|
+
const total = ops.length;
|
|
1544
|
+
const page = ops.slice(offset, offset + limit);
|
|
1545
|
+
return json({
|
|
1546
|
+
data: page.map((op, index) => summarizeKernelOp(op, offset + index)),
|
|
1547
|
+
total,
|
|
1548
|
+
limit,
|
|
1549
|
+
offset
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
function summarizeKernelOp(op, index) {
|
|
1553
|
+
const entityIds = /* @__PURE__ */ new Set();
|
|
1554
|
+
for (const fact of op.facts ?? []) entityIds.add(fact.e);
|
|
1555
|
+
for (const fact of op.deleteFacts ?? []) entityIds.add(fact.e);
|
|
1556
|
+
return {
|
|
1557
|
+
index,
|
|
1558
|
+
hash: op.hash,
|
|
1559
|
+
kind: op.kind,
|
|
1560
|
+
timestamp: op.timestamp,
|
|
1561
|
+
agentId: op.agentId,
|
|
1562
|
+
previousHash: op.previousHash,
|
|
1563
|
+
factCount: op.facts?.length ?? 0,
|
|
1564
|
+
linkCount: op.links?.length ?? 0,
|
|
1565
|
+
deleteFactCount: op.deleteFacts?.length ?? 0,
|
|
1566
|
+
deleteLinkCount: op.deleteLinks?.length ?? 0,
|
|
1567
|
+
entityCount: entityIds.size
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
async function handleList(url, auth, tenantId, ctx) {
|
|
1571
|
+
const type = url.searchParams.get("type") ?? void 0;
|
|
1572
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "100");
|
|
1573
|
+
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
|
1574
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1575
|
+
let entities = kernel.listEntities(type);
|
|
1576
|
+
if (ctx.permissions && type) {
|
|
1577
|
+
entities = entities.filter(
|
|
1578
|
+
(e) => ctx.permissions.check(auth, e.type, "read", e)
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
const page = entities.slice(offset, offset + limit);
|
|
1582
|
+
return json({
|
|
1583
|
+
data: page.map(entityToJson),
|
|
1584
|
+
total: entities.length,
|
|
1585
|
+
limit,
|
|
1586
|
+
offset
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
async function handleQuery(req, auth, tenantId, ctx) {
|
|
1590
|
+
const body = await req.json();
|
|
1591
|
+
if (!body.query) return json({ error: "query is required" }, 400);
|
|
1592
|
+
let parsed;
|
|
1593
|
+
try {
|
|
1594
|
+
parsed = parseSimple(body.query);
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
return json({ error: "Invalid query", message: String(err) }, 400);
|
|
1597
|
+
}
|
|
1598
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1599
|
+
const result = await kernel.query(parsed);
|
|
1600
|
+
recordGraphIo(ctx, tenantId);
|
|
1601
|
+
const bindings = hydrateBindings(
|
|
1602
|
+
kernel,
|
|
1603
|
+
result.bindings
|
|
1604
|
+
);
|
|
1605
|
+
return json({
|
|
1606
|
+
bindings,
|
|
1607
|
+
executionTime: result.executionTime
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
async function readJsonObject(req, route2) {
|
|
1611
|
+
const raw = await req.text();
|
|
1612
|
+
if (!raw.trim()) {
|
|
1613
|
+
if (process.env.TRELLIS_DEBUG) {
|
|
1614
|
+
console.warn(`[trellis] ${req.method} ${route2}: empty request body`);
|
|
1615
|
+
}
|
|
1616
|
+
return {
|
|
1617
|
+
ok: false,
|
|
1618
|
+
response: json(
|
|
1619
|
+
{
|
|
1620
|
+
error: "Bad Request",
|
|
1621
|
+
message: "Request body is empty \u2014 client may not have sent JSON (common on iOS HTTP dev)",
|
|
1622
|
+
method: req.method,
|
|
1623
|
+
path: route2,
|
|
1624
|
+
bodyBytes: 0
|
|
1625
|
+
},
|
|
1626
|
+
400
|
|
1627
|
+
)
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
const data = JSON.parse(raw);
|
|
1632
|
+
return { ok: true, data };
|
|
1633
|
+
} catch (err) {
|
|
1634
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1635
|
+
if (process.env.TRELLIS_DEBUG) {
|
|
1636
|
+
console.warn(
|
|
1637
|
+
`[trellis] ${req.method} ${route2}: invalid JSON (${raw.length}B):`,
|
|
1638
|
+
msg
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
return {
|
|
1642
|
+
ok: false,
|
|
1643
|
+
response: json(
|
|
1644
|
+
{
|
|
1645
|
+
error: "Bad Request",
|
|
1646
|
+
message: `Invalid JSON: ${msg}`,
|
|
1647
|
+
method: req.method,
|
|
1648
|
+
path: route2,
|
|
1649
|
+
bodyBytes: raw.length
|
|
1650
|
+
},
|
|
1651
|
+
400
|
|
1652
|
+
)
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
async function handleCreateOntology(req, auth, tenantId, ctx) {
|
|
1657
|
+
const parsed = await readJsonObject(req, "/ontologies");
|
|
1658
|
+
if (!parsed.ok) return parsed.response;
|
|
1659
|
+
const schema = parsed.data;
|
|
1660
|
+
if (!schema || !schema["@id"] || !Array.isArray(schema.fields)) {
|
|
1661
|
+
return json(
|
|
1662
|
+
{ error: "A SchemaDefinition (with @id and fields) is required" },
|
|
1663
|
+
400
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
if ((schema.tier ?? "user") === "core") {
|
|
1667
|
+
return json({ error: "Core ontologies are immutable" }, 403);
|
|
1668
|
+
}
|
|
1669
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1670
|
+
const exists = kernel.listOntologies().some((ont) => ont["@id"] === schema["@id"]);
|
|
1671
|
+
if (exists) {
|
|
1672
|
+
return json({ id: schema["@id"], registered: false, existed: true }, 200);
|
|
1673
|
+
}
|
|
1674
|
+
try {
|
|
1675
|
+
kernel.createOntology(schema);
|
|
1676
|
+
recordGraphIo(ctx, tenantId);
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1679
|
+
if (msg.includes("already exists")) {
|
|
1680
|
+
return json({ id: schema["@id"], registered: false, existed: true }, 200);
|
|
1681
|
+
}
|
|
1682
|
+
return json({ error: "Could not register schema", message: msg }, 409);
|
|
1683
|
+
}
|
|
1684
|
+
return json({ id: schema["@id"], registered: true }, 201);
|
|
1685
|
+
}
|
|
1686
|
+
async function handleUpdateOntology(req, id, auth, tenantId, ctx) {
|
|
1687
|
+
const parsed = await readJsonObject(req, "/ontologies");
|
|
1688
|
+
if (!parsed.ok) return parsed.response;
|
|
1689
|
+
const updates = parsed.data;
|
|
1690
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1691
|
+
const existing = kernel.getOntology(id);
|
|
1692
|
+
if (!existing) {
|
|
1693
|
+
return json({ error: `Ontology ${id} not found` }, 404);
|
|
1694
|
+
}
|
|
1695
|
+
if ((existing.tier ?? "user") === "core") {
|
|
1696
|
+
return json({ error: "Core ontologies are immutable" }, 403);
|
|
1697
|
+
}
|
|
1698
|
+
try {
|
|
1699
|
+
kernel.updateOntology(id, updates);
|
|
1700
|
+
recordGraphIo(ctx, tenantId);
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1703
|
+
return json({ error: "Could not update schema", message: msg }, 400);
|
|
1704
|
+
}
|
|
1705
|
+
const updated = kernel.getOntology(id);
|
|
1706
|
+
return json(updated ?? { id, updated: true });
|
|
1707
|
+
}
|
|
1708
|
+
async function handleUpload(req, auth, tenantId, ctx) {
|
|
1709
|
+
if (!auth.authenticated && ctx.config.apiKey) {
|
|
1710
|
+
return json({ error: "Unauthorized" }, 401);
|
|
1711
|
+
}
|
|
1712
|
+
const contentType = req.headers.get("content-type") ?? "application/octet-stream";
|
|
1713
|
+
const buffer = new Uint8Array(await req.arrayBuffer());
|
|
1714
|
+
const hashBuf = await crypto.subtle.digest("SHA-256", buffer);
|
|
1715
|
+
const hash = `blob:${Array.from(new Uint8Array(hashBuf)).map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
1716
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1717
|
+
const backend = kernel.getBackend();
|
|
1718
|
+
if (!backend.hasBlob(hash)) {
|
|
1719
|
+
backend.putBlob(hash, buffer);
|
|
1720
|
+
}
|
|
1721
|
+
return json({ hash, size: buffer.length, contentType }, 201);
|
|
1722
|
+
}
|
|
1723
|
+
async function handleFileDownload(hash, ctx, tenantId) {
|
|
1724
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1725
|
+
const backend = kernel.getBackend();
|
|
1726
|
+
const blob = backend.getBlob(hash);
|
|
1727
|
+
if (!blob) return json({ error: "Not Found" }, 404);
|
|
1728
|
+
const cleanBuf = blob.buffer.slice(
|
|
1729
|
+
blob.byteOffset,
|
|
1730
|
+
blob.byteOffset + blob.byteLength
|
|
1731
|
+
);
|
|
1732
|
+
ctx.meter.recordEgress(resolveUsageTenantId(tenantId), blob.byteLength);
|
|
1733
|
+
return new Response(cleanBuf, {
|
|
1734
|
+
headers: { "Content-Type": "application/octet-stream" }
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
async function handleRegister(req, tenantId, ctx) {
|
|
1738
|
+
if (!ctx.config.jwtSecret) {
|
|
1739
|
+
return json({ error: "Auth not configured (no jwtSecret)" }, 501);
|
|
1740
|
+
}
|
|
1741
|
+
const body = await req.json();
|
|
1742
|
+
if (!body.email || !body.password) {
|
|
1743
|
+
return json({ error: "email and password are required" }, 400);
|
|
1744
|
+
}
|
|
1745
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1746
|
+
const existing = kernel.listEntities("User", { email: body.email });
|
|
1747
|
+
if (existing.length > 0) {
|
|
1748
|
+
return json({ error: "Email already registered" }, 409);
|
|
1749
|
+
}
|
|
1750
|
+
const userId = `user:${crypto.randomUUID()}`;
|
|
1751
|
+
const pwHash = await hashPassword(body.password);
|
|
1752
|
+
await kernel.createEntity(userId, "User", {
|
|
1753
|
+
email: body.email,
|
|
1754
|
+
name: body.name ?? "",
|
|
1755
|
+
passwordHash: pwHash,
|
|
1756
|
+
role: "user",
|
|
1757
|
+
...tenantId ? { tenantId } : {}
|
|
1758
|
+
});
|
|
1759
|
+
const token = await signJwt(
|
|
1760
|
+
{ sub: userId, email: body.email, roles: ["user"], tenantId },
|
|
1761
|
+
ctx.config.jwtSecret
|
|
1762
|
+
);
|
|
1763
|
+
return json({ token, userId }, 201);
|
|
1764
|
+
}
|
|
1765
|
+
async function handleLogin(req, tenantId, ctx) {
|
|
1766
|
+
if (!ctx.config.jwtSecret) {
|
|
1767
|
+
return json({ error: "Auth not configured (no jwtSecret)" }, 501);
|
|
1768
|
+
}
|
|
1769
|
+
const body = await req.json();
|
|
1770
|
+
if (!body.email || !body.password) {
|
|
1771
|
+
return json({ error: "email and password are required" }, 400);
|
|
1772
|
+
}
|
|
1773
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1774
|
+
const users = kernel.listEntities("User", { email: body.email });
|
|
1775
|
+
if (users.length === 0) {
|
|
1776
|
+
return json({ error: "Invalid credentials" }, 401);
|
|
1777
|
+
}
|
|
1778
|
+
const user = users[0];
|
|
1779
|
+
const pwHashFact = user.facts.find((f) => f.a === "passwordHash");
|
|
1780
|
+
const roleFact = user.facts.find((f) => f.a === "role");
|
|
1781
|
+
if (!pwHashFact || !await verifyPassword(body.password, String(pwHashFact.v))) {
|
|
1782
|
+
return json({ error: "Invalid credentials" }, 401);
|
|
1783
|
+
}
|
|
1784
|
+
const role = String(roleFact?.v ?? "user");
|
|
1785
|
+
const token = await signJwt(
|
|
1786
|
+
{ sub: user.id, email: body.email, roles: [role], tenantId },
|
|
1787
|
+
ctx.config.jwtSecret
|
|
1788
|
+
);
|
|
1789
|
+
return json({ token, userId: user.id });
|
|
1790
|
+
}
|
|
1791
|
+
function handleOAuthRedirect(providerName, url, ctx) {
|
|
1792
|
+
const provider = ctx.oauthProviders[providerName] ?? getBuiltinProvider(providerName, ctx);
|
|
1793
|
+
if (!provider)
|
|
1794
|
+
return json({ error: `Unknown provider: ${providerName}` }, 400);
|
|
1795
|
+
const redirectUri = `${url.origin}/auth/oauth/${providerName}/callback`;
|
|
1796
|
+
const state = crypto.randomUUID();
|
|
1797
|
+
const authUrl = buildOAuthUrl(provider, redirectUri, state);
|
|
1798
|
+
return Response.redirect(authUrl, 302);
|
|
1799
|
+
}
|
|
1800
|
+
async function handleOAuthCallback(url, providerName, tenantId, ctx) {
|
|
1801
|
+
if (!ctx.config.jwtSecret) {
|
|
1802
|
+
return json({ error: "Auth not configured (no jwtSecret)" }, 501);
|
|
1803
|
+
}
|
|
1804
|
+
const code = url.searchParams.get("code");
|
|
1805
|
+
if (!code) return json({ error: "Missing code" }, 400);
|
|
1806
|
+
const provider = ctx.oauthProviders[providerName] ?? getBuiltinProvider(providerName, ctx);
|
|
1807
|
+
if (!provider)
|
|
1808
|
+
return json({ error: `Unknown provider: ${providerName}` }, 400);
|
|
1809
|
+
const redirectUri = `${url.origin}/auth/oauth/${providerName}/callback`;
|
|
1810
|
+
let profile;
|
|
1811
|
+
try {
|
|
1812
|
+
profile = await exchangeOAuthCode(provider, code, redirectUri);
|
|
1813
|
+
} catch (err) {
|
|
1814
|
+
return json({ error: "OAuth exchange failed", message: String(err) }, 400);
|
|
1815
|
+
}
|
|
1816
|
+
const kernel = await ctx.pool.preload(tenantId);
|
|
1817
|
+
const oauthId = `oauth:${providerName}:${profile.id}`;
|
|
1818
|
+
let users = kernel.listEntities("User", { oauthId });
|
|
1819
|
+
let userId;
|
|
1820
|
+
if (users.length === 0) {
|
|
1821
|
+
userId = `user:${crypto.randomUUID()}`;
|
|
1822
|
+
await kernel.createEntity(userId, "User", {
|
|
1823
|
+
email: profile.email,
|
|
1824
|
+
name: profile.name,
|
|
1825
|
+
avatarUrl: profile.avatarUrl ?? "",
|
|
1826
|
+
oauthId,
|
|
1827
|
+
oauthProvider: providerName,
|
|
1828
|
+
role: "user",
|
|
1829
|
+
...tenantId ? { tenantId } : {}
|
|
1830
|
+
});
|
|
1831
|
+
} else {
|
|
1832
|
+
userId = users[0].id;
|
|
1833
|
+
}
|
|
1834
|
+
const token = await signJwt(
|
|
1835
|
+
{ sub: userId, email: profile.email, roles: ["user"], tenantId },
|
|
1836
|
+
ctx.config.jwtSecret
|
|
1837
|
+
);
|
|
1838
|
+
return json({ token, userId });
|
|
1839
|
+
}
|
|
1840
|
+
function getBuiltinProvider(name, ctx) {
|
|
1841
|
+
if (name === "google") {
|
|
1842
|
+
const cid = process.env.GOOGLE_CLIENT_ID;
|
|
1843
|
+
const csec = process.env.GOOGLE_CLIENT_SECRET;
|
|
1844
|
+
if (!cid || !csec) return null;
|
|
1845
|
+
return { ...GOOGLE_PROVIDER, clientId: cid, clientSecret: csec };
|
|
1846
|
+
}
|
|
1847
|
+
if (name === "github") {
|
|
1848
|
+
const cid = process.env.GITHUB_CLIENT_ID;
|
|
1849
|
+
const csec = process.env.GITHUB_CLIENT_SECRET;
|
|
1850
|
+
if (!cid || !csec) return null;
|
|
1851
|
+
return { ...GITHUB_PROVIDER, clientId: cid, clientSecret: csec };
|
|
1852
|
+
}
|
|
1853
|
+
return null;
|
|
1854
|
+
}
|
|
1855
|
+
async function hashPassword(password) {
|
|
1856
|
+
const salt = crypto.randomUUID().replace(/-/g, "");
|
|
1857
|
+
const enc = new TextEncoder();
|
|
1858
|
+
const keyMat = await crypto.subtle.importKey(
|
|
1859
|
+
"raw",
|
|
1860
|
+
enc.encode(password),
|
|
1861
|
+
"PBKDF2",
|
|
1862
|
+
false,
|
|
1863
|
+
["deriveBits"]
|
|
1864
|
+
);
|
|
1865
|
+
const bits = await crypto.subtle.deriveBits(
|
|
1866
|
+
{
|
|
1867
|
+
name: "PBKDF2",
|
|
1868
|
+
salt: enc.encode(salt),
|
|
1869
|
+
iterations: 1e5,
|
|
1870
|
+
hash: "SHA-256"
|
|
1871
|
+
},
|
|
1872
|
+
keyMat,
|
|
1873
|
+
256
|
|
1874
|
+
);
|
|
1875
|
+
const hash = Array.from(new Uint8Array(bits)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1876
|
+
return `${salt}:${hash}`;
|
|
1877
|
+
}
|
|
1878
|
+
async function verifyPassword(password, stored) {
|
|
1879
|
+
const [salt, expectedHash] = stored.split(":");
|
|
1880
|
+
if (!salt || !expectedHash) return false;
|
|
1881
|
+
const enc = new TextEncoder();
|
|
1882
|
+
const keyMat = await crypto.subtle.importKey(
|
|
1883
|
+
"raw",
|
|
1884
|
+
enc.encode(password),
|
|
1885
|
+
"PBKDF2",
|
|
1886
|
+
false,
|
|
1887
|
+
["deriveBits"]
|
|
1888
|
+
);
|
|
1889
|
+
const bits = await crypto.subtle.deriveBits(
|
|
1890
|
+
{
|
|
1891
|
+
name: "PBKDF2",
|
|
1892
|
+
salt: enc.encode(salt),
|
|
1893
|
+
iterations: 1e5,
|
|
1894
|
+
hash: "SHA-256"
|
|
1895
|
+
},
|
|
1896
|
+
keyMat,
|
|
1897
|
+
256
|
|
1898
|
+
);
|
|
1899
|
+
const hash = Array.from(new Uint8Array(bits)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1900
|
+
return hash === expectedHash;
|
|
1901
|
+
}
|
|
1902
|
+
function handleAdminUsage(req, url, ctx) {
|
|
1903
|
+
if (!process.env.TURTLEDB_ADMIN_KEY) {
|
|
1904
|
+
return json({ error: "Admin usage not configured" }, 501);
|
|
1905
|
+
}
|
|
1906
|
+
if (!verifyAdminKey(req)) {
|
|
1907
|
+
return json({ error: "Unauthorized" }, 401);
|
|
1908
|
+
}
|
|
1909
|
+
const tenant = url.searchParams.get("tenant");
|
|
1910
|
+
if (!tenant) {
|
|
1911
|
+
return json({ error: "tenant query param is required" }, 400);
|
|
1912
|
+
}
|
|
1913
|
+
const refresh = url.searchParams.get("refresh") === "1";
|
|
1914
|
+
if (refresh) {
|
|
1915
|
+
ctx.meter.sampleStorage(ctx.pool, tenant);
|
|
1916
|
+
}
|
|
1917
|
+
const day = url.searchParams.get("day") ?? void 0;
|
|
1918
|
+
return json(ctx.meter.getUsage(tenant, day));
|
|
1919
|
+
}
|
|
1920
|
+
function entityToJson(entity) {
|
|
1921
|
+
return entityRecordToPlain(entity);
|
|
1922
|
+
}
|
|
1923
|
+
function json(data, status = 200) {
|
|
1924
|
+
return new Response(JSON.stringify(data), {
|
|
1925
|
+
status,
|
|
1926
|
+
headers: { "Content-Type": "application/json" }
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
export {
|
|
1931
|
+
ANONYMOUS,
|
|
1932
|
+
signJwt,
|
|
1933
|
+
verifyJwt,
|
|
1934
|
+
resolveAuth,
|
|
1935
|
+
GOOGLE_PROVIDER,
|
|
1936
|
+
GITHUB_PROVIDER,
|
|
1937
|
+
buildOAuthUrl,
|
|
1938
|
+
exchangeOAuthCode,
|
|
1939
|
+
PermissionRegistry,
|
|
1940
|
+
PermissionError,
|
|
1941
|
+
PUBLIC_READ,
|
|
1942
|
+
FULLY_PUBLIC,
|
|
1943
|
+
OWNER_ONLY,
|
|
1944
|
+
ADMIN_ONLY,
|
|
1945
|
+
SubscriptionManager,
|
|
1946
|
+
startServer,
|
|
1947
|
+
startServerCrossRuntime
|
|
1948
|
+
};
|