@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/index.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, defineProvider, defineTool, isPacked, isVisibleInNamespace, mapReasoningLevel, providerConst, providerEnv, providerValue } from '@standardagents/spec';
5
5
  import 'virtual:@standardagents-providers';
6
6
  import { z } from 'zod';
@@ -14098,10 +14098,35 @@ var init_ThreadStateImpl = __esm({
14098
14098
  return { parentThreadId, parentStub };
14099
14099
  }
14100
14100
  _getAgentBuilderStub() {
14101
- const env = this._threadInstance.env;
14101
+ const env = this._getRuntimeEnv();
14102
14102
  const agentBuilderId = env.AGENT_BUILDER.idFromName("singleton");
14103
14103
  return env.AGENT_BUILDER.get(agentBuilderId);
14104
14104
  }
14105
+ /**
14106
+ * Environment bindings for this state. In-DO construction uses the thread
14107
+ * instance's own env; stub-backed states (thread endpoints, the user
14108
+ * scope's `thread()`) have no `instance.env` over RPC and use the env the
14109
+ * dispatcher recorded in the runtime context instead.
14110
+ */
14111
+ _getRuntimeEnv() {
14112
+ const instanceEnv = this._threadInstance.env;
14113
+ const env = instanceEnv?.AGENT_BUILDER ? instanceEnv : this._runtimeContext.env;
14114
+ if (!env?.AGENT_BUILDER) {
14115
+ throw new Error(
14116
+ "No runtime environment available on this ThreadState. Stub-backed states must be constructed with runtimeContext.env."
14117
+ );
14118
+ }
14119
+ return env;
14120
+ }
14121
+ /** The per-user hub stub for this thread's user (user-scope surfaces). */
14122
+ _getUserHubStub(userId) {
14123
+ const env = this._getRuntimeEnv();
14124
+ const namespace = env.AGENT_BUILDER_USER_HUB;
14125
+ if (!namespace) {
14126
+ throw new Error("User scope unavailable: AGENT_BUILDER_USER_HUB binding is missing.");
14127
+ }
14128
+ return namespace.get(namespace.idFromName(userId));
14129
+ }
14105
14130
  // ─────────────────────────────────────────────────────────────────────────
14106
14131
  // Skills — instance-global Agent Skills library, backed by the
14107
14132
  // DurableAgentBuilder singleton (skills are never stored per-thread or on
@@ -14118,7 +14143,67 @@ var init_ThreadStateImpl = __esm({
14118
14143
  const stub = this._getAgentBuilderStub();
14119
14144
  const user = await stub.getUserById(userId).catch(() => null);
14120
14145
  if (!user) return null;
14146
+ const inUserScope = this._runtimeContext.inUserScope === true;
14147
+ const agentId = this._metadata.agent_id;
14148
+ const hub = () => this._getUserHubStub(userId);
14149
+ const capabilities = {
14150
+ call: async (endpoint, payload) => {
14151
+ if (inUserScope) {
14152
+ throw new Error(
14153
+ "Reentrant user-scope call: this ThreadState was raised from user-scope code; state.user().call() is unavailable here."
14154
+ );
14155
+ }
14156
+ const path20 = endpoint.replace(/^\/+/, "");
14157
+ return hub().fetch(
14158
+ new Request(`https://user-hub/users/${encodeURIComponent(userId)}/endpoints/${path20}`, {
14159
+ method: "POST",
14160
+ headers: {
14161
+ "Content-Type": "application/json",
14162
+ // Runtime-internal caller channel — the public routes overwrite
14163
+ // this header, so only thread-scope code can present "thread".
14164
+ "X-Standard-Agents-Caller": "thread"
14165
+ },
14166
+ body: payload === void 0 ? void 0 : JSON.stringify(payload)
14167
+ })
14168
+ );
14169
+ },
14170
+ getValue: async (key) => {
14171
+ return await stub.getUserValue?.(userId, key);
14172
+ },
14173
+ setValue: async (key, value) => {
14174
+ await stub.setUserValue?.(userId, key, value);
14175
+ },
14176
+ emit: (event, data) => {
14177
+ const delivery = hub().broadcastUserEvent(userId, event, data).catch(() => {
14178
+ });
14179
+ this._pendingEventDeliveries.add(delivery);
14180
+ void delivery.finally(
14181
+ () => this._pendingEventDeliveries.delete(delivery)
14182
+ );
14183
+ },
14184
+ fetch: async (grant, input, init) => {
14185
+ const body = init?.body;
14186
+ if (body !== void 0 && body !== null && typeof body !== "string") {
14187
+ throw new Error("Grant fetch bodies must be strings in this runtime.");
14188
+ }
14189
+ return hub().fetch(
14190
+ new Request(`https://user-hub/users/${encodeURIComponent(userId)}/grant-fetch`, {
14191
+ method: "POST",
14192
+ headers: { "Content-Type": "application/json" },
14193
+ body: JSON.stringify({
14194
+ grant,
14195
+ url: String(input),
14196
+ method: init?.method ?? "GET",
14197
+ headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
14198
+ body: body ?? null,
14199
+ caller: { kind: "agent", name: agentId }
14200
+ })
14201
+ })
14202
+ );
14203
+ }
14204
+ };
14121
14205
  return {
14206
+ ...capabilities,
14122
14207
  id: user.id,
14123
14208
  username: user.username,
14124
14209
  role: user.role,
@@ -15476,8 +15561,9 @@ function createHmrReloader() {
15476
15561
  const isPromptFile = file.startsWith(dirs.promptsDir) && file.endsWith(".ts");
15477
15562
  const isAgentFile = file.startsWith(dirs.agentsDir) && file.endsWith(".ts");
15478
15563
  const isEffectFile = file.startsWith(dirs.effectsDir) && file.endsWith(".ts");
15564
+ const isUserEndpointFile = file.startsWith(dirs.usersDir) && file.endsWith(".ts");
15479
15565
  const isConfigFile = isModelFile || isProviderFile || isPromptFile || isAgentFile || isEffectFile;
15480
- if (!isToolFile && !isThreadApiFile && !isHookFile && !isConfigFile) return;
15566
+ if (!isToolFile && !isThreadApiFile && !isHookFile && !isConfigFile && !isUserEndpointFile) return;
15481
15567
  reloader.invalidateSourceFileModule(server, file);
15482
15568
  if (isConfigFile || isToolFile) regenerateTypes();
15483
15569
  if (isToolFile) await reloader.reloadToolsModule(server);
@@ -17503,6 +17589,35 @@ function parseRequestBody(req) {
17503
17589
  req.on("error", reject);
17504
17590
  });
17505
17591
  }
17592
+ function scanUsersDirectory(dir) {
17593
+ const results = [];
17594
+ if (!fs8__default.existsSync(dir)) return results;
17595
+ const walk = (current, prefix) => {
17596
+ let entries;
17597
+ try {
17598
+ entries = fs8__default.readdirSync(current, { withFileTypes: true });
17599
+ } catch (error) {
17600
+ console.error(`Error scanning users directory ${current}:`, error);
17601
+ return;
17602
+ }
17603
+ for (const entry of entries) {
17604
+ const fullPath = path8__default.join(current, entry.name);
17605
+ if (entry.isDirectory()) {
17606
+ walk(fullPath, prefix ? `${prefix}/${entry.name}` : entry.name);
17607
+ continue;
17608
+ }
17609
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue;
17610
+ const base = entry.name.slice(0, -3);
17611
+ const key = prefix ? `${prefix}/${base}` : base;
17612
+ results.push({
17613
+ key,
17614
+ importPath: "./" + path8__default.relative(process.cwd(), fullPath).replace(/\\/g, "/")
17615
+ });
17616
+ }
17617
+ };
17618
+ walk(dir, "");
17619
+ return results;
17620
+ }
17506
17621
 
17507
17622
  // src/plugin/agent-api-middleware.ts
17508
17623
  async function handleAgentApiRequest(context) {
@@ -21100,9 +21215,10 @@ function createDevMiddleware(server, context) {
21100
21215
  });
21101
21216
  }
21102
21217
  async function loadBuilderVirtualModule(context) {
21103
- const { toolsDir, hooksDir, modelsDir, promptsDir, agentsDir, effectsDir, hmr } = context;
21218
+ const { toolsDir, hooksDir, modelsDir, promptsDir, agentsDir, effectsDir, usersDir, hmr } = context;
21104
21219
  const tools = await scanToolsDirectory(toolsDir);
21105
21220
  const hooks = await scanHooksDirectory(hooksDir);
21221
+ const userEndpoints = scanUsersDirectory(usersDir);
21106
21222
  const models = await scanModelsDirectory(modelsDir);
21107
21223
  const prompts = await scanPromptsDirectory(promptsDir);
21108
21224
  const agents = await scanAgentsDirectory(agentsDir);
@@ -21204,6 +21320,17 @@ async function loadBuilderVirtualModule(context) {
21204
21320
  },`;
21205
21321
  }
21206
21322
  }).join("\n");
21323
+ const userEndpointsCode = userEndpoints.map(({ key, importPath }) => {
21324
+ const absPath = toAbsolutePath(importPath);
21325
+ return ` "${key}": async () => {
21326
+ try {
21327
+ return (await import("${absPath}")).default;
21328
+ } catch (error) {
21329
+ console.error('Failed to import user endpoint ${key}:', error);
21330
+ throw error;
21331
+ }
21332
+ },`;
21333
+ }).join("\n");
21207
21334
  return `// Consolidated virtual module: virtual:@standardagents/builder /* v${hmr.versions.builder} */
21208
21335
  // Provides DurableThread, DurableAgentBuilder, ThreadWatchdog, and router
21209
21336
  import { DurableThread as _BaseDurableThread } from '@standardagents/builder/runtime';
@@ -21249,6 +21376,10 @@ const _effects = {
21249
21376
  ${effectsCode}
21250
21377
  };
21251
21378
 
21379
+ const _userEndpoints = {
21380
+ ${userEndpointsCode}
21381
+ };
21382
+
21252
21383
  /**
21253
21384
  * DurableThread with all virtual module methods already implemented.
21254
21385
  * Exported by the generated AgentBuilder Worker entry; user projects do not
@@ -21338,10 +21469,21 @@ export class CodeExecutionBridge extends _BaseCodeExecutionBridge {}
21338
21469
  export class ThreadWatchdog extends _BaseThreadWatchdog {}
21339
21470
 
21340
21471
  /**
21341
- * Per-user hub companion. Coordination only (persistent daemon socket, wake
21342
- * signal, sharded account KV); needs no virtual-module registries.
21472
+ * Per-user hub companion: persistent daemon socket, wake signal, sharded
21473
+ * account KV \u2014 and the USER SCOPE (spec: Users): packaged user endpoints from
21474
+ * the users/ directory executed under per-user serialization, the user event
21475
+ * stream, and user lifecycle hooks (filtered from the shared hooks registry
21476
+ * by hook type at dispatch).
21343
21477
  */
21344
- export class DurableUserHub extends _BaseDurableUserHub {}
21478
+ export class DurableUserHub extends _BaseDurableUserHub {
21479
+ userEndpoints() {
21480
+ return _userEndpoints;
21481
+ }
21482
+
21483
+ userHooks() {
21484
+ return _hooks;
21485
+ }
21486
+ }
21345
21487
  `;
21346
21488
  }
21347
21489
  async function loadRegistryVirtualModule(context) {
@@ -22397,6 +22539,7 @@ function agentbuilder(options = {}) {
22397
22539
  const promptsDir = options.promptsDir ? path8__default.resolve(process.cwd(), options.promptsDir) : path8__default.resolve(process.cwd(), "agents/prompts");
22398
22540
  const agentsDir = options.agentsDir ? path8__default.resolve(process.cwd(), options.agentsDir) : path8__default.resolve(process.cwd(), "agents/agents");
22399
22541
  const effectsDir = options.effectsDir ? path8__default.resolve(process.cwd(), options.effectsDir) : path8__default.resolve(process.cwd(), "agents/effects");
22542
+ const usersDir = options.usersDir ? path8__default.resolve(process.cwd(), options.usersDir) : path8__default.resolve(process.cwd(), "agents/users");
22400
22543
  const outputDir = path8__default.resolve(process.cwd(), ".agents");
22401
22544
  const generatedWorkerEntry = path8__default.join(outputDir, "worker.ts");
22402
22545
  const isProviderPackageInstalled = (packageName) => {
@@ -22459,6 +22602,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
22459
22602
  toolsDir,
22460
22603
  hooksDir,
22461
22604
  threadApiDir,
22605
+ usersDir,
22462
22606
  modelsDir,
22463
22607
  providersDir,
22464
22608
  promptsDir,
@@ -22469,6 +22613,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
22469
22613
  toolsDir,
22470
22614
  hooksDir,
22471
22615
  threadApiDir,
22616
+ usersDir,
22472
22617
  modelsDir,
22473
22618
  providersDir,
22474
22619
  promptsDir,
@@ -22556,6 +22701,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
22556
22701
  this.addWatchFile(promptsDir);
22557
22702
  this.addWatchFile(agentsDir);
22558
22703
  this.addWatchFile(effectsDir);
22704
+ this.addWatchFile(usersDir);
22559
22705
  },
22560
22706
  configureServer(server) {
22561
22707
  createDevMiddleware(server, devMiddlewareContext);
@@ -22660,7 +22806,9 @@ function createThreadEndpointHandler(handler) {
22660
22806
  const durableId = env.AGENT_BUILDER_THREAD.idFromName(threadId);
22661
22807
  const stub = env.AGENT_BUILDER_THREAD.get(durableId);
22662
22808
  const instance = stub;
22663
- const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata);
22809
+ const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata, {
22810
+ env
22811
+ });
22664
22812
  const endpointParams = {};
22665
22813
  for (const [key, value] of Object.entries(params)) {
22666
22814
  if (key === "threadId" || key === "id" && !params.threadId) {
@@ -34540,6 +34688,232 @@ function formatMs(ms) {
34540
34688
  const minutes = Math.floor(seconds / 60);
34541
34689
  return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
34542
34690
  }
34691
+
34692
+ // src/users/route-match.ts
34693
+ var METHOD_SUFFIXES = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete"]);
34694
+ function parseUserRouteKey(key) {
34695
+ const parts = key.split("/");
34696
+ let file = parts.pop() ?? "";
34697
+ let method = "GET";
34698
+ const dot = file.lastIndexOf(".");
34699
+ if (dot >= 0) {
34700
+ const suffix = file.slice(dot + 1).toLowerCase();
34701
+ if (METHOD_SUFFIXES.has(suffix)) {
34702
+ method = suffix.toUpperCase();
34703
+ file = file.slice(0, dot);
34704
+ }
34705
+ }
34706
+ const segments = [...parts];
34707
+ if (file !== "index") segments.push(file);
34708
+ let dynamism = 0;
34709
+ for (const segment of segments) {
34710
+ if (segment === "[*]") dynamism = 2;
34711
+ else if (dynamism < 2 && segment.startsWith("[") && segment.endsWith("]")) dynamism = 1;
34712
+ }
34713
+ return { key, method, segments, dynamism };
34714
+ }
34715
+ function matchSegments(route, path20) {
34716
+ const params = {};
34717
+ for (let i = 0; i < route.length; i++) {
34718
+ const seg = route[i];
34719
+ if (seg === "[*]") {
34720
+ if (i !== route.length - 1) return null;
34721
+ params["*"] = path20.slice(i).join("/");
34722
+ return params;
34723
+ }
34724
+ const value = path20[i];
34725
+ if (value === void 0) return null;
34726
+ if (seg.startsWith("[") && seg.endsWith("]")) {
34727
+ params[seg.slice(1, -1)] = value;
34728
+ } else if (seg !== value) {
34729
+ return null;
34730
+ }
34731
+ }
34732
+ return path20.length === route.length ? params : null;
34733
+ }
34734
+ function matchUserRoute(keys, method, endpointPath) {
34735
+ const path20 = endpointPath.split("/").filter((s) => s.length > 0);
34736
+ const wanted = method.toUpperCase();
34737
+ let best = null;
34738
+ for (const key of keys) {
34739
+ const route = parseUserRouteKey(key);
34740
+ if (route.method !== wanted) continue;
34741
+ const params = matchSegments(route.segments, path20);
34742
+ if (!params) continue;
34743
+ if (!best || route.dynamism < best.route.dynamism) {
34744
+ best = { route, params };
34745
+ }
34746
+ }
34747
+ return best ? { key: best.route.key, params: best.params } : null;
34748
+ }
34749
+
34750
+ // src/users/UserStateImpl.ts
34751
+ init_ThreadStateImpl();
34752
+ var UserStateImpl = class {
34753
+ userId;
34754
+ host;
34755
+ /** Caller descriptor for grant `allow` policy checks. */
34756
+ caller;
34757
+ constructor(userId, host, caller) {
34758
+ this.userId = userId;
34759
+ this.host = host;
34760
+ this.caller = caller;
34761
+ }
34762
+ singleton() {
34763
+ const ns = this.host.env.AGENT_BUILDER;
34764
+ if (!ns) throw new Error("AGENT_BUILDER binding unavailable in the user scope.");
34765
+ return ns.get(ns.idFromName("singleton"));
34766
+ }
34767
+ async identity() {
34768
+ const user = await this.singleton().getUserById(this.userId);
34769
+ if (!user) throw new Error(`Unknown user: ${this.userId}`);
34770
+ return {
34771
+ id: user.id,
34772
+ username: user.username,
34773
+ role: user.role,
34774
+ permissions: user.permissions ?? null,
34775
+ email: user.email ?? null,
34776
+ display_name: user.display_name ?? null,
34777
+ avatar_url: user.avatar_url ?? null
34778
+ };
34779
+ }
34780
+ async getValue(key) {
34781
+ return await this.host.getUserValue(this.userId, key);
34782
+ }
34783
+ async setValue(key, value) {
34784
+ await this.host.setUserValue(this.userId, key, value);
34785
+ }
34786
+ async listValues(options) {
34787
+ const { entries, total } = await this.host.listUserValues(this.userId, options);
34788
+ return { entries, total };
34789
+ }
34790
+ async env(name) {
34791
+ const values = await this.singleton().getUserEnv(this.userId);
34792
+ const value = values[name];
34793
+ if (value === void 0) throw new Error(`Environment variable not found: ${name}`);
34794
+ return value;
34795
+ }
34796
+ emit(event, data) {
34797
+ this.host.broadcastUserEvent(this.userId, event, data);
34798
+ }
34799
+ async listThreads(options) {
34800
+ const { threads, total } = await this.singleton().listThreads({
34801
+ user_id: this.userId,
34802
+ agent_name: options?.agentId,
34803
+ limit: options?.limit,
34804
+ offset: options?.offset
34805
+ });
34806
+ return {
34807
+ threads: threads.map((t) => ({
34808
+ id: t.id,
34809
+ agent_id: t.agent_id ?? t.agent_name ?? "",
34810
+ tags: t.tags ?? [],
34811
+ created_at: t.created_at
34812
+ })),
34813
+ total
34814
+ };
34815
+ }
34816
+ async fetch(grant, input, init) {
34817
+ const body = init?.body;
34818
+ if (body !== void 0 && body !== null && typeof body !== "string") {
34819
+ throw new Error("Grant fetch bodies must be strings in this runtime.");
34820
+ }
34821
+ return this.host.grantFetch(this.userId, grant, {
34822
+ url: String(input),
34823
+ method: init?.method ?? "GET",
34824
+ headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
34825
+ body: body ?? null,
34826
+ caller: this.caller
34827
+ });
34828
+ }
34829
+ /**
34830
+ * Raise one of this user's threads at rest — the same stub-backed
34831
+ * materialization thread endpoints receive. `inUserScope` marks the state
34832
+ * so a reentrant `state.user().call()` fails instead of deadlocking the
34833
+ * serialized user execution (spec: Users §2.2.3).
34834
+ */
34835
+ async thread(threadId) {
34836
+ const metadata = await this.singleton().getThread(threadId);
34837
+ if (!metadata) throw new Error(`Thread not found: ${threadId}`);
34838
+ if (metadata.user_id !== this.userId) {
34839
+ throw new Error(`Thread ${threadId} does not belong to user ${this.userId}.`);
34840
+ }
34841
+ const meta = metadata;
34842
+ if (!meta.agent_id && meta.agent_name) meta.agent_id = meta.agent_name;
34843
+ const ns = this.host.env.AGENT_BUILDER_THREAD;
34844
+ if (!ns) throw new Error("AGENT_BUILDER_THREAD binding unavailable in the user scope.");
34845
+ const instance = ns.get(ns.idFromName(threadId));
34846
+ return ThreadStateImpl.fromThreadInstance(instance, meta, {
34847
+ env: this.host.env,
34848
+ inUserScope: true
34849
+ });
34850
+ }
34851
+ };
34852
+
34853
+ // src/users/serialize.ts
34854
+ function createExecutionChain() {
34855
+ let tail2 = Promise.resolve();
34856
+ return {
34857
+ run(fn) {
34858
+ const next = tail2.then(fn, fn);
34859
+ tail2 = next.catch(() => {
34860
+ });
34861
+ return next;
34862
+ }
34863
+ };
34864
+ }
34865
+
34866
+ // src/users/grants.ts
34867
+ function originAllowed(origins, target) {
34868
+ if (!Array.isArray(origins)) return false;
34869
+ for (const allowed of origins) {
34870
+ if (allowed.startsWith("*.")) {
34871
+ const suffix = allowed.slice(1);
34872
+ if (target.protocol === "https:" && target.hostname.endsWith(suffix)) return true;
34873
+ } else {
34874
+ try {
34875
+ if (new URL(allowed).origin === target.origin) return true;
34876
+ } catch {
34877
+ }
34878
+ }
34879
+ }
34880
+ return false;
34881
+ }
34882
+ function callerAllowed(allow, caller) {
34883
+ if (!allow) return false;
34884
+ const lists = {
34885
+ tool: allow.tools,
34886
+ endpoint: allow.endpoints,
34887
+ userEndpoint: allow.userEndpoints,
34888
+ userHook: allow.userEndpoints,
34889
+ agent: allow.agents
34890
+ };
34891
+ const list = lists[caller.kind];
34892
+ if (!list) return false;
34893
+ const bare = caller.name.replace(/\.(get|post|put|patch|delete)$/i, "");
34894
+ return list.includes(caller.name) || list.includes(bare);
34895
+ }
34896
+ function injectGrantCredential(config, secret, target, headers) {
34897
+ const format = config.inject?.format ?? "SECRET";
34898
+ switch (config.kind) {
34899
+ case "bearer":
34900
+ headers.set("Authorization", `Bearer ${secret}`);
34901
+ return true;
34902
+ case "header":
34903
+ headers.set(config.inject?.header ?? "Authorization", format.replace(/SECRET/g, secret));
34904
+ return true;
34905
+ case "query":
34906
+ target.searchParams.set(config.inject?.query ?? "key", secret);
34907
+ return true;
34908
+ case "basic":
34909
+ headers.set("Authorization", `Basic ${btoa(secret)}`);
34910
+ return true;
34911
+ default:
34912
+ return false;
34913
+ }
34914
+ }
34915
+
34916
+ // src/durable-objects/DurableUserHub.ts
34543
34917
  var DurableUserHub = class extends DurableObject {
34544
34918
  /** Persistent client (daemon) sockets, restored across hibernation. */
34545
34919
  clientSockets = /* @__PURE__ */ new Set();
@@ -34547,11 +34921,9 @@ var DurableUserHub = class extends DurableObject {
34547
34921
  migrated = false;
34548
34922
  constructor(ctx, env) {
34549
34923
  super(ctx, env);
34550
- if (typeof WebSocketRequestResponsePair !== "undefined") {
34551
- ctx.setWebSocketAutoResponse(
34552
- new WebSocketRequestResponsePair("stream_ping", "stream_pong")
34553
- );
34554
- }
34924
+ ctx.blockConcurrencyWhile(async () => {
34925
+ this.ensureMigrated();
34926
+ });
34555
34927
  for (const ws of ctx.getWebSockets()) {
34556
34928
  const attachment = ws.deserializeAttachment();
34557
34929
  if (attachment?.type === "hub_client") {
@@ -34579,8 +34951,38 @@ var DurableUserHub = class extends DurableObject {
34579
34951
  this.ctx.storage.sql.exec(
34580
34952
  `CREATE INDEX IF NOT EXISTS idx_user_values_user ON user_values(user_id)`
34581
34953
  );
34954
+ this.ctx.storage.sql.exec(`
34955
+ CREATE TABLE IF NOT EXISTS user_grants (
34956
+ user_id TEXT NOT NULL,
34957
+ name TEXT NOT NULL,
34958
+ secret TEXT NOT NULL,
34959
+ config TEXT NOT NULL,
34960
+ created_at INTEGER NOT NULL,
34961
+ updated_at INTEGER NOT NULL,
34962
+ PRIMARY KEY (user_id, name)
34963
+ )
34964
+ `);
34582
34965
  this.migrated = true;
34583
34966
  }
34967
+ // ─── User scope: registries (overridden by the generated virtual module) ──
34968
+ /**
34969
+ * Returns the user endpoints registry for lazy-loading `users/` files.
34970
+ * Keys are endpoint file paths relative to `users/` with the extension
34971
+ * stripped except the method suffix (e.g. `standardcode/lease_acquire.post`).
34972
+ * Implemented when DurableUserHub is imported from
34973
+ * 'virtual:@standardagents/builder'.
34974
+ */
34975
+ userEndpoints() {
34976
+ return {};
34977
+ }
34978
+ /**
34979
+ * Returns the hooks registry (same registry the thread runtime uses). The
34980
+ * hub filters it to user-scope hook types at dispatch time. Implemented
34981
+ * when DurableUserHub is imported from 'virtual:@standardagents/builder'.
34982
+ */
34983
+ userHooks() {
34984
+ return {};
34985
+ }
34584
34986
  // ─── Persistent client socket ──────────────────────────────────────────────
34585
34987
  /**
34586
34988
  * Accept the user's persistent client (daemon) WebSocket. The daemon holds
@@ -34589,6 +34991,16 @@ var DurableUserHub = class extends DurableObject {
34589
34991
  */
34590
34992
  async fetch(request) {
34591
34993
  const url = new URL(request.url);
34994
+ const scopeMatch = /^\/users\/([^/]+)\/(stream|endpoints|grant-fetch)(?:\/(.*))?$/.exec(
34995
+ url.pathname
34996
+ );
34997
+ if (scopeMatch) {
34998
+ const userId = decodeURIComponent(scopeMatch[1]);
34999
+ const kind = scopeMatch[2];
35000
+ if (kind === "stream") return this.acceptUserStream(request, url, userId);
35001
+ if (kind === "grant-fetch") return this.handleGrantFetch(request, userId);
35002
+ return this.runUserEndpoint(request, userId, scopeMatch[3] ?? "");
35003
+ }
34592
35004
  if (request.headers.get("Upgrade") === "websocket") {
34593
35005
  const pair = new WebSocketPair();
34594
35006
  const [client, server] = [pair[0], pair[1]];
@@ -34597,6 +35009,7 @@ var DurableUserHub = class extends DurableObject {
34597
35009
  clientName: url.searchParams.get("client_name"),
34598
35010
  clientKind: url.searchParams.get("client_kind")
34599
35011
  };
35012
+ this.ensureHeartbeatAutoResponse();
34600
35013
  this.ctx.acceptWebSocket(server);
34601
35014
  server.serializeAttachment({ type: "hub_client", ...info });
34602
35015
  this.clientSockets.add(server);
@@ -34609,16 +35022,265 @@ var DurableUserHub = class extends DurableObject {
34609
35022
  }
34610
35023
  return new Response("Not found", { status: 404 });
34611
35024
  }
34612
- async webSocketMessage(ws, _message) {
34613
- if (!this.clientSockets.has(ws)) this.clientSockets.add(ws);
35025
+ // ─── User scope: event stream (spec: Users §4) ─────────────────────────────
35026
+ /** Idle sockets answer heartbeats at the edge without waking the object.
35027
+ * Registered at accept time rather than in the constructor so a hub that
35028
+ * never accepts a socket stays fully evictable (this also keeps
35029
+ * vitest-pool-workers' isolated storage able to pop the DO's frame). */
35030
+ ensureHeartbeatAutoResponse() {
35031
+ if (typeof WebSocketRequestResponsePair !== "undefined") {
35032
+ this.ctx.setWebSocketAutoResponse(
35033
+ new WebSocketRequestResponsePair("stream_ping", "stream_pong")
35034
+ );
35035
+ }
35036
+ }
35037
+ /** Sockets attached to the user event stream, restored after hibernation. */
35038
+ streamSockets() {
35039
+ return this.ctx.getWebSockets().filter((ws) => ws.deserializeAttachment()?.type === "user_stream");
35040
+ }
35041
+ acceptUserStream(request, url, userId) {
35042
+ if (request.headers.get("Upgrade") !== "websocket") {
35043
+ return Response.json({ error: "WebSocket upgrade required" }, { status: 426 });
35044
+ }
35045
+ const pair = new WebSocketPair();
35046
+ const [client, server] = [pair[0], pair[1]];
35047
+ const clientId = url.searchParams.get("client_id") ?? crypto.randomUUID();
35048
+ const clientKind = url.searchParams.get("client_kind");
35049
+ this.ensureHeartbeatAutoResponse();
35050
+ this.ctx.acceptWebSocket(server);
35051
+ server.serializeAttachment({ type: "user_stream", userId, clientId, clientKind });
35052
+ try {
35053
+ server.send(JSON.stringify({ event: "stream_ready", data: { client_id: clientId } }));
35054
+ } catch {
35055
+ }
35056
+ this.dispatchUserHooks(userId, "user_client_attached", [
35057
+ { id: clientId, kind: clientKind ?? void 0 }
35058
+ ]);
35059
+ return new Response(null, { status: 101, webSocket: client });
35060
+ }
35061
+ /**
35062
+ * Deliver an event frame to every socket attached to this user's stream.
35063
+ * At-most-once, live-only (spec: Users §4) — no history, no acks.
35064
+ */
35065
+ broadcastUserEvent(userId, event, data) {
35066
+ const frame = JSON.stringify({ event, data });
35067
+ for (const ws of this.streamSockets()) {
35068
+ const attachment = ws.deserializeAttachment();
35069
+ if (attachment?.userId !== userId) continue;
35070
+ try {
35071
+ ws.send(frame);
35072
+ } catch {
35073
+ }
35074
+ }
35075
+ }
35076
+ // ─── User scope: serialized endpoint execution (spec: Users §2.1, §3) ─────
35077
+ /** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
35078
+ executionChain = createExecutionChain();
35079
+ scopeHost() {
35080
+ return {
35081
+ getUserValue: (userId, key) => this.getUserValue(userId, key),
35082
+ setUserValue: (userId, key, value) => this.setUserValue(userId, key, value),
35083
+ listUserValues: (userId, options) => this.listUserValues(userId, options ?? {}),
35084
+ broadcastUserEvent: (userId, event, data) => this.broadcastUserEvent(userId, event, data),
35085
+ grantFetch: (userId, grant, req) => this.grantFetch(userId, grant, req),
35086
+ env: this.env
35087
+ };
35088
+ }
35089
+ async runUserEndpoint(request, userId, endpointPath) {
35090
+ const registry = this.userEndpoints();
35091
+ const match = matchUserRoute(Object.keys(registry), request.method, endpointPath);
35092
+ if (!match) return Response.json({ error: "Not found" }, { status: 404 });
35093
+ let loaded;
35094
+ try {
35095
+ loaded = await registry[match.key]();
35096
+ } catch (error) {
35097
+ return Response.json(
35098
+ { error: `Failed to load user endpoint: ${match.key}` },
35099
+ { status: 500 }
35100
+ );
35101
+ }
35102
+ const module = loaded;
35103
+ const handler = module.default ?? loaded;
35104
+ if (!isUserEndpoint(handler)) {
35105
+ return Response.json(
35106
+ { error: `${match.key} is not a defineUserEndpoint export` },
35107
+ { status: 500 }
35108
+ );
35109
+ }
35110
+ return this.executionChain.run(async () => {
35111
+ const user = new UserStateImpl(userId, this.scopeHost(), {
35112
+ kind: "userEndpoint",
35113
+ name: match.key
35114
+ });
35115
+ try {
35116
+ return await handler.__handler(request, user, match.params);
35117
+ } catch (error) {
35118
+ return Response.json(
35119
+ { error: error instanceof Error ? error.message : "User endpoint failed" },
35120
+ { status: 500 }
35121
+ );
35122
+ }
35123
+ });
35124
+ }
35125
+ // ─── User scope: lifecycle hooks (spec: Users §7) ──────────────────────────
35126
+ /**
35127
+ * Fire every definition of a user hook type. Observational and best-effort:
35128
+ * failures are swallowed and dispatch never blocks the triggering
35129
+ * operation. Nested dispatch is suppressed so a `user_value_changed` hook
35130
+ * writing values cannot recurse.
35131
+ */
35132
+ inUserHookDispatch = false;
35133
+ dispatchUserHooks(userId, hookType, args) {
35134
+ if (this.inUserHookDispatch) return;
35135
+ const registry = this.userHooks();
35136
+ const names = Object.keys(registry);
35137
+ if (names.length === 0) return;
35138
+ const run = async () => {
35139
+ const user = new UserStateImpl(userId, this.scopeHost(), {
35140
+ kind: "userHook",
35141
+ name: hookType
35142
+ });
35143
+ for (const name of names) {
35144
+ try {
35145
+ const loaded = await registry[name]();
35146
+ const definition = loaded.default ?? loaded;
35147
+ if (definition?.hook !== hookType || typeof definition.execute !== "function") {
35148
+ continue;
35149
+ }
35150
+ this.inUserHookDispatch = true;
35151
+ try {
35152
+ await definition.execute(user, ...args);
35153
+ } finally {
35154
+ this.inUserHookDispatch = false;
35155
+ }
35156
+ } catch {
35157
+ }
35158
+ }
35159
+ };
35160
+ this.ctx.waitUntil(run());
34614
35161
  }
34615
35162
  async webSocketClose(ws) {
35163
+ const attachment = ws.deserializeAttachment();
35164
+ if (attachment?.type === "user_stream") {
35165
+ this.dispatchUserHooks(String(attachment.userId), "user_client_detached", [
35166
+ {
35167
+ id: typeof attachment.clientId === "string" ? attachment.clientId : "unknown",
35168
+ kind: typeof attachment.clientKind === "string" ? attachment.clientKind : void 0
35169
+ }
35170
+ ]);
35171
+ return;
35172
+ }
34616
35173
  this.clientSockets.delete(ws);
34617
35174
  this.clientInfo.delete(ws);
34618
35175
  }
35176
+ // ─── User scope: credential grants (spec: Users §6) ───────────────────────
35177
+ /**
35178
+ * Store or replace a grant. `secret` is write-only from this point on.
35179
+ * Policy (`CredentialGrant` minus the secret) is kept as JSON config.
35180
+ */
35181
+ async setGrant(userId, grant, secret) {
35182
+ this.ensureMigrated();
35183
+ const { name, ...config } = grant;
35184
+ const now = Date.now();
35185
+ await this.ctx.storage.sql.exec(
35186
+ `INSERT INTO user_grants (user_id, name, secret, config, created_at, updated_at)
35187
+ VALUES (?1, ?2, ?3, ?4, ?5, ?5)
35188
+ ON CONFLICT(user_id, name) DO UPDATE SET
35189
+ secret = excluded.secret, config = excluded.config, updated_at = excluded.updated_at`,
35190
+ userId,
35191
+ name,
35192
+ secret,
35193
+ JSON.stringify(config),
35194
+ now
35195
+ );
35196
+ }
35197
+ async deleteGrant(userId, name) {
35198
+ this.ensureMigrated();
35199
+ await this.ctx.storage.sql.exec(
35200
+ `DELETE FROM user_grants WHERE user_id = ? AND name = ?`,
35201
+ userId,
35202
+ name
35203
+ );
35204
+ }
35205
+ /** Metadata-only listing — never returns secret material. */
35206
+ async listGrants(userId) {
35207
+ this.ensureMigrated();
35208
+ const cursor = await this.ctx.storage.sql.exec(
35209
+ `SELECT name, config FROM user_grants WHERE user_id = ? ORDER BY name ASC`,
35210
+ userId
35211
+ );
35212
+ return cursor.toArray().map((row) => {
35213
+ let config = {};
35214
+ try {
35215
+ config = JSON.parse(row.config);
35216
+ } catch {
35217
+ }
35218
+ return { name: row.name, ...config };
35219
+ });
35220
+ }
35221
+ /** Perform a privileged fetch with the grant's credential injected. */
35222
+ async grantFetch(userId, grantName, req) {
35223
+ this.ensureMigrated();
35224
+ const cursor = await this.ctx.storage.sql.exec(
35225
+ `SELECT secret, config FROM user_grants WHERE user_id = ? AND name = ? LIMIT 1`,
35226
+ userId,
35227
+ grantName
35228
+ );
35229
+ const row = cursor.toArray()[0];
35230
+ if (!row) {
35231
+ return Response.json({ error: "grant_not_found", grant: grantName }, { status: 404 });
35232
+ }
35233
+ let config;
35234
+ try {
35235
+ config = JSON.parse(row.config);
35236
+ } catch {
35237
+ return Response.json({ error: "grant_config_invalid" }, { status: 500 });
35238
+ }
35239
+ let target;
35240
+ try {
35241
+ target = new URL(req.url);
35242
+ } catch {
35243
+ return Response.json({ error: "grant_target_invalid" }, { status: 400 });
35244
+ }
35245
+ if (!originAllowed(config.origins, target)) {
35246
+ return Response.json({ error: "grant_origin_denied" }, { status: 403 });
35247
+ }
35248
+ if (!callerAllowed(config.allow, req.caller)) {
35249
+ return Response.json({ error: "grant_caller_denied" }, { status: 403 });
35250
+ }
35251
+ const headers = new Headers(req.headers);
35252
+ if (!injectGrantCredential(config, row.secret, target, headers)) {
35253
+ return Response.json({ error: "grant_kind_unsupported" }, { status: 501 });
35254
+ }
35255
+ try {
35256
+ const response = await fetch(target.toString(), {
35257
+ method: req.method,
35258
+ headers,
35259
+ body: req.body ?? void 0
35260
+ });
35261
+ return new Response(response.body, { status: response.status, headers: response.headers });
35262
+ } catch {
35263
+ return Response.json({ error: "grant_fetch_failed" }, { status: 502 });
35264
+ }
35265
+ }
35266
+ async handleGrantFetch(request, userId) {
35267
+ const body = await request.json().catch(() => null);
35268
+ if (!body?.grant || !body.url) {
35269
+ return Response.json({ error: "grant and url required" }, { status: 400 });
35270
+ }
35271
+ return this.grantFetch(userId, body.grant, {
35272
+ url: body.url,
35273
+ method: body.method ?? "GET",
35274
+ headers: body.headers ?? {},
35275
+ body: body.body ?? null,
35276
+ caller: body.caller ?? { kind: "unknown", name: "unknown" }
35277
+ });
35278
+ }
35279
+ async webSocketMessage(ws, _message) {
35280
+ if (!this.clientSockets.has(ws)) this.clientSockets.add(ws);
35281
+ }
34619
35282
  async webSocketError(ws) {
34620
- this.clientSockets.delete(ws);
34621
- this.clientInfo.delete(ws);
35283
+ await this.webSocketClose(ws);
34622
35284
  }
34623
35285
  /** Whether a persistent client (daemon) is connected right now. */
34624
35286
  async hasClient() {
@@ -34678,6 +35340,7 @@ var DurableUserHub = class extends DurableObject {
34678
35340
  userId,
34679
35341
  key
34680
35342
  );
35343
+ this.dispatchUserHooks(userId, "user_value_changed", [key]);
34681
35344
  return;
34682
35345
  }
34683
35346
  const serialized = JSON.stringify(value);
@@ -34693,6 +35356,7 @@ var DurableUserHub = class extends DurableObject {
34693
35356
  serialized,
34694
35357
  now
34695
35358
  );
35359
+ this.dispatchUserHooks(userId, "user_value_changed", [key]);
34696
35360
  }
34697
35361
  async listUserValues(userId, options = {}) {
34698
35362
  this.ensureMigrated();