@opendatalabs/personal-server-ts-lite 0.0.1-canary.00328bf
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge.d.ts +23 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +53 -0
- package/dist/bridge.js.map +1 -0
- package/dist/browser-runtime.d.ts +58 -0
- package/dist/browser-runtime.d.ts.map +1 -0
- package/dist/browser-runtime.js +214 -0
- package/dist/browser-runtime.js.map +1 -0
- package/dist/browser-tls-rustls/browser_tls_rustls.d.ts +49 -0
- package/dist/browser-tls-rustls/browser_tls_rustls.js +401 -0
- package/dist/browser-tls-rustls/browser_tls_rustls_bg.wasm +0 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +408 -0
- package/dist/client.js.map +1 -0
- package/dist/derivatives.d.ts +51 -0
- package/dist/derivatives.d.ts.map +1 -0
- package/dist/derivatives.js +87 -0
- package/dist/derivatives.js.map +1 -0
- package/dist/diagnostics.d.ts +166 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +265 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-store.d.ts +35 -0
- package/dist/mcp-store.d.ts.map +1 -0
- package/dist/mcp-store.js +208 -0
- package/dist/mcp-store.js.map +1 -0
- package/dist/owner-binding.d.ts +7 -0
- package/dist/owner-binding.d.ts.map +1 -0
- package/dist/owner-binding.js +18 -0
- package/dist/owner-binding.js.map +1 -0
- package/dist/persistence.d.ts +69 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +58 -0
- package/dist/persistence.js.map +1 -0
- package/dist/relay-tls.d.ts +61 -0
- package/dist/relay-tls.d.ts.map +1 -0
- package/dist/relay-tls.js +316 -0
- package/dist/relay-tls.js.map +1 -0
- package/dist/relay.d.ts +149 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +646 -0
- package/dist/relay.js.map +1 -0
- package/dist/runtime.d.ts +188 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +1178 -0
- package/dist/runtime.js.map +1 -0
- package/dist/state.d.ts +72 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +366 -0
- package/dist/state.js.map +1 -0
- package/dist/storage-utils.d.ts +8 -0
- package/dist/storage-utils.d.ts.map +1 -0
- package/dist/storage-utils.js +67 -0
- package/dist/storage-utils.js.map +1 -0
- package/dist/storage.d.ts +61 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +718 -0
- package/dist/storage.js.map +1 -0
- package/dist/sync.d.ts +58 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +216 -0
- package/dist/sync.js.map +1 -0
- package/package.json +40 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
import { GrantRequiredError, InvalidSignatureError, MissingAuthError, NotOwnerError, ProtocolError, PsUnavailableError, UnregisteredBuilderError, } from "@opendatalabs/personal-server-ts-core/errors";
|
|
2
|
+
import { authenticateRequest, } from "@opendatalabs/personal-server-ts-core/auth";
|
|
3
|
+
import { approveDeviceSessionContract, createMemoryDeviceSessionStore, initiateDeviceSessionContract, pollDeviceSessionContract, provisionDeviceTokenContract, revokeDeviceTokenContract, } from "@opendatalabs/personal-server-ts-core/contracts";
|
|
4
|
+
import { handlePersonalServerAccessLogsRequest, handlePersonalServerConfigRequest, handlePersonalServerDataRequest, handlePersonalServerGrantsRequest, handlePersonalServerOauthTokenRequest, handlePersonalServerSyncRequest, } from "@opendatalabs/personal-server-ts-core/api";
|
|
5
|
+
import { handlePersonalServerDerivativesRequest, } from "@opendatalabs/personal-server-ts-core/derivatives";
|
|
6
|
+
import { verifyDataReadPolicy, } from "@opendatalabs/personal-server-ts-core/policy";
|
|
7
|
+
import { createInMemoryWriteProofReplayStore, createWriteSessionAuthorization, handleWriteSessionRequest, } from "@opendatalabs/personal-server-ts-core/write";
|
|
8
|
+
import { approveMcpOAuthAuthorization, approveMcpOAuthAuthorizationWithScopes, approveMcpConnection, buildMcpProtectedResourceMetadataUrl, buildStableMcpUrl, createInMemoryMcpOAuthAuthorizationStore, createMcpConnection, createMcpOAuthAuthorization, createMcpDataReadClient, handleMcpStreamableHttpRequest, hashConnectionToken, listMcpConnectionViews, loadMcpGranteeAccount, McpConnectionNotFoundError, McpConnectionStateError, McpOAuthAuthorizationError, redeemMcpOAuthAuthorizationCode, refreshMcpOAuthToken, requestMcpScopeAccess, toMcpOAuthAuthorizationView, revokeMcpConnection, toMcpConnectionView, McpActivityRecorder, } from "@opendatalabs/personal-server-ts-core/mcp";
|
|
9
|
+
import { createDefaultPsLiteAccessLogStore, createDefaultPsLiteSaveConfig, createDefaultPsLiteTokenStore, } from "./state.js";
|
|
10
|
+
import { collectDiagnosticsWithTimeout, DiagnosticsRecorder, } from "./diagnostics.js";
|
|
11
|
+
function jsonResponse(body, init) {
|
|
12
|
+
const headers = new Headers(init?.headers);
|
|
13
|
+
headers.set("Content-Type", "application/json");
|
|
14
|
+
return new Response(JSON.stringify(body), { ...init, headers });
|
|
15
|
+
}
|
|
16
|
+
function protocolErrorResponse(err) {
|
|
17
|
+
return jsonResponse(err.toJSON(), { status: err.code });
|
|
18
|
+
}
|
|
19
|
+
function errorResponse(status, errorCode, message) {
|
|
20
|
+
return jsonResponse({
|
|
21
|
+
error: {
|
|
22
|
+
code: status,
|
|
23
|
+
errorCode,
|
|
24
|
+
message,
|
|
25
|
+
},
|
|
26
|
+
}, { status });
|
|
27
|
+
}
|
|
28
|
+
function mcpUnauthorized(origin, message = "MCP authorization required") {
|
|
29
|
+
return jsonResponse({
|
|
30
|
+
error: {
|
|
31
|
+
code: 401,
|
|
32
|
+
errorCode: "MCP_AUTH_REQUIRED",
|
|
33
|
+
message,
|
|
34
|
+
},
|
|
35
|
+
}, {
|
|
36
|
+
status: 401,
|
|
37
|
+
headers: {
|
|
38
|
+
"WWW-Authenticate": `Bearer resource_metadata="${buildMcpProtectedResourceMetadataUrl(origin)}", scope="vana:read"`,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Grant types this OAuth server implements — advertised and enforced here. */
|
|
43
|
+
const AUTHORIZATION_CODE_GRANT = "authorization_code";
|
|
44
|
+
const REFRESH_TOKEN_GRANT = "refresh_token";
|
|
45
|
+
const SUPPORTED_GRANT_TYPES = [
|
|
46
|
+
AUTHORIZATION_CODE_GRANT,
|
|
47
|
+
REFRESH_TOKEN_GRANT,
|
|
48
|
+
];
|
|
49
|
+
function isSupportedGrantType(value) {
|
|
50
|
+
return SUPPORTED_GRANT_TYPES.includes(value);
|
|
51
|
+
}
|
|
52
|
+
function authorizationServerMetadata(origin) {
|
|
53
|
+
return {
|
|
54
|
+
issuer: origin,
|
|
55
|
+
authorization_endpoint: `${origin}/mcp/oauth/authorize`,
|
|
56
|
+
token_endpoint: `${origin}/mcp/oauth/token`,
|
|
57
|
+
registration_endpoint: `${origin}/mcp/oauth/register`,
|
|
58
|
+
response_types_supported: ["code"],
|
|
59
|
+
grant_types_supported: [...SUPPORTED_GRANT_TYPES],
|
|
60
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
61
|
+
code_challenge_methods_supported: ["S256"],
|
|
62
|
+
scopes_supported: ["vana:read"],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function protectedResourceMetadata(origin) {
|
|
66
|
+
return {
|
|
67
|
+
resource: buildStableMcpUrl(origin),
|
|
68
|
+
authorization_servers: [origin],
|
|
69
|
+
bearer_methods_supported: ["header"],
|
|
70
|
+
scopes_supported: ["vana:read"],
|
|
71
|
+
resource_name: "Vana Personal Server MCP",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function resolveMcpApprovalUrl(value) {
|
|
75
|
+
if (!value)
|
|
76
|
+
return null;
|
|
77
|
+
return typeof value === "function" ? value() : value;
|
|
78
|
+
}
|
|
79
|
+
function redirectWithOAuthError(redirectUri, error, description, state) {
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(redirectUri);
|
|
82
|
+
url.searchParams.set("error", error);
|
|
83
|
+
url.searchParams.set("error_description", description);
|
|
84
|
+
if (state)
|
|
85
|
+
url.searchParams.set("state", state);
|
|
86
|
+
return Response.redirect(url.toString(), 302);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return jsonResponse({
|
|
90
|
+
error,
|
|
91
|
+
error_description: description,
|
|
92
|
+
...(state ? { state } : {}),
|
|
93
|
+
}, { status: 400 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function parseFormBody(request) {
|
|
97
|
+
return new URLSearchParams(await request.text());
|
|
98
|
+
}
|
|
99
|
+
function unavailableResponse() {
|
|
100
|
+
const err = new PsUnavailableError({
|
|
101
|
+
runtime: "ps-lite",
|
|
102
|
+
reason: "Browser runtime is inactive",
|
|
103
|
+
});
|
|
104
|
+
return protocolErrorResponse(err);
|
|
105
|
+
}
|
|
106
|
+
function parseBearerToken(request) {
|
|
107
|
+
const authorization = request.headers.get("authorization");
|
|
108
|
+
if (!authorization)
|
|
109
|
+
return null;
|
|
110
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization);
|
|
111
|
+
return match?.[1] ?? null;
|
|
112
|
+
}
|
|
113
|
+
function assertBearerToken(request, expectedToken, ownerOnly = false) {
|
|
114
|
+
const token = parseBearerToken(request);
|
|
115
|
+
if (!token) {
|
|
116
|
+
throw new MissingAuthError();
|
|
117
|
+
}
|
|
118
|
+
if (token !== expectedToken) {
|
|
119
|
+
throw ownerOnly ? new NotOwnerError() : new InvalidSignatureError();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function createMissingAuthAdapter() {
|
|
123
|
+
return {
|
|
124
|
+
async authorizeOwner() {
|
|
125
|
+
throw new MissingAuthError();
|
|
126
|
+
},
|
|
127
|
+
async authorizeBuilderList() {
|
|
128
|
+
throw new MissingAuthError();
|
|
129
|
+
},
|
|
130
|
+
async authorizeBuilderRead() {
|
|
131
|
+
throw new MissingAuthError();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The two write-session methods of the auth port, built on the SHARED core
|
|
137
|
+
* authorizer (`createWriteSessionAuthorization`) — the same code the Node
|
|
138
|
+
* server's adapter runs. `authorizeWrite` falls back to the adapter's owner
|
|
139
|
+
* gate for any request that carries no live session, so owner ingest behaves
|
|
140
|
+
* exactly as it did before write sessions existed.
|
|
141
|
+
*/
|
|
142
|
+
function writeSessionAuthMethods(writeSessions, authorizeOwner) {
|
|
143
|
+
const authorization = createWriteSessionAuthorization({
|
|
144
|
+
serverOrigin: writeSessions.serverOrigin,
|
|
145
|
+
serverOwner: writeSessions.serverOwner,
|
|
146
|
+
sessionStore: writeSessions.store,
|
|
147
|
+
replayStore: writeSessions.replayStore ?? createInMemoryWriteProofReplayStore(),
|
|
148
|
+
policyPorts: writeSessions.policyPorts,
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
async authorizeWrite(input) {
|
|
152
|
+
const delegated = await authorization.authorizeSessionWrite(input);
|
|
153
|
+
if (delegated)
|
|
154
|
+
return delegated;
|
|
155
|
+
await authorizeOwner(input.request);
|
|
156
|
+
},
|
|
157
|
+
async authorizeWriteSession(request) {
|
|
158
|
+
return authorization.recognizeWriteSession(request);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function createBearerTokenPsLiteAuth(options) {
|
|
163
|
+
const authorizeOwner = async (request) => {
|
|
164
|
+
assertBearerToken(request, options.ownerToken, true);
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
authorizeOwner,
|
|
168
|
+
...(options.writeSessions
|
|
169
|
+
? writeSessionAuthMethods(options.writeSessions, authorizeOwner)
|
|
170
|
+
: {}),
|
|
171
|
+
async authorizeBuilderList(request) {
|
|
172
|
+
if (parseBearerToken(request) === options.ownerToken)
|
|
173
|
+
return;
|
|
174
|
+
assertBearerToken(request, options.builderToken);
|
|
175
|
+
},
|
|
176
|
+
async authorizeBuilderRead(input) {
|
|
177
|
+
if (parseBearerToken(input.request) === options.ownerToken) {
|
|
178
|
+
return { builder: "owner", grantId: "owner" };
|
|
179
|
+
}
|
|
180
|
+
assertBearerToken(input.request, options.builderToken);
|
|
181
|
+
if (!input.grantId) {
|
|
182
|
+
throw new GrantRequiredError({
|
|
183
|
+
reason: "No grantId in request",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// This adapter echoes the caller-supplied grantId into the auth
|
|
187
|
+
// result, so it must never mint the server-internal sentinels that
|
|
188
|
+
// confer the unredacted owner view and the x402 payment exemption.
|
|
189
|
+
if (input.grantId === "owner" || input.grantId === "policy-bypass") {
|
|
190
|
+
throw new GrantRequiredError({
|
|
191
|
+
reason: "Reserved grantId",
|
|
192
|
+
grantId: input.grantId,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return { grantId: input.grantId };
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function authenticatePsLiteRequest(request, options) {
|
|
200
|
+
return authenticateRequest({
|
|
201
|
+
request,
|
|
202
|
+
serverOrigin: options.origin,
|
|
203
|
+
serverOwner: options.ownerAddress,
|
|
204
|
+
accessToken: options.accessToken,
|
|
205
|
+
sessionTokenVerifier: options.tokenStore,
|
|
206
|
+
now: options.now,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function isOwnerSigner(auth, ownerAddress) {
|
|
210
|
+
return auth.auth.signer.toLowerCase() === ownerAddress.toLowerCase();
|
|
211
|
+
}
|
|
212
|
+
function dataReadPolicyPortsRequired() {
|
|
213
|
+
return new ProtocolError(500, "SERVER_NOT_CONFIGURED", "Server is not configured", {
|
|
214
|
+
reason: "PS Lite read policy ports are not configured",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
export function createWeb3SignedPsLiteAuth(options) {
|
|
218
|
+
const authorizeOwner = async (request) => {
|
|
219
|
+
const auth = await authenticatePsLiteRequest(request, options);
|
|
220
|
+
if (!isOwnerSigner(auth, options.ownerAddress)) {
|
|
221
|
+
throw new NotOwnerError({
|
|
222
|
+
expected: options.ownerAddress,
|
|
223
|
+
actual: auth.auth.signer,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
authorizeOwner,
|
|
229
|
+
...(options.writeSessions
|
|
230
|
+
? writeSessionAuthMethods({
|
|
231
|
+
...options.writeSessions,
|
|
232
|
+
serverOrigin: options.origin,
|
|
233
|
+
serverOwner: options.ownerAddress,
|
|
234
|
+
}, authorizeOwner)
|
|
235
|
+
: {}),
|
|
236
|
+
async authorizeBuilderList(request) {
|
|
237
|
+
const auth = await authenticatePsLiteRequest(request, options);
|
|
238
|
+
if (auth.isPolicyBypass || isOwnerSigner(auth, options.ownerAddress)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!options.dataReadPolicyPorts) {
|
|
242
|
+
throw dataReadPolicyPortsRequired();
|
|
243
|
+
}
|
|
244
|
+
const builder = await options.dataReadPolicyPorts.authSessionVerifier.getBuilder(auth.auth.signer);
|
|
245
|
+
if (!builder) {
|
|
246
|
+
throw new UnregisteredBuilderError();
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
async authorizeBuilderRead(input) {
|
|
250
|
+
const auth = await authenticatePsLiteRequest(input.request, options);
|
|
251
|
+
if (auth.isPolicyBypass) {
|
|
252
|
+
return { builder: auth.auth.signer, grantId: "policy-bypass" };
|
|
253
|
+
}
|
|
254
|
+
if (isOwnerSigner(auth, options.ownerAddress)) {
|
|
255
|
+
return { builder: auth.auth.signer, grantId: "owner" };
|
|
256
|
+
}
|
|
257
|
+
if (!options.dataReadPolicyPorts) {
|
|
258
|
+
throw dataReadPolicyPortsRequired();
|
|
259
|
+
}
|
|
260
|
+
const grant = await verifyDataReadPolicy({
|
|
261
|
+
signer: auth.auth.signer,
|
|
262
|
+
grantId: auth.auth.payload.grantId ?? input.grantId,
|
|
263
|
+
requestedScope: input.scope,
|
|
264
|
+
fileId: input.fileId,
|
|
265
|
+
// Bind the read to this PS's owner — the grant must have been issued
|
|
266
|
+
// by us. Required now that the policy fails closed on a missing owner.
|
|
267
|
+
serverOwner: options.ownerAddress,
|
|
268
|
+
}, options.dataReadPolicyPorts);
|
|
269
|
+
return { builder: auth.auth.signer, grantId: grant.id };
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function toDataStoragePort(storage) {
|
|
274
|
+
if ("listScopes" in storage) {
|
|
275
|
+
return storage;
|
|
276
|
+
}
|
|
277
|
+
throw new Error("PS Lite runtime requires a persistent DataStoragePort. Use createIndexedDbPsLiteRuntime() or createPersistentPsLiteStorage().");
|
|
278
|
+
}
|
|
279
|
+
function createLogId() {
|
|
280
|
+
return globalThis.crypto?.randomUUID?.() ?? `log-${Date.now()}`;
|
|
281
|
+
}
|
|
282
|
+
function randomHex(byteLength) {
|
|
283
|
+
const bytes = new Uint8Array(byteLength);
|
|
284
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
285
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
286
|
+
}
|
|
287
|
+
function bearerToken(request) {
|
|
288
|
+
const authorization = request.headers.get("authorization");
|
|
289
|
+
if (!authorization?.startsWith("Bearer "))
|
|
290
|
+
return null;
|
|
291
|
+
return authorization.slice(7);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Detached copy of the gateway block the caller built the gateway client and
|
|
295
|
+
* the server signer from, so a later live config write cannot mutate it.
|
|
296
|
+
*/
|
|
297
|
+
function snapshotGatewayConfig(config) {
|
|
298
|
+
const gateway = config?.gateway;
|
|
299
|
+
if (!gateway)
|
|
300
|
+
return undefined;
|
|
301
|
+
return {
|
|
302
|
+
...gateway,
|
|
303
|
+
...(gateway.contracts ? { contracts: { ...gateway.contracts } } : {}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function resolveX402Settings(config, gateway = config?.gateway) {
|
|
307
|
+
// Paid reads need the complete EIP-712 gateway domain. A partial gateway is
|
|
308
|
+
// valid while payment is disabled, but must never enter the x402 path.
|
|
309
|
+
const paymentEnabled = config?.payment?.enabled ?? false;
|
|
310
|
+
const gatewayConfigComplete = Boolean(gateway &&
|
|
311
|
+
typeof gateway.url === "string" &&
|
|
312
|
+
typeof gateway.chainId === "number" &&
|
|
313
|
+
gateway.contracts &&
|
|
314
|
+
typeof gateway.contracts.dataPortabilityEscrow === "string" &&
|
|
315
|
+
typeof gateway.contracts.dataRegistry === "string");
|
|
316
|
+
if (paymentEnabled && !gatewayConfigComplete) {
|
|
317
|
+
throw new Error("PS-Lite x402 payment is enabled (config.payment.enabled) but the gateway " +
|
|
318
|
+
"config is incomplete. A complete gateway config — url, chainId, and " +
|
|
319
|
+
"contracts (dataPortabilityEscrow, dataRegistry) — is required to charge " +
|
|
320
|
+
"for reads. Provide the full config or disable payment.");
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
paymentEnabled,
|
|
324
|
+
gatewayConfig: gatewayConfigComplete
|
|
325
|
+
? gateway
|
|
326
|
+
: undefined,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
export function createPsLiteRuntime(options) {
|
|
330
|
+
let active = options.active ?? false;
|
|
331
|
+
const now = options.now ?? (() => new Date());
|
|
332
|
+
const auth = options.auth ?? createMissingAuthAdapter();
|
|
333
|
+
const dataStorage = toDataStoragePort(options.storage);
|
|
334
|
+
// Wire diagnostics by default so GET /v1/diagnostics is always available.
|
|
335
|
+
const diagnostics = options.diagnostics ?? new DiagnosticsRecorder();
|
|
336
|
+
options = { ...options, diagnostics };
|
|
337
|
+
diagnostics.push({ phase: "booting", detail: "runtime created" });
|
|
338
|
+
let accessLogReader = options.accessLogReader;
|
|
339
|
+
let accessLogWriter = options.accessLogWriter;
|
|
340
|
+
if (!accessLogReader || !accessLogWriter) {
|
|
341
|
+
const accessLogStore = createDefaultPsLiteAccessLogStore();
|
|
342
|
+
accessLogReader ??= accessLogStore;
|
|
343
|
+
accessLogWriter ??= accessLogStore;
|
|
344
|
+
}
|
|
345
|
+
const tokenStore = options.tokenStore ?? createDefaultPsLiteTokenStore();
|
|
346
|
+
const saveConfig = options.saveConfig ?? createDefaultPsLiteSaveConfig();
|
|
347
|
+
// The gateway client (options.gateway) and the server signer are built ONCE
|
|
348
|
+
// by the caller, from the startup gateway block: the gateway URL and the
|
|
349
|
+
// EIP-712 domain (chainId + contracts) are baked into them. A live config
|
|
350
|
+
// write persists a new gateway block but cannot rebuild those dependencies,
|
|
351
|
+
// so every gateway-derived request setting stays pinned to this snapshot —
|
|
352
|
+
// otherwise a config save (the UI PUTs a schema-defaulted config, so an
|
|
353
|
+
// omitted gateway block silently becomes the schema default) would issue
|
|
354
|
+
// x402 challenges, and query a gateway, for a domain the accessRecord
|
|
355
|
+
// signature was never made against. Gateway changes take effect on restart.
|
|
356
|
+
const boundGatewayConfig = snapshotGatewayConfig(options.config);
|
|
357
|
+
// Validate initial x402 readiness. The same derivation runs for every read
|
|
358
|
+
// and before every config write so payment policy cannot drift from the live
|
|
359
|
+
// config after a same-runtime update.
|
|
360
|
+
resolveX402Settings(options.config, boundGatewayConfig);
|
|
361
|
+
// The data handler's x402 path only needs signRecordDataAccess; narrow the
|
|
362
|
+
// (optional) signer so a grant-only signer doesn't masquerade as one that can
|
|
363
|
+
// sign access records.
|
|
364
|
+
const x402ServerSigner = options.serverSigner?.signRecordDataAccess
|
|
365
|
+
? { signRecordDataAccess: options.serverSigner.signRecordDataAccess }
|
|
366
|
+
: undefined;
|
|
367
|
+
// One replay guard for the handshake proof and (when the caller shares it
|
|
368
|
+
// with the auth adapter) the per-write proofs, matching createApp's single
|
|
369
|
+
// writeProofReplayStore in the Node build.
|
|
370
|
+
const writeProofReplayStore = options.writeProofReplayStore ?? createInMemoryWriteProofReplayStore();
|
|
371
|
+
const deviceSessions = createMemoryDeviceSessionStore();
|
|
372
|
+
const mcpOAuthAuthorizationStore = options.mcpOAuthAuthorizationStore ??
|
|
373
|
+
createInMemoryMcpOAuthAuthorizationStore();
|
|
374
|
+
const activityRecorder = options.mcpConnectionStore
|
|
375
|
+
? new McpActivityRecorder()
|
|
376
|
+
: undefined;
|
|
377
|
+
async function withProtocolErrors(handler) {
|
|
378
|
+
try {
|
|
379
|
+
return await handler();
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
if (err instanceof ProtocolError) {
|
|
383
|
+
return protocolErrorResponse(err);
|
|
384
|
+
}
|
|
385
|
+
return errorResponse(500, "INTERNAL_ERROR", "Internal server error");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function sendContractResult(result) {
|
|
389
|
+
return jsonResponse(result.body, { status: result.status });
|
|
390
|
+
}
|
|
391
|
+
function ownerAddress() {
|
|
392
|
+
return options.serverOwner ?? options.identity?.address;
|
|
393
|
+
}
|
|
394
|
+
async function handleAuthDevice(request, url) {
|
|
395
|
+
if (url.pathname === "/auth/device") {
|
|
396
|
+
if (request.method !== "POST") {
|
|
397
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
398
|
+
}
|
|
399
|
+
return sendContractResult(initiateDeviceSessionContract({
|
|
400
|
+
sessionStore: deviceSessions,
|
|
401
|
+
serverOwner: ownerAddress(),
|
|
402
|
+
requestOrigin: url.origin,
|
|
403
|
+
approvalOrigin: url.origin,
|
|
404
|
+
sessionId: randomHex(32),
|
|
405
|
+
pollToken: randomHex(32),
|
|
406
|
+
now: now().getTime(),
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
if (url.pathname === "/auth/device/poll") {
|
|
410
|
+
if (request.method !== "GET") {
|
|
411
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
412
|
+
}
|
|
413
|
+
return sendContractResult(pollDeviceSessionContract({
|
|
414
|
+
sessionStore: deviceSessions,
|
|
415
|
+
pollToken: url.searchParams.get("token"),
|
|
416
|
+
serverOwner: ownerAddress(),
|
|
417
|
+
now: now().getTime(),
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
if (url.pathname === "/auth/device/approve") {
|
|
421
|
+
const sessionId = url.searchParams.get("session");
|
|
422
|
+
if (!sessionId) {
|
|
423
|
+
return request.method === "GET"
|
|
424
|
+
? new Response("Missing session parameter", { status: 400 })
|
|
425
|
+
: jsonResponse({ error: { code: 400, message: "Missing session parameter" } }, { status: 400 });
|
|
426
|
+
}
|
|
427
|
+
const session = deviceSessions.get(sessionId);
|
|
428
|
+
if (!session) {
|
|
429
|
+
return request.method === "GET"
|
|
430
|
+
? new Response("Session expired or invalid", { status: 404 })
|
|
431
|
+
: jsonResponse({ error: { code: 404, message: "Session expired or invalid" } }, { status: 404 });
|
|
432
|
+
}
|
|
433
|
+
if (request.method === "GET") {
|
|
434
|
+
return new Response("Device authorization pending", {
|
|
435
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (request.method !== "POST") {
|
|
439
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
440
|
+
}
|
|
441
|
+
if (session.status === "approved") {
|
|
442
|
+
return jsonResponse({ status: "already_approved" });
|
|
443
|
+
}
|
|
444
|
+
await auth.authorizeOwner(request);
|
|
445
|
+
return sendContractResult(await approveDeviceSessionContract({
|
|
446
|
+
sessionStore: deviceSessions,
|
|
447
|
+
tokenStore,
|
|
448
|
+
sessionId,
|
|
449
|
+
serverOwner: ownerAddress(),
|
|
450
|
+
accessToken: `vana_ps_${randomHex(32)}`,
|
|
451
|
+
now: now().getTime(),
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
if (url.pathname === "/auth/device/token") {
|
|
455
|
+
if (request.method === "DELETE") {
|
|
456
|
+
const token = bearerToken(request);
|
|
457
|
+
if (!token) {
|
|
458
|
+
return jsonResponse({ error: { code: 401, message: "Missing Bearer token" } }, { status: 401 });
|
|
459
|
+
}
|
|
460
|
+
return sendContractResult(await revokeDeviceTokenContract({
|
|
461
|
+
tokenStore,
|
|
462
|
+
bearerToken: token,
|
|
463
|
+
}));
|
|
464
|
+
}
|
|
465
|
+
if (request.method !== "POST") {
|
|
466
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
467
|
+
}
|
|
468
|
+
const token = bearerToken(request);
|
|
469
|
+
if (!options.accessToken || token !== options.accessToken) {
|
|
470
|
+
return jsonResponse({
|
|
471
|
+
error: {
|
|
472
|
+
code: 403,
|
|
473
|
+
message: "Only control-plane tokens can provision Personal Server session tokens",
|
|
474
|
+
},
|
|
475
|
+
}, { status: 403 });
|
|
476
|
+
}
|
|
477
|
+
let body;
|
|
478
|
+
try {
|
|
479
|
+
body = (await request.json());
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return jsonResponse({ error: { code: 400, message: "Request body must be valid JSON" } }, { status: 400 });
|
|
483
|
+
}
|
|
484
|
+
if (!body.token || typeof body.token !== "string") {
|
|
485
|
+
return jsonResponse({ error: { code: 400, message: "Missing token" } }, { status: 400 });
|
|
486
|
+
}
|
|
487
|
+
return sendContractResult(await provisionDeviceTokenContract({
|
|
488
|
+
tokenStore,
|
|
489
|
+
body,
|
|
490
|
+
now: now().getTime(),
|
|
491
|
+
}));
|
|
492
|
+
}
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
kind: "ps-lite",
|
|
497
|
+
storage: options.storage,
|
|
498
|
+
activate() {
|
|
499
|
+
active = true;
|
|
500
|
+
options.syncManager?.start?.();
|
|
501
|
+
options.derivatives?.scheduler.start();
|
|
502
|
+
},
|
|
503
|
+
deactivate() {
|
|
504
|
+
active = false;
|
|
505
|
+
void options.syncManager?.stop?.();
|
|
506
|
+
options.derivatives?.scheduler.stop();
|
|
507
|
+
},
|
|
508
|
+
isAvailable() {
|
|
509
|
+
return active;
|
|
510
|
+
},
|
|
511
|
+
async fetch(request) {
|
|
512
|
+
return withProtocolErrors(async () => {
|
|
513
|
+
const url = new URL(request.url);
|
|
514
|
+
if (url.pathname === "/health") {
|
|
515
|
+
const apiOrigin = url.origin;
|
|
516
|
+
const identity = options.identity ?? null;
|
|
517
|
+
let serverId = null;
|
|
518
|
+
if (identity && options.gateway) {
|
|
519
|
+
try {
|
|
520
|
+
const server = await options.gateway.getServer(identity.address);
|
|
521
|
+
serverId = server?.id ?? null;
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
serverId = null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const registration = options.serverOwner && identity
|
|
528
|
+
? {
|
|
529
|
+
ownerAddress: options.serverOwner,
|
|
530
|
+
serverAddress: identity.address,
|
|
531
|
+
publicKey: identity.publicKey,
|
|
532
|
+
serverUrl: apiOrigin,
|
|
533
|
+
serverId,
|
|
534
|
+
registered: Boolean(serverId),
|
|
535
|
+
}
|
|
536
|
+
: null;
|
|
537
|
+
const capabilities = dataStorage.capabilities;
|
|
538
|
+
const stateCapabilities = {
|
|
539
|
+
tokens: tokenStore
|
|
540
|
+
.capabilities?.tokens ?? "custom",
|
|
541
|
+
accessLogs: accessLogReader.capabilities?.accessLogs ?? "custom",
|
|
542
|
+
config: options.stateCapabilities?.config ??
|
|
543
|
+
(options.saveConfig ? "custom" : "indexeddb"),
|
|
544
|
+
};
|
|
545
|
+
return jsonResponse({
|
|
546
|
+
status: active ? "healthy" : "unavailable",
|
|
547
|
+
runtime: "ps-lite",
|
|
548
|
+
storage: options.storage.kind,
|
|
549
|
+
capabilities: capabilities ?? null,
|
|
550
|
+
stateCapabilities,
|
|
551
|
+
owner: options.serverOwner ?? null,
|
|
552
|
+
apiOrigin,
|
|
553
|
+
gatewayUrl: options.config?.gateway?.url ?? null,
|
|
554
|
+
gatewayConfig: options.config?.gateway ?? null,
|
|
555
|
+
identity,
|
|
556
|
+
registration,
|
|
557
|
+
active,
|
|
558
|
+
checkedAt: now().toISOString(),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (url.pathname === "/v1/diagnostics") {
|
|
562
|
+
if (request.method !== "GET") {
|
|
563
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
await auth.authorizeOwner(request);
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
if (err instanceof ProtocolError) {
|
|
570
|
+
return protocolErrorResponse(err);
|
|
571
|
+
}
|
|
572
|
+
throw err;
|
|
573
|
+
}
|
|
574
|
+
if (!options.diagnostics) {
|
|
575
|
+
return jsonResponse({
|
|
576
|
+
error: {
|
|
577
|
+
code: 404,
|
|
578
|
+
errorCode: "DIAGNOSTICS_NOT_CONFIGURED",
|
|
579
|
+
message: "Diagnostics recorder not configured for this runtime",
|
|
580
|
+
},
|
|
581
|
+
}, { status: 404 });
|
|
582
|
+
}
|
|
583
|
+
const syncStatus = options.syncManager?.getStatus() ?? null;
|
|
584
|
+
const snapshot = await collectDiagnosticsWithTimeout(options.diagnostics, {
|
|
585
|
+
runtimeActive: active,
|
|
586
|
+
syncStatus,
|
|
587
|
+
storage: dataStorage,
|
|
588
|
+
});
|
|
589
|
+
return jsonResponse(snapshot);
|
|
590
|
+
}
|
|
591
|
+
if (!active) {
|
|
592
|
+
return unavailableResponse();
|
|
593
|
+
}
|
|
594
|
+
const dataPrefix = "/v1/data";
|
|
595
|
+
if (url.pathname === dataPrefix ||
|
|
596
|
+
url.pathname.startsWith(`${dataPrefix}/`)) {
|
|
597
|
+
const x402 = resolveX402Settings(options.config, boundGatewayConfig);
|
|
598
|
+
return handlePersonalServerDataRequest(request, {
|
|
599
|
+
storage: dataStorage,
|
|
600
|
+
auth,
|
|
601
|
+
accessLogWriter,
|
|
602
|
+
readFulfillmentReporter: options.readFulfillmentReporter,
|
|
603
|
+
syncManager: options.syncManager ?? null,
|
|
604
|
+
scopeDeletions: options.scopeDeletions,
|
|
605
|
+
now,
|
|
606
|
+
createLogId,
|
|
607
|
+
// x402 payment enforcement for builder reads. Only engages with a
|
|
608
|
+
// complete gateway config (validated at construction); a partial
|
|
609
|
+
// config leaves these undefined so reads are served free instead
|
|
610
|
+
// of yielding a malformed challenge.
|
|
611
|
+
paymentEnabled: x402.paymentEnabled,
|
|
612
|
+
gateway: options.gateway,
|
|
613
|
+
gatewayConfig: x402.gatewayConfig,
|
|
614
|
+
gatewayUrl: x402.gatewayConfig?.url,
|
|
615
|
+
serverAddress: options.identity?.address,
|
|
616
|
+
// The data owner signed into the RecordDataAccess attestation.
|
|
617
|
+
// Distinct from serverAddress (the server keypair): the core only
|
|
618
|
+
// embeds the x402 accessRecord when BOTH serverSigner and
|
|
619
|
+
// serverOwner are present (see x402.ts buildChallenge). We pass
|
|
620
|
+
// the real owner — not identity.address — so the on-chain
|
|
621
|
+
// recordDataAccess attributes the read to the correct owner; if
|
|
622
|
+
// serverOwner is absent the accessRecord is safely omitted.
|
|
623
|
+
serverOwner: options.serverOwner,
|
|
624
|
+
serverSigner: x402ServerSigner,
|
|
625
|
+
lineageGateway: options.lineageGateway,
|
|
626
|
+
// A new local version marks every question that reads the
|
|
627
|
+
// scope stale; the recompute waits for a reader.
|
|
628
|
+
onDataWritten: options.derivatives
|
|
629
|
+
? (event) => options.derivatives?.scheduler.markSourceChanged(event.scope, { lineageSources: event.lineageSources })
|
|
630
|
+
: undefined,
|
|
631
|
+
// And an authorized read of a derived scope is that reader.
|
|
632
|
+
onDataRead: options.derivatives
|
|
633
|
+
? (event) => options.derivatives?.scheduler.markDemand(event.scope)
|
|
634
|
+
: undefined,
|
|
635
|
+
}, { basePath: dataPrefix });
|
|
636
|
+
}
|
|
637
|
+
// Write API handshake. Mounted only when a session store is wired,
|
|
638
|
+
// so a runtime without delegated writes has no endpoint to probe.
|
|
639
|
+
if (url.pathname === "/v1/write/session") {
|
|
640
|
+
if (!options.writeSessionStore) {
|
|
641
|
+
return errorResponse(404, "NOT_FOUND", "Not found");
|
|
642
|
+
}
|
|
643
|
+
if (request.method !== "POST") {
|
|
644
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
645
|
+
}
|
|
646
|
+
if (!options.gateway) {
|
|
647
|
+
return errorResponse(500, "SERVER_NOT_CONFIGURED", "Gateway client is not configured");
|
|
648
|
+
}
|
|
649
|
+
return handleWriteSessionRequest(request, {
|
|
650
|
+
serverOrigin: options.serverOrigin ?? url.origin,
|
|
651
|
+
// The real data owner only — never the identity.address fallback
|
|
652
|
+
// the other routes accept. The grant's grantor must equal what
|
|
653
|
+
// the auth adapter binds delegated writes to, and a session
|
|
654
|
+
// minted against the SERVER key would authorize nothing. Absent
|
|
655
|
+
// = the handshake answers 500 rather than guessing.
|
|
656
|
+
serverOwner: options.serverOwner,
|
|
657
|
+
sessionStore: options.writeSessionStore,
|
|
658
|
+
replayStore: writeProofReplayStore,
|
|
659
|
+
authSessionVerifier: options.gateway,
|
|
660
|
+
grantVerifier: options.gateway,
|
|
661
|
+
accessToken: options.accessToken,
|
|
662
|
+
tokenStore,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
const derivativesPrefix = "/v1/derivatives";
|
|
666
|
+
if (url.pathname.startsWith(`${derivativesPrefix}/`)) {
|
|
667
|
+
return handlePersonalServerDerivativesRequest(request, { auth, compute: options.derivatives ?? null, now }, { basePath: derivativesPrefix });
|
|
668
|
+
}
|
|
669
|
+
if (url.pathname.startsWith("/auth/device")) {
|
|
670
|
+
const response = await handleAuthDevice(request, url);
|
|
671
|
+
if (response)
|
|
672
|
+
return response;
|
|
673
|
+
}
|
|
674
|
+
if (url.pathname === "/oauth/token") {
|
|
675
|
+
return handlePersonalServerOauthTokenRequest(request, {
|
|
676
|
+
tokenStore,
|
|
677
|
+
controlPlaneSecret: options.accessToken,
|
|
678
|
+
randomToken: () => `vana_ps_${randomHex(32)}`,
|
|
679
|
+
now,
|
|
680
|
+
deviceSessions: {
|
|
681
|
+
findByDeviceCode(deviceCode) {
|
|
682
|
+
const session = deviceSessions.findByPollToken(deviceCode);
|
|
683
|
+
if (!session)
|
|
684
|
+
return null;
|
|
685
|
+
return {
|
|
686
|
+
status: session.status,
|
|
687
|
+
accessToken: session.accessToken,
|
|
688
|
+
accessTokenExpiresAt: session.accessTokenExpiresAt,
|
|
689
|
+
sessionId: session.sessionId,
|
|
690
|
+
};
|
|
691
|
+
},
|
|
692
|
+
consume(sessionId) {
|
|
693
|
+
deviceSessions.delete(sessionId);
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const grantsPrefix = "/v1/grants";
|
|
699
|
+
if (url.pathname === grantsPrefix ||
|
|
700
|
+
url.pathname === `${grantsPrefix}/` ||
|
|
701
|
+
url.pathname.startsWith(`${grantsPrefix}/`)) {
|
|
702
|
+
return handlePersonalServerGrantsRequest(request, {
|
|
703
|
+
auth,
|
|
704
|
+
gateway: options.gateway,
|
|
705
|
+
gatewayConfig: options.config?.gateway,
|
|
706
|
+
serverOwner: options.serverOwner ?? options.identity?.address,
|
|
707
|
+
serverSigner: options.serverSigner,
|
|
708
|
+
now,
|
|
709
|
+
}, { basePath: grantsPrefix });
|
|
710
|
+
}
|
|
711
|
+
const accessLogsPrefix = "/v1/access-logs";
|
|
712
|
+
if (url.pathname === accessLogsPrefix ||
|
|
713
|
+
url.pathname === `${accessLogsPrefix}/`) {
|
|
714
|
+
return handlePersonalServerAccessLogsRequest(request, { auth, accessLogReader }, { basePath: accessLogsPrefix });
|
|
715
|
+
}
|
|
716
|
+
const syncPrefix = "/v1/sync";
|
|
717
|
+
if (url.pathname === `${syncPrefix}/trigger` ||
|
|
718
|
+
url.pathname === `${syncPrefix}/status` ||
|
|
719
|
+
url.pathname.startsWith(`${syncPrefix}/file/`)) {
|
|
720
|
+
return handlePersonalServerSyncRequest(request, { auth, syncManager: options.syncManager ?? null }, { basePath: syncPrefix });
|
|
721
|
+
}
|
|
722
|
+
if (url.pathname === "/ui/api/config") {
|
|
723
|
+
return handlePersonalServerConfigRequest(request, {
|
|
724
|
+
auth,
|
|
725
|
+
async readConfig() {
|
|
726
|
+
return options.config ?? {};
|
|
727
|
+
},
|
|
728
|
+
async writeConfig(config) {
|
|
729
|
+
// Validate the policy that will actually run: the incoming
|
|
730
|
+
// payment toggle against the pinned gateway, so a persisted
|
|
731
|
+
// config can never leave live reads without a usable domain.
|
|
732
|
+
resolveX402Settings(config, boundGatewayConfig);
|
|
733
|
+
await saveConfig(config);
|
|
734
|
+
// Reflect the persisted config in memory so reads (and
|
|
735
|
+
// GET /ui/api/config) stop serving the pre-write snapshot. The
|
|
736
|
+
// gateway block is reported as persisted but stays pinned for
|
|
737
|
+
// behavior until the next restart rebuilds gateway + signer.
|
|
738
|
+
if (options.config &&
|
|
739
|
+
typeof config === "object" &&
|
|
740
|
+
config !== null) {
|
|
741
|
+
Object.assign(options.config, config);
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
if (options.mcpConnectionStore) {
|
|
747
|
+
const mcpResponse = await handleMcpRoute({
|
|
748
|
+
request,
|
|
749
|
+
url,
|
|
750
|
+
store: options.mcpConnectionStore,
|
|
751
|
+
authorizationStore: mcpOAuthAuthorizationStore,
|
|
752
|
+
approvalUrl: options.mcpOAuthApprovalUrl,
|
|
753
|
+
auth,
|
|
754
|
+
dataStorage,
|
|
755
|
+
accessLogWriter,
|
|
756
|
+
readFulfillmentReporter: options.readFulfillmentReporter,
|
|
757
|
+
now,
|
|
758
|
+
runtimeAvailability: { isAvailable: () => active },
|
|
759
|
+
serverOrigin: url.origin,
|
|
760
|
+
gateway: options.gateway,
|
|
761
|
+
// Pinned like the x402 path: grant registration is signed with the
|
|
762
|
+
// startup EIP-712 domain, so the MCP route must see the same one.
|
|
763
|
+
gatewayConfig: boundGatewayConfig,
|
|
764
|
+
serverOwner: options.serverOwner ?? options.identity?.address,
|
|
765
|
+
serverSigner: options.serverSigner,
|
|
766
|
+
activityRecorder,
|
|
767
|
+
});
|
|
768
|
+
if (mcpResponse)
|
|
769
|
+
return mcpResponse;
|
|
770
|
+
}
|
|
771
|
+
return errorResponse(404, "NOT_FOUND", "Not found");
|
|
772
|
+
});
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* MCP route dispatcher used inside `createPsLiteRuntime.fetch`. Handles both
|
|
778
|
+
* the owner-only `/v1/mcp/connections` management endpoints and the public
|
|
779
|
+
* `/mcp/:connectionToken` Streamable HTTP endpoint that Claude dials.
|
|
780
|
+
*
|
|
781
|
+
* Returns `null` for paths that don't match — the caller continues to the
|
|
782
|
+
* 404. Errors are passed through; the caller is already inside
|
|
783
|
+
* `withProtocolErrors`.
|
|
784
|
+
*/
|
|
785
|
+
async function handleMcpRoute(input) {
|
|
786
|
+
const pathname = input.url.pathname;
|
|
787
|
+
const ownerPrefix = "/v1/mcp/connections";
|
|
788
|
+
const ownerAuthorizationPrefix = "/v1/mcp/oauth/authorizations";
|
|
789
|
+
if (pathname === "/.well-known/oauth-protected-resource/mcp") {
|
|
790
|
+
if (!resolveMcpApprovalUrl(input.approvalUrl)) {
|
|
791
|
+
return errorResponse(404, "MCP_OAUTH_NOT_CONFIGURED", "MCP OAuth is not configured");
|
|
792
|
+
}
|
|
793
|
+
return jsonResponse(protectedResourceMetadata(input.serverOrigin));
|
|
794
|
+
}
|
|
795
|
+
if (pathname === "/.well-known/oauth-authorization-server") {
|
|
796
|
+
if (!resolveMcpApprovalUrl(input.approvalUrl)) {
|
|
797
|
+
return errorResponse(404, "MCP_OAUTH_NOT_CONFIGURED", "MCP OAuth is not configured");
|
|
798
|
+
}
|
|
799
|
+
return jsonResponse(authorizationServerMetadata(input.serverOrigin));
|
|
800
|
+
}
|
|
801
|
+
if (pathname === "/mcp/oauth/register") {
|
|
802
|
+
if (!resolveMcpApprovalUrl(input.approvalUrl)) {
|
|
803
|
+
return errorResponse(404, "MCP_OAUTH_NOT_CONFIGURED", "MCP OAuth is not configured");
|
|
804
|
+
}
|
|
805
|
+
if (input.request.method !== "POST") {
|
|
806
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
807
|
+
}
|
|
808
|
+
let body = {};
|
|
809
|
+
try {
|
|
810
|
+
body = (await input.request.json());
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
body = {};
|
|
814
|
+
}
|
|
815
|
+
return jsonResponse({
|
|
816
|
+
client_id: `mcp-client-${crypto.randomUUID()}`,
|
|
817
|
+
client_name: body.client_name ?? "Claude",
|
|
818
|
+
redirect_uris: Array.isArray(body.redirect_uris)
|
|
819
|
+
? body.redirect_uris
|
|
820
|
+
: [],
|
|
821
|
+
token_endpoint_auth_method: "none",
|
|
822
|
+
grant_types: [...SUPPORTED_GRANT_TYPES],
|
|
823
|
+
response_types: ["code"],
|
|
824
|
+
}, { status: 201 });
|
|
825
|
+
}
|
|
826
|
+
if (pathname === "/mcp/oauth/authorize") {
|
|
827
|
+
if (!resolveMcpApprovalUrl(input.approvalUrl)) {
|
|
828
|
+
return errorResponse(404, "MCP_OAUTH_NOT_CONFIGURED", "MCP OAuth is not configured");
|
|
829
|
+
}
|
|
830
|
+
if (input.request.method !== "GET") {
|
|
831
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
832
|
+
}
|
|
833
|
+
const responseType = input.url.searchParams.get("response_type");
|
|
834
|
+
const clientId = input.url.searchParams.get("client_id") ?? "";
|
|
835
|
+
const redirectUri = input.url.searchParams.get("redirect_uri") ?? "";
|
|
836
|
+
const codeChallenge = input.url.searchParams.get("code_challenge") ?? "";
|
|
837
|
+
const codeChallengeMethod = input.url.searchParams.get("code_challenge_method") ?? "";
|
|
838
|
+
const state = input.url.searchParams.get("state");
|
|
839
|
+
const scope = input.url.searchParams.get("scope") ?? "vana:read";
|
|
840
|
+
if (responseType !== "code") {
|
|
841
|
+
return redirectWithOAuthError(redirectUri, "unsupported_response_type", "Only response_type=code is supported", state);
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
const created = await createMcpOAuthAuthorization({
|
|
845
|
+
clientId,
|
|
846
|
+
redirectUri,
|
|
847
|
+
codeChallenge,
|
|
848
|
+
codeChallengeMethod,
|
|
849
|
+
scope,
|
|
850
|
+
...(state ? { state } : {}),
|
|
851
|
+
}, {
|
|
852
|
+
connectionStore: input.store,
|
|
853
|
+
authorizationStore: input.authorizationStore,
|
|
854
|
+
publicOrigin: input.serverOrigin,
|
|
855
|
+
now: input.now,
|
|
856
|
+
});
|
|
857
|
+
const approvalUrl = resolveMcpApprovalUrl(input.approvalUrl);
|
|
858
|
+
if (!approvalUrl) {
|
|
859
|
+
return errorResponse(500, "MCP_APPROVAL_URL_MISSING", "MCP OAuth approval URL is not configured");
|
|
860
|
+
}
|
|
861
|
+
const approve = new URL(approvalUrl);
|
|
862
|
+
approve.searchParams.set("mcp_authorization", created.authorizationId);
|
|
863
|
+
approve.searchParams.set("ps_origin", input.serverOrigin);
|
|
864
|
+
return Response.redirect(approve.toString(), 302);
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
868
|
+
return redirectWithOAuthError(redirectUri, err instanceof McpOAuthAuthorizationError
|
|
869
|
+
? err.code
|
|
870
|
+
: "invalid_request", message, state);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (pathname === "/mcp/oauth/token") {
|
|
874
|
+
if (!resolveMcpApprovalUrl(input.approvalUrl)) {
|
|
875
|
+
return errorResponse(404, "MCP_OAUTH_NOT_CONFIGURED", "MCP OAuth is not configured");
|
|
876
|
+
}
|
|
877
|
+
if (input.request.method !== "POST") {
|
|
878
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
879
|
+
}
|
|
880
|
+
const body = await parseFormBody(input.request);
|
|
881
|
+
const grantType = body.get("grant_type") ?? "";
|
|
882
|
+
if (!isSupportedGrantType(grantType)) {
|
|
883
|
+
return jsonResponse({
|
|
884
|
+
error: "unsupported_grant_type",
|
|
885
|
+
error_description: `Supported grant types: ${SUPPORTED_GRANT_TYPES.join(", ")}`,
|
|
886
|
+
}, { status: 400 });
|
|
887
|
+
}
|
|
888
|
+
const clientId = body.get("client_id") ?? "";
|
|
889
|
+
try {
|
|
890
|
+
const token = grantType === REFRESH_TOKEN_GRANT
|
|
891
|
+
? await refreshMcpOAuthToken({ refreshToken: body.get("refresh_token") ?? "", clientId }, { connectionStore: input.store, now: input.now })
|
|
892
|
+
: await redeemMcpOAuthAuthorizationCode({
|
|
893
|
+
authorizationCode: body.get("code") ?? "",
|
|
894
|
+
codeVerifier: body.get("code_verifier") ?? "",
|
|
895
|
+
clientId,
|
|
896
|
+
redirectUri: body.get("redirect_uri") ?? "",
|
|
897
|
+
}, {
|
|
898
|
+
authorizationStore: input.authorizationStore,
|
|
899
|
+
connectionStore: input.store,
|
|
900
|
+
now: input.now,
|
|
901
|
+
});
|
|
902
|
+
return jsonResponse({
|
|
903
|
+
access_token: token.accessToken,
|
|
904
|
+
token_type: "Bearer",
|
|
905
|
+
expires_in: token.expiresIn,
|
|
906
|
+
refresh_token: token.refreshToken,
|
|
907
|
+
...(token.scope ? { scope: token.scope } : {}),
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
catch (err) {
|
|
911
|
+
return jsonResponse({
|
|
912
|
+
error: err instanceof McpOAuthAuthorizationError
|
|
913
|
+
? err.code
|
|
914
|
+
: "invalid_grant",
|
|
915
|
+
error_description: err instanceof Error ? err.message : String(err),
|
|
916
|
+
}, { status: 400 });
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (pathname === ownerAuthorizationPrefix ||
|
|
920
|
+
pathname.startsWith(`${ownerAuthorizationPrefix}/`)) {
|
|
921
|
+
try {
|
|
922
|
+
await input.auth.authorizeOwner(input.request);
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
if (err instanceof ProtocolError) {
|
|
926
|
+
return protocolErrorResponse(err);
|
|
927
|
+
}
|
|
928
|
+
throw err;
|
|
929
|
+
}
|
|
930
|
+
const tail = pathname.slice(ownerAuthorizationPrefix.length + 1);
|
|
931
|
+
const [id, action] = tail.split("/");
|
|
932
|
+
if (!id) {
|
|
933
|
+
return errorResponse(404, "NOT_FOUND", "Not found");
|
|
934
|
+
}
|
|
935
|
+
if (!action && input.request.method === "GET") {
|
|
936
|
+
const record = await input.authorizationStore.getById(id);
|
|
937
|
+
if (!record) {
|
|
938
|
+
return errorResponse(404, "NOT_FOUND", "Authorization not found");
|
|
939
|
+
}
|
|
940
|
+
return jsonResponse(toMcpOAuthAuthorizationView(record));
|
|
941
|
+
}
|
|
942
|
+
if (action === "approve") {
|
|
943
|
+
if (input.request.method !== "POST") {
|
|
944
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
945
|
+
}
|
|
946
|
+
let body = {};
|
|
947
|
+
try {
|
|
948
|
+
body = (await input.request.json());
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
return errorResponse(400, "INVALID_BODY", "Body must be JSON");
|
|
952
|
+
}
|
|
953
|
+
if (Array.isArray(body.scopes) && body.scopes.length > 0) {
|
|
954
|
+
if (!input.gateway || !input.gatewayConfig?.url) {
|
|
955
|
+
return errorResponse(500, "SERVER_NOT_CONFIGURED", "Gateway config is not configured");
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const approved = await approveMcpOAuthAuthorizationWithScopes({
|
|
959
|
+
authorizationId: id,
|
|
960
|
+
scopes: body.scopes,
|
|
961
|
+
...(body.expiresAt !== undefined
|
|
962
|
+
? { expiresAt: body.expiresAt }
|
|
963
|
+
: {}),
|
|
964
|
+
...(body.nonce !== undefined ? { nonce: body.nonce } : {}),
|
|
965
|
+
}, {
|
|
966
|
+
connectionStore: input.store,
|
|
967
|
+
authorizationStore: input.authorizationStore,
|
|
968
|
+
gateway: input.gateway,
|
|
969
|
+
gatewayConfig: input.gatewayConfig,
|
|
970
|
+
gatewayUrl: input.gatewayConfig.url,
|
|
971
|
+
serverOwner: input.serverOwner,
|
|
972
|
+
serverSigner: input.serverSigner,
|
|
973
|
+
now: input.now,
|
|
974
|
+
});
|
|
975
|
+
return jsonResponse({ redirectTo: approved.redirectTo });
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
if (err instanceof McpOAuthAuthorizationError) {
|
|
979
|
+
return errorResponse(err.status, err.code, err.message);
|
|
980
|
+
}
|
|
981
|
+
throw err;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (!Array.isArray(body.grants) || body.grants.length === 0) {
|
|
985
|
+
return errorResponse(400, "GRANTS_REQUIRED", "Approve requires grants or scopes");
|
|
986
|
+
}
|
|
987
|
+
try {
|
|
988
|
+
const approved = await approveMcpOAuthAuthorization({ authorizationId: id, grants: body.grants }, {
|
|
989
|
+
connectionStore: input.store,
|
|
990
|
+
authorizationStore: input.authorizationStore,
|
|
991
|
+
now: input.now,
|
|
992
|
+
});
|
|
993
|
+
return jsonResponse({ redirectTo: approved.redirectTo });
|
|
994
|
+
}
|
|
995
|
+
catch (err) {
|
|
996
|
+
if (err instanceof McpOAuthAuthorizationError) {
|
|
997
|
+
return errorResponse(err.status, err.code, err.message);
|
|
998
|
+
}
|
|
999
|
+
throw err;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return errorResponse(404, "NOT_FOUND", "Not found");
|
|
1003
|
+
}
|
|
1004
|
+
// Owner management endpoints
|
|
1005
|
+
if (pathname === ownerPrefix || pathname.startsWith(`${ownerPrefix}/`)) {
|
|
1006
|
+
try {
|
|
1007
|
+
await input.auth.authorizeOwner(input.request);
|
|
1008
|
+
}
|
|
1009
|
+
catch (err) {
|
|
1010
|
+
if (err instanceof ProtocolError) {
|
|
1011
|
+
return protocolErrorResponse(err);
|
|
1012
|
+
}
|
|
1013
|
+
throw err;
|
|
1014
|
+
}
|
|
1015
|
+
// POST /v1/mcp/connections — create
|
|
1016
|
+
if (pathname === ownerPrefix) {
|
|
1017
|
+
if (input.request.method === "POST") {
|
|
1018
|
+
let body = {};
|
|
1019
|
+
try {
|
|
1020
|
+
body = (await input.request.json());
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
body = {};
|
|
1024
|
+
}
|
|
1025
|
+
const created = await createMcpConnection({ displayName: body.displayName }, {
|
|
1026
|
+
store: input.store,
|
|
1027
|
+
publicOrigin: input.serverOrigin,
|
|
1028
|
+
now: input.now,
|
|
1029
|
+
});
|
|
1030
|
+
return jsonResponse(created, { status: 201 });
|
|
1031
|
+
}
|
|
1032
|
+
if (input.request.method === "GET") {
|
|
1033
|
+
const records = await listMcpConnectionViews(input.store);
|
|
1034
|
+
return jsonResponse({ connections: records });
|
|
1035
|
+
}
|
|
1036
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
1037
|
+
}
|
|
1038
|
+
// /v1/mcp/connections/:id[/approve]
|
|
1039
|
+
const tail = pathname.slice(ownerPrefix.length + 1);
|
|
1040
|
+
const [id, action] = tail.split("/");
|
|
1041
|
+
if (!id) {
|
|
1042
|
+
return errorResponse(404, "NOT_FOUND", "Not found");
|
|
1043
|
+
}
|
|
1044
|
+
if (action === "approve") {
|
|
1045
|
+
if (input.request.method !== "POST") {
|
|
1046
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
1047
|
+
}
|
|
1048
|
+
let body = {};
|
|
1049
|
+
try {
|
|
1050
|
+
body = (await input.request.json());
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
return errorResponse(400, "INVALID_BODY", "Body must be JSON");
|
|
1054
|
+
}
|
|
1055
|
+
if (!Array.isArray(body.grants) || body.grants.length === 0) {
|
|
1056
|
+
return errorResponse(400, "GRANTS_REQUIRED", "Approve requires at least one grant — mint grants in the consent flow first");
|
|
1057
|
+
}
|
|
1058
|
+
try {
|
|
1059
|
+
const updated = await approveMcpConnection({ connectionId: id, grants: body.grants }, { store: input.store, now: input.now });
|
|
1060
|
+
return jsonResponse(toMcpConnectionView(updated));
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
if (err instanceof McpConnectionNotFoundError) {
|
|
1064
|
+
return errorResponse(404, "NOT_FOUND", err.message);
|
|
1065
|
+
}
|
|
1066
|
+
if (err instanceof McpConnectionStateError) {
|
|
1067
|
+
return errorResponse(409, "INVALID_STATE", err.message);
|
|
1068
|
+
}
|
|
1069
|
+
throw err;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (!action) {
|
|
1073
|
+
if (input.request.method === "DELETE") {
|
|
1074
|
+
try {
|
|
1075
|
+
const updated = await revokeMcpConnection(id, {
|
|
1076
|
+
store: input.store,
|
|
1077
|
+
now: input.now,
|
|
1078
|
+
});
|
|
1079
|
+
return jsonResponse(toMcpConnectionView(updated));
|
|
1080
|
+
}
|
|
1081
|
+
catch (err) {
|
|
1082
|
+
if (err instanceof McpConnectionNotFoundError) {
|
|
1083
|
+
return errorResponse(404, "NOT_FOUND", err.message);
|
|
1084
|
+
}
|
|
1085
|
+
throw err;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
1089
|
+
}
|
|
1090
|
+
return errorResponse(404, "NOT_FOUND", "Not found");
|
|
1091
|
+
}
|
|
1092
|
+
async function handleMcpToken(rawToken, options) {
|
|
1093
|
+
if (!rawToken) {
|
|
1094
|
+
if (!options.oauthChallenge) {
|
|
1095
|
+
return errorResponse(401, "INVALID_TOKEN", "Missing MCP connection token");
|
|
1096
|
+
}
|
|
1097
|
+
return mcpUnauthorized(input.serverOrigin);
|
|
1098
|
+
}
|
|
1099
|
+
const tokenHash = await hashConnectionToken(rawToken);
|
|
1100
|
+
const record = await input.store.getByTokenHash(tokenHash);
|
|
1101
|
+
if (!record) {
|
|
1102
|
+
if (!options.oauthChallenge) {
|
|
1103
|
+
return errorResponse(401, "INVALID_TOKEN", "Unknown or revoked MCP connection");
|
|
1104
|
+
}
|
|
1105
|
+
return mcpUnauthorized(input.serverOrigin, "Unknown or revoked MCP connection");
|
|
1106
|
+
}
|
|
1107
|
+
const granteeAccount = loadMcpGranteeAccount({
|
|
1108
|
+
address: record.granteeAddress,
|
|
1109
|
+
publicKey: record.granteePublicKey,
|
|
1110
|
+
encryptedPrivateKey: record.encryptedGranteePrivateKey,
|
|
1111
|
+
});
|
|
1112
|
+
const readClient = createMcpDataReadClient({
|
|
1113
|
+
serverOrigin: input.serverOrigin,
|
|
1114
|
+
granteeAccount,
|
|
1115
|
+
dataApiDeps: {
|
|
1116
|
+
storage: input.dataStorage,
|
|
1117
|
+
auth: input.auth,
|
|
1118
|
+
accessLogWriter: input.accessLogWriter,
|
|
1119
|
+
readFulfillmentReporter: input.readFulfillmentReporter,
|
|
1120
|
+
runtimeAvailability: input.runtimeAvailability,
|
|
1121
|
+
now: input.now,
|
|
1122
|
+
createLogId,
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
const response = await handleMcpStreamableHttpRequest(input.request, {
|
|
1126
|
+
connection: record,
|
|
1127
|
+
readClient,
|
|
1128
|
+
activityRecorder: input.activityRecorder,
|
|
1129
|
+
requestScopeAccess: async (request) => {
|
|
1130
|
+
return requestMcpScopeAccess({ connectionId: record.id, ...request }, { store: input.store, now: input.now });
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
void input.store
|
|
1134
|
+
.update(record.id, {
|
|
1135
|
+
lastUsedAt: input.now().toISOString(),
|
|
1136
|
+
})
|
|
1137
|
+
.catch((err) => {
|
|
1138
|
+
console.warn("[mcp] failed to update lastUsedAt", err);
|
|
1139
|
+
});
|
|
1140
|
+
return response;
|
|
1141
|
+
}
|
|
1142
|
+
// Owner-only activity feed endpoint.
|
|
1143
|
+
if (pathname === "/v1/mcp/activity") {
|
|
1144
|
+
try {
|
|
1145
|
+
await input.auth.authorizeOwner(input.request);
|
|
1146
|
+
}
|
|
1147
|
+
catch (err) {
|
|
1148
|
+
if (err instanceof ProtocolError) {
|
|
1149
|
+
return protocolErrorResponse(err);
|
|
1150
|
+
}
|
|
1151
|
+
throw err;
|
|
1152
|
+
}
|
|
1153
|
+
if (input.request.method !== "GET") {
|
|
1154
|
+
return errorResponse(405, "METHOD_NOT_ALLOWED", "Method not allowed");
|
|
1155
|
+
}
|
|
1156
|
+
const snapshot = input.activityRecorder
|
|
1157
|
+
? input.activityRecorder.snapshot()
|
|
1158
|
+
: { events: [], running: 0, total: 0 };
|
|
1159
|
+
return jsonResponse(snapshot);
|
|
1160
|
+
}
|
|
1161
|
+
// Claude-facing stable endpoint: /mcp with Bearer token.
|
|
1162
|
+
if (pathname === "/mcp") {
|
|
1163
|
+
return handleMcpToken(bearerToken(input.request), {
|
|
1164
|
+
oauthChallenge: Boolean(resolveMcpApprovalUrl(input.approvalUrl)),
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
// Legacy Claude-facing endpoint: /mcp/:token
|
|
1168
|
+
const mcpPrefix = "/mcp/";
|
|
1169
|
+
if (pathname.startsWith(mcpPrefix)) {
|
|
1170
|
+
const rawToken = decodeURIComponent(pathname.slice(mcpPrefix.length));
|
|
1171
|
+
if (!rawToken || rawToken.includes("/")) {
|
|
1172
|
+
return errorResponse(404, "NOT_FOUND", "Not found");
|
|
1173
|
+
}
|
|
1174
|
+
return handleMcpToken(rawToken, { oauthChallenge: false });
|
|
1175
|
+
}
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
//# sourceMappingURL=runtime.js.map
|