@economic/agents 1.3.0 → 1.4.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/README.md CHANGED
@@ -270,6 +270,58 @@ execute: async (args, { experimental_context }) => {
270
270
 
271
271
  ---
272
272
 
273
+ ### JWT Authentication
274
+
275
+ Authenticate WebSocket connections by implementing `getJwtAuthConfig` on your agent. When defined, JWT verification runs in `onConnect` — failed auth closes the connection, successful auth stores claims in `session`.
276
+
277
+ ```typescript
278
+ import type { JWTPayload } from "jose";
279
+ import { ChatAgentHarness, type AgentToolContext } from "@economic/agents";
280
+
281
+ interface Session {
282
+ clientId: string;
283
+ userGuid: string;
284
+ agreementNumber: number;
285
+ }
286
+
287
+ export class MyAgent extends ChatAgentHarness<Env, RequestBody> {
288
+ getJwtAuthConfig(request: Request) {
289
+ const origin = request.headers.get("Origin") ?? "";
290
+ const isStaging = origin.includes("staging");
291
+
292
+ return {
293
+ allowedIssuers: isStaging
294
+ ? [/^https:\/\/auth\.staging\.example\.com$/]
295
+ : ["https://auth.example.com"],
296
+ audience: "my-api",
297
+ requiredScopes: ["read"],
298
+ getClaims: (payload: JWTPayload): Session => ({
299
+ clientId: payload.client_id as string,
300
+ userGuid: payload.user_guid as string,
301
+ agreementNumber: payload.agreement_number as number,
302
+ }),
303
+ };
304
+ }
305
+
306
+ // Session is available in tool context
307
+ getModel(ctx: AgentToolContext<RequestBody>) {
308
+ console.log(ctx.session); // { clientId, userGuid, agreementNumber }
309
+ return openai("gpt-4o");
310
+ }
311
+ }
312
+ ```
313
+
314
+ - `allowedIssuers` — array of strings or RegExp patterns for trusted issuers
315
+ - `audience` — expected `aud` claim
316
+ - `requiredScopes` — optional array of required OAuth scopes
317
+ - `getClaims(payload)` — extract claims from verified JWT payload
318
+
319
+ Claims are available as `ctx.session` in `getModel`, `getSystemPrompt`, `getTools`, `getSkills`, and tool `execute` functions.
320
+
321
+ If `getJwtAuthConfig` is not implemented, no authentication is performed and `ctx.session` is `undefined`.
322
+
323
+ ---
324
+
273
325
  ### Source URLs from Tools
274
326
 
275
327
  Any tool can surface source URLs into the message stream by including a `sources` array in its return value. Detected automatically by `buildLLMParams` — no additional wiring needed.
@@ -426,29 +478,6 @@ const conversations = await agent.call("getConversations");
426
478
 
427
479
  ---
428
480
 
429
- ## Hono
430
-
431
- Hono tooling is exported on `@economic/agents/hono`.
432
-
433
- ### JWT Auth Middleware
434
-
435
- Bearer JWT verification middleware for Hono, imported from `@economic/agents/hono`. Verifies tokens via JWKS derived from the token's `iss` claim.
436
-
437
- ```typescript
438
- import { jwtAuth } from "@economic/agents/hono";
439
-
440
- app.use(
441
- "/api/*",
442
- jwtAuth({
443
- allowedIssuers: ["https://login.example.com"],
444
- audience: "my-api",
445
- requiredScopes: ["read", "write"],
446
- }),
447
- );
448
- ```
449
-
450
- ---
451
-
452
481
  ## API Reference
453
482
 
454
483
  ### `@economic/agents`
@@ -460,7 +489,7 @@ app.use(
460
489
  | `ChatAgentHarness` | Opinionated chat harness with getModel/getSystemPrompt/getTools/getSkills |
461
490
  | `buildLLMParams` | Standalone function to build streamText/generateText params |
462
491
  | `Skill` | Type: named group of tools with optional guidance |
463
- | `AgentToolContext` | Type: request body merged with `logEvent` for tool context |
492
+ | `AgentToolContext` | Type: request body merged with `session` and `logEvent` for tool context |
464
493
  | `OnChatMessageOptions` | Type: options passed to `onChatMessage` |
465
494
  | `BuildLLMParamsConfig` | Type: config for standalone `buildLLMParams` |
466
495
 
@@ -474,13 +503,6 @@ React hooks are in a separate package. See [`@economic/agents-react`](../react/R
474
503
  | `UseAIChatAgentOptions` | Type: options for `useAIChatAgent` (`agent`, `host`, `chatId`, optional `basePath`, `toolContext`, `connectionParams`, …) |
475
504
  | `AgentConnectionStatus` | Type: `"connecting" \| "connected" \| "disconnected" \| "unauthorized"` |
476
505
 
477
- ### `@economic/agents/hono`
478
-
479
- | Export | Description |
480
- | --------------- | ------------------------------------------- |
481
- | `jwtAuth` | Hono middleware for Bearer JWT verification |
482
- | `JwtAuthConfig` | Type: config for `jwtAuth` |
483
-
484
506
  ### CLI
485
507
 
486
508
  | Command | Description |
package/dist/index.d.mts CHANGED
@@ -65,6 +65,21 @@ type BuildLLMParamsConfig = Omit<LLMParams, "prompt"> & {
65
65
  */
66
66
  declare function buildLLMParams(config: BuildLLMParamsConfig): LLMParams;
67
67
  //#endregion
68
+ //#region src/server/features/auth/index.d.ts
69
+ interface JwtAuthConfig<TClaims extends Record<string, unknown> = Record<string, unknown>> {
70
+ /** Issuers whose tokens are accepted (exact string or RegExp). */
71
+ allowedIssuers: readonly (string | RegExp)[];
72
+ /** Expected `aud` claim. */
73
+ audience: string;
74
+ /** Required OAuth scopes; token `scope` must include all (empty = no scope check). */
75
+ requiredScopes?: readonly string[];
76
+ /**
77
+ * Extract the claims you need from the verified JWT payload.
78
+ * These will be stored and made available in the agent's tool context.
79
+ */
80
+ getClaims: (payload: JWTPayload) => TClaims;
81
+ }
82
+ //#endregion
68
83
  //#region src/server/agents/Agent.d.ts
69
84
  /**
70
85
  * Base agent for Cloudflare Agents SDK Durable Objects with lazy skill loading,
@@ -77,6 +92,19 @@ declare function buildLLMParams(config: BuildLLMParamsConfig): LLMParams;
77
92
  * extend {@link ChatAgent} instead.
78
93
  */
79
94
  declare abstract class Agent<Env extends Cloudflare.Env = Cloudflare.Env> extends Agent$1<Env & AgentEnv> {
95
+ /**
96
+ * Verified session claims from JWT authentication.
97
+ * Set in `onConnect` when `getJwtAuthConfig` is implemented.
98
+ */
99
+ private session?;
100
+ /**
101
+ * Override to enable JWT authentication on WebSocket connections.
102
+ * Return the auth config based on the incoming request, or undefined to skip auth.
103
+ *
104
+ * @param request - The WebSocket upgrade request
105
+ * @returns JWT auth config or undefined to skip authentication
106
+ */
107
+ protected getJwtAuthConfig?(request: Request): JwtAuthConfig<Record<string, unknown>> | undefined;
80
108
  /**
81
109
  * Returns the user ID from the durable object name.
82
110
  */
@@ -102,21 +130,6 @@ declare abstract class Agent<Env extends Cloudflare.Env = Cloudflare.Env> extend
102
130
  }): Promise<LLMParams>;
103
131
  }
104
132
  //#endregion
105
- //#region src/server/features/auth/index.d.ts
106
- interface JwtAuthConfig<TClaims extends Record<string, unknown> = Record<string, unknown>> {
107
- /** Issuers whose tokens are accepted (exact string or RegExp). */
108
- allowedIssuers: readonly (string | RegExp)[];
109
- /** Expected `aud` claim. */
110
- audience: string;
111
- /** Required OAuth scopes; token `scope` must include all (empty = no scope check). */
112
- requiredScopes?: readonly string[];
113
- /**
114
- * Extract the claims you need from the verified JWT payload.
115
- * These will be stored and made available in the agent's tool context.
116
- */
117
- getClaims: (payload: JWTPayload) => TClaims;
118
- }
119
- //#endregion
120
133
  //#region src/server/agents/ChatAgent.d.ts
121
134
  /**
122
135
  * Chat agent for Cloudflare Agents SDK: lazy skill loading, audit logging,
package/dist/index.mjs CHANGED
@@ -189,7 +189,7 @@ function buildSystemPromptWithSkills(basePrompt, availableSkillList, loadedGuida
189
189
  ## Tools
190
190
 
191
191
  ### Loading More Tools:
192
- Use \`activate_skill\` to load these skills (BE PROACTIVE on requesting tools based on the user's request AND you DON'T need to mention that you are loading more tools):
192
+ Use \`activate_skill\` to load these skills. NEVER ask the user for permission to load a skill - just load it silently when relevant. The user should not be aware of skills or tools; activate them autonomously based on the request:
193
193
 
194
194
  ${availableSkillList}`;
195
195
  if (loadedGuidance) prompt += `
@@ -369,6 +369,106 @@ function buildTurnLogPayload(event) {
369
369
  };
370
370
  }
371
371
  //#endregion
372
+ //#region src/server/features/auth/index.ts
373
+ const jwksByIssuer = /* @__PURE__ */ new Map();
374
+ function getJwksForIssuer(issuer) {
375
+ const normalized = issuer.replace(/\/$/, "");
376
+ let jwks = jwksByIssuer.get(normalized);
377
+ if (!jwks) {
378
+ jwks = createRemoteJWKSet(new URL(`${normalized}/.well-known/openid-configuration/jwks`));
379
+ jwksByIssuer.set(normalized, jwks);
380
+ }
381
+ return jwks;
382
+ }
383
+ function isIssuerAllowed(iss, allowed) {
384
+ return allowed.some((rule) => typeof rule === "string" ? rule === iss : rule.test(iss));
385
+ }
386
+ function extractTokenFromRequest(request) {
387
+ const authorization = request.headers.get("Authorization");
388
+ if (authorization) {
389
+ const match = authorization.match(/^Bearer\s+(.+)$/i);
390
+ if (match?.[1]) return match[1].trim();
391
+ }
392
+ const wsProtocol = request.headers.get("Sec-WebSocket-Protocol");
393
+ if (wsProtocol) {
394
+ const parts = wsProtocol.split(",").map((p) => p.trim());
395
+ const idx = parts.indexOf("bearer");
396
+ if (idx !== -1 && parts[idx + 1]) return parts[idx + 1];
397
+ }
398
+ return null;
399
+ }
400
+ function hasRequiredScopes(tokenScope, required) {
401
+ if (required.length === 0) return true;
402
+ if (tokenScope === void 0) return false;
403
+ const tokens = Array.isArray(tokenScope) ? tokenScope : tokenScope.split(" ").map((s) => s.trim()).filter(Boolean);
404
+ const granted = new Set(tokens);
405
+ return required.every((scope) => granted.has(scope));
406
+ }
407
+ /**
408
+ * Verify a JWT from a request and extract claims.
409
+ *
410
+ * Extracts the token from `Authorization: Bearer` header first,
411
+ * then falls back to `Sec-WebSocket-Protocol: bearer, <token>` for WebSocket connections.
412
+ *
413
+ * Expected auth failures (missing/expired token, insufficient scope) return a failure result.
414
+ * Unexpected errors (network issues, malformed requests, untrusted issuers) throw and should
415
+ * be caught and logged by the caller.
416
+ *
417
+ * @param request - The incoming request (from ConnectionContext)
418
+ * @param config - JWT verification configuration
419
+ * @returns Result object with either verified claims or expected auth failure
420
+ * @throws Error for unexpected failures that should be logged
421
+ */
422
+ async function verifyJwt(request, config) {
423
+ const token = extractTokenFromRequest(request);
424
+ if (!token) return {
425
+ success: false,
426
+ status: 401,
427
+ message: "Unauthorized: Missing authentication token"
428
+ };
429
+ let unverifiedPayload;
430
+ try {
431
+ unverifiedPayload = decodeJwt(token);
432
+ } catch {
433
+ throw new Error("Invalid token format");
434
+ }
435
+ const iss = typeof unverifiedPayload.iss === "string" ? unverifiedPayload.iss : void 0;
436
+ if (!iss) throw new Error("Missing issuer claim in token");
437
+ if (!isIssuerAllowed(iss, config.allowedIssuers)) throw new Error(`Untrusted issuer: ${iss}`);
438
+ const jwks = getJwksForIssuer(iss);
439
+ let payload;
440
+ try {
441
+ payload = (await jwtVerify(token, jwks, {
442
+ issuer: iss,
443
+ audience: config.audience
444
+ })).payload;
445
+ } catch (error) {
446
+ if (error instanceof errors.JWTExpired) return {
447
+ success: false,
448
+ status: 401,
449
+ message: "Unauthorized: Token expired"
450
+ };
451
+ if (error instanceof errors.JWTClaimValidationFailed) return {
452
+ success: false,
453
+ status: 401,
454
+ message: "Unauthorized: Token claim validation failed"
455
+ };
456
+ if (error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JWKSNoMatchingKey) throw new Error("Invalid token signature");
457
+ throw error;
458
+ }
459
+ const requiredScopes = config.requiredScopes ?? [];
460
+ const scopeClaim = payload.scope;
461
+ if (!hasRequiredScopes(scopeClaim, requiredScopes)) return {
462
+ success: false,
463
+ status: 403,
464
+ message: "Forbidden: Insufficient scope"
465
+ };
466
+ return {
467
+ success: true,
468
+ claims: config.getClaims(payload)
469
+ };
470
+ }
471
+ //#endregion
372
472
  //#region src/server/agents/Agent.ts
373
473
  /**
374
474
  * Base agent for Cloudflare Agents SDK Durable Objects with lazy skill loading,
@@ -381,6 +481,11 @@ function buildTurnLogPayload(event) {
381
481
  * extend {@link ChatAgent} instead.
382
482
  */
383
483
  var Agent = class extends Agent$1 {
484
+ /**
485
+ * Verified session claims from JWT authentication.
486
+ * Set in `onConnect` when `getJwtAuthConfig` is implemented.
487
+ */
488
+ session;
384
489
  /**
385
490
  * Returns the user ID from the durable object name.
386
491
  */
@@ -389,15 +494,33 @@ var Agent = class extends Agent$1 {
389
494
  }
390
495
  async onConnect(connection, ctx) {
391
496
  if (!this.env.AGENT_DB) {
392
- console.error("[Agent] Connection rejected: no AGENT_DB bound");
497
+ console.error("[ChatAgent] Connection rejected: no AGENT_DB bound");
393
498
  connection.close(3e3, "Could not connect to agent, database not found");
394
499
  return;
395
500
  }
396
501
  if (!this.getUserId()) {
397
- console.error("[Agent] Connection rejected: name must be in the format userId:uniqueChatId");
502
+ console.error("[ChatAgent] Connection rejected: name must be in the format userId:uniqueChatId");
398
503
  connection.close(3e3, "Could not connect to agent, name is not in correct format");
399
504
  return;
400
505
  }
506
+ if (this.getJwtAuthConfig) {
507
+ const config = this.getJwtAuthConfig(ctx.request);
508
+ if (config) {
509
+ let result;
510
+ try {
511
+ result = await verifyJwt(ctx.request, config);
512
+ } catch (error) {
513
+ console.error("[ChatAgent] JWT verification error", error);
514
+ connection.close(4001, "Unauthorized");
515
+ return;
516
+ }
517
+ if (!result.success) {
518
+ connection.close(result.status === 401 ? 4001 : 4003, result.message);
519
+ return;
520
+ }
521
+ this.session = result.claims;
522
+ }
523
+ }
401
524
  return super.onConnect(connection, ctx);
402
525
  }
403
526
  /**
@@ -424,7 +547,9 @@ var Agent = class extends Agent$1 {
424
547
  async buildLLMParams(config) {
425
548
  const activeSkills = await getStoredSkills(this.sql.bind(this));
426
549
  const experimental_context = {
550
+ ...config.experimental_context,
427
551
  ...config.options?.body,
552
+ session: this.session,
428
553
  logEvent: this.logEvent.bind(this)
429
554
  };
430
555
  const onFinish = async (event) => {
@@ -653,106 +778,6 @@ function getDeleteConversationScheduleIds(schedules) {
653
778
  return schedules.filter((schedule) => schedule.callback === DELETE_CONVERSATION_CALLBACK).map((schedule) => schedule.id);
654
779
  }
655
780
  //#endregion
656
- //#region src/server/features/auth/index.ts
657
- const jwksByIssuer = /* @__PURE__ */ new Map();
658
- function getJwksForIssuer(issuer) {
659
- const normalized = issuer.replace(/\/$/, "");
660
- let jwks = jwksByIssuer.get(normalized);
661
- if (!jwks) {
662
- jwks = createRemoteJWKSet(new URL(`${normalized}/.well-known/openid-configuration/jwks`));
663
- jwksByIssuer.set(normalized, jwks);
664
- }
665
- return jwks;
666
- }
667
- function isIssuerAllowed(iss, allowed) {
668
- return allowed.some((rule) => typeof rule === "string" ? rule === iss : rule.test(iss));
669
- }
670
- function extractTokenFromRequest(request) {
671
- const authorization = request.headers.get("Authorization");
672
- if (authorization) {
673
- const match = authorization.match(/^Bearer\s+(.+)$/i);
674
- if (match?.[1]) return match[1].trim();
675
- }
676
- const wsProtocol = request.headers.get("Sec-WebSocket-Protocol");
677
- if (wsProtocol) {
678
- const parts = wsProtocol.split(",").map((p) => p.trim());
679
- const idx = parts.indexOf("bearer");
680
- if (idx !== -1 && parts[idx + 1]) return parts[idx + 1];
681
- }
682
- return null;
683
- }
684
- function hasRequiredScopes(tokenScope, required) {
685
- if (required.length === 0) return true;
686
- if (tokenScope === void 0) return false;
687
- const tokens = Array.isArray(tokenScope) ? tokenScope : tokenScope.split(" ").map((s) => s.trim()).filter(Boolean);
688
- const granted = new Set(tokens);
689
- return required.every((scope) => granted.has(scope));
690
- }
691
- /**
692
- * Verify a JWT from a request and extract claims.
693
- *
694
- * Extracts the token from `Authorization: Bearer` header first,
695
- * then falls back to `Sec-WebSocket-Protocol: bearer, <token>` for WebSocket connections.
696
- *
697
- * Expected auth failures (missing/expired token, insufficient scope) return a failure result.
698
- * Unexpected errors (network issues, malformed requests, untrusted issuers) throw and should
699
- * be caught and logged by the caller.
700
- *
701
- * @param request - The incoming request (from ConnectionContext)
702
- * @param config - JWT verification configuration
703
- * @returns Result object with either verified claims or expected auth failure
704
- * @throws Error for unexpected failures that should be logged
705
- */
706
- async function verifyJwt(request, config) {
707
- const token = extractTokenFromRequest(request);
708
- if (!token) return {
709
- success: false,
710
- status: 401,
711
- message: "Unauthorized: Missing authentication token"
712
- };
713
- let unverifiedPayload;
714
- try {
715
- unverifiedPayload = decodeJwt(token);
716
- } catch {
717
- throw new Error("Invalid token format");
718
- }
719
- const iss = typeof unverifiedPayload.iss === "string" ? unverifiedPayload.iss : void 0;
720
- if (!iss) throw new Error("Missing issuer claim in token");
721
- if (!isIssuerAllowed(iss, config.allowedIssuers)) throw new Error(`Untrusted issuer: ${iss}`);
722
- const jwks = getJwksForIssuer(iss);
723
- let payload;
724
- try {
725
- payload = (await jwtVerify(token, jwks, {
726
- issuer: iss,
727
- audience: config.audience
728
- })).payload;
729
- } catch (error) {
730
- if (error instanceof errors.JWTExpired) return {
731
- success: false,
732
- status: 401,
733
- message: "Unauthorized: Token expired"
734
- };
735
- if (error instanceof errors.JWTClaimValidationFailed) return {
736
- success: false,
737
- status: 401,
738
- message: "Unauthorized: Token claim validation failed"
739
- };
740
- if (error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JWKSNoMatchingKey) throw new Error("Invalid token signature");
741
- throw error;
742
- }
743
- const requiredScopes = config.requiredScopes ?? [];
744
- const scopeClaim = payload.scope;
745
- if (!hasRequiredScopes(scopeClaim, requiredScopes)) return {
746
- success: false,
747
- status: 403,
748
- message: "Forbidden: Insufficient scope"
749
- };
750
- return {
751
- success: true,
752
- claims: config.getClaims(payload)
753
- };
754
- }
755
- //#endregion
756
781
  //#region src/server/agents/ChatAgent.ts
757
782
  /**
758
783
  * Chat agent for Cloudflare Agents SDK: lazy skill loading, audit logging,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@economic/agents",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "A starter for creating a TypeScript package.",
5
5
  "license": "MIT",
6
6
  "bin": {