@standardagents/builder 0.29.2 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/built-in-routes.js +188 -2
- package/dist/built-in-routes.js.map +1 -1
- package/dist/index.js +681 -17
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +66 -5
- package/dist/plugin.js.map +1 -1
- package/dist/runtime.d.ts +54 -2
- package/dist/runtime.js +615 -12
- package/dist/runtime.js.map +1 -1
- package/package.json +5 -5
package/dist/runtime.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getProviderModelPricing } from '@standardagents/provider-pricing';
|
|
2
2
|
import { jsonPathReader } from '@formkit/jsonreader';
|
|
3
|
-
import { ProviderError, mapReasoningLevel, THREAD_STREAM_REASONING_PARAM } from '@standardagents/spec';
|
|
3
|
+
import { ProviderError, mapReasoningLevel, THREAD_STREAM_REASONING_PARAM, isUserEndpoint } from '@standardagents/spec';
|
|
4
4
|
export { ProviderError, belongsToPackage, defineAgent, defineHook, defineModel, definePrompt, defineTool, isPacked, isVisibleInNamespace, mapReasoningLevel } from '@standardagents/spec';
|
|
5
5
|
import 'virtual:@standardagents-providers';
|
|
6
6
|
import { z } from 'zod';
|
|
@@ -14089,10 +14089,35 @@ var init_ThreadStateImpl = __esm({
|
|
|
14089
14089
|
return { parentThreadId, parentStub };
|
|
14090
14090
|
}
|
|
14091
14091
|
_getAgentBuilderStub() {
|
|
14092
|
-
const env = this.
|
|
14092
|
+
const env = this._getRuntimeEnv();
|
|
14093
14093
|
const agentBuilderId = env.AGENT_BUILDER.idFromName("singleton");
|
|
14094
14094
|
return env.AGENT_BUILDER.get(agentBuilderId);
|
|
14095
14095
|
}
|
|
14096
|
+
/**
|
|
14097
|
+
* Environment bindings for this state. In-DO construction uses the thread
|
|
14098
|
+
* instance's own env; stub-backed states (thread endpoints, the user
|
|
14099
|
+
* scope's `thread()`) have no `instance.env` over RPC and use the env the
|
|
14100
|
+
* dispatcher recorded in the runtime context instead.
|
|
14101
|
+
*/
|
|
14102
|
+
_getRuntimeEnv() {
|
|
14103
|
+
const instanceEnv = this._threadInstance.env;
|
|
14104
|
+
const env = instanceEnv?.AGENT_BUILDER ? instanceEnv : this._runtimeContext.env;
|
|
14105
|
+
if (!env?.AGENT_BUILDER) {
|
|
14106
|
+
throw new Error(
|
|
14107
|
+
"No runtime environment available on this ThreadState. Stub-backed states must be constructed with runtimeContext.env."
|
|
14108
|
+
);
|
|
14109
|
+
}
|
|
14110
|
+
return env;
|
|
14111
|
+
}
|
|
14112
|
+
/** The per-user hub stub for this thread's user (user-scope surfaces). */
|
|
14113
|
+
_getUserHubStub(userId) {
|
|
14114
|
+
const env = this._getRuntimeEnv();
|
|
14115
|
+
const namespace = env.AGENT_BUILDER_USER_HUB;
|
|
14116
|
+
if (!namespace) {
|
|
14117
|
+
throw new Error("User scope unavailable: AGENT_BUILDER_USER_HUB binding is missing.");
|
|
14118
|
+
}
|
|
14119
|
+
return namespace.get(namespace.idFromName(userId));
|
|
14120
|
+
}
|
|
14096
14121
|
// ─────────────────────────────────────────────────────────────────────────
|
|
14097
14122
|
// Skills — instance-global Agent Skills library, backed by the
|
|
14098
14123
|
// DurableAgentBuilder singleton (skills are never stored per-thread or on
|
|
@@ -14109,7 +14134,67 @@ var init_ThreadStateImpl = __esm({
|
|
|
14109
14134
|
const stub = this._getAgentBuilderStub();
|
|
14110
14135
|
const user = await stub.getUserById(userId).catch(() => null);
|
|
14111
14136
|
if (!user) return null;
|
|
14137
|
+
const inUserScope = this._runtimeContext.inUserScope === true;
|
|
14138
|
+
const agentId = this._metadata.agent_id;
|
|
14139
|
+
const hub = () => this._getUserHubStub(userId);
|
|
14140
|
+
const capabilities = {
|
|
14141
|
+
call: async (endpoint, payload) => {
|
|
14142
|
+
if (inUserScope) {
|
|
14143
|
+
throw new Error(
|
|
14144
|
+
"Reentrant user-scope call: this ThreadState was raised from user-scope code; state.user().call() is unavailable here."
|
|
14145
|
+
);
|
|
14146
|
+
}
|
|
14147
|
+
const path = endpoint.replace(/^\/+/, "");
|
|
14148
|
+
return hub().fetch(
|
|
14149
|
+
new Request(`https://user-hub/users/${encodeURIComponent(userId)}/endpoints/${path}`, {
|
|
14150
|
+
method: "POST",
|
|
14151
|
+
headers: {
|
|
14152
|
+
"Content-Type": "application/json",
|
|
14153
|
+
// Runtime-internal caller channel — the public routes overwrite
|
|
14154
|
+
// this header, so only thread-scope code can present "thread".
|
|
14155
|
+
"X-Standard-Agents-Caller": "thread"
|
|
14156
|
+
},
|
|
14157
|
+
body: payload === void 0 ? void 0 : JSON.stringify(payload)
|
|
14158
|
+
})
|
|
14159
|
+
);
|
|
14160
|
+
},
|
|
14161
|
+
getValue: async (key) => {
|
|
14162
|
+
return await stub.getUserValue?.(userId, key);
|
|
14163
|
+
},
|
|
14164
|
+
setValue: async (key, value) => {
|
|
14165
|
+
await stub.setUserValue?.(userId, key, value);
|
|
14166
|
+
},
|
|
14167
|
+
emit: (event, data) => {
|
|
14168
|
+
const delivery = hub().broadcastUserEvent(userId, event, data).catch(() => {
|
|
14169
|
+
});
|
|
14170
|
+
this._pendingEventDeliveries.add(delivery);
|
|
14171
|
+
void delivery.finally(
|
|
14172
|
+
() => this._pendingEventDeliveries.delete(delivery)
|
|
14173
|
+
);
|
|
14174
|
+
},
|
|
14175
|
+
fetch: async (grant, input, init) => {
|
|
14176
|
+
const body = init?.body;
|
|
14177
|
+
if (body !== void 0 && body !== null && typeof body !== "string") {
|
|
14178
|
+
throw new Error("Grant fetch bodies must be strings in this runtime.");
|
|
14179
|
+
}
|
|
14180
|
+
return hub().fetch(
|
|
14181
|
+
new Request(`https://user-hub/users/${encodeURIComponent(userId)}/grant-fetch`, {
|
|
14182
|
+
method: "POST",
|
|
14183
|
+
headers: { "Content-Type": "application/json" },
|
|
14184
|
+
body: JSON.stringify({
|
|
14185
|
+
grant,
|
|
14186
|
+
url: String(input),
|
|
14187
|
+
method: init?.method ?? "GET",
|
|
14188
|
+
headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
|
|
14189
|
+
body: body ?? null,
|
|
14190
|
+
caller: { kind: "agent", name: agentId }
|
|
14191
|
+
})
|
|
14192
|
+
})
|
|
14193
|
+
);
|
|
14194
|
+
}
|
|
14195
|
+
};
|
|
14112
14196
|
return {
|
|
14197
|
+
...capabilities,
|
|
14113
14198
|
id: user.id,
|
|
14114
14199
|
username: user.username,
|
|
14115
14200
|
role: user.role,
|
|
@@ -14319,7 +14404,9 @@ function createThreadEndpointHandler(handler) {
|
|
|
14319
14404
|
const durableId = env.AGENT_BUILDER_THREAD.idFromName(threadId);
|
|
14320
14405
|
const stub = env.AGENT_BUILDER_THREAD.get(durableId);
|
|
14321
14406
|
const instance = stub;
|
|
14322
|
-
const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata
|
|
14407
|
+
const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata, {
|
|
14408
|
+
env
|
|
14409
|
+
});
|
|
14323
14410
|
const endpointParams = {};
|
|
14324
14411
|
for (const [key, value] of Object.entries(params)) {
|
|
14325
14412
|
if (key === "threadId" || key === "id" && !params.threadId) {
|
|
@@ -26199,6 +26286,232 @@ function formatMs(ms) {
|
|
|
26199
26286
|
const minutes = Math.floor(seconds / 60);
|
|
26200
26287
|
return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
26201
26288
|
}
|
|
26289
|
+
|
|
26290
|
+
// src/users/route-match.ts
|
|
26291
|
+
var METHOD_SUFFIXES = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete"]);
|
|
26292
|
+
function parseUserRouteKey(key) {
|
|
26293
|
+
const parts = key.split("/");
|
|
26294
|
+
let file = parts.pop() ?? "";
|
|
26295
|
+
let method = "GET";
|
|
26296
|
+
const dot = file.lastIndexOf(".");
|
|
26297
|
+
if (dot >= 0) {
|
|
26298
|
+
const suffix = file.slice(dot + 1).toLowerCase();
|
|
26299
|
+
if (METHOD_SUFFIXES.has(suffix)) {
|
|
26300
|
+
method = suffix.toUpperCase();
|
|
26301
|
+
file = file.slice(0, dot);
|
|
26302
|
+
}
|
|
26303
|
+
}
|
|
26304
|
+
const segments = [...parts];
|
|
26305
|
+
if (file !== "index") segments.push(file);
|
|
26306
|
+
let dynamism = 0;
|
|
26307
|
+
for (const segment of segments) {
|
|
26308
|
+
if (segment === "[*]") dynamism = 2;
|
|
26309
|
+
else if (dynamism < 2 && segment.startsWith("[") && segment.endsWith("]")) dynamism = 1;
|
|
26310
|
+
}
|
|
26311
|
+
return { key, method, segments, dynamism };
|
|
26312
|
+
}
|
|
26313
|
+
function matchSegments(route, path) {
|
|
26314
|
+
const params = {};
|
|
26315
|
+
for (let i = 0; i < route.length; i++) {
|
|
26316
|
+
const seg = route[i];
|
|
26317
|
+
if (seg === "[*]") {
|
|
26318
|
+
if (i !== route.length - 1) return null;
|
|
26319
|
+
params["*"] = path.slice(i).join("/");
|
|
26320
|
+
return params;
|
|
26321
|
+
}
|
|
26322
|
+
const value = path[i];
|
|
26323
|
+
if (value === void 0) return null;
|
|
26324
|
+
if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
26325
|
+
params[seg.slice(1, -1)] = value;
|
|
26326
|
+
} else if (seg !== value) {
|
|
26327
|
+
return null;
|
|
26328
|
+
}
|
|
26329
|
+
}
|
|
26330
|
+
return path.length === route.length ? params : null;
|
|
26331
|
+
}
|
|
26332
|
+
function matchUserRoute(keys, method, endpointPath) {
|
|
26333
|
+
const path = endpointPath.split("/").filter((s) => s.length > 0);
|
|
26334
|
+
const wanted = method.toUpperCase();
|
|
26335
|
+
let best = null;
|
|
26336
|
+
for (const key of keys) {
|
|
26337
|
+
const route = parseUserRouteKey(key);
|
|
26338
|
+
if (route.method !== wanted) continue;
|
|
26339
|
+
const params = matchSegments(route.segments, path);
|
|
26340
|
+
if (!params) continue;
|
|
26341
|
+
if (!best || route.dynamism < best.route.dynamism) {
|
|
26342
|
+
best = { route, params };
|
|
26343
|
+
}
|
|
26344
|
+
}
|
|
26345
|
+
return best ? { key: best.route.key, params: best.params } : null;
|
|
26346
|
+
}
|
|
26347
|
+
|
|
26348
|
+
// src/users/UserStateImpl.ts
|
|
26349
|
+
init_ThreadStateImpl();
|
|
26350
|
+
var UserStateImpl = class {
|
|
26351
|
+
userId;
|
|
26352
|
+
host;
|
|
26353
|
+
/** Caller descriptor for grant `allow` policy checks. */
|
|
26354
|
+
caller;
|
|
26355
|
+
constructor(userId, host, caller) {
|
|
26356
|
+
this.userId = userId;
|
|
26357
|
+
this.host = host;
|
|
26358
|
+
this.caller = caller;
|
|
26359
|
+
}
|
|
26360
|
+
singleton() {
|
|
26361
|
+
const ns = this.host.env.AGENT_BUILDER;
|
|
26362
|
+
if (!ns) throw new Error("AGENT_BUILDER binding unavailable in the user scope.");
|
|
26363
|
+
return ns.get(ns.idFromName("singleton"));
|
|
26364
|
+
}
|
|
26365
|
+
async identity() {
|
|
26366
|
+
const user = await this.singleton().getUserById(this.userId);
|
|
26367
|
+
if (!user) throw new Error(`Unknown user: ${this.userId}`);
|
|
26368
|
+
return {
|
|
26369
|
+
id: user.id,
|
|
26370
|
+
username: user.username,
|
|
26371
|
+
role: user.role,
|
|
26372
|
+
permissions: user.permissions ?? null,
|
|
26373
|
+
email: user.email ?? null,
|
|
26374
|
+
display_name: user.display_name ?? null,
|
|
26375
|
+
avatar_url: user.avatar_url ?? null
|
|
26376
|
+
};
|
|
26377
|
+
}
|
|
26378
|
+
async getValue(key) {
|
|
26379
|
+
return await this.host.getUserValue(this.userId, key);
|
|
26380
|
+
}
|
|
26381
|
+
async setValue(key, value) {
|
|
26382
|
+
await this.host.setUserValue(this.userId, key, value);
|
|
26383
|
+
}
|
|
26384
|
+
async listValues(options) {
|
|
26385
|
+
const { entries, total } = await this.host.listUserValues(this.userId, options);
|
|
26386
|
+
return { entries, total };
|
|
26387
|
+
}
|
|
26388
|
+
async env(name) {
|
|
26389
|
+
const values = await this.singleton().getUserEnv(this.userId);
|
|
26390
|
+
const value = values[name];
|
|
26391
|
+
if (value === void 0) throw new Error(`Environment variable not found: ${name}`);
|
|
26392
|
+
return value;
|
|
26393
|
+
}
|
|
26394
|
+
emit(event, data) {
|
|
26395
|
+
this.host.broadcastUserEvent(this.userId, event, data);
|
|
26396
|
+
}
|
|
26397
|
+
async listThreads(options) {
|
|
26398
|
+
const { threads, total } = await this.singleton().listThreads({
|
|
26399
|
+
user_id: this.userId,
|
|
26400
|
+
agent_name: options?.agentId,
|
|
26401
|
+
limit: options?.limit,
|
|
26402
|
+
offset: options?.offset
|
|
26403
|
+
});
|
|
26404
|
+
return {
|
|
26405
|
+
threads: threads.map((t) => ({
|
|
26406
|
+
id: t.id,
|
|
26407
|
+
agent_id: t.agent_id ?? t.agent_name ?? "",
|
|
26408
|
+
tags: t.tags ?? [],
|
|
26409
|
+
created_at: t.created_at
|
|
26410
|
+
})),
|
|
26411
|
+
total
|
|
26412
|
+
};
|
|
26413
|
+
}
|
|
26414
|
+
async fetch(grant, input, init) {
|
|
26415
|
+
const body = init?.body;
|
|
26416
|
+
if (body !== void 0 && body !== null && typeof body !== "string") {
|
|
26417
|
+
throw new Error("Grant fetch bodies must be strings in this runtime.");
|
|
26418
|
+
}
|
|
26419
|
+
return this.host.grantFetch(this.userId, grant, {
|
|
26420
|
+
url: String(input),
|
|
26421
|
+
method: init?.method ?? "GET",
|
|
26422
|
+
headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
|
|
26423
|
+
body: body ?? null,
|
|
26424
|
+
caller: this.caller
|
|
26425
|
+
});
|
|
26426
|
+
}
|
|
26427
|
+
/**
|
|
26428
|
+
* Raise one of this user's threads at rest — the same stub-backed
|
|
26429
|
+
* materialization thread endpoints receive. `inUserScope` marks the state
|
|
26430
|
+
* so a reentrant `state.user().call()` fails instead of deadlocking the
|
|
26431
|
+
* serialized user execution (spec: Users §2.2.3).
|
|
26432
|
+
*/
|
|
26433
|
+
async thread(threadId) {
|
|
26434
|
+
const metadata = await this.singleton().getThread(threadId);
|
|
26435
|
+
if (!metadata) throw new Error(`Thread not found: ${threadId}`);
|
|
26436
|
+
if (metadata.user_id !== this.userId) {
|
|
26437
|
+
throw new Error(`Thread ${threadId} does not belong to user ${this.userId}.`);
|
|
26438
|
+
}
|
|
26439
|
+
const meta = metadata;
|
|
26440
|
+
if (!meta.agent_id && meta.agent_name) meta.agent_id = meta.agent_name;
|
|
26441
|
+
const ns = this.host.env.AGENT_BUILDER_THREAD;
|
|
26442
|
+
if (!ns) throw new Error("AGENT_BUILDER_THREAD binding unavailable in the user scope.");
|
|
26443
|
+
const instance = ns.get(ns.idFromName(threadId));
|
|
26444
|
+
return ThreadStateImpl.fromThreadInstance(instance, meta, {
|
|
26445
|
+
env: this.host.env,
|
|
26446
|
+
inUserScope: true
|
|
26447
|
+
});
|
|
26448
|
+
}
|
|
26449
|
+
};
|
|
26450
|
+
|
|
26451
|
+
// src/users/serialize.ts
|
|
26452
|
+
function createExecutionChain() {
|
|
26453
|
+
let tail2 = Promise.resolve();
|
|
26454
|
+
return {
|
|
26455
|
+
run(fn) {
|
|
26456
|
+
const next = tail2.then(fn, fn);
|
|
26457
|
+
tail2 = next.catch(() => {
|
|
26458
|
+
});
|
|
26459
|
+
return next;
|
|
26460
|
+
}
|
|
26461
|
+
};
|
|
26462
|
+
}
|
|
26463
|
+
|
|
26464
|
+
// src/users/grants.ts
|
|
26465
|
+
function originAllowed(origins, target) {
|
|
26466
|
+
if (!Array.isArray(origins)) return false;
|
|
26467
|
+
for (const allowed of origins) {
|
|
26468
|
+
if (allowed.startsWith("*.")) {
|
|
26469
|
+
const suffix = allowed.slice(1);
|
|
26470
|
+
if (target.protocol === "https:" && target.hostname.endsWith(suffix)) return true;
|
|
26471
|
+
} else {
|
|
26472
|
+
try {
|
|
26473
|
+
if (new URL(allowed).origin === target.origin) return true;
|
|
26474
|
+
} catch {
|
|
26475
|
+
}
|
|
26476
|
+
}
|
|
26477
|
+
}
|
|
26478
|
+
return false;
|
|
26479
|
+
}
|
|
26480
|
+
function callerAllowed(allow, caller) {
|
|
26481
|
+
if (!allow) return false;
|
|
26482
|
+
const lists = {
|
|
26483
|
+
tool: allow.tools,
|
|
26484
|
+
endpoint: allow.endpoints,
|
|
26485
|
+
userEndpoint: allow.userEndpoints,
|
|
26486
|
+
userHook: allow.userEndpoints,
|
|
26487
|
+
agent: allow.agents
|
|
26488
|
+
};
|
|
26489
|
+
const list = lists[caller.kind];
|
|
26490
|
+
if (!list) return false;
|
|
26491
|
+
const bare = caller.name.replace(/\.(get|post|put|patch|delete)$/i, "");
|
|
26492
|
+
return list.includes(caller.name) || list.includes(bare);
|
|
26493
|
+
}
|
|
26494
|
+
function injectGrantCredential(config, secret, target, headers) {
|
|
26495
|
+
const format = config.inject?.format ?? "SECRET";
|
|
26496
|
+
switch (config.kind) {
|
|
26497
|
+
case "bearer":
|
|
26498
|
+
headers.set("Authorization", `Bearer ${secret}`);
|
|
26499
|
+
return true;
|
|
26500
|
+
case "header":
|
|
26501
|
+
headers.set(config.inject?.header ?? "Authorization", format.replace(/SECRET/g, secret));
|
|
26502
|
+
return true;
|
|
26503
|
+
case "query":
|
|
26504
|
+
target.searchParams.set(config.inject?.query ?? "key", secret);
|
|
26505
|
+
return true;
|
|
26506
|
+
case "basic":
|
|
26507
|
+
headers.set("Authorization", `Basic ${btoa(secret)}`);
|
|
26508
|
+
return true;
|
|
26509
|
+
default:
|
|
26510
|
+
return false;
|
|
26511
|
+
}
|
|
26512
|
+
}
|
|
26513
|
+
|
|
26514
|
+
// src/durable-objects/DurableUserHub.ts
|
|
26202
26515
|
var DurableUserHub = class extends DurableObject {
|
|
26203
26516
|
/** Persistent client (daemon) sockets, restored across hibernation. */
|
|
26204
26517
|
clientSockets = /* @__PURE__ */ new Set();
|
|
@@ -26206,11 +26519,9 @@ var DurableUserHub = class extends DurableObject {
|
|
|
26206
26519
|
migrated = false;
|
|
26207
26520
|
constructor(ctx, env) {
|
|
26208
26521
|
super(ctx, env);
|
|
26209
|
-
|
|
26210
|
-
|
|
26211
|
-
|
|
26212
|
-
);
|
|
26213
|
-
}
|
|
26522
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
26523
|
+
this.ensureMigrated();
|
|
26524
|
+
});
|
|
26214
26525
|
for (const ws of ctx.getWebSockets()) {
|
|
26215
26526
|
const attachment = ws.deserializeAttachment();
|
|
26216
26527
|
if (attachment?.type === "hub_client") {
|
|
@@ -26238,8 +26549,38 @@ var DurableUserHub = class extends DurableObject {
|
|
|
26238
26549
|
this.ctx.storage.sql.exec(
|
|
26239
26550
|
`CREATE INDEX IF NOT EXISTS idx_user_values_user ON user_values(user_id)`
|
|
26240
26551
|
);
|
|
26552
|
+
this.ctx.storage.sql.exec(`
|
|
26553
|
+
CREATE TABLE IF NOT EXISTS user_grants (
|
|
26554
|
+
user_id TEXT NOT NULL,
|
|
26555
|
+
name TEXT NOT NULL,
|
|
26556
|
+
secret TEXT NOT NULL,
|
|
26557
|
+
config TEXT NOT NULL,
|
|
26558
|
+
created_at INTEGER NOT NULL,
|
|
26559
|
+
updated_at INTEGER NOT NULL,
|
|
26560
|
+
PRIMARY KEY (user_id, name)
|
|
26561
|
+
)
|
|
26562
|
+
`);
|
|
26241
26563
|
this.migrated = true;
|
|
26242
26564
|
}
|
|
26565
|
+
// ─── User scope: registries (overridden by the generated virtual module) ──
|
|
26566
|
+
/**
|
|
26567
|
+
* Returns the user endpoints registry for lazy-loading `users/` files.
|
|
26568
|
+
* Keys are endpoint file paths relative to `users/` with the extension
|
|
26569
|
+
* stripped except the method suffix (e.g. `standardcode/lease_acquire.post`).
|
|
26570
|
+
* Implemented when DurableUserHub is imported from
|
|
26571
|
+
* 'virtual:@standardagents/builder'.
|
|
26572
|
+
*/
|
|
26573
|
+
userEndpoints() {
|
|
26574
|
+
return {};
|
|
26575
|
+
}
|
|
26576
|
+
/**
|
|
26577
|
+
* Returns the hooks registry (same registry the thread runtime uses). The
|
|
26578
|
+
* hub filters it to user-scope hook types at dispatch time. Implemented
|
|
26579
|
+
* when DurableUserHub is imported from 'virtual:@standardagents/builder'.
|
|
26580
|
+
*/
|
|
26581
|
+
userHooks() {
|
|
26582
|
+
return {};
|
|
26583
|
+
}
|
|
26243
26584
|
// ─── Persistent client socket ──────────────────────────────────────────────
|
|
26244
26585
|
/**
|
|
26245
26586
|
* Accept the user's persistent client (daemon) WebSocket. The daemon holds
|
|
@@ -26248,6 +26589,16 @@ var DurableUserHub = class extends DurableObject {
|
|
|
26248
26589
|
*/
|
|
26249
26590
|
async fetch(request) {
|
|
26250
26591
|
const url = new URL(request.url);
|
|
26592
|
+
const scopeMatch = /^\/users\/([^/]+)\/(stream|endpoints|grant-fetch)(?:\/(.*))?$/.exec(
|
|
26593
|
+
url.pathname
|
|
26594
|
+
);
|
|
26595
|
+
if (scopeMatch) {
|
|
26596
|
+
const userId = decodeURIComponent(scopeMatch[1]);
|
|
26597
|
+
const kind = scopeMatch[2];
|
|
26598
|
+
if (kind === "stream") return this.acceptUserStream(request, url, userId);
|
|
26599
|
+
if (kind === "grant-fetch") return this.handleGrantFetch(request, userId);
|
|
26600
|
+
return this.runUserEndpoint(request, userId, scopeMatch[3] ?? "");
|
|
26601
|
+
}
|
|
26251
26602
|
if (request.headers.get("Upgrade") === "websocket") {
|
|
26252
26603
|
const pair = new WebSocketPair();
|
|
26253
26604
|
const [client, server] = [pair[0], pair[1]];
|
|
@@ -26256,6 +26607,7 @@ var DurableUserHub = class extends DurableObject {
|
|
|
26256
26607
|
clientName: url.searchParams.get("client_name"),
|
|
26257
26608
|
clientKind: url.searchParams.get("client_kind")
|
|
26258
26609
|
};
|
|
26610
|
+
this.ensureHeartbeatAutoResponse();
|
|
26259
26611
|
this.ctx.acceptWebSocket(server);
|
|
26260
26612
|
server.serializeAttachment({ type: "hub_client", ...info });
|
|
26261
26613
|
this.clientSockets.add(server);
|
|
@@ -26268,16 +26620,265 @@ var DurableUserHub = class extends DurableObject {
|
|
|
26268
26620
|
}
|
|
26269
26621
|
return new Response("Not found", { status: 404 });
|
|
26270
26622
|
}
|
|
26271
|
-
|
|
26272
|
-
|
|
26623
|
+
// ─── User scope: event stream (spec: Users §4) ─────────────────────────────
|
|
26624
|
+
/** Idle sockets answer heartbeats at the edge without waking the object.
|
|
26625
|
+
* Registered at accept time rather than in the constructor so a hub that
|
|
26626
|
+
* never accepts a socket stays fully evictable (this also keeps
|
|
26627
|
+
* vitest-pool-workers' isolated storage able to pop the DO's frame). */
|
|
26628
|
+
ensureHeartbeatAutoResponse() {
|
|
26629
|
+
if (typeof WebSocketRequestResponsePair !== "undefined") {
|
|
26630
|
+
this.ctx.setWebSocketAutoResponse(
|
|
26631
|
+
new WebSocketRequestResponsePair("stream_ping", "stream_pong")
|
|
26632
|
+
);
|
|
26633
|
+
}
|
|
26634
|
+
}
|
|
26635
|
+
/** Sockets attached to the user event stream, restored after hibernation. */
|
|
26636
|
+
streamSockets() {
|
|
26637
|
+
return this.ctx.getWebSockets().filter((ws) => ws.deserializeAttachment()?.type === "user_stream");
|
|
26638
|
+
}
|
|
26639
|
+
acceptUserStream(request, url, userId) {
|
|
26640
|
+
if (request.headers.get("Upgrade") !== "websocket") {
|
|
26641
|
+
return Response.json({ error: "WebSocket upgrade required" }, { status: 426 });
|
|
26642
|
+
}
|
|
26643
|
+
const pair = new WebSocketPair();
|
|
26644
|
+
const [client, server] = [pair[0], pair[1]];
|
|
26645
|
+
const clientId = url.searchParams.get("client_id") ?? crypto.randomUUID();
|
|
26646
|
+
const clientKind = url.searchParams.get("client_kind");
|
|
26647
|
+
this.ensureHeartbeatAutoResponse();
|
|
26648
|
+
this.ctx.acceptWebSocket(server);
|
|
26649
|
+
server.serializeAttachment({ type: "user_stream", userId, clientId, clientKind });
|
|
26650
|
+
try {
|
|
26651
|
+
server.send(JSON.stringify({ event: "stream_ready", data: { client_id: clientId } }));
|
|
26652
|
+
} catch {
|
|
26653
|
+
}
|
|
26654
|
+
this.dispatchUserHooks(userId, "user_client_attached", [
|
|
26655
|
+
{ id: clientId, kind: clientKind ?? void 0 }
|
|
26656
|
+
]);
|
|
26657
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
26658
|
+
}
|
|
26659
|
+
/**
|
|
26660
|
+
* Deliver an event frame to every socket attached to this user's stream.
|
|
26661
|
+
* At-most-once, live-only (spec: Users §4) — no history, no acks.
|
|
26662
|
+
*/
|
|
26663
|
+
broadcastUserEvent(userId, event, data) {
|
|
26664
|
+
const frame = JSON.stringify({ event, data });
|
|
26665
|
+
for (const ws of this.streamSockets()) {
|
|
26666
|
+
const attachment = ws.deserializeAttachment();
|
|
26667
|
+
if (attachment?.userId !== userId) continue;
|
|
26668
|
+
try {
|
|
26669
|
+
ws.send(frame);
|
|
26670
|
+
} catch {
|
|
26671
|
+
}
|
|
26672
|
+
}
|
|
26673
|
+
}
|
|
26674
|
+
// ─── User scope: serialized endpoint execution (spec: Users §2.1, §3) ─────
|
|
26675
|
+
/** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
|
|
26676
|
+
executionChain = createExecutionChain();
|
|
26677
|
+
scopeHost() {
|
|
26678
|
+
return {
|
|
26679
|
+
getUserValue: (userId, key) => this.getUserValue(userId, key),
|
|
26680
|
+
setUserValue: (userId, key, value) => this.setUserValue(userId, key, value),
|
|
26681
|
+
listUserValues: (userId, options) => this.listUserValues(userId, options ?? {}),
|
|
26682
|
+
broadcastUserEvent: (userId, event, data) => this.broadcastUserEvent(userId, event, data),
|
|
26683
|
+
grantFetch: (userId, grant, req) => this.grantFetch(userId, grant, req),
|
|
26684
|
+
env: this.env
|
|
26685
|
+
};
|
|
26686
|
+
}
|
|
26687
|
+
async runUserEndpoint(request, userId, endpointPath) {
|
|
26688
|
+
const registry = this.userEndpoints();
|
|
26689
|
+
const match = matchUserRoute(Object.keys(registry), request.method, endpointPath);
|
|
26690
|
+
if (!match) return Response.json({ error: "Not found" }, { status: 404 });
|
|
26691
|
+
let loaded;
|
|
26692
|
+
try {
|
|
26693
|
+
loaded = await registry[match.key]();
|
|
26694
|
+
} catch (error) {
|
|
26695
|
+
return Response.json(
|
|
26696
|
+
{ error: `Failed to load user endpoint: ${match.key}` },
|
|
26697
|
+
{ status: 500 }
|
|
26698
|
+
);
|
|
26699
|
+
}
|
|
26700
|
+
const module = loaded;
|
|
26701
|
+
const handler = module.default ?? loaded;
|
|
26702
|
+
if (!isUserEndpoint(handler)) {
|
|
26703
|
+
return Response.json(
|
|
26704
|
+
{ error: `${match.key} is not a defineUserEndpoint export` },
|
|
26705
|
+
{ status: 500 }
|
|
26706
|
+
);
|
|
26707
|
+
}
|
|
26708
|
+
return this.executionChain.run(async () => {
|
|
26709
|
+
const user = new UserStateImpl(userId, this.scopeHost(), {
|
|
26710
|
+
kind: "userEndpoint",
|
|
26711
|
+
name: match.key
|
|
26712
|
+
});
|
|
26713
|
+
try {
|
|
26714
|
+
return await handler.__handler(request, user, match.params);
|
|
26715
|
+
} catch (error) {
|
|
26716
|
+
return Response.json(
|
|
26717
|
+
{ error: error instanceof Error ? error.message : "User endpoint failed" },
|
|
26718
|
+
{ status: 500 }
|
|
26719
|
+
);
|
|
26720
|
+
}
|
|
26721
|
+
});
|
|
26722
|
+
}
|
|
26723
|
+
// ─── User scope: lifecycle hooks (spec: Users §7) ──────────────────────────
|
|
26724
|
+
/**
|
|
26725
|
+
* Fire every definition of a user hook type. Observational and best-effort:
|
|
26726
|
+
* failures are swallowed and dispatch never blocks the triggering
|
|
26727
|
+
* operation. Nested dispatch is suppressed so a `user_value_changed` hook
|
|
26728
|
+
* writing values cannot recurse.
|
|
26729
|
+
*/
|
|
26730
|
+
inUserHookDispatch = false;
|
|
26731
|
+
dispatchUserHooks(userId, hookType, args) {
|
|
26732
|
+
if (this.inUserHookDispatch) return;
|
|
26733
|
+
const registry = this.userHooks();
|
|
26734
|
+
const names = Object.keys(registry);
|
|
26735
|
+
if (names.length === 0) return;
|
|
26736
|
+
const run = async () => {
|
|
26737
|
+
const user = new UserStateImpl(userId, this.scopeHost(), {
|
|
26738
|
+
kind: "userHook",
|
|
26739
|
+
name: hookType
|
|
26740
|
+
});
|
|
26741
|
+
for (const name of names) {
|
|
26742
|
+
try {
|
|
26743
|
+
const loaded = await registry[name]();
|
|
26744
|
+
const definition = loaded.default ?? loaded;
|
|
26745
|
+
if (definition?.hook !== hookType || typeof definition.execute !== "function") {
|
|
26746
|
+
continue;
|
|
26747
|
+
}
|
|
26748
|
+
this.inUserHookDispatch = true;
|
|
26749
|
+
try {
|
|
26750
|
+
await definition.execute(user, ...args);
|
|
26751
|
+
} finally {
|
|
26752
|
+
this.inUserHookDispatch = false;
|
|
26753
|
+
}
|
|
26754
|
+
} catch {
|
|
26755
|
+
}
|
|
26756
|
+
}
|
|
26757
|
+
};
|
|
26758
|
+
this.ctx.waitUntil(run());
|
|
26273
26759
|
}
|
|
26274
26760
|
async webSocketClose(ws) {
|
|
26761
|
+
const attachment = ws.deserializeAttachment();
|
|
26762
|
+
if (attachment?.type === "user_stream") {
|
|
26763
|
+
this.dispatchUserHooks(String(attachment.userId), "user_client_detached", [
|
|
26764
|
+
{
|
|
26765
|
+
id: typeof attachment.clientId === "string" ? attachment.clientId : "unknown",
|
|
26766
|
+
kind: typeof attachment.clientKind === "string" ? attachment.clientKind : void 0
|
|
26767
|
+
}
|
|
26768
|
+
]);
|
|
26769
|
+
return;
|
|
26770
|
+
}
|
|
26275
26771
|
this.clientSockets.delete(ws);
|
|
26276
26772
|
this.clientInfo.delete(ws);
|
|
26277
26773
|
}
|
|
26774
|
+
// ─── User scope: credential grants (spec: Users §6) ───────────────────────
|
|
26775
|
+
/**
|
|
26776
|
+
* Store or replace a grant. `secret` is write-only from this point on.
|
|
26777
|
+
* Policy (`CredentialGrant` minus the secret) is kept as JSON config.
|
|
26778
|
+
*/
|
|
26779
|
+
async setGrant(userId, grant, secret) {
|
|
26780
|
+
this.ensureMigrated();
|
|
26781
|
+
const { name, ...config } = grant;
|
|
26782
|
+
const now = Date.now();
|
|
26783
|
+
await this.ctx.storage.sql.exec(
|
|
26784
|
+
`INSERT INTO user_grants (user_id, name, secret, config, created_at, updated_at)
|
|
26785
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
|
26786
|
+
ON CONFLICT(user_id, name) DO UPDATE SET
|
|
26787
|
+
secret = excluded.secret, config = excluded.config, updated_at = excluded.updated_at`,
|
|
26788
|
+
userId,
|
|
26789
|
+
name,
|
|
26790
|
+
secret,
|
|
26791
|
+
JSON.stringify(config),
|
|
26792
|
+
now
|
|
26793
|
+
);
|
|
26794
|
+
}
|
|
26795
|
+
async deleteGrant(userId, name) {
|
|
26796
|
+
this.ensureMigrated();
|
|
26797
|
+
await this.ctx.storage.sql.exec(
|
|
26798
|
+
`DELETE FROM user_grants WHERE user_id = ? AND name = ?`,
|
|
26799
|
+
userId,
|
|
26800
|
+
name
|
|
26801
|
+
);
|
|
26802
|
+
}
|
|
26803
|
+
/** Metadata-only listing — never returns secret material. */
|
|
26804
|
+
async listGrants(userId) {
|
|
26805
|
+
this.ensureMigrated();
|
|
26806
|
+
const cursor = await this.ctx.storage.sql.exec(
|
|
26807
|
+
`SELECT name, config FROM user_grants WHERE user_id = ? ORDER BY name ASC`,
|
|
26808
|
+
userId
|
|
26809
|
+
);
|
|
26810
|
+
return cursor.toArray().map((row) => {
|
|
26811
|
+
let config = {};
|
|
26812
|
+
try {
|
|
26813
|
+
config = JSON.parse(row.config);
|
|
26814
|
+
} catch {
|
|
26815
|
+
}
|
|
26816
|
+
return { name: row.name, ...config };
|
|
26817
|
+
});
|
|
26818
|
+
}
|
|
26819
|
+
/** Perform a privileged fetch with the grant's credential injected. */
|
|
26820
|
+
async grantFetch(userId, grantName, req) {
|
|
26821
|
+
this.ensureMigrated();
|
|
26822
|
+
const cursor = await this.ctx.storage.sql.exec(
|
|
26823
|
+
`SELECT secret, config FROM user_grants WHERE user_id = ? AND name = ? LIMIT 1`,
|
|
26824
|
+
userId,
|
|
26825
|
+
grantName
|
|
26826
|
+
);
|
|
26827
|
+
const row = cursor.toArray()[0];
|
|
26828
|
+
if (!row) {
|
|
26829
|
+
return Response.json({ error: "grant_not_found", grant: grantName }, { status: 404 });
|
|
26830
|
+
}
|
|
26831
|
+
let config;
|
|
26832
|
+
try {
|
|
26833
|
+
config = JSON.parse(row.config);
|
|
26834
|
+
} catch {
|
|
26835
|
+
return Response.json({ error: "grant_config_invalid" }, { status: 500 });
|
|
26836
|
+
}
|
|
26837
|
+
let target;
|
|
26838
|
+
try {
|
|
26839
|
+
target = new URL(req.url);
|
|
26840
|
+
} catch {
|
|
26841
|
+
return Response.json({ error: "grant_target_invalid" }, { status: 400 });
|
|
26842
|
+
}
|
|
26843
|
+
if (!originAllowed(config.origins, target)) {
|
|
26844
|
+
return Response.json({ error: "grant_origin_denied" }, { status: 403 });
|
|
26845
|
+
}
|
|
26846
|
+
if (!callerAllowed(config.allow, req.caller)) {
|
|
26847
|
+
return Response.json({ error: "grant_caller_denied" }, { status: 403 });
|
|
26848
|
+
}
|
|
26849
|
+
const headers = new Headers(req.headers);
|
|
26850
|
+
if (!injectGrantCredential(config, row.secret, target, headers)) {
|
|
26851
|
+
return Response.json({ error: "grant_kind_unsupported" }, { status: 501 });
|
|
26852
|
+
}
|
|
26853
|
+
try {
|
|
26854
|
+
const response = await fetch(target.toString(), {
|
|
26855
|
+
method: req.method,
|
|
26856
|
+
headers,
|
|
26857
|
+
body: req.body ?? void 0
|
|
26858
|
+
});
|
|
26859
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
26860
|
+
} catch {
|
|
26861
|
+
return Response.json({ error: "grant_fetch_failed" }, { status: 502 });
|
|
26862
|
+
}
|
|
26863
|
+
}
|
|
26864
|
+
async handleGrantFetch(request, userId) {
|
|
26865
|
+
const body = await request.json().catch(() => null);
|
|
26866
|
+
if (!body?.grant || !body.url) {
|
|
26867
|
+
return Response.json({ error: "grant and url required" }, { status: 400 });
|
|
26868
|
+
}
|
|
26869
|
+
return this.grantFetch(userId, body.grant, {
|
|
26870
|
+
url: body.url,
|
|
26871
|
+
method: body.method ?? "GET",
|
|
26872
|
+
headers: body.headers ?? {},
|
|
26873
|
+
body: body.body ?? null,
|
|
26874
|
+
caller: body.caller ?? { kind: "unknown", name: "unknown" }
|
|
26875
|
+
});
|
|
26876
|
+
}
|
|
26877
|
+
async webSocketMessage(ws, _message) {
|
|
26878
|
+
if (!this.clientSockets.has(ws)) this.clientSockets.add(ws);
|
|
26879
|
+
}
|
|
26278
26880
|
async webSocketError(ws) {
|
|
26279
|
-
this.
|
|
26280
|
-
this.clientInfo.delete(ws);
|
|
26881
|
+
await this.webSocketClose(ws);
|
|
26281
26882
|
}
|
|
26282
26883
|
/** Whether a persistent client (daemon) is connected right now. */
|
|
26283
26884
|
async hasClient() {
|
|
@@ -26337,6 +26938,7 @@ var DurableUserHub = class extends DurableObject {
|
|
|
26337
26938
|
userId,
|
|
26338
26939
|
key
|
|
26339
26940
|
);
|
|
26941
|
+
this.dispatchUserHooks(userId, "user_value_changed", [key]);
|
|
26340
26942
|
return;
|
|
26341
26943
|
}
|
|
26342
26944
|
const serialized = JSON.stringify(value);
|
|
@@ -26352,6 +26954,7 @@ var DurableUserHub = class extends DurableObject {
|
|
|
26352
26954
|
serialized,
|
|
26353
26955
|
now
|
|
26354
26956
|
);
|
|
26957
|
+
this.dispatchUserHooks(userId, "user_value_changed", [key]);
|
|
26355
26958
|
}
|
|
26356
26959
|
async listUserValues(userId, options = {}) {
|
|
26357
26960
|
this.ensureMigrated();
|