diff --git a/.gitignore b/.gitignore index 9e8fd80d..ce1ae621 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,5 @@ playwright-report/ # Generated CLI skills **/.agents/skills/ +apps/testing/ag-auth-test-app/cookies.txt +apps/testing/ag-auth-test-app/agentuity.json diff --git a/AGENTS.md b/AGENTS.md index 0ea1c754..01cb463a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ See [docs/testing.md](docs/testing.md) for detailed standards. - Import from `../src/` in tests - Use `@agentuity/test-utils` for mocks - All errors AND warnings must be zero +- When running tests, prefer using a subagent (Task tool) to avoid context bloat from test output ## Special Instructions diff --git a/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/agent/AGENTS.md b/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/agent/AGENTS.md new file mode 100644 index 00000000..65412abc --- /dev/null +++ b/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/agent/AGENTS.md @@ -0,0 +1,308 @@ +# Agents Folder Guide + +This folder contains AI agents for your Agentuity application. Each agent is organized in its own subdirectory. + +## Generated Types + +The `src/generated/` folder contains auto-generated TypeScript files: + +- `registry.ts` - Agent registry with strongly-typed agent definitions and schema types +- `routes.ts` - Route registry for API, WebSocket, and SSE endpoints +- `app.ts` - Application entry point (regenerated on every build) + +**Important:** Never edit files in `src/generated/` - they are overwritten on every build. + +Import generated types in your agents: + +```typescript +import type { HelloInput, HelloOutput } from '../generated/registry'; +``` + +## Directory Structure + +Each agent folder must contain: + +- **agent.ts** (required) - Agent definition with schema and handler + +Example structure: + +``` +src/agent/ +├── hello/ +│ └── agent.ts +├── process-data/ +│ └── agent.ts +└── (generated files in src/generated/) +``` + +**Note:** HTTP routes are defined separately in `src/api/` - see the API folder guide for details. + +## Creating an Agent + +### Basic Agent (agent.ts) + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; + +const agent = createAgent('my-agent', { + description: 'What this agent does', + schema: { + input: s.object({ + name: s.string(), + age: s.number(), + }), + output: s.string(), + }, + handler: async (ctx, input) => { + // Access context: ctx.app, ctx.config, ctx.logger, ctx.kv, ctx.vector, ctx.stream + return `Hello, ${input.name}! You are ${input.age} years old.`; + }, +}); + +export default agent; +``` + +### Agent with Lifecycle (setup/shutdown) + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; + +const agent = createAgent('lifecycle-agent', { + description: 'Agent with setup and shutdown', + schema: { + input: s.object({ message: s.string() }), + output: s.object({ result: s.string() }), + }, + setup: async (app) => { + // Initialize resources (runs once on startup) + // app contains: appName, version, startedAt, config + return { + agentId: `agent-${Math.random().toString(36).substr(2, 9)}`, + connectionPool: ['conn-1', 'conn-2'], + }; + }, + handler: async (ctx, input) => { + // Access setup config via ctx.config (fully typed) + ctx.logger.info('Agent ID:', ctx.config.agentId); + ctx.logger.info('Connections:', ctx.config.connectionPool); + return { result: `Processed: ${input.message}` }; + }, + shutdown: async (app, config) => { + // Cleanup resources (runs on shutdown) + console.log('Shutting down agent:', config.agentId); + }, +}); + +export default agent; +``` + +### Agent with Event Listeners + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; + +const agent = createAgent('event-agent', { + schema: { + input: s.object({ data: s.string() }), + output: s.string(), + }, + handler: async (ctx, input) => { + return `Processed: ${input.data}`; + }, +}); + +agent.addEventListener('started', (eventName, agent, ctx) => { + ctx.logger.info('Agent started'); +}); + +agent.addEventListener('completed', (eventName, agent, ctx) => { + ctx.logger.info('Agent completed'); +}); + +agent.addEventListener('errored', (eventName, agent, ctx, error) => { + ctx.logger.error('Agent errored:', error); +}); + +export default agent; +``` + +## Agent Context (ctx) + +The handler receives a context object with: + +- **ctx.app** - Application state (appName, version, startedAt, config from createApp) +- **ctx.config** - Agent-specific config (from setup return value, fully typed) +- **ctx.logger** - Structured logger (info, warn, error, debug, trace) +- **ctx.tracer** - OpenTelemetry tracer for custom spans +- **ctx.sessionId** - Unique session identifier +- **ctx.kv** - Key-value storage +- **ctx.vector** - Vector storage for embeddings +- **ctx.stream** - Stream storage for real-time data +- **ctx.state** - In-memory request-scoped state (Map) +- **ctx.thread** - Thread information for multi-turn conversations +- **ctx.session** - Session information +- **ctx.waitUntil** - Schedule background tasks + +## Examples + +### Using Key-Value Storage + +```typescript +handler: async (ctx, input) => { + await ctx.kv.set('user:123', { name: 'Alice', age: 30 }); + const user = await ctx.kv.get('user:123'); + await ctx.kv.delete('user:123'); + const keys = await ctx.kv.list('user:*'); + return user; +}; +``` + +### Using Vector Storage + +```typescript +handler: async (ctx, input) => { + await ctx.vector.upsert('docs', [ + { id: '1', values: [0.1, 0.2, 0.3], metadata: { text: 'Hello' } }, + ]); + const results = await ctx.vector.query('docs', [0.1, 0.2, 0.3], { topK: 5 }); + return results; +}; +``` + +### Using Streams + +```typescript +handler: async (ctx, input) => { + const stream = await ctx.stream.create('agent-logs'); + await ctx.stream.write(stream.id, 'Processing step 1'); + await ctx.stream.write(stream.id, 'Processing step 2'); + return { streamId: stream.id }; +}; +``` + +### Background Tasks with waitUntil + +```typescript +handler: async (ctx, input) => { + // Schedule background work that continues after response + ctx.waitUntil(async () => { + await ctx.kv.set('processed', Date.now()); + ctx.logger.info('Background task complete'); + }); + + return { status: 'processing' }; +}; +``` + +### Calling Another Agent + +```typescript +// Import the agent directly +import otherAgent from '../other-agent/agent'; + +handler: async (ctx, input) => { + const result = await otherAgent.run({ data: input.value }); + return `Other agent returned: ${result}`; +}; +``` + +## Subagents (Nested Agents) + +Agents can have subagents organized one level deep. This is useful for grouping related functionality. + +### Directory Structure for Subagents + +``` +src/agent/ +└── team/ # Parent agent + ├── agent.ts # Parent agent + ├── members/ # Subagent + │ └── agent.ts + └── tasks/ # Subagent + └── agent.ts +``` + +### Parent Agent + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; + +const agent = createAgent('team', { + description: 'Team Manager', + schema: { + input: s.object({ action: s.union([s.literal('info'), s.literal('count')]) }), + output: s.object({ + message: s.string(), + timestamp: s.string(), + }), + }, + handler: async (ctx, { action }) => { + return { + message: 'Team parent agent - manages members and tasks', + timestamp: new Date().toISOString(), + }; + }, +}); + +export default agent; +``` + +### Subagent (Accessing Parent) + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; +import parentAgent from '../agent'; + +const agent = createAgent('team.members', { + description: 'Members Subagent', + schema: { + input: s.object({ + action: s.union([s.literal('list'), s.literal('add'), s.literal('remove')]), + name: s.optional(s.string()), + }), + output: s.object({ + members: s.array(s.string()), + parentInfo: s.optional(s.string()), + }), + }, + handler: async (ctx, { action, name }) => { + // Call parent agent directly + const parentResult = await parentAgent.run({ action: 'info' }); + const parentInfo = `Parent says: ${parentResult.message}`; + + let members = ['Alice', 'Bob']; + if (action === 'add' && name) { + members.push(name); + } + + return { members, parentInfo }; + }, +}); + +export default agent; +``` + +### Key Points About Subagents + +- **One level deep**: Only one level of nesting is supported (no nested subagents) +- **Access parent**: Import and call parent agents directly +- **Agent names**: Subagents have dotted names like `"team.members"` +- **Shared context**: Subagents share the same app context (kv, logger, etc.) + +## Rules + +- Each agent folder name becomes the agent's route name (e.g., `hello/` → `/agent/hello`) +- **agent.ts** must export default the agent instance +- The first argument to `createAgent()` is the agent name (must match folder structure) +- Input/output schemas are enforced with @agentuity/schema validation +- Setup return value type automatically flows to ctx.config (fully typed) +- Use ctx.logger for logging, not console.log +- Import agents directly to call them (recommended approach) +- Subagents are one level deep only (team/members/, not team/members/subagent/) + + \ No newline at end of file diff --git a/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/api/AGENTS.md b/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/api/AGENTS.md new file mode 100644 index 00000000..7703c74a --- /dev/null +++ b/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/api/AGENTS.md @@ -0,0 +1,362 @@ +# APIs Folder Guide + +This folder contains REST API routes for your Agentuity application. Each API is organized in its own subdirectory. + +## Generated Types + +The `src/generated/` folder contains auto-generated TypeScript files: + +- `routes.ts` - Route registry with strongly-typed route definitions and schema types +- `registry.ts` - Agent registry (for calling agents from routes) +- `app.ts` - Application entry point (regenerated on every build) + +**Important:** Never edit files in `src/generated/` - they are overwritten on every build. + +Import generated types in your routes: + +```typescript +import type { POST_Api_UsersInput, POST_Api_UsersOutput } from '../generated/routes'; +``` + +## Directory Structure + +Each API folder must contain: + +- **route.ts** (required) - HTTP route definitions using Hono router + +Example structure: + +``` +src/api/ +├── index.ts (optional, mounted at /api) +├── status/ +│ └── route.ts (mounted at /api/status) +├── users/ +│ └── route.ts (mounted at /api/users) +├── agent-call/ + └── route.ts (mounted at /api/agent-call) +``` + +## Creating an API + +### Basic API (route.ts) + +```typescript +import { createRouter } from '@agentuity/runtime'; + +const router = createRouter(); + +// GET /api/status +router.get('/', (c) => { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: '1.0.0', + }); +}); + +// POST /api/status +router.post('/', async (c) => { + const body = await c.req.json(); + return c.json({ received: body }); +}); + +export default router; +``` + +### API with Request Validation + +```typescript +import { createRouter } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; +import { validator } from 'hono/validator'; + +const router = createRouter(); + +const createUserSchema = s.object({ + name: s.string(), + email: s.string(), + age: s.number(), +}); + +router.post( + '/', + validator('json', (value, c) => { + const result = createUserSchema['~standard'].validate(value); + if (result.issues) { + return c.json({ error: 'Validation failed', issues: result.issues }, 400); + } + return result.value; + }), + async (c) => { + const data = c.req.valid('json'); + // data is fully typed: { name: string, email: string, age: number } + return c.json({ + success: true, + user: data, + }); + } +); + +export default router; +``` + +### API Calling Agents + +APIs can call agents directly by importing them: + +```typescript +import { createRouter } from '@agentuity/runtime'; +import helloAgent from '@agent/hello'; + +const router = createRouter(); + +router.get('/', async (c) => { + // Call an agent directly + const result = await helloAgent.run({ name: 'API Caller', age: 42 }); + + return c.json({ + success: true, + agentResult: result, + }); +}); + +router.post('/with-input', async (c) => { + const body = await c.req.json(); + const { name, age } = body; + + // Call agent with dynamic input + const result = await helloAgent.run({ name, age }); + + return c.json({ + success: true, + agentResult: result, + }); +}); + +export default router; +``` + +### API with Agent Validation + +Use `agent.validator()` for automatic input validation from agent schemas: + +```typescript +import { createRouter } from '@agentuity/runtime'; +import myAgent from '@agent/my-agent'; + +const router = createRouter(); + +// POST with automatic validation using agent's input schema +router.post('/', myAgent.validator(), async (c) => { + const data = c.req.valid('json'); // Fully typed from agent schema! + const result = await myAgent.run(data); + return c.json({ success: true, result }); +}); + +export default router; +``` + +### API with Logging + +```typescript +import { createRouter } from '@agentuity/runtime'; + +const router = createRouter(); + +router.get('/log-test', (c) => { + c.var.logger.info('Info message'); + c.var.logger.error('Error message'); + c.var.logger.warn('Warning message'); + c.var.logger.debug('Debug message'); + c.var.logger.trace('Trace message'); + + return c.text('Check logs'); +}); + +export default router; +``` + +## Route Context (c) + +The route handler receives a Hono context object with: + +- **c.req** - Request object (c.req.json(), c.req.param(), c.req.query(), etc.) +- **c.json()** - Return JSON response +- **c.text()** - Return text response +- **c.html()** - Return HTML response +- **c.redirect()** - Redirect to URL +- **c.var.logger** - Structured logger (info, warn, error, debug, trace) +- **c.var.kv** - Key-value storage +- **c.var.vector** - Vector storage +- **c.var.stream** - Stream management +- **Import agents directly** - Import and call agents directly (recommended) + +## HTTP Methods + +```typescript +const router = createRouter(); + +router.get('/path', (c) => { + /* ... */ +}); +router.post('/path', (c) => { + /* ... */ +}); +router.put('/path', (c) => { + /* ... */ +}); +router.patch('/path', (c) => { + /* ... */ +}); +router.delete('/path', (c) => { + /* ... */ +}); +router.options('/path', (c) => { + /* ... */ +}); +``` + +## Path Parameters + +```typescript +// GET /api/users/:id +router.get('/:id', (c) => { + const id = c.req.param('id'); + return c.json({ userId: id }); +}); + +// GET /api/posts/:postId/comments/:commentId +router.get('/:postId/comments/:commentId', (c) => { + const postId = c.req.param('postId'); + const commentId = c.req.param('commentId'); + return c.json({ postId, commentId }); +}); +``` + +## Query Parameters + +```typescript +// GET /api/search?q=hello&limit=10 +router.get('/search', (c) => { + const query = c.req.query('q'); + const limit = c.req.query('limit') || '20'; + return c.json({ query, limit: parseInt(limit) }); +}); +``` + +## Request Body + +```typescript +// JSON body +router.post('/', async (c) => { + const body = await c.req.json(); + return c.json({ received: body }); +}); + +// Form data +router.post('/upload', async (c) => { + const formData = await c.req.formData(); + const file = formData.get('file'); + return c.json({ fileName: file?.name }); +}); +``` + +## Error Handling + +```typescript +import myAgent from '@agent/my-agent'; + +router.get('/', async (c) => { + try { + const result = await myAgent.run({ data: 'test' }); + return c.json({ success: true, result }); + } catch (error) { + c.var.logger.error('Agent call failed:', error); + return c.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + 500 + ); + } +}); +``` + +## Response Types + +```typescript +// JSON response +return c.json({ data: 'value' }); + +// Text response +return c.text('Hello World'); + +// HTML response +return c.html('

Hello

'); + +// Custom status code +return c.json({ error: 'Not found' }, 404); + +// Redirect +return c.redirect('/new-path'); + +// Headers +return c.json({ data: 'value' }, 200, { + 'X-Custom-Header': 'value', +}); +``` + +## Streaming Routes + +```typescript +import { createRouter } from '@agentuity/runtime'; + +const router = createRouter(); + +// Stream response +router.stream('/events', (c) => { + return new ReadableStream({ + start(controller) { + controller.enqueue('event 1\n'); + controller.enqueue('event 2\n'); + controller.close(); + }, + }); +}); + +// Server-Sent Events +router.sse('/notifications', (c) => { + return (stream) => { + stream.writeSSE({ data: 'Hello', event: 'message' }); + stream.writeSSE({ data: 'World', event: 'message' }); + }; +}); + +// WebSocket +router.websocket('/ws', (c) => { + return (ws) => { + ws.onOpen(() => { + ws.send('Connected!'); + }); + ws.onMessage((event) => { + ws.send(`Echo: ${event.data}`); + }); + }; +}); + +export default router; +``` + +## Rules + +- Each API folder name becomes the route name (e.g., `status/` → `/api/status`) +- **route.ts** must export default the router instance +- Use c.var.logger for logging, not console.log +- Import agents directly to call them (e.g., `import agent from '@agent/name'`) +- Validation should use @agentuity/schema or agent.validator() for type safety +- Return appropriate HTTP status codes +- APIs run at `/api/{folderName}` by default + + \ No newline at end of file diff --git a/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/web/AGENTS.md b/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/web/AGENTS.md new file mode 100644 index 00000000..503e0a79 --- /dev/null +++ b/apps/testing/ag-auth-test-app/.agents/agentuity/sdk/web/AGENTS.md @@ -0,0 +1,511 @@ +# Web Folder Guide + +This folder contains your React-based web application that communicates with your Agentuity agents. + +## Generated Types + +The `src/generated/` folder contains auto-generated TypeScript files: + +- `routes.ts` - Route registry with type-safe API, WebSocket, and SSE route definitions +- `registry.ts` - Agent registry with input/output types + +**Important:** Never edit files in `src/generated/` - they are overwritten on every build. + +Import generated types in your components: + +```typescript +// Routes are typed automatically via module augmentation +import { useAPI } from '@agentuity/react'; + +// The route 'GET /api/users' is fully typed +const { data } = useAPI('GET /api/users'); +``` + +## Directory Structure + +Required files: + +- **App.tsx** (required) - Main React application component +- **frontend.tsx** (required) - Frontend entry point with client-side rendering +- **index.html** (required) - HTML template +- **public/** (optional) - Static assets (images, CSS, JS files) + +Example structure: + +``` +src/web/ +├── App.tsx +├── frontend.tsx +├── index.html +└── public/ + ├── styles.css + ├── logo.svg + └── script.js +``` + +## Creating the Web App + +### App.tsx - Main Component + +```typescript +import { AgentuityProvider, useAPI } from '@agentuity/react'; +import { useState } from 'react'; + +function HelloForm() { + const [name, setName] = useState('World'); + const { invoke, isLoading, data: greeting } = useAPI('POST /api/hello'); + + return ( +
+ setName(e.target.value)} + disabled={isLoading} + /> + + + +
{greeting ?? 'Waiting for response'}
+
+ ); +} + +export function App() { + return ( + +
+

Welcome to Agentuity

+ +
+
+ ); +} +``` + +### frontend.tsx - Entry Point + +```typescript +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const root = document.getElementById('root'); +if (!root) throw new Error('Root element not found'); + +createRoot(root).render(); +``` + +### index.html - HTML Template + +```html + + + + + + My Agentuity App + + +
+ + + +``` + +## React Hooks + +All hooks from `@agentuity/react` must be used within an `AgentuityProvider`. **Always use these hooks instead of raw `fetch()` calls** - they provide type safety, automatic error handling, and integration with the Agentuity platform. + +### useAPI - Type-Safe API Calls + +The primary hook for making HTTP requests. **Use this instead of `fetch()`.** + +```typescript +import { useAPI } from '@agentuity/react'; + +function MyComponent() { + // GET requests auto-execute and return refetch + const { data, isLoading, error, refetch } = useAPI('GET /api/users'); + + // POST/PUT/DELETE return invoke for manual execution + const { invoke, data: result, isLoading: saving } = useAPI('POST /api/users'); + + const handleCreate = async () => { + // Input is fully typed from route schema! + await invoke({ name: 'Alice', email: 'alice@example.com' }); + }; + + return ( +
+ + {result &&

Created: {result.name}

} +
+ ); +} +``` + +**useAPI Return Values:** + +| Property | Type | Description | +| ------------ | ------------------------ | ----------------------------------------- | +| `data` | `T \| undefined` | Response data (typed from route schema) | +| `error` | `Error \| null` | Error if request failed | +| `isLoading` | `boolean` | True during initial load | +| `isFetching` | `boolean` | True during any fetch (including refetch) | +| `isSuccess` | `boolean` | True if last request succeeded | +| `isError` | `boolean` | True if last request failed | +| `invoke` | `(input?) => Promise` | Manual trigger (POST/PUT/DELETE) | +| `refetch` | `() => Promise` | Refetch data (GET) | +| `reset` | `() => void` | Reset state to initial | + +### useAPI Options + +```typescript +// GET with query parameters and caching +const { data } = useAPI({ + route: 'GET /api/search', + query: { q: 'react', limit: '10' }, + staleTime: 5000, // Cache for 5 seconds + refetchInterval: 10000, // Auto-refetch every 10 seconds + enabled: true, // Set to false to disable auto-fetch +}); + +// POST with callbacks +const { invoke } = useAPI({ + route: 'POST /api/users', + onSuccess: (data) => console.log('Created:', data), + onError: (error) => console.error('Failed:', error), +}); + +// Streaming responses with onChunk +const { invoke } = useAPI({ + route: 'POST /api/stream', + onChunk: (chunk) => console.log('Received chunk:', chunk), + delimiter: '\n', // Split stream by newlines (default) +}); + +// Custom headers +const { data } = useAPI({ + route: 'GET /api/protected', + headers: { 'X-Custom-Header': 'value' }, +}); +``` + +### useWebsocket - WebSocket Connection + +For bidirectional real-time communication. Automatically handles reconnection. + +```typescript +import { useWebsocket } from '@agentuity/react'; + +function ChatComponent() { + const { isConnected, data, send, messages, clearMessages, error, reset } = useWebsocket('/api/chat'); + + return ( +
+

Status: {isConnected ? 'Connected' : 'Disconnected'}

+ +
+ {messages.map((msg, i) => ( +

{JSON.stringify(msg)}

+ ))} +
+ +
+ ); +} +``` + +**useWebsocket Return Values:** + +| Property | Type | Description | +| --------------- | ---------------- | ---------------------------------------- | +| `isConnected` | `boolean` | True when WebSocket is connected | +| `data` | `T \| undefined` | Most recent message received | +| `messages` | `T[]` | Array of all received messages | +| `send` | `(data) => void` | Send a message (typed from route schema) | +| `clearMessages` | `() => void` | Clear the messages array | +| `close` | `() => void` | Close the connection | +| `error` | `Error \| null` | Error if connection failed | +| `isError` | `boolean` | True if there's an error | +| `reset` | `() => void` | Reset state and reconnect | +| `readyState` | `number` | WebSocket ready state | + +### useEventStream - Server-Sent Events + +For server-to-client streaming (one-way). Use when server pushes updates to client. + +```typescript +import { useEventStream } from '@agentuity/react'; + +function NotificationsComponent() { + const { isConnected, data, error, close, reset } = useEventStream('/api/notifications'); + + return ( +
+

Connected: {isConnected ? 'Yes' : 'No'}

+ {error &&

Error: {error.message}

} +

Latest: {JSON.stringify(data)}

+ +
+ ); +} +``` + +**useEventStream Return Values:** + +| Property | Type | Description | +| ------------- | ---------------- | ---------------------------------- | +| `isConnected` | `boolean` | True when EventSource is connected | +| `data` | `T \| undefined` | Most recent event data | +| `error` | `Error \| null` | Error if connection failed | +| `isError` | `boolean` | True if there's an error | +| `close` | `() => void` | Close the connection | +| `reset` | `() => void` | Reset state and reconnect | +| `readyState` | `number` | EventSource ready state | + +### useAgentuity - Access Context + +Access the Agentuity context for base URL and configuration. + +```typescript +import { useAgentuity } from '@agentuity/react'; + +function MyComponent() { + const { baseUrl } = useAgentuity(); + + return

API Base: {baseUrl}

; +} +``` + +### useAuth - Authentication State + +Access and manage authentication state. + +```typescript +import { useAuth } from '@agentuity/react'; + +function AuthStatus() { + const { isAuthenticated, authHeader, setAuthHeader, authLoading } = useAuth(); + + const handleLogin = async (token: string) => { + setAuthHeader?.(`Bearer ${token}`); + }; + + const handleLogout = () => { + setAuthHeader?.(null); + }; + + if (authLoading) return

Loading...

; + + return ( +
+ {isAuthenticated ? ( + + ) : ( + + )} +
+ ); +} +``` + +**useAuth Return Values:** + +| Property | Type | Description | +| ----------------- | ------------------- | ------------------------------------------- | +| `isAuthenticated` | `boolean` | True if user has auth token and not loading | +| `authHeader` | `string \| null` | Current auth header (e.g., "Bearer ...") | +| `setAuthHeader` | `(token) => void` | Set auth header (null to clear) | +| `authLoading` | `boolean` | True during auth state changes | +| `setAuthLoading` | `(loading) => void` | Set auth loading state | + +## Complete Example + +```typescript +import { AgentuityProvider, useAPI, useWebsocket } from '@agentuity/react'; +import { useEffect, useState } from 'react'; + +function Dashboard() { + const [count, setCount] = useState(0); + const { invoke, data: agentResult } = useAPI('POST /api/process'); + const { isConnected, send, data: wsMessage } = useWebsocket('/api/live'); + + useEffect(() => { + if (isConnected) { + const interval = setInterval(() => { + send({ ping: Date.now() }); + }, 1000); + return () => clearInterval(interval); + } + }, [isConnected, send]); + + return ( +
+

My Agentuity App

+ +
+

Count: {count}

+ +
+ +
+ +

{JSON.stringify(agentResult)}

+
+ +
+ WebSocket: + {isConnected ? JSON.stringify(wsMessage) : 'Not connected'} +
+
+ ); +} + +export function App() { + return ( + + + + ); +} +``` + +## Static Assets + +Place static files in the **public/** folder: + +``` +src/web/public/ +├── logo.svg +├── styles.css +└── script.js +``` + +Reference them in your HTML or components: + +```html + + + +``` + +```typescript +// In React components +Logo +``` + +## Styling + +### Inline Styles + +```typescript +
+ Styled content +
+``` + +### CSS Files + +Create `public/styles.css`: + +```css +body { + background-color: #09090b; + color: #fff; + font-family: sans-serif; +} +``` + +Import in `index.html`: + +```html + +``` + +### Style Tag in Component + +```typescript +
+ + +
+``` + +## RPC-Style API Client + +For non-React contexts (like utility functions or event handlers), use `createClient`: + +```typescript +import { createClient } from '@agentuity/react'; + +// Create a typed client (uses global baseUrl and auth from AgentuityProvider) +const api = createClient(); + +// Type-safe RPC-style calls - routes become nested objects +// Route 'GET /api/users' becomes api.users.get() +// Route 'POST /api/users' becomes api.users.post() +// Route 'GET /api/users/:id' becomes api.users.id.get({ id: '123' }) + +async function fetchData() { + const users = await api.users.get(); + const newUser = await api.users.post({ name: 'Alice', email: 'alice@example.com' }); + const user = await api.users.id.get({ id: '123' }); + return { users, newUser, user }; +} +``` + +**When to use `createClient` vs `useAPI`:** + +| Use Case | Recommendation | +| ------------------------- | -------------- | +| React component rendering | `useAPI` hook | +| Event handlers | Either works | +| Utility functions | `createClient` | +| Non-React code | `createClient` | +| Need loading/error state | `useAPI` hook | +| Need caching/refetch | `useAPI` hook | + +## Best Practices + +- Wrap your app with **AgentuityProvider** for hooks to work +- **Always use `useAPI` instead of `fetch()`** for type safety and error handling +- Use **useAPI** for type-safe HTTP requests (GET, POST, PUT, DELETE) +- Use **useWebsocket** for bidirectional real-time communication +- Use **useEventStream** for server-to-client streaming +- Use **useAuth** for authentication state management +- Handle loading and error states in UI +- Place reusable components in separate files +- Keep static assets in the **public/** folder + +## Rules + +- **App.tsx** must export a function named `App` +- **frontend.tsx** must render the `App` component to `#root` +- **index.html** must have a `
` +- Routes are typed via module augmentation from `src/generated/routes.ts` +- The web app is served at `/` by default +- Static files in `public/` are served at `/public/*` +- Module script tag: `` +- **Never use raw `fetch()` calls** - always use `useAPI` or `createClient` + + \ No newline at end of file diff --git a/apps/testing/ag-auth-test-app/.env.example b/apps/testing/ag-auth-test-app/.env.example new file mode 100644 index 00000000..75051b95 --- /dev/null +++ b/apps/testing/ag-auth-test-app/.env.example @@ -0,0 +1,7 @@ +# Database URL for BetterAuth +# Get your database URL from: agentuity cloud database list --region use --json +DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require + +# BetterAuth secret for signing tokens (min 32 characters) +# Generate with: openssl rand -base64 32 +BETTER_AUTH_SECRET=your-secret-key-at-least-32-characters diff --git a/apps/testing/ag-auth-test-app/.gitignore b/apps/testing/ag-auth-test-app/.gitignore new file mode 100644 index 00000000..a1609616 --- /dev/null +++ b/apps/testing/ag-auth-test-app/.gitignore @@ -0,0 +1,42 @@ +# Agentuity build files +.agentuity/ + +# Dependencies +node_modules/ + +# Environment files +.env +.env.local +.env.* + +# Auto-generated AGENTS.md files in source directories +src/web/AGENTS.md +src/api/AGENTS.md +src/agent/AGENTS.md + +# Output +out +dist +*.tgz + +# Code coverage +coverage +*.lcov + +# Logs +/logs +*.log + +# Caches +.eslintcache +.cache +*.tsbuildinfo + +# IDE +.idea +.DS_Store + +# Auth test artifacts +cookies.txt +dev.db +*.db diff --git a/apps/testing/ag-auth-test-app/AGENTS.md b/apps/testing/ag-auth-test-app/AGENTS.md new file mode 100644 index 00000000..b923811a --- /dev/null +++ b/apps/testing/ag-auth-test-app/AGENTS.md @@ -0,0 +1,64 @@ +# Agent Guidelines for ag-auth-test-app + +## Commands + +- **Build**: `bun run build` (compiles your application) +- **Dev**: `bun run dev` (starts development server) +- **Typecheck**: `bun run typecheck` (runs TypeScript type checking) +- **Deploy**: `bun run deploy` (deploys your app to the Agentuity cloud) + +## Agent-Friendly CLI + +The Agentuity CLI is designed to be agent-friendly with programmatic interfaces, structured output, and comprehensive introspection. + +Read the [AGENTS.md](./node_modules/@agentuity/cli/AGENTS.md) file in the Agentuity CLI for more information on how to work with this project. + +## Instructions + +- This project uses Bun instead of NodeJS and TypeScript for all source code +- This is an Agentuity Agent project + +## Web Frontend (src/web/) + +The `src/web/` folder contains your React frontend, which is automatically bundled by the Agentuity build system. + +**File Structure:** + +- `index.html` - Main HTML file with ` + + +
+ + diff --git a/apps/testing/ag-auth-test-app/src/web/public/.gitkeep b/apps/testing/ag-auth-test-app/src/web/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/testing/ag-auth-test-app/src/web/public/favicon.ico b/apps/testing/ag-auth-test-app/src/web/public/favicon.ico new file mode 100644 index 00000000..21f46e6f Binary files /dev/null and b/apps/testing/ag-auth-test-app/src/web/public/favicon.ico differ diff --git a/apps/testing/ag-auth-test-app/tsconfig.json b/apps/testing/ag-auth-test-app/tsconfig.json new file mode 100644 index 00000000..9b379e0f --- /dev/null +++ b/apps/testing/ag-auth-test-app/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "paths": { + "@agent/*": ["./src/agent/*"], + "@api/*": ["./src/api/*"] + } + }, + "include": ["src/**/*", "app.ts"] +} diff --git a/apps/testing/integration-suite/src/generated/app.ts b/apps/testing/integration-suite/src/generated/app.ts index e7384c94..c3cb7899 100644 --- a/apps/testing/integration-suite/src/generated/app.ts +++ b/apps/testing/integration-suite/src/generated/app.ts @@ -207,16 +207,16 @@ if (isDevelopment() && process.env.VITE_PORT) { // Mount API routes const { default: router_0 } = await import('../api/index.js'); app.route('/api', router_0); -const { default: router_1 } = await import('../api/custom-name/foobar.js'); -app.route('/api/custom-name/foobar', router_1); -const { default: router_2 } = await import('../api/users/profile/route.js'); -app.route('/api/users/profile', router_2); -const { default: router_3 } = await import('../api/my-service/index.js'); -app.route('/api/my-service', router_3); -const { default: router_4 } = await import('../api/auth/route.js'); -app.route('/api/auth', router_4); -const { default: router_5 } = await import('../api/middleware-test/route.js'); -app.route('/api/middleware-test', router_5); +const { default: router_1 } = await import('../api/users/profile/route.js'); +app.route('/api/users/profile', router_1); +const { default: router_2 } = await import('../api/my-service/index.js'); +app.route('/api/my-service', router_2); +const { default: router_3 } = await import('../api/middleware-test/route.js'); +app.route('/api/middleware-test', router_3); +const { default: router_4 } = await import('../api/custom-name/foobar.js'); +app.route('/api/custom-name/foobar', router_4); +const { default: router_5 } = await import('../api/auth/route.js'); +app.route('/api/auth', router_5); // Web routes - Runtime mode detection (dev proxies to Vite, prod serves static) if (isDevelopment()) { diff --git a/apps/testing/integration-suite/src/generated/registry.ts b/apps/testing/integration-suite/src/generated/registry.ts index 57ed8a4c..61026608 100644 --- a/apps/testing/integration-suite/src/generated/registry.ts +++ b/apps/testing/integration-suite/src/generated/registry.ts @@ -23,26 +23,43 @@ import schemaOptional from '../agent/schema/optional.js'; import envSdkKeyCheck from '../agent/env/sdk-key-check.js'; import storageVectorCrud from '../agent/storage/vector/crud.js'; import storageVectorSearch from '../agent/storage/vector/search.js'; -import storageBinaryUploadDownload from '../agent/storage/binary/upload-download.js'; import storageStreamCrud from '../agent/storage/stream/crud.js'; -import storageStreamTypes from '../agent/storage/stream/types.js'; import storageStreamMetadata from '../agent/storage/stream/metadata.js'; +import storageStreamTypes from '../agent/storage/stream/types.js'; import storageKvCrud from '../agent/storage/kv/crud.js'; -import storageKvTypes from '../agent/storage/kv/types.js'; import storageKvIsolation from '../agent/storage/kv/isolation.js'; +import storageKvTypes from '../agent/storage/kv/types.js'; +import storageBinaryUploadDownload from '../agent/storage/binary/upload-download.js'; import state from '../agent/state/agent.js'; -import stateWriter from '../agent/state/writer-agent.js'; import stateReader from '../agent/state/reader-agent.js'; -import cli from '../agent/cli/agent.js'; -import utilsStringHelper from '../agent/utils/helpers/agent.js'; +import stateWriter from '../agent/state/writer-agent.js'; +import sessionBasic from '../agent/session/session-basic.js'; +import sessionEvents from '../agent/session/session-events.js'; +import schemaComplex from '../agent/schema/complex.js'; +import schemaOptional from '../agent/schema/optional.js'; +import schemaTypes from '../agent/schema/types.js'; +import routingGet from '../agent/routing/routing-get.js'; +import routingHeaders from '../agent/routing/routing-headers.js'; +import routingMethods from '../agent/routing/routing-methods.js'; +import routingParams from '../agent/routing/routing-params.js'; +import routingPost from '../agent/routing/routing-post.js'; +import resilienceCrashAttempts from '../agent/resilience/crash-attempts.js'; import lifecycleWaituntil from '../agent/lifecycle/waituntil.js'; +import eventsAgent from '../agent/events/agent-events.js'; +import eventsRemoval from '../agent/events/listener-removal.js'; +import eventsMultiple from '../agent/events/multiple-listeners.js'; +import eventsSession from '../agent/events/session-events.js'; +import eventsThread from '../agent/events/thread-events.js'; +import evalsBasic from '../agent/evals/basic.js'; +import errorsPropagation from '../agent/errors/propagation.js'; +import errorsStructured from '../agent/errors/structured.js'; +import errorsValidation from '../agent/errors/validation.js'; +import envSdkKeyCheck from '../agent/env/sdk-key-check.js'; +import cli from '../agent/cli/agent.js'; +import async from '../agent/basic/basic-async.js'; import noInput from '../agent/basic/basic-no-input.js'; import noOutput from '../agent/basic/basic-no-output.js'; -import async from '../agent/basic/basic-async.js'; import simple from '../agent/basic/basic-simple.js'; -import evalsBasic from '../agent/evals/basic.js'; -import websocketEcho from '../agent/websocket/echo-agent.js'; -import v1DataProcessor from '../agent/v1/data/agent.js'; import type { AgentRunner } from '@agentuity/runtime'; import type { InferInput, InferOutput } from '@agentuity/core'; @@ -51,71 +68,66 @@ import type { InferInput, InferOutput } from '@agentuity/core'; // ============================================================================ /** - * Input type for session-basic agent - * Basic session ID and state access + * Input type for websocket-echo agent + * WebSocket echo server for testing */ -export type SessionBasicInput = InferInput; +export type WebsocketEchoInput = InferInput; /** - * Output type for session-basic agent - * Basic session ID and state access + * Output type for websocket-echo agent + * WebSocket echo server for testing */ -export type SessionBasicOutput = InferOutput; +export type WebsocketEchoOutput = InferOutput; /** - * Input schema type for session-basic agent - * Basic session ID and state access + * Input schema type for websocket-echo agent + * WebSocket echo server for testing */ -export type SessionBasicInputSchema = typeof sessionBasic['inputSchema']; +export type WebsocketEchoInputSchema = typeof websocketEcho['inputSchema']; /** - * Output schema type for session-basic agent - * Basic session ID and state access + * Output schema type for websocket-echo agent + * WebSocket echo server for testing */ -export type SessionBasicOutputSchema = typeof sessionBasic['outputSchema']; +export type WebsocketEchoOutputSchema = typeof websocketEcho['outputSchema']; /** - * Agent type for session-basic - * Basic session ID and state access + * Agent type for websocket-echo + * WebSocket echo server for testing */ -export type SessionBasicAgent = AgentRunner< - SessionBasicInputSchema, - SessionBasicOutputSchema, - typeof sessionBasic['stream'] extends true ? true : false +export type WebsocketEchoAgent = AgentRunner< + WebsocketEchoInputSchema, + WebsocketEchoOutputSchema, + typeof websocketEcho['stream'] extends true ? true : false >; /** - * Input type for session-events agent - * Session and thread event listeners + * Input type for v1-data-processor agent */ -export type SessionEventsInput = InferInput; +export type V1DataProcessorInput = InferInput; /** - * Output type for session-events agent - * Session and thread event listeners + * Output type for v1-data-processor agent */ -export type SessionEventsOutput = InferOutput; +export type V1DataProcessorOutput = InferOutput; /** - * Input schema type for session-events agent - * Session and thread event listeners + * Input schema type for v1-data-processor agent */ -export type SessionEventsInputSchema = typeof sessionEvents['inputSchema']; +export type V1DataProcessorInputSchema = typeof v1DataProcessor['inputSchema']; /** - * Output schema type for session-events agent - * Session and thread event listeners + * Output schema type for v1-data-processor agent */ -export type SessionEventsOutputSchema = typeof sessionEvents['outputSchema']; +export type V1DataProcessorOutputSchema = typeof v1DataProcessor['outputSchema']; /** - * Agent type for session-events - * Session and thread event listeners + * Agent type for v1-data-processor */ -export type SessionEventsAgent = AgentRunner< - SessionEventsInputSchema, - SessionEventsOutputSchema, - typeof sessionEvents['stream'] extends true ? true : false +export type V1DataProcessorAgent = AgentRunner< + V1DataProcessorInputSchema, + V1DataProcessorOutputSchema, + typeof v1DataProcessor['stream'] extends true ? true : false >; /** @@ -156,340 +168,608 @@ export type AiSdkGatewayCheckAgent = AgentRunner< * Input type for events-multiple agent * Tests multiple event listeners on same event */ -export type EventsMultipleInput = InferInput; +export type UtilsStringHelperInput = InferInput; /** - * Output type for events-multiple agent - * Tests multiple event listeners on same event + * Output type for utils-string-helper agent */ -export type EventsMultipleOutput = InferOutput; +export type UtilsStringHelperOutput = InferOutput; /** - * Input schema type for events-multiple agent - * Tests multiple event listeners on same event + * Input schema type for utils-string-helper agent */ -export type EventsMultipleInputSchema = typeof eventsMultiple['inputSchema']; +export type UtilsStringHelperInputSchema = typeof utilsStringHelper['inputSchema']; /** - * Output schema type for events-multiple agent - * Tests multiple event listeners on same event + * Output schema type for utils-string-helper agent */ -export type EventsMultipleOutputSchema = typeof eventsMultiple['outputSchema']; +export type UtilsStringHelperOutputSchema = typeof utilsStringHelper['outputSchema']; /** - * Agent type for events-multiple - * Tests multiple event listeners on same event + * Agent type for utils-string-helper */ -export type EventsMultipleAgent = AgentRunner< - EventsMultipleInputSchema, - EventsMultipleOutputSchema, - typeof eventsMultiple['stream'] extends true ? true : false +export type UtilsStringHelperAgent = AgentRunner< + UtilsStringHelperInputSchema, + UtilsStringHelperOutputSchema, + typeof utilsStringHelper['stream'] extends true ? true : false >; /** - * Input type for events-session agent - * Tests session event listeners (completed) + * Input type for storage-vector-crud agent + * Vector storage CRUD operations */ -export type EventsSessionInput = InferInput; +export type StorageVectorCrudInput = InferInput; /** - * Output type for events-session agent - * Tests session event listeners (completed) + * Output type for storage-vector-crud agent + * Vector storage CRUD operations */ -export type EventsSessionOutput = InferOutput; +export type StorageVectorCrudOutput = InferOutput; /** - * Input schema type for events-session agent - * Tests session event listeners (completed) + * Input schema type for storage-vector-crud agent + * Vector storage CRUD operations */ -export type EventsSessionInputSchema = typeof eventsSession['inputSchema']; +export type StorageVectorCrudInputSchema = typeof storageVectorCrud['inputSchema']; /** - * Output schema type for events-session agent - * Tests session event listeners (completed) + * Output schema type for storage-vector-crud agent + * Vector storage CRUD operations */ -export type EventsSessionOutputSchema = typeof eventsSession['outputSchema']; +export type StorageVectorCrudOutputSchema = typeof storageVectorCrud['outputSchema']; /** - * Agent type for events-session - * Tests session event listeners (completed) + * Agent type for storage-vector-crud + * Vector storage CRUD operations */ -export type EventsSessionAgent = AgentRunner< - EventsSessionInputSchema, - EventsSessionOutputSchema, - typeof eventsSession['stream'] extends true ? true : false +export type StorageVectorCrudAgent = AgentRunner< + StorageVectorCrudInputSchema, + StorageVectorCrudOutputSchema, + typeof storageVectorCrud['stream'] extends true ? true : false >; /** - * Input type for events-removal agent - * Tests event listener removal (removeEventListener) + * Input type for storage-vector-search agent + * Vector storage search operations */ -export type EventsRemovalInput = InferInput; +export type StorageVectorSearchInput = InferInput; /** - * Output type for events-removal agent - * Tests event listener removal (removeEventListener) + * Output type for storage-vector-search agent + * Vector storage search operations */ -export type EventsRemovalOutput = InferOutput; +export type StorageVectorSearchOutput = InferOutput; /** - * Input schema type for events-removal agent - * Tests event listener removal (removeEventListener) + * Input schema type for storage-vector-search agent + * Vector storage search operations */ -export type EventsRemovalInputSchema = typeof eventsRemoval['inputSchema']; +export type StorageVectorSearchInputSchema = typeof storageVectorSearch['inputSchema']; /** - * Output schema type for events-removal agent - * Tests event listener removal (removeEventListener) + * Output schema type for storage-vector-search agent + * Vector storage search operations */ -export type EventsRemovalOutputSchema = typeof eventsRemoval['outputSchema']; +export type StorageVectorSearchOutputSchema = typeof storageVectorSearch['outputSchema']; /** - * Agent type for events-removal - * Tests event listener removal (removeEventListener) + * Agent type for storage-vector-search + * Vector storage search operations */ -export type EventsRemovalAgent = AgentRunner< - EventsRemovalInputSchema, - EventsRemovalOutputSchema, - typeof eventsRemoval['stream'] extends true ? true : false +export type StorageVectorSearchAgent = AgentRunner< + StorageVectorSearchInputSchema, + StorageVectorSearchOutputSchema, + typeof storageVectorSearch['stream'] extends true ? true : false >; /** - * Input type for events-agent agent - * Tests agent event listeners (started, completed, errored) + * Input type for storage-stream-crud agent + * Stream storage CRUD operations */ -export type EventsAgentInput = InferInput; +export type StorageStreamCrudInput = InferInput; /** - * Output type for events-agent agent - * Tests agent event listeners (started, completed, errored) + * Output type for storage-stream-crud agent + * Stream storage CRUD operations */ -export type EventsAgentOutput = InferOutput; +export type StorageStreamCrudOutput = InferOutput; /** - * Input schema type for events-agent agent - * Tests agent event listeners (started, completed, errored) + * Input schema type for storage-stream-crud agent + * Stream storage CRUD operations */ -export type EventsAgentInputSchema = typeof eventsAgent['inputSchema']; +export type StorageStreamCrudInputSchema = typeof storageStreamCrud['inputSchema']; /** - * Output schema type for events-agent agent - * Tests agent event listeners (started, completed, errored) + * Output schema type for storage-stream-crud agent + * Stream storage CRUD operations */ -export type EventsAgentOutputSchema = typeof eventsAgent['outputSchema']; +export type StorageStreamCrudOutputSchema = typeof storageStreamCrud['outputSchema']; /** - * Agent type for events-agent - * Tests agent event listeners (started, completed, errored) + * Agent type for storage-stream-crud + * Stream storage CRUD operations */ -export type EventsAgentAgent = AgentRunner< - EventsAgentInputSchema, - EventsAgentOutputSchema, - typeof eventsAgent['stream'] extends true ? true : false +export type StorageStreamCrudAgent = AgentRunner< + StorageStreamCrudInputSchema, + StorageStreamCrudOutputSchema, + typeof storageStreamCrud['stream'] extends true ? true : false >; /** - * Input type for events-thread agent - * Tests thread event listeners (destroyed) + * Input type for storage-stream-metadata agent + * Stream storage metadata operations */ -export type EventsThreadInput = InferInput; +export type StorageStreamMetadataInput = InferInput; /** - * Output type for events-thread agent - * Tests thread event listeners (destroyed) + * Output type for storage-stream-metadata agent + * Stream storage metadata operations */ -export type EventsThreadOutput = InferOutput; +export type StorageStreamMetadataOutput = InferOutput; /** - * Input schema type for events-thread agent - * Tests thread event listeners (destroyed) + * Input schema type for storage-stream-metadata agent + * Stream storage metadata operations */ -export type EventsThreadInputSchema = typeof eventsThread['inputSchema']; +export type StorageStreamMetadataInputSchema = typeof storageStreamMetadata['inputSchema']; /** - * Output schema type for events-thread agent - * Tests thread event listeners (destroyed) + * Output schema type for storage-stream-metadata agent + * Stream storage metadata operations */ -export type EventsThreadOutputSchema = typeof eventsThread['outputSchema']; +export type StorageStreamMetadataOutputSchema = typeof storageStreamMetadata['outputSchema']; /** - * Agent type for events-thread - * Tests thread event listeners (destroyed) + * Agent type for storage-stream-metadata + * Stream storage metadata operations */ -export type EventsThreadAgent = AgentRunner< - EventsThreadInputSchema, - EventsThreadOutputSchema, - typeof eventsThread['stream'] extends true ? true : false +export type StorageStreamMetadataAgent = AgentRunner< + StorageStreamMetadataInputSchema, + StorageStreamMetadataOutputSchema, + typeof storageStreamMetadata['stream'] extends true ? true : false >; /** - * Input type for errors-validation agent - * Test schema validation error handling + * Input type for storage-stream-types agent + * Stream storage with different data types */ -export type ErrorsValidationInput = InferInput; +export type StorageStreamTypesInput = InferInput; /** - * Output type for errors-validation agent - * Test schema validation error handling + * Output type for storage-stream-types agent + * Stream storage with different data types */ -export type ErrorsValidationOutput = InferOutput; +export type StorageStreamTypesOutput = InferOutput; /** - * Input schema type for errors-validation agent - * Test schema validation error handling + * Input schema type for storage-stream-types agent + * Stream storage with different data types */ -export type ErrorsValidationInputSchema = typeof errorsValidation['inputSchema']; +export type StorageStreamTypesInputSchema = typeof storageStreamTypes['inputSchema']; /** - * Output schema type for errors-validation agent - * Test schema validation error handling + * Output schema type for storage-stream-types agent + * Stream storage with different data types */ -export type ErrorsValidationOutputSchema = typeof errorsValidation['outputSchema']; +export type StorageStreamTypesOutputSchema = typeof storageStreamTypes['outputSchema']; /** - * Agent type for errors-validation - * Test schema validation error handling + * Agent type for storage-stream-types + * Stream storage with different data types */ -export type ErrorsValidationAgent = AgentRunner< - ErrorsValidationInputSchema, - ErrorsValidationOutputSchema, - typeof errorsValidation['stream'] extends true ? true : false +export type StorageStreamTypesAgent = AgentRunner< + StorageStreamTypesInputSchema, + StorageStreamTypesOutputSchema, + typeof storageStreamTypes['stream'] extends true ? true : false >; /** - * Input type for errors-structured agent - * Test StructuredError patterns + * Input type for storage-kv-crud agent + * KeyValue storage CRUD operations */ -export type ErrorsStructuredInput = InferInput; +export type StorageKvCrudInput = InferInput; /** - * Output type for errors-structured agent - * Test StructuredError patterns + * Output type for storage-kv-crud agent + * KeyValue storage CRUD operations */ -export type ErrorsStructuredOutput = InferOutput; +export type StorageKvCrudOutput = InferOutput; /** - * Input schema type for errors-structured agent - * Test StructuredError patterns + * Input schema type for storage-kv-crud agent + * KeyValue storage CRUD operations */ -export type ErrorsStructuredInputSchema = typeof errorsStructured['inputSchema']; +export type StorageKvCrudInputSchema = typeof storageKvCrud['inputSchema']; /** - * Output schema type for errors-structured agent - * Test StructuredError patterns + * Output schema type for storage-kv-crud agent + * KeyValue storage CRUD operations */ -export type ErrorsStructuredOutputSchema = typeof errorsStructured['outputSchema']; +export type StorageKvCrudOutputSchema = typeof storageKvCrud['outputSchema']; /** - * Agent type for errors-structured - * Test StructuredError patterns + * Agent type for storage-kv-crud + * KeyValue storage CRUD operations */ -export type ErrorsStructuredAgent = AgentRunner< - ErrorsStructuredInputSchema, - ErrorsStructuredOutputSchema, - typeof errorsStructured['stream'] extends true ? true : false +export type StorageKvCrudAgent = AgentRunner< + StorageKvCrudInputSchema, + StorageKvCrudOutputSchema, + typeof storageKvCrud['stream'] extends true ? true : false >; /** - * Input type for errors-propagation agent - * Test error propagation patterns + * Input type for storage-kv-isolation agent + * Test KV isolation between requests */ -export type ErrorsPropagationInput = InferInput; +export type StorageKvIsolationInput = InferInput; /** - * Output type for errors-propagation agent - * Test error propagation patterns + * Output type for storage-kv-isolation agent + * Test KV isolation between requests */ -export type ErrorsPropagationOutput = InferOutput; +export type StorageKvIsolationOutput = InferOutput; /** - * Input schema type for errors-propagation agent - * Test error propagation patterns + * Input schema type for storage-kv-isolation agent + * Test KV isolation between requests */ -export type ErrorsPropagationInputSchema = typeof errorsPropagation['inputSchema']; +export type StorageKvIsolationInputSchema = typeof storageKvIsolation['inputSchema']; /** - * Output schema type for errors-propagation agent - * Test error propagation patterns + * Output schema type for storage-kv-isolation agent + * Test KV isolation between requests */ -export type ErrorsPropagationOutputSchema = typeof errorsPropagation['outputSchema']; +export type StorageKvIsolationOutputSchema = typeof storageKvIsolation['outputSchema']; /** - * Agent type for errors-propagation - * Test error propagation patterns + * Agent type for storage-kv-isolation + * Test KV isolation between requests */ -export type ErrorsPropagationAgent = AgentRunner< - ErrorsPropagationInputSchema, - ErrorsPropagationOutputSchema, - typeof errorsPropagation['stream'] extends true ? true : false +export type StorageKvIsolationAgent = AgentRunner< + StorageKvIsolationInputSchema, + StorageKvIsolationOutputSchema, + typeof storageKvIsolation['stream'] extends true ? true : false +>; + +/** + * Input type for storage-kv-types agent + * KeyValue storage with different value types + */ +export type StorageKvTypesInput = InferInput; + +/** + * Output type for storage-kv-types agent + * KeyValue storage with different value types + */ +export type StorageKvTypesOutput = InferOutput; + +/** + * Input schema type for storage-kv-types agent + * KeyValue storage with different value types + */ +export type StorageKvTypesInputSchema = typeof storageKvTypes['inputSchema']; + +/** + * Output schema type for storage-kv-types agent + * KeyValue storage with different value types + */ +export type StorageKvTypesOutputSchema = typeof storageKvTypes['outputSchema']; + +/** + * Agent type for storage-kv-types + * KeyValue storage with different value types + */ +export type StorageKvTypesAgent = AgentRunner< + StorageKvTypesInputSchema, + StorageKvTypesOutputSchema, + typeof storageKvTypes['stream'] extends true ? true : false +>; + +/** + * Input type for storage-binary-upload-download agent + * Upload and download binary data with integrity verification + */ +export type StorageBinaryUploadDownloadInput = InferInput; + +/** + * Output type for storage-binary-upload-download agent + * Upload and download binary data with integrity verification + */ +export type StorageBinaryUploadDownloadOutput = InferOutput; + +/** + * Input schema type for storage-binary-upload-download agent + * Upload and download binary data with integrity verification + */ +export type StorageBinaryUploadDownloadInputSchema = typeof storageBinaryUploadDownload['inputSchema']; + +/** + * Output schema type for storage-binary-upload-download agent + * Upload and download binary data with integrity verification + */ +export type StorageBinaryUploadDownloadOutputSchema = typeof storageBinaryUploadDownload['outputSchema']; + +/** + * Agent type for storage-binary-upload-download + * Upload and download binary data with integrity verification + */ +export type StorageBinaryUploadDownloadAgent = AgentRunner< + StorageBinaryUploadDownloadInputSchema, + StorageBinaryUploadDownloadOutputSchema, + typeof storageBinaryUploadDownload['stream'] extends true ? true : false +>; + +/** + * Input type for state agent + * Test thread and session state persistence across requests + */ +export type StateInput = InferInput; + +/** + * Output type for state agent + * Test thread and session state persistence across requests + */ +export type StateOutput = InferOutput; + +/** + * Input schema type for state agent + * Test thread and session state persistence across requests + */ +export type StateInputSchema = typeof state['inputSchema']; + +/** + * Output schema type for state agent + * Test thread and session state persistence across requests + */ +export type StateOutputSchema = typeof state['outputSchema']; + +/** + * Agent type for state + * Test thread and session state persistence across requests + */ +export type StateAgent = AgentRunner< + StateInputSchema, + StateOutputSchema, + typeof state['stream'] extends true ? true : false +>; + +/** + * Input type for state-reader agent + * Read thread state set by other agents + */ +export type StateReaderInput = InferInput; + +/** + * Output type for state-reader agent + * Read thread state set by other agents + */ +export type StateReaderOutput = InferOutput; + +/** + * Input schema type for state-reader agent + * Read thread state set by other agents + */ +export type StateReaderInputSchema = typeof stateReader['inputSchema']; + +/** + * Output schema type for state-reader agent + * Read thread state set by other agents + */ +export type StateReaderOutputSchema = typeof stateReader['outputSchema']; + +/** + * Agent type for state-reader + * Read thread state set by other agents + */ +export type StateReaderAgent = AgentRunner< + StateReaderInputSchema, + StateReaderOutputSchema, + typeof stateReader['stream'] extends true ? true : false +>; + +/** + * Input type for state-writer agent + * Write thread state for other agents to read + */ +export type StateWriterInput = InferInput; + +/** + * Output type for state-writer agent + * Write thread state for other agents to read + */ +export type StateWriterOutput = InferOutput; + +/** + * Input schema type for state-writer agent + * Write thread state for other agents to read + */ +export type StateWriterInputSchema = typeof stateWriter['inputSchema']; + +/** + * Output schema type for state-writer agent + * Write thread state for other agents to read + */ +export type StateWriterOutputSchema = typeof stateWriter['outputSchema']; + +/** + * Agent type for state-writer + * Write thread state for other agents to read + */ +export type StateWriterAgent = AgentRunner< + StateWriterInputSchema, + StateWriterOutputSchema, + typeof stateWriter['stream'] extends true ? true : false +>; + +/** + * Input type for session-basic agent + * Basic session ID and state access + */ +export type SessionBasicInput = InferInput; + +/** + * Output type for session-basic agent + * Basic session ID and state access + */ +export type SessionBasicOutput = InferOutput; + +/** + * Input schema type for session-basic agent + * Basic session ID and state access + */ +export type SessionBasicInputSchema = typeof sessionBasic['inputSchema']; + +/** + * Output schema type for session-basic agent + * Basic session ID and state access + */ +export type SessionBasicOutputSchema = typeof sessionBasic['outputSchema']; + +/** + * Agent type for session-basic + * Basic session ID and state access + */ +export type SessionBasicAgent = AgentRunner< + SessionBasicInputSchema, + SessionBasicOutputSchema, + typeof sessionBasic['stream'] extends true ? true : false +>; + +/** + * Input type for session-events agent + * Session and thread event listeners + */ +export type SessionEventsInput = InferInput; + +/** + * Output type for session-events agent + * Session and thread event listeners + */ +export type SessionEventsOutput = InferOutput; + +/** + * Input schema type for session-events agent + * Session and thread event listeners + */ +export type SessionEventsInputSchema = typeof sessionEvents['inputSchema']; + +/** + * Output schema type for session-events agent + * Session and thread event listeners + */ +export type SessionEventsOutputSchema = typeof sessionEvents['outputSchema']; + +/** + * Agent type for session-events + * Session and thread event listeners + */ +export type SessionEventsAgent = AgentRunner< + SessionEventsInputSchema, + SessionEventsOutputSchema, + typeof sessionEvents['stream'] extends true ? true : false +>; + +/** + * Input type for schema-complex agent + * Test complex nested schemas + */ +export type SchemaComplexInput = InferInput; + +/** + * Output type for schema-complex agent + * Test complex nested schemas + */ +export type SchemaComplexOutput = InferOutput; + +/** + * Input schema type for schema-complex agent + * Test complex nested schemas + */ +export type SchemaComplexInputSchema = typeof schemaComplex['inputSchema']; + +/** + * Output schema type for schema-complex agent + * Test complex nested schemas + */ +export type SchemaComplexOutputSchema = typeof schemaComplex['outputSchema']; + +/** + * Agent type for schema-complex + * Test complex nested schemas + */ +export type SchemaComplexAgent = AgentRunner< + SchemaComplexInputSchema, + SchemaComplexOutputSchema, + typeof schemaComplex['stream'] extends true ? true : false >; /** - * Input type for routing-params agent - * Agent for testing route parameters + * Input type for schema-optional agent + * Test optional fields and defaults */ -export type RoutingParamsInput = InferInput; +export type SchemaOptionalInput = InferInput; /** - * Output type for routing-params agent - * Agent for testing route parameters + * Output type for schema-optional agent + * Test optional fields and defaults */ -export type RoutingParamsOutput = InferOutput; +export type SchemaOptionalOutput = InferOutput; /** - * Input schema type for routing-params agent - * Agent for testing route parameters + * Input schema type for schema-optional agent + * Test optional fields and defaults */ -export type RoutingParamsInputSchema = typeof routingParams['inputSchema']; +export type SchemaOptionalInputSchema = typeof schemaOptional['inputSchema']; /** - * Output schema type for routing-params agent - * Agent for testing route parameters + * Output schema type for schema-optional agent + * Test optional fields and defaults */ -export type RoutingParamsOutputSchema = typeof routingParams['outputSchema']; +export type SchemaOptionalOutputSchema = typeof schemaOptional['outputSchema']; /** - * Agent type for routing-params - * Agent for testing route parameters + * Agent type for schema-optional + * Test optional fields and defaults */ -export type RoutingParamsAgent = AgentRunner< - RoutingParamsInputSchema, - RoutingParamsOutputSchema, - typeof routingParams['stream'] extends true ? true : false +export type SchemaOptionalAgent = AgentRunner< + SchemaOptionalInputSchema, + SchemaOptionalOutputSchema, + typeof schemaOptional['stream'] extends true ? true : false >; /** - * Input type for routing-headers agent - * Agent that works with custom headers + * Input type for schema-types agent + * Test basic schema types */ -export type RoutingHeadersInput = InferInput; +export type SchemaTypesInput = InferInput; /** - * Output type for routing-headers agent - * Agent that works with custom headers + * Output type for schema-types agent + * Test basic schema types */ -export type RoutingHeadersOutput = InferOutput; +export type SchemaTypesOutput = InferOutput; /** - * Input schema type for routing-headers agent - * Agent that works with custom headers + * Input schema type for schema-types agent + * Test basic schema types */ -export type RoutingHeadersInputSchema = typeof routingHeaders['inputSchema']; +export type SchemaTypesInputSchema = typeof schemaTypes['inputSchema']; /** - * Output schema type for routing-headers agent - * Agent that works with custom headers + * Output schema type for schema-types agent + * Test basic schema types */ -export type RoutingHeadersOutputSchema = typeof routingHeaders['outputSchema']; +export type SchemaTypesOutputSchema = typeof schemaTypes['outputSchema']; /** - * Agent type for routing-headers - * Agent that works with custom headers + * Agent type for schema-types + * Test basic schema types */ -export type RoutingHeadersAgent = AgentRunner< - RoutingHeadersInputSchema, - RoutingHeadersOutputSchema, - typeof routingHeaders['stream'] extends true ? true : false +export type SchemaTypesAgent = AgentRunner< + SchemaTypesInputSchema, + SchemaTypesOutputSchema, + typeof schemaTypes['stream'] extends true ? true : false >; /** @@ -527,37 +807,37 @@ export type RoutingGetAgent = AgentRunner< >; /** - * Input type for routing-post agent - * POST endpoint that accepts JSON body + * Input type for routing-headers agent + * Agent that works with custom headers */ -export type RoutingPostInput = InferInput; +export type RoutingHeadersInput = InferInput; /** - * Output type for routing-post agent - * POST endpoint that accepts JSON body + * Output type for routing-headers agent + * Agent that works with custom headers */ -export type RoutingPostOutput = InferOutput; +export type RoutingHeadersOutput = InferOutput; /** - * Input schema type for routing-post agent - * POST endpoint that accepts JSON body + * Input schema type for routing-headers agent + * Agent that works with custom headers */ -export type RoutingPostInputSchema = typeof routingPost['inputSchema']; +export type RoutingHeadersInputSchema = typeof routingHeaders['inputSchema']; /** - * Output schema type for routing-post agent - * POST endpoint that accepts JSON body + * Output schema type for routing-headers agent + * Agent that works with custom headers */ -export type RoutingPostOutputSchema = typeof routingPost['outputSchema']; +export type RoutingHeadersOutputSchema = typeof routingHeaders['outputSchema']; /** - * Agent type for routing-post - * POST endpoint that accepts JSON body + * Agent type for routing-headers + * Agent that works with custom headers */ -export type RoutingPostAgent = AgentRunner< - RoutingPostInputSchema, - RoutingPostOutputSchema, - typeof routingPost['stream'] extends true ? true : false +export type RoutingHeadersAgent = AgentRunner< + RoutingHeadersInputSchema, + RoutingHeadersOutputSchema, + typeof routingHeaders['stream'] extends true ? true : false >; /** @@ -595,581 +875,479 @@ export type RoutingMethodsAgent = AgentRunner< >; /** - * Input type for resilience-crash-attempts agent - * Tests various ways that should NOT crash the server - */ -export type ResilienceCrashAttemptsInput = InferInput; - -/** - * Output type for resilience-crash-attempts agent - * Tests various ways that should NOT crash the server - */ -export type ResilienceCrashAttemptsOutput = InferOutput; - -/** - * Input schema type for resilience-crash-attempts agent - * Tests various ways that should NOT crash the server - */ -export type ResilienceCrashAttemptsInputSchema = typeof resilienceCrashAttempts['inputSchema']; - -/** - * Output schema type for resilience-crash-attempts agent - * Tests various ways that should NOT crash the server - */ -export type ResilienceCrashAttemptsOutputSchema = typeof resilienceCrashAttempts['outputSchema']; - -/** - * Agent type for resilience-crash-attempts - * Tests various ways that should NOT crash the server - */ -export type ResilienceCrashAttemptsAgent = AgentRunner< - ResilienceCrashAttemptsInputSchema, - ResilienceCrashAttemptsOutputSchema, - typeof resilienceCrashAttempts['stream'] extends true ? true : false ->; - -/** - * Input type for schema-complex agent - * Test complex nested schemas - */ -export type SchemaComplexInput = InferInput; - -/** - * Output type for schema-complex agent - * Test complex nested schemas - */ -export type SchemaComplexOutput = InferOutput; - -/** - * Input schema type for schema-complex agent - * Test complex nested schemas - */ -export type SchemaComplexInputSchema = typeof schemaComplex['inputSchema']; - -/** - * Output schema type for schema-complex agent - * Test complex nested schemas - */ -export type SchemaComplexOutputSchema = typeof schemaComplex['outputSchema']; - -/** - * Agent type for schema-complex - * Test complex nested schemas - */ -export type SchemaComplexAgent = AgentRunner< - SchemaComplexInputSchema, - SchemaComplexOutputSchema, - typeof schemaComplex['stream'] extends true ? true : false ->; - -/** - * Input type for schema-types agent - * Test basic schema types - */ -export type SchemaTypesInput = InferInput; - -/** - * Output type for schema-types agent - * Test basic schema types - */ -export type SchemaTypesOutput = InferOutput; - -/** - * Input schema type for schema-types agent - * Test basic schema types - */ -export type SchemaTypesInputSchema = typeof schemaTypes['inputSchema']; - -/** - * Output schema type for schema-types agent - * Test basic schema types - */ -export type SchemaTypesOutputSchema = typeof schemaTypes['outputSchema']; - -/** - * Agent type for schema-types - * Test basic schema types - */ -export type SchemaTypesAgent = AgentRunner< - SchemaTypesInputSchema, - SchemaTypesOutputSchema, - typeof schemaTypes['stream'] extends true ? true : false ->; - -/** - * Input type for schema-optional agent - * Test optional fields and defaults + * Input type for routing-params agent + * Agent for testing route parameters */ -export type SchemaOptionalInput = InferInput; +export type RoutingParamsInput = InferInput; /** - * Output type for schema-optional agent - * Test optional fields and defaults + * Output type for routing-params agent + * Agent for testing route parameters */ -export type SchemaOptionalOutput = InferOutput; +export type RoutingParamsOutput = InferOutput; /** - * Input schema type for schema-optional agent - * Test optional fields and defaults + * Input schema type for routing-params agent + * Agent for testing route parameters */ -export type SchemaOptionalInputSchema = typeof schemaOptional['inputSchema']; +export type RoutingParamsInputSchema = typeof routingParams['inputSchema']; /** - * Output schema type for schema-optional agent - * Test optional fields and defaults + * Output schema type for routing-params agent + * Agent for testing route parameters */ -export type SchemaOptionalOutputSchema = typeof schemaOptional['outputSchema']; +export type RoutingParamsOutputSchema = typeof routingParams['outputSchema']; /** - * Agent type for schema-optional - * Test optional fields and defaults + * Agent type for routing-params + * Agent for testing route parameters */ -export type SchemaOptionalAgent = AgentRunner< - SchemaOptionalInputSchema, - SchemaOptionalOutputSchema, - typeof schemaOptional['stream'] extends true ? true : false +export type RoutingParamsAgent = AgentRunner< + RoutingParamsInputSchema, + RoutingParamsOutputSchema, + typeof routingParams['stream'] extends true ? true : false >; /** - * Input type for env-sdk-key-check agent - * Verifies AGENTUITY_SDK_KEY is available in process.env + * Input type for routing-post agent + * POST endpoint that accepts JSON body */ -export type EnvSdkKeyCheckInput = InferInput; +export type RoutingPostInput = InferInput; /** - * Output type for env-sdk-key-check agent - * Verifies AGENTUITY_SDK_KEY is available in process.env + * Output type for routing-post agent + * POST endpoint that accepts JSON body */ -export type EnvSdkKeyCheckOutput = InferOutput; +export type RoutingPostOutput = InferOutput; /** - * Input schema type for env-sdk-key-check agent - * Verifies AGENTUITY_SDK_KEY is available in process.env + * Input schema type for routing-post agent + * POST endpoint that accepts JSON body */ -export type EnvSdkKeyCheckInputSchema = typeof envSdkKeyCheck['inputSchema']; +export type RoutingPostInputSchema = typeof routingPost['inputSchema']; /** - * Output schema type for env-sdk-key-check agent - * Verifies AGENTUITY_SDK_KEY is available in process.env + * Output schema type for routing-post agent + * POST endpoint that accepts JSON body */ -export type EnvSdkKeyCheckOutputSchema = typeof envSdkKeyCheck['outputSchema']; +export type RoutingPostOutputSchema = typeof routingPost['outputSchema']; /** - * Agent type for env-sdk-key-check - * Verifies AGENTUITY_SDK_KEY is available in process.env + * Agent type for routing-post + * POST endpoint that accepts JSON body */ -export type EnvSdkKeyCheckAgent = AgentRunner< - EnvSdkKeyCheckInputSchema, - EnvSdkKeyCheckOutputSchema, - typeof envSdkKeyCheck['stream'] extends true ? true : false +export type RoutingPostAgent = AgentRunner< + RoutingPostInputSchema, + RoutingPostOutputSchema, + typeof routingPost['stream'] extends true ? true : false >; /** - * Input type for storage-vector-crud agent - * Vector storage CRUD operations + * Input type for resilience-crash-attempts agent + * Tests various ways that should NOT crash the server */ -export type StorageVectorCrudInput = InferInput; +export type ResilienceCrashAttemptsInput = InferInput; /** - * Output type for storage-vector-crud agent - * Vector storage CRUD operations + * Output type for resilience-crash-attempts agent + * Tests various ways that should NOT crash the server */ -export type StorageVectorCrudOutput = InferOutput; +export type ResilienceCrashAttemptsOutput = InferOutput; /** - * Input schema type for storage-vector-crud agent - * Vector storage CRUD operations + * Input schema type for resilience-crash-attempts agent + * Tests various ways that should NOT crash the server */ -export type StorageVectorCrudInputSchema = typeof storageVectorCrud['inputSchema']; +export type ResilienceCrashAttemptsInputSchema = typeof resilienceCrashAttempts['inputSchema']; /** - * Output schema type for storage-vector-crud agent - * Vector storage CRUD operations + * Output schema type for resilience-crash-attempts agent + * Tests various ways that should NOT crash the server */ -export type StorageVectorCrudOutputSchema = typeof storageVectorCrud['outputSchema']; +export type ResilienceCrashAttemptsOutputSchema = typeof resilienceCrashAttempts['outputSchema']; /** - * Agent type for storage-vector-crud - * Vector storage CRUD operations + * Agent type for resilience-crash-attempts + * Tests various ways that should NOT crash the server */ -export type StorageVectorCrudAgent = AgentRunner< - StorageVectorCrudInputSchema, - StorageVectorCrudOutputSchema, - typeof storageVectorCrud['stream'] extends true ? true : false +export type ResilienceCrashAttemptsAgent = AgentRunner< + ResilienceCrashAttemptsInputSchema, + ResilienceCrashAttemptsOutputSchema, + typeof resilienceCrashAttempts['stream'] extends true ? true : false >; /** - * Input type for storage-vector-search agent - * Vector storage search operations + * Input type for lifecycle-waituntil agent + * WaitUntil background task testing */ -export type StorageVectorSearchInput = InferInput; +export type LifecycleWaituntilInput = InferInput; /** - * Output type for storage-vector-search agent - * Vector storage search operations + * Output type for lifecycle-waituntil agent + * WaitUntil background task testing */ -export type StorageVectorSearchOutput = InferOutput; +export type LifecycleWaituntilOutput = InferOutput; /** - * Input schema type for storage-vector-search agent - * Vector storage search operations + * Input schema type for lifecycle-waituntil agent + * WaitUntil background task testing */ -export type StorageVectorSearchInputSchema = typeof storageVectorSearch['inputSchema']; +export type LifecycleWaituntilInputSchema = typeof lifecycleWaituntil['inputSchema']; /** - * Output schema type for storage-vector-search agent - * Vector storage search operations + * Output schema type for lifecycle-waituntil agent + * WaitUntil background task testing */ -export type StorageVectorSearchOutputSchema = typeof storageVectorSearch['outputSchema']; +export type LifecycleWaituntilOutputSchema = typeof lifecycleWaituntil['outputSchema']; /** - * Agent type for storage-vector-search - * Vector storage search operations + * Agent type for lifecycle-waituntil + * WaitUntil background task testing */ -export type StorageVectorSearchAgent = AgentRunner< - StorageVectorSearchInputSchema, - StorageVectorSearchOutputSchema, - typeof storageVectorSearch['stream'] extends true ? true : false +export type LifecycleWaituntilAgent = AgentRunner< + LifecycleWaituntilInputSchema, + LifecycleWaituntilOutputSchema, + typeof lifecycleWaituntil['stream'] extends true ? true : false >; /** - * Input type for storage-binary-upload-download agent - * Upload and download binary data with integrity verification + * Input type for events-agent agent + * Tests agent event listeners (started, completed, errored) */ -export type StorageBinaryUploadDownloadInput = InferInput; +export type EventsAgentInput = InferInput; /** - * Output type for storage-binary-upload-download agent - * Upload and download binary data with integrity verification + * Output type for events-agent agent + * Tests agent event listeners (started, completed, errored) */ -export type StorageBinaryUploadDownloadOutput = InferOutput; +export type EventsAgentOutput = InferOutput; /** - * Input schema type for storage-binary-upload-download agent - * Upload and download binary data with integrity verification + * Input schema type for events-agent agent + * Tests agent event listeners (started, completed, errored) */ -export type StorageBinaryUploadDownloadInputSchema = typeof storageBinaryUploadDownload['inputSchema']; +export type EventsAgentInputSchema = typeof eventsAgent['inputSchema']; /** - * Output schema type for storage-binary-upload-download agent - * Upload and download binary data with integrity verification + * Output schema type for events-agent agent + * Tests agent event listeners (started, completed, errored) */ -export type StorageBinaryUploadDownloadOutputSchema = typeof storageBinaryUploadDownload['outputSchema']; +export type EventsAgentOutputSchema = typeof eventsAgent['outputSchema']; /** - * Agent type for storage-binary-upload-download - * Upload and download binary data with integrity verification + * Agent type for events-agent + * Tests agent event listeners (started, completed, errored) */ -export type StorageBinaryUploadDownloadAgent = AgentRunner< - StorageBinaryUploadDownloadInputSchema, - StorageBinaryUploadDownloadOutputSchema, - typeof storageBinaryUploadDownload['stream'] extends true ? true : false +export type EventsAgentAgent = AgentRunner< + EventsAgentInputSchema, + EventsAgentOutputSchema, + typeof eventsAgent['stream'] extends true ? true : false >; /** - * Input type for storage-stream-crud agent - * Stream storage CRUD operations + * Input type for events-removal agent + * Tests event listener removal (removeEventListener) */ -export type StorageStreamCrudInput = InferInput; +export type EventsRemovalInput = InferInput; /** - * Output type for storage-stream-crud agent - * Stream storage CRUD operations + * Output type for events-removal agent + * Tests event listener removal (removeEventListener) */ -export type StorageStreamCrudOutput = InferOutput; +export type EventsRemovalOutput = InferOutput; /** - * Input schema type for storage-stream-crud agent - * Stream storage CRUD operations + * Input schema type for events-removal agent + * Tests event listener removal (removeEventListener) */ -export type StorageStreamCrudInputSchema = typeof storageStreamCrud['inputSchema']; +export type EventsRemovalInputSchema = typeof eventsRemoval['inputSchema']; /** - * Output schema type for storage-stream-crud agent - * Stream storage CRUD operations + * Output schema type for events-removal agent + * Tests event listener removal (removeEventListener) */ -export type StorageStreamCrudOutputSchema = typeof storageStreamCrud['outputSchema']; +export type EventsRemovalOutputSchema = typeof eventsRemoval['outputSchema']; /** - * Agent type for storage-stream-crud - * Stream storage CRUD operations + * Agent type for events-removal + * Tests event listener removal (removeEventListener) */ -export type StorageStreamCrudAgent = AgentRunner< - StorageStreamCrudInputSchema, - StorageStreamCrudOutputSchema, - typeof storageStreamCrud['stream'] extends true ? true : false +export type EventsRemovalAgent = AgentRunner< + EventsRemovalInputSchema, + EventsRemovalOutputSchema, + typeof eventsRemoval['stream'] extends true ? true : false >; /** - * Input type for storage-stream-types agent - * Stream storage with different data types + * Input type for events-multiple agent + * Tests multiple event listeners on same event */ -export type StorageStreamTypesInput = InferInput; +export type EventsMultipleInput = InferInput; /** - * Output type for storage-stream-types agent - * Stream storage with different data types + * Output type for events-multiple agent + * Tests multiple event listeners on same event */ -export type StorageStreamTypesOutput = InferOutput; +export type EventsMultipleOutput = InferOutput; /** - * Input schema type for storage-stream-types agent - * Stream storage with different data types + * Input schema type for events-multiple agent + * Tests multiple event listeners on same event */ -export type StorageStreamTypesInputSchema = typeof storageStreamTypes['inputSchema']; +export type EventsMultipleInputSchema = typeof eventsMultiple['inputSchema']; /** - * Output schema type for storage-stream-types agent - * Stream storage with different data types + * Output schema type for events-multiple agent + * Tests multiple event listeners on same event */ -export type StorageStreamTypesOutputSchema = typeof storageStreamTypes['outputSchema']; +export type EventsMultipleOutputSchema = typeof eventsMultiple['outputSchema']; /** - * Agent type for storage-stream-types - * Stream storage with different data types + * Agent type for events-multiple + * Tests multiple event listeners on same event */ -export type StorageStreamTypesAgent = AgentRunner< - StorageStreamTypesInputSchema, - StorageStreamTypesOutputSchema, - typeof storageStreamTypes['stream'] extends true ? true : false +export type EventsMultipleAgent = AgentRunner< + EventsMultipleInputSchema, + EventsMultipleOutputSchema, + typeof eventsMultiple['stream'] extends true ? true : false >; /** - * Input type for storage-stream-metadata agent - * Stream storage metadata operations + * Input type for events-session agent + * Tests session event listeners (completed) */ -export type StorageStreamMetadataInput = InferInput; +export type EventsSessionInput = InferInput; /** - * Output type for storage-stream-metadata agent - * Stream storage metadata operations + * Output type for events-session agent + * Tests session event listeners (completed) */ -export type StorageStreamMetadataOutput = InferOutput; +export type EventsSessionOutput = InferOutput; /** - * Input schema type for storage-stream-metadata agent - * Stream storage metadata operations + * Input schema type for events-session agent + * Tests session event listeners (completed) */ -export type StorageStreamMetadataInputSchema = typeof storageStreamMetadata['inputSchema']; +export type EventsSessionInputSchema = typeof eventsSession['inputSchema']; /** - * Output schema type for storage-stream-metadata agent - * Stream storage metadata operations + * Output schema type for events-session agent + * Tests session event listeners (completed) */ -export type StorageStreamMetadataOutputSchema = typeof storageStreamMetadata['outputSchema']; +export type EventsSessionOutputSchema = typeof eventsSession['outputSchema']; /** - * Agent type for storage-stream-metadata - * Stream storage metadata operations + * Agent type for events-session + * Tests session event listeners (completed) */ -export type StorageStreamMetadataAgent = AgentRunner< - StorageStreamMetadataInputSchema, - StorageStreamMetadataOutputSchema, - typeof storageStreamMetadata['stream'] extends true ? true : false +export type EventsSessionAgent = AgentRunner< + EventsSessionInputSchema, + EventsSessionOutputSchema, + typeof eventsSession['stream'] extends true ? true : false >; /** - * Input type for storage-kv-crud agent - * KeyValue storage CRUD operations + * Input type for events-thread agent + * Tests thread event listeners (destroyed) */ -export type StorageKvCrudInput = InferInput; +export type EventsThreadInput = InferInput; /** - * Output type for storage-kv-crud agent - * KeyValue storage CRUD operations + * Output type for events-thread agent + * Tests thread event listeners (destroyed) */ -export type StorageKvCrudOutput = InferOutput; +export type EventsThreadOutput = InferOutput; /** - * Input schema type for storage-kv-crud agent - * KeyValue storage CRUD operations + * Input schema type for events-thread agent + * Tests thread event listeners (destroyed) */ -export type StorageKvCrudInputSchema = typeof storageKvCrud['inputSchema']; +export type EventsThreadInputSchema = typeof eventsThread['inputSchema']; /** - * Output schema type for storage-kv-crud agent - * KeyValue storage CRUD operations + * Output schema type for events-thread agent + * Tests thread event listeners (destroyed) */ -export type StorageKvCrudOutputSchema = typeof storageKvCrud['outputSchema']; +export type EventsThreadOutputSchema = typeof eventsThread['outputSchema']; /** - * Agent type for storage-kv-crud - * KeyValue storage CRUD operations + * Agent type for events-thread + * Tests thread event listeners (destroyed) */ -export type StorageKvCrudAgent = AgentRunner< - StorageKvCrudInputSchema, - StorageKvCrudOutputSchema, - typeof storageKvCrud['stream'] extends true ? true : false +export type EventsThreadAgent = AgentRunner< + EventsThreadInputSchema, + EventsThreadOutputSchema, + typeof eventsThread['stream'] extends true ? true : false >; /** - * Input type for storage-kv-types agent - * KeyValue storage with different value types + * Input type for evals-basic agent + * Agent with evals for testing */ -export type StorageKvTypesInput = InferInput; +export type EvalsBasicInput = InferInput; /** - * Output type for storage-kv-types agent - * KeyValue storage with different value types + * Output type for evals-basic agent + * Agent with evals for testing */ -export type StorageKvTypesOutput = InferOutput; +export type EvalsBasicOutput = InferOutput; /** - * Input schema type for storage-kv-types agent - * KeyValue storage with different value types + * Input schema type for evals-basic agent + * Agent with evals for testing */ -export type StorageKvTypesInputSchema = typeof storageKvTypes['inputSchema']; +export type EvalsBasicInputSchema = typeof evalsBasic['inputSchema']; /** - * Output schema type for storage-kv-types agent - * KeyValue storage with different value types + * Output schema type for evals-basic agent + * Agent with evals for testing */ -export type StorageKvTypesOutputSchema = typeof storageKvTypes['outputSchema']; +export type EvalsBasicOutputSchema = typeof evalsBasic['outputSchema']; /** - * Agent type for storage-kv-types - * KeyValue storage with different value types + * Agent type for evals-basic + * Agent with evals for testing */ -export type StorageKvTypesAgent = AgentRunner< - StorageKvTypesInputSchema, - StorageKvTypesOutputSchema, - typeof storageKvTypes['stream'] extends true ? true : false +export type EvalsBasicAgent = AgentRunner< + EvalsBasicInputSchema, + EvalsBasicOutputSchema, + typeof evalsBasic['stream'] extends true ? true : false >; /** - * Input type for storage-kv-isolation agent - * Test KV isolation between requests + * Input type for errors-propagation agent + * Test error propagation patterns */ -export type StorageKvIsolationInput = InferInput; +export type ErrorsPropagationInput = InferInput; /** - * Output type for storage-kv-isolation agent - * Test KV isolation between requests + * Output type for errors-propagation agent + * Test error propagation patterns */ -export type StorageKvIsolationOutput = InferOutput; +export type ErrorsPropagationOutput = InferOutput; /** - * Input schema type for storage-kv-isolation agent - * Test KV isolation between requests + * Input schema type for errors-propagation agent + * Test error propagation patterns */ -export type StorageKvIsolationInputSchema = typeof storageKvIsolation['inputSchema']; +export type ErrorsPropagationInputSchema = typeof errorsPropagation['inputSchema']; /** - * Output schema type for storage-kv-isolation agent - * Test KV isolation between requests + * Output schema type for errors-propagation agent + * Test error propagation patterns */ -export type StorageKvIsolationOutputSchema = typeof storageKvIsolation['outputSchema']; +export type ErrorsPropagationOutputSchema = typeof errorsPropagation['outputSchema']; /** - * Agent type for storage-kv-isolation - * Test KV isolation between requests + * Agent type for errors-propagation + * Test error propagation patterns */ -export type StorageKvIsolationAgent = AgentRunner< - StorageKvIsolationInputSchema, - StorageKvIsolationOutputSchema, - typeof storageKvIsolation['stream'] extends true ? true : false +export type ErrorsPropagationAgent = AgentRunner< + ErrorsPropagationInputSchema, + ErrorsPropagationOutputSchema, + typeof errorsPropagation['stream'] extends true ? true : false >; /** - * Input type for state agent - * Test thread and session state persistence across requests + * Input type for errors-structured agent + * Test StructuredError patterns */ -export type StateInput = InferInput; +export type ErrorsStructuredInput = InferInput; /** - * Output type for state agent - * Test thread and session state persistence across requests + * Output type for errors-structured agent + * Test StructuredError patterns */ -export type StateOutput = InferOutput; +export type ErrorsStructuredOutput = InferOutput; /** - * Input schema type for state agent - * Test thread and session state persistence across requests + * Input schema type for errors-structured agent + * Test StructuredError patterns */ -export type StateInputSchema = typeof state['inputSchema']; +export type ErrorsStructuredInputSchema = typeof errorsStructured['inputSchema']; /** - * Output schema type for state agent - * Test thread and session state persistence across requests + * Output schema type for errors-structured agent + * Test StructuredError patterns */ -export type StateOutputSchema = typeof state['outputSchema']; +export type ErrorsStructuredOutputSchema = typeof errorsStructured['outputSchema']; /** - * Agent type for state - * Test thread and session state persistence across requests + * Agent type for errors-structured + * Test StructuredError patterns */ -export type StateAgent = AgentRunner< - StateInputSchema, - StateOutputSchema, - typeof state['stream'] extends true ? true : false +export type ErrorsStructuredAgent = AgentRunner< + ErrorsStructuredInputSchema, + ErrorsStructuredOutputSchema, + typeof errorsStructured['stream'] extends true ? true : false >; /** - * Input type for state-writer agent - * Write thread state for other agents to read + * Input type for errors-validation agent + * Test schema validation error handling */ -export type StateWriterInput = InferInput; +export type ErrorsValidationInput = InferInput; /** - * Output type for state-writer agent - * Write thread state for other agents to read + * Output type for errors-validation agent + * Test schema validation error handling */ -export type StateWriterOutput = InferOutput; +export type ErrorsValidationOutput = InferOutput; /** - * Input schema type for state-writer agent - * Write thread state for other agents to read + * Input schema type for errors-validation agent + * Test schema validation error handling */ -export type StateWriterInputSchema = typeof stateWriter['inputSchema']; +export type ErrorsValidationInputSchema = typeof errorsValidation['inputSchema']; /** - * Output schema type for state-writer agent - * Write thread state for other agents to read + * Output schema type for errors-validation agent + * Test schema validation error handling */ -export type StateWriterOutputSchema = typeof stateWriter['outputSchema']; +export type ErrorsValidationOutputSchema = typeof errorsValidation['outputSchema']; /** - * Agent type for state-writer - * Write thread state for other agents to read + * Agent type for errors-validation + * Test schema validation error handling */ -export type StateWriterAgent = AgentRunner< - StateWriterInputSchema, - StateWriterOutputSchema, - typeof stateWriter['stream'] extends true ? true : false +export type ErrorsValidationAgent = AgentRunner< + ErrorsValidationInputSchema, + ErrorsValidationOutputSchema, + typeof errorsValidation['stream'] extends true ? true : false >; /** - * Input type for state-reader agent - * Read thread state set by other agents + * Input type for env-sdk-key-check agent + * Verifies AGENTUITY_SDK_KEY is available in process.env */ -export type StateReaderInput = InferInput; +export type EnvSdkKeyCheckInput = InferInput; /** - * Output type for state-reader agent - * Read thread state set by other agents + * Output type for env-sdk-key-check agent + * Verifies AGENTUITY_SDK_KEY is available in process.env */ -export type StateReaderOutput = InferOutput; +export type EnvSdkKeyCheckOutput = InferOutput; /** - * Input schema type for state-reader agent - * Read thread state set by other agents + * Input schema type for env-sdk-key-check agent + * Verifies AGENTUITY_SDK_KEY is available in process.env */ -export type StateReaderInputSchema = typeof stateReader['inputSchema']; +export type EnvSdkKeyCheckInputSchema = typeof envSdkKeyCheck['inputSchema']; /** - * Output schema type for state-reader agent - * Read thread state set by other agents + * Output schema type for env-sdk-key-check agent + * Verifies AGENTUITY_SDK_KEY is available in process.env */ -export type StateReaderOutputSchema = typeof stateReader['outputSchema']; +export type EnvSdkKeyCheckOutputSchema = typeof envSdkKeyCheck['outputSchema']; /** - * Agent type for state-reader - * Read thread state set by other agents + * Agent type for env-sdk-key-check + * Verifies AGENTUITY_SDK_KEY is available in process.env */ -export type StateReaderAgent = AgentRunner< - StateReaderInputSchema, - StateReaderOutputSchema, - typeof stateReader['stream'] extends true ? true : false +export type EnvSdkKeyCheckAgent = AgentRunner< + EnvSdkKeyCheckInputSchema, + EnvSdkKeyCheckOutputSchema, + typeof envSdkKeyCheck['stream'] extends true ? true : false >; /** @@ -1207,66 +1385,37 @@ export type CliAgent = AgentRunner< >; /** - * Input type for utils-string-helper agent - */ -export type UtilsStringHelperInput = InferInput; - -/** - * Output type for utils-string-helper agent - */ -export type UtilsStringHelperOutput = InferOutput; - -/** - * Input schema type for utils-string-helper agent - */ -export type UtilsStringHelperInputSchema = typeof utilsStringHelper['inputSchema']; - -/** - * Output schema type for utils-string-helper agent - */ -export type UtilsStringHelperOutputSchema = typeof utilsStringHelper['outputSchema']; - -/** - * Agent type for utils-string-helper - */ -export type UtilsStringHelperAgent = AgentRunner< - UtilsStringHelperInputSchema, - UtilsStringHelperOutputSchema, - typeof utilsStringHelper['stream'] extends true ? true : false ->; - -/** - * Input type for lifecycle-waituntil agent - * WaitUntil background task testing + * Input type for async agent + * Agent with async handler execution */ -export type LifecycleWaituntilInput = InferInput; +export type AsyncInput = InferInput; /** - * Output type for lifecycle-waituntil agent - * WaitUntil background task testing + * Output type for async agent + * Agent with async handler execution */ -export type LifecycleWaituntilOutput = InferOutput; +export type AsyncOutput = InferOutput; /** - * Input schema type for lifecycle-waituntil agent - * WaitUntil background task testing + * Input schema type for async agent + * Agent with async handler execution */ -export type LifecycleWaituntilInputSchema = typeof lifecycleWaituntil['inputSchema']; +export type AsyncInputSchema = typeof async['inputSchema']; /** - * Output schema type for lifecycle-waituntil agent - * WaitUntil background task testing + * Output schema type for async agent + * Agent with async handler execution */ -export type LifecycleWaituntilOutputSchema = typeof lifecycleWaituntil['outputSchema']; +export type AsyncOutputSchema = typeof async['outputSchema']; /** - * Agent type for lifecycle-waituntil - * WaitUntil background task testing + * Agent type for async + * Agent with async handler execution */ -export type LifecycleWaituntilAgent = AgentRunner< - LifecycleWaituntilInputSchema, - LifecycleWaituntilOutputSchema, - typeof lifecycleWaituntil['stream'] extends true ? true : false +export type AsyncAgent = AgentRunner< + AsyncInputSchema, + AsyncOutputSchema, + typeof async['stream'] extends true ? true : false >; /** @@ -1337,40 +1486,6 @@ export type NoOutputAgent = AgentRunner< typeof noOutput['stream'] extends true ? true : false >; -/** - * Input type for async agent - * Agent with async handler execution - */ -export type AsyncInput = InferInput; - -/** - * Output type for async agent - * Agent with async handler execution - */ -export type AsyncOutput = InferOutput; - -/** - * Input schema type for async agent - * Agent with async handler execution - */ -export type AsyncInputSchema = typeof async['inputSchema']; - -/** - * Output schema type for async agent - * Agent with async handler execution - */ -export type AsyncOutputSchema = typeof async['outputSchema']; - -/** - * Agent type for async - * Agent with async handler execution - */ -export type AsyncAgent = AgentRunner< - AsyncInputSchema, - AsyncOutputSchema, - typeof async['stream'] extends true ? true : false ->; - /** * Input type for simple agent * Basic agent with input/output validation @@ -1405,103 +1520,6 @@ export type SimpleAgent = AgentRunner< typeof simple['stream'] extends true ? true : false >; -/** - * Input type for evals-basic agent - * Agent with evals for testing - */ -export type EvalsBasicInput = InferInput; - -/** - * Output type for evals-basic agent - * Agent with evals for testing - */ -export type EvalsBasicOutput = InferOutput; - -/** - * Input schema type for evals-basic agent - * Agent with evals for testing - */ -export type EvalsBasicInputSchema = typeof evalsBasic['inputSchema']; - -/** - * Output schema type for evals-basic agent - * Agent with evals for testing - */ -export type EvalsBasicOutputSchema = typeof evalsBasic['outputSchema']; - -/** - * Agent type for evals-basic - * Agent with evals for testing - */ -export type EvalsBasicAgent = AgentRunner< - EvalsBasicInputSchema, - EvalsBasicOutputSchema, - typeof evalsBasic['stream'] extends true ? true : false ->; - -/** - * Input type for websocket-echo agent - * WebSocket echo server for testing - */ -export type WebsocketEchoInput = InferInput; - -/** - * Output type for websocket-echo agent - * WebSocket echo server for testing - */ -export type WebsocketEchoOutput = InferOutput; - -/** - * Input schema type for websocket-echo agent - * WebSocket echo server for testing - */ -export type WebsocketEchoInputSchema = typeof websocketEcho['inputSchema']; - -/** - * Output schema type for websocket-echo agent - * WebSocket echo server for testing - */ -export type WebsocketEchoOutputSchema = typeof websocketEcho['outputSchema']; - -/** - * Agent type for websocket-echo - * WebSocket echo server for testing - */ -export type WebsocketEchoAgent = AgentRunner< - WebsocketEchoInputSchema, - WebsocketEchoOutputSchema, - typeof websocketEcho['stream'] extends true ? true : false ->; - -/** - * Input type for v1-data-processor agent - */ -export type V1DataProcessorInput = InferInput; - -/** - * Output type for v1-data-processor agent - */ -export type V1DataProcessorOutput = InferOutput; - -/** - * Input schema type for v1-data-processor agent - */ -export type V1DataProcessorInputSchema = typeof v1DataProcessor['inputSchema']; - -/** - * Output schema type for v1-data-processor agent - */ -export type V1DataProcessorOutputSchema = typeof v1DataProcessor['outputSchema']; - -/** - * Agent type for v1-data-processor - */ -export type V1DataProcessorAgent = AgentRunner< - V1DataProcessorInputSchema, - V1DataProcessorOutputSchema, - typeof v1DataProcessor['stream'] extends true ? true : false ->; - // ============================================================================ // Agent Definitions // ============================================================================ @@ -1530,131 +1548,28 @@ export type V1DataProcessorAgent = AgentRunner< */ export const AgentDefinitions = { /** - * session-basic - * Basic session ID and state access - * @type {SessionBasicAgent} - */ - sessionBasic, - /** - * session-events - * Session and thread event listeners - * @type {SessionEventsAgent} - */ - sessionEvents, - /** - * ai-sdk-gateway-check - * Verifies AI Gateway configuration and API key injection (issue #348) - * @type {AiSdkGatewayCheckAgent} - */ - aiSdkGatewayCheck, - /** - * events-multiple - * Tests multiple event listeners on same event - * @type {EventsMultipleAgent} - */ - eventsMultiple, - /** - * events-session - * Tests session event listeners (completed) - * @type {EventsSessionAgent} - */ - eventsSession, - /** - * events-removal - * Tests event listener removal (removeEventListener) - * @type {EventsRemovalAgent} - */ - eventsRemoval, - /** - * events-agent - * Tests agent event listeners (started, completed, errored) - * @type {EventsAgentAgent} - */ - eventsAgent, - /** - * events-thread - * Tests thread event listeners (destroyed) - * @type {EventsThreadAgent} - */ - eventsThread, - /** - * errors-validation - * Test schema validation error handling - * @type {ErrorsValidationAgent} - */ - errorsValidation, - /** - * errors-structured - * Test StructuredError patterns - * @type {ErrorsStructuredAgent} - */ - errorsStructured, - /** - * errors-propagation - * Test error propagation patterns - * @type {ErrorsPropagationAgent} - */ - errorsPropagation, - /** - * routing-params - * Agent for testing route parameters - * @type {RoutingParamsAgent} - */ - routingParams, - /** - * routing-headers - * Agent that works with custom headers - * @type {RoutingHeadersAgent} - */ - routingHeaders, - /** - * routing-get - * GET endpoint that reads query parameters - * @type {RoutingGetAgent} - */ - routingGet, - /** - * routing-post - * POST endpoint that accepts JSON body - * @type {RoutingPostAgent} - */ - routingPost, - /** - * routing-methods - * Agent that supports multiple HTTP methods - * @type {RoutingMethodsAgent} - */ - routingMethods, - /** - * resilience-crash-attempts - * Tests various ways that should NOT crash the server - * @type {ResilienceCrashAttemptsAgent} - */ - resilienceCrashAttempts, - /** - * schema-complex - * Test complex nested schemas - * @type {SchemaComplexAgent} + * websocket-echo + * WebSocket echo server for testing + * @type {WebsocketEchoAgent} */ - schemaComplex, + websocketEcho, /** - * schema-types - * Test basic schema types - * @type {SchemaTypesAgent} + * v1-data-processor + * @type {V1DataProcessorAgent} */ - schemaTypes, + v1DataProcessor, /** - * schema-optional - * Test optional fields and defaults - * @type {SchemaOptionalAgent} + * ai-sdk-gateway-check + * Verifies AI Gateway configuration and API key injection (issue #348) + * @type {AiSdkGatewayCheckAgent} */ - schemaOptional, + aiSdkGatewayCheck, /** - * env-sdk-key-check - * Verifies AGENTUITY_SDK_KEY is available in process.env - * @type {EnvSdkKeyCheckAgent} + * events-multiple + * Tests multiple event listeners on same event + * @type {EventsMultipleAgent} */ - envSdkKeyCheck, + utilsStringHelper, /** * storage-vector-crud * Vector storage CRUD operations @@ -1667,36 +1582,36 @@ export const AgentDefinitions = { * @type {StorageVectorSearchAgent} */ storageVectorSearch, - /** - * storage-binary-upload-download - * Upload and download binary data with integrity verification - * @type {StorageBinaryUploadDownloadAgent} - */ - storageBinaryUploadDownload, /** * storage-stream-crud * Stream storage CRUD operations * @type {StorageStreamCrudAgent} */ storageStreamCrud, - /** - * storage-stream-types - * Stream storage with different data types - * @type {StorageStreamTypesAgent} - */ - storageStreamTypes, /** * storage-stream-metadata * Stream storage metadata operations * @type {StorageStreamMetadataAgent} */ storageStreamMetadata, + /** + * storage-stream-types + * Stream storage with different data types + * @type {StorageStreamTypesAgent} + */ + storageStreamTypes, /** * storage-kv-crud * KeyValue storage CRUD operations * @type {StorageKvCrudAgent} */ storageKvCrud, + /** + * storage-kv-isolation + * Test KV isolation between requests + * @type {StorageKvIsolationAgent} + */ + storageKvIsolation, /** * storage-kv-types * KeyValue storage with different value types @@ -1704,17 +1619,23 @@ export const AgentDefinitions = { */ storageKvTypes, /** - * storage-kv-isolation - * Test KV isolation between requests - * @type {StorageKvIsolationAgent} + * storage-binary-upload-download + * Upload and download binary data with integrity verification + * @type {StorageBinaryUploadDownloadAgent} */ - storageKvIsolation, + storageBinaryUploadDownload, /** * state * Test thread and session state persistence across requests * @type {StateAgent} */ state, + /** + * state-reader + * Read thread state set by other agents + * @type {StateReaderAgent} + */ + stateReader, /** * state-writer * Write thread state for other agents to read @@ -1722,22 +1643,71 @@ export const AgentDefinitions = { */ stateWriter, /** - * state-reader - * Read thread state set by other agents - * @type {StateReaderAgent} + * session-basic + * Basic session ID and state access + * @type {SessionBasicAgent} */ - stateReader, + sessionBasic, /** - * cli - * Execute CLI commands for deployment, API keys, and other operations - * @type {CliAgent} + * session-events + * Session and thread event listeners + * @type {SessionEventsAgent} */ - cli, + sessionEvents, /** - * utils-string-helper - * @type {UtilsStringHelperAgent} + * schema-complex + * Test complex nested schemas + * @type {SchemaComplexAgent} */ - utilsStringHelper, + schemaComplex, + /** + * schema-optional + * Test optional fields and defaults + * @type {SchemaOptionalAgent} + */ + schemaOptional, + /** + * schema-types + * Test basic schema types + * @type {SchemaTypesAgent} + */ + schemaTypes, + /** + * routing-get + * GET endpoint that reads query parameters + * @type {RoutingGetAgent} + */ + routingGet, + /** + * routing-headers + * Agent that works with custom headers + * @type {RoutingHeadersAgent} + */ + routingHeaders, + /** + * routing-methods + * Agent that supports multiple HTTP methods + * @type {RoutingMethodsAgent} + */ + routingMethods, + /** + * routing-params + * Agent for testing route parameters + * @type {RoutingParamsAgent} + */ + routingParams, + /** + * routing-post + * POST endpoint that accepts JSON body + * @type {RoutingPostAgent} + */ + routingPost, + /** + * resilience-crash-attempts + * Tests various ways that should NOT crash the server + * @type {ResilienceCrashAttemptsAgent} + */ + resilienceCrashAttempts, /** * lifecycle-waituntil * WaitUntil background task testing @@ -1745,29 +1715,35 @@ export const AgentDefinitions = { */ lifecycleWaituntil, /** - * no-input - * Agent with no input schema (void input) - * @type {NoInputAgent} + * events-agent + * Tests agent event listeners (started, completed, errored) + * @type {EventsAgentAgent} */ - noInput, + eventsAgent, /** - * no-output - * Agent with void output (side effects only) - * @type {NoOutputAgent} + * events-removal + * Tests event listener removal (removeEventListener) + * @type {EventsRemovalAgent} */ - noOutput, + eventsRemoval, /** - * async - * Agent with async handler execution - * @type {AsyncAgent} + * events-multiple + * Tests multiple event listeners on same event + * @type {EventsMultipleAgent} */ - async, + eventsMultiple, /** - * simple - * Basic agent with input/output validation - * @type {SimpleAgent} + * events-session + * Tests session event listeners (completed) + * @type {EventsSessionAgent} */ - simple, + eventsSession, + /** + * events-thread + * Tests thread event listeners (destroyed) + * @type {EventsThreadAgent} + */ + eventsThread, /** * evals-basic * Agent with evals for testing @@ -1775,16 +1751,59 @@ export const AgentDefinitions = { */ evalsBasic, /** - * websocket-echo - * WebSocket echo server for testing - * @type {WebsocketEchoAgent} + * errors-propagation + * Test error propagation patterns + * @type {ErrorsPropagationAgent} */ - websocketEcho, + errorsPropagation, /** - * v1-data-processor - * @type {V1DataProcessorAgent} + * errors-structured + * Test StructuredError patterns + * @type {ErrorsStructuredAgent} */ - v1DataProcessor, + errorsStructured, + /** + * errors-validation + * Test schema validation error handling + * @type {ErrorsValidationAgent} + */ + errorsValidation, + /** + * env-sdk-key-check + * Verifies AGENTUITY_SDK_KEY is available in process.env + * @type {EnvSdkKeyCheckAgent} + */ + envSdkKeyCheck, + /** + * cli + * Execute CLI commands for deployment, API keys, and other operations + * @type {CliAgent} + */ + cli, + /** + * async + * Agent with async handler execution + * @type {AsyncAgent} + */ + async, + /** + * no-input + * Agent with no input schema (void input) + * @type {NoInputAgent} + */ + noInput, + /** + * no-output + * Agent with void output (side effects only) + * @type {NoOutputAgent} + */ + noOutput, + /** + * simple + * Basic agent with input/output validation + * @type {SimpleAgent} + */ + simple, } as const; // ============================================================================ @@ -1818,26 +1837,43 @@ declare module "@agentuity/runtime" { envSdkKeyCheck: EnvSdkKeyCheckAgent; storageVectorCrud: StorageVectorCrudAgent; storageVectorSearch: StorageVectorSearchAgent; - storageBinaryUploadDownload: StorageBinaryUploadDownloadAgent; storageStreamCrud: StorageStreamCrudAgent; - storageStreamTypes: StorageStreamTypesAgent; storageStreamMetadata: StorageStreamMetadataAgent; + storageStreamTypes: StorageStreamTypesAgent; storageKvCrud: StorageKvCrudAgent; - storageKvTypes: StorageKvTypesAgent; storageKvIsolation: StorageKvIsolationAgent; + storageKvTypes: StorageKvTypesAgent; + storageBinaryUploadDownload: StorageBinaryUploadDownloadAgent; state: StateAgent; - stateWriter: StateWriterAgent; stateReader: StateReaderAgent; - cli: CliAgent; - utilsStringHelper: UtilsStringHelperAgent; + stateWriter: StateWriterAgent; + sessionBasic: SessionBasicAgent; + sessionEvents: SessionEventsAgent; + schemaComplex: SchemaComplexAgent; + schemaOptional: SchemaOptionalAgent; + schemaTypes: SchemaTypesAgent; + routingGet: RoutingGetAgent; + routingHeaders: RoutingHeadersAgent; + routingMethods: RoutingMethodsAgent; + routingParams: RoutingParamsAgent; + routingPost: RoutingPostAgent; + resilienceCrashAttempts: ResilienceCrashAttemptsAgent; lifecycleWaituntil: LifecycleWaituntilAgent; + eventsAgent: EventsAgentAgent; + eventsRemoval: EventsRemovalAgent; + eventsMultiple: EventsMultipleAgent; + eventsSession: EventsSessionAgent; + eventsThread: EventsThreadAgent; + evalsBasic: EvalsBasicAgent; + errorsPropagation: ErrorsPropagationAgent; + errorsStructured: ErrorsStructuredAgent; + errorsValidation: ErrorsValidationAgent; + envSdkKeyCheck: EnvSdkKeyCheckAgent; + cli: CliAgent; + async: AsyncAgent; noInput: NoInputAgent; noOutput: NoOutputAgent; - async: AsyncAgent; simple: SimpleAgent; - evalsBasic: EvalsBasicAgent; - websocketEcho: WebsocketEchoAgent; - v1DataProcessor: V1DataProcessorAgent; } } diff --git a/apps/testing/integration-suite/src/generated/routes.ts b/apps/testing/integration-suite/src/generated/routes.ts index 3584cb78..38976c51 100644 --- a/apps/testing/integration-suite/src/generated/routes.ts +++ b/apps/testing/integration-suite/src/generated/routes.ts @@ -126,72 +126,72 @@ declare module '@agentuity/react' { outputSchema: POSTApiAgentStateWriterOutputSchema; stream: typeof stateWriterAgent extends { stream?: infer S } ? S : false; }; - 'GET /api/custom-name/custom': { + 'GET /api/users/profile': { inputSchema: never; outputSchema: never; stream: false; }; - 'POST /api/custom-name/test': { + 'PATCH /api/users/profile': { inputSchema: never; outputSchema: never; stream: false; }; - 'GET /api/users/profile': { + 'DELETE /api/users/profile': { inputSchema: never; outputSchema: never; stream: false; }; - 'PATCH /api/users/profile': { + 'GET /api/my-service/status': { inputSchema: never; outputSchema: never; stream: false; }; - 'DELETE /api/users/profile': { + 'GET /api/my-service/info': { inputSchema: never; outputSchema: never; stream: false; }; - 'GET /api/my-service/status': { + 'GET /api/middleware-test/check-all': { inputSchema: never; outputSchema: never; stream: false; }; - 'GET /api/my-service/info': { + 'GET /api/middleware-test/query-database': { inputSchema: never; outputSchema: never; stream: false; }; - 'POST /api/auth/login': { + 'GET /api/middleware-test/check-auth': { inputSchema: never; outputSchema: never; stream: false; }; - 'POST /api/auth/logout': { + 'GET /api/middleware-test/analytics-info': { inputSchema: never; outputSchema: never; stream: false; }; - 'GET /api/auth/verify': { + 'GET /api/custom-name/custom': { inputSchema: never; outputSchema: never; stream: false; }; - 'GET /api/middleware-test/check-all': { + 'POST /api/custom-name/test': { inputSchema: never; outputSchema: never; stream: false; }; - 'GET /api/middleware-test/query-database': { + 'POST /api/auth/login': { inputSchema: never; outputSchema: never; stream: false; }; - 'GET /api/middleware-test/check-auth': { + 'POST /api/auth/logout': { inputSchema: never; outputSchema: never; stream: false; }; - 'GET /api/middleware-test/analytics-info': { + 'GET /api/auth/verify': { inputSchema: never; outputSchema: never; stream: false; @@ -307,20 +307,6 @@ declare module '@agentuity/react' { post: { input: POSTApiAgentStateWriterInput; output: POSTApiAgentStateWriterOutput; type: 'api' }; }; }; - customName: { - custom: { - /** - * Route: GET /api/custom-name/custom - */ - get: { input: never; output: never; type: 'api' }; - }; - test: { - /** - * Route: POST /api/custom-name/test - */ - post: { input: never; output: never; type: 'api' }; - }; - }; users: { profile: { /** @@ -351,26 +337,6 @@ declare module '@agentuity/react' { get: { input: never; output: never; type: 'api' }; }; }; - auth: { - login: { - /** - * Route: POST /api/auth/login - */ - post: { input: never; output: never; type: 'api' }; - }; - logout: { - /** - * Route: POST /api/auth/logout - */ - post: { input: never; output: never; type: 'api' }; - }; - verify: { - /** - * Route: GET /api/auth/verify - */ - get: { input: never; output: never; type: 'api' }; - }; - }; middlewareTest: { checkAll: { /** @@ -397,6 +363,40 @@ declare module '@agentuity/react' { get: { input: never; output: never; type: 'api' }; }; }; + customName: { + custom: { + /** + * Route: GET /api/custom-name/custom + */ + get: { input: never; output: never; type: 'api' }; + }; + test: { + /** + * Route: POST /api/custom-name/test + */ + post: { input: never; output: never; type: 'api' }; + }; + }; + auth: { + login: { + /** + * Route: POST /api/auth/login + */ + post: { input: never; output: never; type: 'api' }; + }; + logout: { + /** + * Route: POST /api/auth/logout + */ + post: { input: never; output: never; type: 'api' }; + }; + verify: { + /** + * Route: GET /api/auth/verify + */ + get: { input: never; output: never; type: 'api' }; + }; + }; ws: { echo: { /** @@ -497,18 +497,6 @@ const _rpcRouteMetadata = { } } }, - "customName": { - "custom": { - "get": { - "type": "api" - } - }, - "test": { - "post": { - "type": "api" - } - } - }, "users": { "profile": { "get": { @@ -534,40 +522,52 @@ const _rpcRouteMetadata = { } } }, - "auth": { - "login": { - "post": { + "middlewareTest": { + "checkAll": { + "get": { "type": "api" } }, - "logout": { - "post": { + "queryDatabase": { + "get": { "type": "api" } }, - "verify": { + "checkAuth": { + "get": { + "type": "api" + } + }, + "analyticsInfo": { "get": { "type": "api" } } }, - "middlewareTest": { - "checkAll": { + "customName": { + "custom": { "get": { "type": "api" } }, - "queryDatabase": { - "get": { + "test": { + "post": { + "type": "api" + } + } + }, + "auth": { + "login": { + "post": { "type": "api" } }, - "checkAuth": { - "get": { + "logout": { + "post": { "type": "api" } }, - "analyticsInfo": { + "verify": { "get": { "type": "api" } diff --git a/bun.lock b/bun.lock index 832c1713..eaaa0647 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,33 @@ "name": "testing", "version": "0.0.105", }, + "apps/testing/ag-auth-test-app": { + "name": "ag-auth-test-app", + "version": "0.0.1", + "dependencies": { + "@agentuity/auth": "workspace:*", + "@agentuity/cli": "workspace:*", + "@agentuity/core": "workspace:*", + "@agentuity/react": "workspace:*", + "@agentuity/runtime": "workspace:*", + "@agentuity/schema": "workspace:*", + "@agentuity/workbench": "workspace:*", + "@ai-sdk/openai": "^3.0.1", + "ai": "^6.0.3", + "better-auth": "^1.4.9", + "hono": "^4.7.13", + "pg": "^8.16.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pg": "^8.15.4", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5", + }, + }, "apps/testing/cloud-deployment": { "name": "cloud-deployment-tests", "version": "0.0.1", @@ -125,6 +152,7 @@ "@clerk/clerk-react": "^5.46.1", "@types/jsonwebtoken": "^9.0.10", "@types/react": "^18.3.18", + "better-auth": "^1.4.9", "hono": "^4.6.14", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^3.2.0", @@ -137,6 +165,7 @@ "@auth0/auth0-react": "^2.11.0", "@clerk/backend": "^1.0.0", "@clerk/clerk-react": "^5.0.0", + "better-auth": "^1.4.9", "hono": "^4.0.0", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^3.2.0", @@ -146,6 +175,7 @@ "@auth0/auth0-react", "@clerk/backend", "@clerk/clerk-react", + "better-auth", "jsonwebtoken", "jwks-rsa", ], @@ -157,6 +187,7 @@ "agentuity": "./bin/cli.ts", }, "dependencies": { + "@agentuity/auth": "workspace:*", "@agentuity/core": "workspace:*", "@agentuity/server": "workspace:*", "@datasert/cronjs-parser": "^1.4.0", @@ -521,6 +552,14 @@ "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@better-auth/core": ["@better-auth/core@1.4.9", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.7", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-JT2q4NDkQzN22KclUEoZ7qU6tl9HUTfK1ctg2oWlT87SEagkwJcnrUwS9VznL+u9ziOIfY27P0f7/jSnmvLcoQ=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.4.9", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.9" } }, "sha512-Tthy1/Gmx+pYlbvRQPBTKfVei8+pJwvH1NZp+5SbhwA6K2EXIaoonx/K6N/AXYs2aKUpyR4/gzqDesDjL7zd6A=="], + + "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], @@ -681,6 +720,10 @@ "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -1421,6 +1464,8 @@ "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "ag-auth-test-app": ["ag-auth-test-app@workspace:apps/testing/ag-auth-test-app"], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agentuity-vscode": ["agentuity-vscode@workspace:packages/vscode"], @@ -1493,6 +1538,10 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + "better-auth": ["better-auth@1.4.9", "", { "dependencies": { "@better-auth/core": "1.4.9", "@better-auth/telemetry": "1.4.9", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.7", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-usSdjuyTzZwIvM8fjF8YGhPncxV3MAg3dHUO9uPUnf0yklXUSYISiH1+imk6/Z+UBqsscyyPRnbIyjyK97p7YA=="], + + "better-call": ["better-call@1.1.7", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], @@ -1725,13 +1774,15 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -2135,7 +2186,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], @@ -2183,6 +2234,8 @@ "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="], + "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], "langsmith": ["langsmith@0.3.87", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-XXR1+9INH8YX96FKWc5tie0QixWz6tOqAsAKfcJyPkE0xPep+NDz0IQLR32q4bn10QK3LqD2HN6T3n6z1YLW7Q=="], @@ -2423,6 +2476,8 @@ "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -2531,12 +2586,22 @@ "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], + + "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], + + "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="], + "pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -2685,6 +2750,8 @@ "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="], + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -2715,6 +2782,8 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], @@ -2761,6 +2830,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.22", "", {}, "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], @@ -3073,6 +3144,8 @@ "@langchain/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], @@ -3253,6 +3326,8 @@ "jsonwebtoken/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "jwks-rsa/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], @@ -3261,8 +3336,6 @@ "langsmith/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], - "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -3287,8 +3360,6 @@ "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "prebuild-install/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/packages/auth/README.md b/packages/auth/README.md index 41e307e6..3e606a7f 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -19,13 +19,122 @@ bun add @agentuity/auth ## Supported Providers -- **Clerk** - `@agentuity/auth/clerk` +- **Agentuity (BetterAuth)** - `@agentuity/auth/agentuity` - First-class auth with database-backed sessions +- **Clerk** - `@agentuity/auth/clerk` - Third-party auth provider - WorkOS - Coming soon - Auth0 - Coming soon -- Better Auth - Coming soon ## Quick Start +### Agentuity (BetterAuth) Integration + +The recommended auth provider for Agentuity projects. Uses BetterAuth with Agentuity PostgreSQL for database-backed sessions, organizations, API keys, and JWT tokens. + +#### 1. Setup with CLI + +```bash +agentuity project auth init +``` + +This will: + +- Configure your database connection +- Install required dependencies +- Generate `src/auth.ts` with default configuration +- Run database migrations + +#### 2. Server Setup (Hono) + +```typescript +// src/auth.ts (generated by CLI) +import { Pool } from 'pg'; +import { + createAgentuityAuth, + createSessionMiddleware, + mountBetterAuthRoutes, +} from '@agentuity/auth/agentuity'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL! }); + +export const auth = createAgentuityAuth({ + database: pool, + basePath: '/api/auth', + secret: process.env.BETTER_AUTH_SECRET!, +}); + +export const authMiddleware = createSessionMiddleware(auth); +``` + +```typescript +// src/api/index.ts +import { createRouter } from '@agentuity/runtime'; +import { auth, authMiddleware } from '../auth'; +import { mountBetterAuthRoutes } from '@agentuity/auth/agentuity'; + +const api = createRouter(); + +// Mount BetterAuth routes (sign-in, sign-up, sign-out, session, etc.) +api.on(['GET', 'POST'], '/api/auth/*', mountBetterAuthRoutes(auth)); + +// Protect API routes +api.use('/api/*', authMiddleware); + +api.get('/api/me', async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ id: user.id, email: user.email }); +}); + +export default api; +``` + +#### 3. Client Setup (React) + +```tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { AgentuityProvider } from '@agentuity/react'; +import { AgentuityBetterAuth } from '@agentuity/auth/agentuity'; +import { App } from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +); +``` + +#### 4. Environment Variables + +```env +DATABASE_URL=postgres://user:pass@host:5432/dbname +BETTER_AUTH_SECRET=your-secret-here # Generate with: openssl rand -hex 32 +``` + +#### 5. Agent access + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { withSession } from '@agentuity/auth/agentuity'; + +export default createAgent('my-agent', { + handler: withSession( + async (ctx, { auth, org }, input) => { + if (auth) { + return `Hello, ${auth.user.email}!`; + } + return 'Hello, anonymous!'; + }, + { optional: true } + ), +}); +``` + +--- + ### Clerk Integration #### 1. Install Clerk @@ -182,7 +291,7 @@ router.use( ## API Reference -### Client Components +### Clerk Components #### `AgentuityClerk` @@ -296,8 +405,10 @@ bun dev 1. **Never log tokens** - Avoid logging `authHeader` or JWT tokens 2. **Use HTTPS in production** - Always use TLS for production deployments 3. **Validate on every request** - Middleware validates tokens on each request -4. **Keep secrets secret** - Never commit `.env` files or expose `CLERK_SECRET_KEY` +4. **Keep secrets secret** - Never commit `.env` files or expose `CLERK_SECRET_KEY` or `BETTER_AUTH_SECRET` 5. **Use environment variables** - Store all keys in environment variables, not code +6. **Rotate secrets periodically** - Rotate `BETTER_AUTH_SECRET` on a regular schedule +7. **Control OTEL PII** - Use `otelSpans: { email: false }` to exclude sensitive data from telemetry when not needed ## Troubleshooting diff --git a/packages/auth/package.json b/packages/auth/package.json index 801024e6..62a2246f 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -14,7 +14,11 @@ "./clerk": "./src/clerk/index.ts", "./auth0": "./src/auth0/index.ts", "./auth0/client": "./src/auth0/client.tsx", - "./auth0/server": "./src/auth0/server.ts" + "./auth0/server": "./src/auth0/server.ts", + "./agentuity": "./src/agentuity/index.ts", + "./agentuity/client": "./src/agentuity/client.tsx", + "./agentuity/react": "./src/agentuity/react.ts", + "./agentuity/migrations": "./src/agentuity/migrations.ts" }, "scripts": { "build": "tsc --build", @@ -30,7 +34,8 @@ "jsonwebtoken": "^9.0.3", "@agentuity/react": "workspace:*", "@agentuity/runtime": "workspace:*", - "hono": "^4.0.0" + "hono": "^4.0.0", + "better-auth": "^1.4.9" }, "peerDependenciesMeta": { "@clerk/clerk-react": { @@ -47,6 +52,9 @@ }, "jsonwebtoken": { "optional": true + }, + "better-auth": { + "optional": true } }, "devDependencies": { @@ -58,6 +66,7 @@ "@clerk/clerk-react": "^5.46.1", "@types/jsonwebtoken": "^9.0.10", "@types/react": "^18.3.18", + "better-auth": "^1.4.9", "hono": "^4.6.14", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^3.2.0", diff --git a/packages/auth/src/agentuity/agent.ts b/packages/auth/src/agentuity/agent.ts new file mode 100644 index 00000000..1e4654ed --- /dev/null +++ b/packages/auth/src/agentuity/agent.ts @@ -0,0 +1,218 @@ +/** + * Agentuity agent auth wrappers. + * + * Provides the unified `withSession` wrapper for protecting agents with authentication. + * Works across all execution contexts: HTTP, agent-to-agent, cron, standalone. + * + * @module agentuity/agent + */ + +import { + inAgentContext, + inHTTPContext, + getAgentContext, + getHTTPContext, + type AgentContext, +} from '@agentuity/runtime'; +import type { + WithSessionOptions, + WithSessionContext, + AgentuityAuthContext, + AgentuityOrgContext, +} from './types'; + +/** + * Key used to cache auth context in AgentContext.state. + * This enables automatic auth propagation between agent-to-agent calls. + */ +const AUTH_STATE_KEY = '@agentuity/auth'; + +/** + * Key used to cache org context in AgentContext.state. + */ +const ORG_STATE_KEY = '@agentuity/org'; + +// ============================================================================= +// Internal Helpers +// ============================================================================= + +/** + * Extract auth context from HTTP context (Hono c.var.auth). + * Returns null if not in HTTP context or no auth present. + */ +function extractAuthFromHttp(): AgentuityAuthContext | null { + if (!inHTTPContext()) return null; + + try { + const c = getHTTPContext(); + + // Check for Agentuity auth wrapper (set by createSessionMiddleware or createApiKeyMiddleware) + const rawAuth = c.var.auth; + if (rawAuth?.raw) { + return { + user: rawAuth.raw.user, + session: rawAuth.raw.session, + org: rawAuth.raw.org ?? null, + }; + } + + // Fallback to raw BetterAuth user/session/org vars + const user = c.var.user; + const session = c.var.session; + const org = c.var.org; + if (user && session) { + return { user, session, org: org ?? null } as AgentuityAuthContext; + } + + return null; + } catch { + return null; + } +} + +/** + * Resolve auth context from current execution environment. + * + * Resolution order: + * 1. Cached in AgentContext.state (for agent-to-agent propagation) + * 2. From HTTP context (BetterAuth session or API key) + * 3. null (cron/standalone with no auth) + */ +function resolveAuth(): AgentuityAuthContext | null { + if (!inAgentContext()) { + // Not in agent context - this shouldn't happen if withSession is used correctly + // Return null and let the wrapper handle the error + return null; + } + + try { + const agentCtx = getAgentContext(); + + // 1) Check cache in AgentContext.state + if (agentCtx.state.has(AUTH_STATE_KEY)) { + return agentCtx.state.get(AUTH_STATE_KEY) as AgentuityAuthContext | null; + } + + // 2) Try HTTP context (BetterAuth middleware) + const fromHttp = extractAuthFromHttp(); + if (fromHttp) { + agentCtx.state.set(AUTH_STATE_KEY, fromHttp); + return fromHttp; + } + + // 3) No auth available (cron, standalone) + agentCtx.state.set(AUTH_STATE_KEY, null); + return null; + } catch { + return null; + } +} + +/** + * Resolve org context, with caching. + * Returns minimal org from auth context (just ID for now). + */ +function resolveOrg(auth: AgentuityAuthContext | null): AgentuityOrgContext | null { + if (!inAgentContext()) return null; + + try { + const agentCtx = getAgentContext(); + + // Check cache + if (agentCtx.state.has(ORG_STATE_KEY)) { + return agentCtx.state.get(ORG_STATE_KEY) as AgentuityOrgContext | null; + } + + const org = auth?.org ?? null; + agentCtx.state.set(ORG_STATE_KEY, org); + return org; + } catch { + return null; + } +} + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Unified auth wrapper for agent handlers. + * + * Works across ALL execution contexts: + * - **HTTP requests**: Extracts auth from BetterAuth session or API key + * - **Agent-to-agent calls**: Automatically inherits auth from calling agent + * - **Cron jobs**: `auth` is null + * - **Standalone invocations**: `auth` is null unless manually set + * + * @example Basic usage (require auth) + * ```typescript + * import { createAgent } from '@agentuity/runtime'; + * import { withSession } from '@agentuity/auth/agentuity'; + * + * export default createAgent('my-agent', { + * handler: withSession(async (ctx, { auth, org }, input) => { + * // auth is guaranteed non-null here + * // ctx is the standard AgentContext + * return { userId: auth.user.id }; + * }), + * }); + * ``` + * + * @example Optional auth (allow anonymous) + * ```typescript + * export default createAgent('public-agent', { + * handler: withSession(async (ctx, { auth }, input) => { + * if (auth) { + * return { message: `Hello, ${auth.user.name}!` }; + * } + * return { message: 'Hello, anonymous!' }; + * }, { optional: true }), + * }); + * ``` + * + * @example With organization context + * ```typescript + * export default createAgent('org-agent', { + * handler: withSession(async (ctx, { auth, org }, input) => { + * if (!org) throw new Error('No organization selected'); + * return { orgId: org.id, role: org.role }; + * }), + * }); + * ``` + */ +export function withSession( + handler: ( + ctx: TContext, + session: WithSessionContext, + input: TInput + ) => Promise | TOutput, + options: WithSessionOptions = {} +): (ctx: TContext, input: TInput) => Promise { + const { optional = false } = options; + + return async (ctx: TContext, input: TInput): Promise => { + // Verify we're in an agent context + if (!inAgentContext()) { + throw new Error( + 'withSession must be used inside an Agentuity agent. ' + + 'Ensure the handler is executed within an agent context (HTTP request, agent call, or createAgentContext).' + ); + } + + // Resolve auth (from HTTP context or cache) + const auth = resolveAuth(); + const org = resolveOrg(auth); + + // Enforce auth requirement + if (!auth && !optional) { + throw new Error('Unauthenticated: This agent requires authentication'); + } + + const sessionCtx: WithSessionContext = { + auth, + org, + }; + + return await handler(ctx, sessionCtx, input); + }; +} diff --git a/packages/auth/src/agentuity/api-key-storage.ts b/packages/auth/src/agentuity/api-key-storage.ts new file mode 100644 index 00000000..5e1c689d --- /dev/null +++ b/packages/auth/src/agentuity/api-key-storage.ts @@ -0,0 +1,153 @@ +/** + * Agentuity KV adapter for BetterAuth API Key plugin. + * + * Provides a BetterAuth-compatible secondaryStorage implementation backed by + * Agentuity's Key-Value storage service (Redis-based). + * + * @module agentuity/api-key-storage + */ + +import type { KeyValueStorage } from '@agentuity/core'; + +/** + * BetterAuth secondaryStorage interface. + * This is what BetterAuth expects for custom storage backends. + */ +export interface BetterAuthSecondaryStorage { + get: (key: string) => Promise | unknown; + set: (key: string, value: string, ttl?: number) => Promise | void; + delete: (key: string) => Promise | void; +} + +/** + * Options for creating Agentuity API key storage adapter. + */ +export interface AgentuityApiKeyStorageOptions { + /** + * Agentuity KeyValueStorage instance. + * Typically obtained from AgentContext.kv or created via KeyValueStorageService. + */ + kv: KeyValueStorage; + + /** + * Namespace for API key storage. + * Defaults to '_agentuity_auth_apikeys'. + */ + namespace?: string; + + /** + * Whether to auto-create the namespace if it doesn't exist. + * Defaults to true. + */ + autoCreateNamespace?: boolean; +} + +/** + * Default namespace for API key storage. + */ +export const AGENTUITY_API_KEY_NAMESPACE = '_agentuity_auth_apikeys'; + +/** + * Minimum TTL in milliseconds that Agentuity KV supports (60 seconds). + * BetterAuth passes TTL in milliseconds. + */ +const MIN_TTL_MS = 60_000; + +/** + * Create a BetterAuth-compatible secondaryStorage adapter backed by Agentuity KV. + * + * This adapter allows BetterAuth's API Key plugin to store keys in Agentuity's + * Redis-based Key-Value storage instead of (or in addition to) the database. + * + * BetterAuth stores API keys using these key patterns: + * - `api-key:${hashedKey}` - Primary lookup by hashed key + * - `api-key:by-id:${id}` - Lookup by ID + * - `api-key:by-user:${userId}` - User's API key list + * + * @example + * ```typescript + * import { createAgentuityApiKeyStorage } from '@agentuity/auth/agentuity'; + * + * // In your auth.ts: + * const storage = createAgentuityApiKeyStorage({ kv: ctx.kv }); + * + * export const auth = createAgentuityAuth({ + * database: pool, + * secondaryStorage: storage, + * // API key plugin will use this storage + * }); + * ``` + */ +export function createAgentuityApiKeyStorage( + options: AgentuityApiKeyStorageOptions +): BetterAuthSecondaryStorage { + const { kv, namespace = AGENTUITY_API_KEY_NAMESPACE, autoCreateNamespace = true } = options; + + let namespaceEnsured = false; + + async function ensureNamespace(): Promise { + if (namespaceEnsured) return; + + if (autoCreateNamespace) { + try { + await kv.createNamespace(namespace); + } catch { + // Namespace may already exist, ignore error + } + } + namespaceEnsured = true; + } + + return { + async get(key: string): Promise { + await ensureNamespace(); + + try { + const result = await kv.get(namespace, key); + if (!result.exists) { + return null; + } + // BetterAuth expects the raw value (usually a JSON string) + return result.data; + } catch (error) { + console.error('[AgentuityApiKeyStorage] get error:', error); + return null; + } + }, + + async set(key: string, value: string, ttl?: number): Promise { + await ensureNamespace(); + + try { + // Agentuity KV requires TTL >= 60 seconds (60000ms) + // BetterAuth passes TTL in milliseconds + let kvTtl: number | undefined; + if (ttl !== undefined && ttl > 0) { + // Ensure minimum TTL of 60 seconds + kvTtl = Math.max(ttl, MIN_TTL_MS); + } + + await kv.set(namespace, key, value, { ttl: kvTtl }); + } catch (error) { + console.error('[AgentuityApiKeyStorage] set error:', error); + throw error; + } + }, + + async delete(key: string): Promise { + await ensureNamespace(); + + try { + await kv.delete(namespace, key); + } catch (error) { + console.error('[AgentuityApiKeyStorage] delete error:', error); + // Don't throw on delete errors - key may not exist + } + }, + }; +} + +/** + * Type helper for BetterAuth secondary storage configuration. + */ +export type AgentuityApiKeyStorage = ReturnType; diff --git a/packages/auth/src/agentuity/client.tsx b/packages/auth/src/agentuity/client.tsx new file mode 100644 index 00000000..5f11f2b7 --- /dev/null +++ b/packages/auth/src/agentuity/client.tsx @@ -0,0 +1,130 @@ +/** + * Agentuity BetterAuth React integration. + * + * @module agentuity/client + */ + +import React, { useEffect } from 'react'; +import { useAuth } from '@agentuity/react'; + +export interface AgentuityBetterAuthProps { + /** React children to render */ + children: React.ReactNode; + + /** + * Endpoint to fetch auth token from. + * Defaults to '/api/auth/token' (matches CLI scaffold basePath: '/api/auth'). + * Should return JSON with { token: string | null }. + */ + tokenEndpoint?: string; + + /** + * Custom function to get the auth token. + * If provided, tokenEndpoint is ignored. + */ + getToken?: () => Promise; + + /** + * Token refresh interval in milliseconds. + * Defaults to 60000 (1 minute). + */ + refreshInterval?: number; + + /** + * Whether the BetterAuth client is still loading. + * When true, shows loading state. + */ + isLoading?: boolean; +} + +/** + * Agentuity authentication provider for BetterAuth. + * + * This component integrates BetterAuth authentication with Agentuity's context, + * automatically injecting auth tokens into API calls via useAPI and useWebsocket. + * + * Must be a child of AgentuityProvider. + * + * @example + * ```tsx + * import { AgentuityProvider } from '@agentuity/react'; + * import { AgentuityBetterAuth } from '@agentuity/auth/agentuity'; + * + * + * + * + * + * + * ``` + * + * @example With custom token endpoint + * ```tsx + * + * + * + * ``` + * + * @example With custom token fetcher + * ```tsx + * { + * const session = await myBetterAuthClient.getSession(); + * return session?.token ?? null; + * }} + * > + * + * + * ``` + */ +export function AgentuityBetterAuth({ + children, + tokenEndpoint = '/api/auth/token', + getToken, + refreshInterval = 60000, + isLoading: externalIsLoading, +}: AgentuityBetterAuthProps) { + const { setAuthHeader, setAuthLoading } = useAuth(); + + useEffect(() => { + if (!setAuthHeader || !setAuthLoading) return; + + if (externalIsLoading) { + setAuthLoading(true); + return; + } + + const fetchToken = async () => { + try { + setAuthLoading(true); + + let token: string | null = null; + + if (getToken) { + token = await getToken(); + } else { + const res = await fetch(tokenEndpoint, { + credentials: 'include', + }); + if (res.ok) { + const data = (await res.json()) as { token?: string | null }; + token = data.token ?? null; + } + } + + setAuthHeader(token ? `Bearer ${token}` : null); + } catch (error) { + console.error('[AgentuityBetterAuth] Failed to get token:', error); + setAuthHeader(null); + } finally { + setAuthLoading(false); + } + }; + + fetchToken(); + + const interval = setInterval(fetchToken, refreshInterval); + return () => clearInterval(interval); + }, [getToken, tokenEndpoint, refreshInterval, setAuthHeader, setAuthLoading, externalIsLoading]); + + return <>{children}; +} diff --git a/packages/auth/src/agentuity/config.ts b/packages/auth/src/agentuity/config.ts new file mode 100644 index 00000000..272aa93d --- /dev/null +++ b/packages/auth/src/agentuity/config.ts @@ -0,0 +1,377 @@ +/** + * Agentuity BetterAuth configuration wrapper. + * + * Provides sensible defaults and wraps BetterAuth with Agentuity-specific helpers. + * + * @module agentuity/config + */ + +import { betterAuth, type BetterAuthOptions } from 'better-auth'; +import { organization, jwt, bearer, apiKey } from 'better-auth/plugins'; + +/** + * Type for BetterAuth trustedOrigins option. + * Matches the signature expected by BetterAuthOptions.trustedOrigins. + */ +type TrustedOrigins = string[] | ((request?: Request) => string[] | Promise); + +/** + * Safely parse a URL and return its origin, or undefined if invalid. + */ +function safeOrigin(url: string | undefined): string | undefined { + if (!url) return undefined; + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +/** + * Resolve the base URL for the auth instance. + * + * Priority: + * 1. Explicit `baseURL` option + * 2. `BETTER_AUTH_URL` env var (Better Auth standard) + * 3. `AGENTUITY_DEPLOYMENT_URL` env var (Agentuity platform-injected) + */ +function resolveBaseURL(explicitBaseURL?: string): string | undefined { + return explicitBaseURL ?? process.env.BETTER_AUTH_URL ?? process.env.AGENTUITY_DEPLOYMENT_URL; +} + +/** + * Create the default trustedOrigins function for Agentuity deployments. + * + * This provides zero-config CORS/origin handling: + * - Trusts the resolved baseURL origin + * - Trusts the AGENTUITY_DEPLOYMENT_URL origin + * - Trusts the same-origin of incoming requests (request.url.origin) + * - Supports additional origins via AGENTUITY_AUTH_TRUSTED_ORIGINS env (comma-separated) + * + * @param baseURL - The resolved base URL for the auth instance + */ +function createDefaultTrustedOrigins(baseURL?: string): (request?: Request) => Promise { + const agentuityURL = process.env.AGENTUITY_DEPLOYMENT_URL; + const extraFromEnv = process.env.AGENTUITY_AUTH_TRUSTED_ORIGINS; + + const staticOrigins = new Set(); + + const baseOrigin = safeOrigin(baseURL); + if (baseOrigin) staticOrigins.add(baseOrigin); + + const agentuityOrigin = safeOrigin(agentuityURL); + if (agentuityOrigin) staticOrigins.add(agentuityOrigin); + + if (extraFromEnv) { + for (const raw of extraFromEnv.split(',')) { + const v = raw.trim(); + if (v) staticOrigins.add(v); + } + } + + return async (request?: Request): Promise => { + const origins = new Set(staticOrigins); + + if (request) { + const requestOrigin = safeOrigin(request.url); + if (requestOrigin) origins.add(requestOrigin); + } + + return [...origins]; + }; +} + +/** + * API extensions added by the organization plugin. + */ +export interface OrganizationApiMethods { + createOrganization: (params: { + body: { name: string; slug: string; logo?: string; metadata?: Record }; + headers?: Headers; + }) => Promise<{ id: string; name: string; slug: string; logo?: string; createdAt: Date }>; + + listOrganizations: (params: { headers?: Headers }) => Promise< + Array<{ + id: string; + name: string; + slug: string; + logo?: string; + }> + >; + + getFullOrganization: (params: { headers?: Headers }) => Promise<{ + id: string; + name: string; + slug: string; + logo?: string; + members?: Array<{ userId: string; role: string }>; + } | null>; + + setActiveOrganization: (params: { + body: { organizationId: string }; + headers?: Headers; + }) => Promise<{ id: string; name: string; slug: string }>; + + createInvitation: (params: { + body: { organizationId: string; email: string; role?: string }; + headers?: Headers; + }) => Promise<{ id: string; email: string; role: string; organizationId: string }>; + + cancelInvitation: (params: { + body: { invitationId: string }; + headers?: Headers; + }) => Promise<{ success: boolean }>; + + rejectInvitation: (params: { + body: { invitationId: string }; + headers?: Headers; + }) => Promise<{ success: boolean }>; + + acceptInvitation: (params: { + body: { invitationId: string }; + headers?: Headers; + }) => Promise<{ success: boolean }>; + + removeMember: (params: { + body: { memberIdOrEmail: string; organizationId?: string }; + headers?: Headers; + }) => Promise<{ success: boolean }>; + + updateMemberRole: (params: { + body: { memberId: string; role: string; organizationId?: string }; + headers?: Headers; + }) => Promise<{ success: boolean }>; + + checkSlug: (params: { + body: { slug: string }; + headers?: Headers; + }) => Promise<{ exists: boolean }>; +} + +/** + * API extensions added by the API Key plugin. + */ +export interface ApiKeyApiMethods { + createApiKey: (params: { + body: { + name?: string; + expiresIn?: number; + prefix?: string; + userId?: string; + remaining?: number; + metadata?: Record; + refillAmount?: number; + refillInterval?: number; + rateLimitTimeWindow?: number; + rateLimitMax?: number; + rateLimitEnabled?: boolean; + }; + headers?: Headers; + }) => Promise<{ + id: string; + name: string; + key: string; + expiresAt?: Date; + createdAt: Date; + }>; + + listApiKeys: (params: { headers?: Headers }) => Promise< + Array<{ + id: string; + name: string; + start: string; + expiresAt?: Date; + createdAt: Date; + }> + >; + + deleteApiKey: (params: { + body: { keyId: string }; + headers?: Headers; + }) => Promise<{ success: boolean }>; + + verifyApiKey: (params: { body: { key: string }; headers?: Headers }) => Promise<{ + valid: boolean; + apiKey?: { + id: string; + name: string; + userId: string; + permissions?: Record; + }; + }>; +} + +/** + * API extensions added by the JWT plugin. + */ +export interface JwtApiMethods { + getToken: (params: { headers?: Headers }) => Promise<{ token: string }>; +} + +/** + * Combined API extensions from all default plugins. + */ +export type DefaultPluginApiMethods = OrganizationApiMethods & ApiKeyApiMethods & JwtApiMethods; + +/** + * API Key plugin configuration options. + */ +export interface ApiKeyPluginOptions { + /** + * Whether to enable API key authentication. + * Defaults to true. + */ + enabled?: boolean; + + /** + * Header names to check for API key. + * Defaults to ['x-api-key', 'X-API-KEY']. + */ + apiKeyHeaders?: string[]; + + /** + * Whether API keys should create mock sessions for the user. + * This allows API key auth to work seamlessly with session-based middleware. + * Defaults to true. + */ + enableSessionForAPIKeys?: boolean; + + /** + * Default prefix for generated API keys. + * Defaults to 'ag_'. + */ + defaultPrefix?: string; + + /** + * Default length for generated API keys (excluding prefix). + * Defaults to 64. + */ + defaultKeyLength?: number; + + /** + * Whether to enable metadata storage on API keys. + * Defaults to true. + */ + enableMetadata?: boolean; +} + +/** + * Configuration options for Agentuity auth. + * Extends BetterAuth options with Agentuity-specific settings. + */ +export interface AgentuityAuthOptions extends BetterAuthOptions { + /** + * Skip default plugins (organization, jwt, bearer, apiKey). + * Use this if you want full control over plugins. + */ + skipDefaultPlugins?: boolean; + + /** + * API Key plugin configuration. + * Set to false to disable the API key plugin entirely. + */ + apiKey?: ApiKeyPluginOptions | false; +} + +/** + * Default API key plugin options. + */ +export const DEFAULT_API_KEY_OPTIONS: Required = { + enabled: true, + apiKeyHeaders: ['x-api-key', 'X-API-KEY'], + enableSessionForAPIKeys: true, + defaultPrefix: 'ag_', + defaultKeyLength: 64, + enableMetadata: true, +}; + +/** + * Default plugins included with Agentuity auth. + * + * @param apiKeyOptions - API key plugin options, or false to disable + */ +export function getDefaultPlugins(apiKeyOptions?: ApiKeyPluginOptions | false) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const plugins: any[] = [organization(), jwt(), bearer()]; + + // Add API key plugin unless explicitly disabled + if (apiKeyOptions !== false) { + const opts = { ...DEFAULT_API_KEY_OPTIONS, ...apiKeyOptions }; + + if (opts.enabled) { + plugins.push( + apiKey({ + apiKeyHeaders: opts.apiKeyHeaders, + enableSessionForAPIKeys: opts.enableSessionForAPIKeys, + defaultPrefix: opts.defaultPrefix, + defaultKeyLength: opts.defaultKeyLength, + enableMetadata: opts.enableMetadata, + }) + ); + } + } + + return plugins; +} + +/** + * Create an Agentuity-configured BetterAuth instance. + * + * This wraps BetterAuth with sensible defaults for Agentuity projects: + * - Organization plugin for multi-tenancy + * - JWT plugin for token-based auth + * - Bearer plugin for API auth + * - API Key plugin for programmatic access (with enableSessionForAPIKeys) + * + * @example Basic usage + * ```typescript + * import { createAgentuityAuth } from '@agentuity/auth/agentuity'; + * + * export const auth = createAgentuityAuth({ + * database: pool, + * basePath: '/api/auth', + * }); + * ``` + */ +export function createAgentuityAuth(options: T) { + const { skipDefaultPlugins, plugins = [], apiKey: apiKeyOptions, ...restOptions } = options; + + const resolvedBaseURL = resolveBaseURL(restOptions.baseURL); + + // Explicitly type to avoid union type inference issues with downstream consumers + const trustedOrigins: TrustedOrigins = + restOptions.trustedOrigins ?? createDefaultTrustedOrigins(resolvedBaseURL); + + const defaultPlugins = skipDefaultPlugins ? [] : getDefaultPlugins(apiKeyOptions); + + const authInstance = betterAuth({ + ...restOptions, + ...(resolvedBaseURL ? { baseURL: resolvedBaseURL } : {}), + trustedOrigins, + plugins: [...defaultPlugins, ...plugins], + }); + + return authInstance as typeof authInstance & { + api: typeof authInstance.api & DefaultPluginApiMethods; + }; +} + +/** + * Type helper for the auth instance with default plugin methods. + */ +export type AgentuityAuthInstance = ReturnType; + +/** + * Alias for createAgentuityAuth. + * + * @example + * ```typescript + * import { withAgentuityAuth } from '@agentuity/auth/agentuity'; + * + * export const auth = withAgentuityAuth({ + * database: pool, + * basePath: '/api/auth', + * }); + * ``` + */ +export const withAgentuityAuth = createAgentuityAuth; diff --git a/packages/auth/src/agentuity/index.ts b/packages/auth/src/agentuity/index.ts new file mode 100644 index 00000000..22c0b127 --- /dev/null +++ b/packages/auth/src/agentuity/index.ts @@ -0,0 +1,121 @@ +/** + * Agentuity BetterAuth integration. + * + * First-class authentication for Agentuity projects using BetterAuth. + * + * @module agentuity + * + * @example Server-side setup + * ```typescript + * // auth.ts + * import { createAgentuityAuth, createSessionMiddleware, ensureAuthSchema } from '@agentuity/auth/agentuity'; + * import { Pool } from 'pg'; + * + * const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + * + * // Auto-create auth tables if they don't exist + * await ensureAuthSchema({ db: pool }); + * + * export const auth = createAgentuityAuth({ + * database: pool, + * basePath: '/api/auth', + * }); + * + * export const authMiddleware = createSessionMiddleware(auth); + * ``` + * + * @example Agent with auth + * ```typescript + * import { createAgent } from '@agentuity/runtime'; + * import { withSession } from '@agentuity/auth/agentuity'; + * + * export default createAgent('my-agent', { + * handler: withSession(async (ctx, { auth, org }, input) => { + * if (!auth) return { error: 'Not authenticated' }; + * return { userId: auth.user.id }; + * }, { optional: true }), + * }); + * ``` + * + * @example Client-side setup + * ```tsx + * import { AgentuityProvider } from '@agentuity/react'; + * import { AgentuityBetterAuth } from '@agentuity/auth/agentuity'; + * + * + * + * + * + * + * ``` + */ + +// ============================================================================= +// Config +// ============================================================================= + +export { + createAgentuityAuth, + withAgentuityAuth, + getDefaultPlugins, + DEFAULT_API_KEY_OPTIONS, +} from './config'; +export type { + AgentuityAuthOptions, + AgentuityAuthInstance, + ApiKeyPluginOptions, + OrganizationApiMethods, + ApiKeyApiMethods, + JwtApiMethods, + DefaultPluginApiMethods, +} from './config'; + +// ============================================================================= +// Migrations +// ============================================================================= + +export { ensureAuthSchema, AGENTUITY_AUTH_BASELINE_SQL } from './migrations'; +export type { DatabaseClient, EnsureAuthSchemaOptions, EnsureAuthSchemaResult } from './migrations'; + +// ============================================================================= +// Server (Hono middleware and handlers) +// ============================================================================= + +export { createSessionMiddleware, createApiKeyMiddleware, mountBetterAuthRoutes } from './server'; +export type { + AgentuityMiddlewareOptions, + AgentuityApiKeyMiddlewareOptions, + AgentuityAuthEnv, + OtelSpansConfig, + MountBetterAuthRoutesOptions, +} from './server'; + +// ============================================================================= +// Client (React) +// ============================================================================= + +export { AgentuityBetterAuth } from './client'; +export type { AgentuityBetterAuthProps } from './client'; + +// ============================================================================= +// Agent Wrappers +// ============================================================================= + +export { withSession } from './agent'; + +// ============================================================================= +// Types +// ============================================================================= + +export type { + AgentuityAuthContext, + AgentuityOrgContext, + AgentuityApiKeyContext, + AgentuityApiKeyPermissions, + AgentuityAuthMethod, + AgentuityBetterAuthAuth, + AgentuityOrgHelpers, + AgentuityApiKeyHelpers, + WithSessionOptions, + WithSessionContext, +} from './types'; diff --git a/packages/auth/src/agentuity/migrations.ts b/packages/auth/src/agentuity/migrations.ts new file mode 100644 index 00000000..1adf1858 --- /dev/null +++ b/packages/auth/src/agentuity/migrations.ts @@ -0,0 +1,230 @@ +/** + * Agentuity BetterAuth database migrations. + * + * Provides baseline SQL schema and runtime migration helpers for BetterAuth + * with Agentuity's default plugins (organization, JWT, bearer, API key). + * + * @module agentuity/migrations + */ + +/** + * Baseline SQL schema for BetterAuth + Agentuity extensions. + * + * This includes: + * - BetterAuth core tables (user, session, account, verification) + * - Organization plugin tables (organization, member, invitation) + * - JWT plugin table (jwks) + * - API Key plugin table (apiKey) + * + * All statements use "IF NOT EXISTS" for idempotent execution. + */ +export const AGENTUITY_AUTH_BASELINE_SQL = ` +-- ============================================================================= +-- BetterAuth Core Tables +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS "user" ( + "id" text NOT NULL PRIMARY KEY, + "name" text NOT NULL, + "email" text NOT NULL UNIQUE, + "emailVerified" boolean NOT NULL DEFAULT false, + "image" text, + "createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS "session" ( + "id" text NOT NULL PRIMARY KEY, + "expiresAt" timestamptz NOT NULL, + "token" text NOT NULL UNIQUE, + "createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamptz NOT NULL, + "ipAddress" text, + "userAgent" text, + "userId" text NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "activeOrganizationId" text +); + +-- SECURITY: The "account" table stores OAuth provider tokens and optional passwords. +-- - accessToken, refreshToken, idToken: OAuth tokens from identity providers. Treat as sensitive. +-- - password: Hashed user password (when using email/password auth). +-- - Restrict direct database access to this table; never expose these fields via APIs or logs. +-- - Consider at-rest encryption for production deployments with sensitive OAuth integrations. +CREATE TABLE IF NOT EXISTS "account" ( + "id" text NOT NULL PRIMARY KEY, + "accountId" text NOT NULL, + "providerId" text NOT NULL, + "userId" text NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "accessToken" text, + "refreshToken" text, + "idToken" text, + "accessTokenExpiresAt" timestamptz, + "refreshTokenExpiresAt" timestamptz, + "scope" text, + "password" text, + "createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS "verification" ( + "id" text NOT NULL PRIMARY KEY, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expiresAt" timestamptz NOT NULL, + "createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +-- ============================================================================= +-- Organization Plugin Tables +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS "organization" ( + "id" text NOT NULL PRIMARY KEY, + "name" text NOT NULL, + "slug" text NOT NULL UNIQUE, + "logo" text, + "createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + "metadata" text +); + +CREATE TABLE IF NOT EXISTS "member" ( + "id" text NOT NULL PRIMARY KEY, + "organizationId" text NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE, + "userId" text NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "role" text NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS "invitation" ( + "id" text NOT NULL PRIMARY KEY, + "organizationId" text NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE, + "email" text NOT NULL, + "role" text, + "status" text NOT NULL, + "expiresAt" timestamptz NOT NULL, + "createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + "inviterId" text NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); + +-- ============================================================================= +-- JWT Plugin Table +-- ============================================================================= +-- SECURITY: The "jwks" table stores JWT signing keys. +-- - privateKey: Contains the private key used for signing JWTs. BetterAuth encrypts this +-- by default using the BETTER_AUTH_SECRET. Never disable encryption in production. +-- - Restrict database access to this table; never expose private keys in logs or APIs. +-- - Rotate keys periodically by allowing old keys to expire (via expiresAt) and creating new ones. + +CREATE TABLE IF NOT EXISTS "jwks" ( + "id" text NOT NULL PRIMARY KEY, + "publicKey" text NOT NULL, + "privateKey" text NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" timestamptz +); + +-- ============================================================================= +-- API Key Plugin Table +-- Note: BetterAuth expects lowercase table name "apikey" (not "apiKey") +-- ============================================================================= +-- SECURITY: The "apikey" table stores API keys for programmatic access. +-- - key: Stores a HASHED version of the API key (not plaintext). BetterAuth hashes keys +-- before storage. The original key is only shown once at creation time. +-- - Restrict database access; never expose this table in admin UIs or logs. +-- - Use the permissions column to implement least-privilege access patterns. + +CREATE TABLE IF NOT EXISTS apikey ( + id text NOT NULL PRIMARY KEY, + name text, + start text, + prefix text, + key text NOT NULL, + "userId" text NOT NULL REFERENCES "user" (id) ON DELETE CASCADE, + "refillInterval" integer, + "refillAmount" integer, + "lastRefillAt" timestamptz, + enabled boolean NOT NULL DEFAULT true, + "rateLimitEnabled" boolean NOT NULL DEFAULT true, + "rateLimitTimeWindow" integer NOT NULL DEFAULT 86400000, + "rateLimitMax" integer NOT NULL DEFAULT 10, + "requestCount" integer NOT NULL DEFAULT 0, + remaining integer, + "lastRequest" timestamptz, + "expiresAt" timestamptz, + "createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + permissions text, + metadata text +); + +-- ============================================================================= +-- Indexes +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS session_userId_idx ON session ("userId"); +CREATE INDEX IF NOT EXISTS account_userId_idx ON account ("userId"); +CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification (identifier); +CREATE INDEX IF NOT EXISTS member_organizationId_idx ON member ("organizationId"); +CREATE INDEX IF NOT EXISTS member_userId_idx ON member ("userId"); +CREATE INDEX IF NOT EXISTS invitation_organizationId_idx ON invitation ("organizationId"); +CREATE INDEX IF NOT EXISTS invitation_email_idx ON invitation (email); +CREATE INDEX IF NOT EXISTS apikey_userId_idx ON apikey ("userId"); +CREATE INDEX IF NOT EXISTS apikey_key_idx ON apikey (key); +`; + +/** + * Database client interface for migrations. + * Compatible with pg.Pool, pg.Client, or any client with a query method. + */ +export interface DatabaseClient { + query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>; +} + +/** + * Options for ensureAuthSchema. + */ +export interface EnsureAuthSchemaOptions { + /** Database client with query method (e.g., pg.Pool) */ + db: DatabaseClient; +} + +/** + * Result of ensureAuthSchema operation. + */ +export interface EnsureAuthSchemaResult { + /** Always true - schema SQL was executed */ + created: boolean; +} + +/** + * Idempotent helper to ensure the auth schema exists. + * + * Runs the full baseline SQL schema. All statements use IF NOT EXISTS, + * making this safe to call on every application startup. + * + * @example + * ```typescript + * import { Pool } from 'pg'; + * import { ensureAuthSchema } from '@agentuity/auth/agentuity'; + * + * const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + * + * // Call at startup - safe to run multiple times + * const { created } = await ensureAuthSchema({ db: pool }); + * if (created) { + * console.log('Auth schema created'); + * } + * ``` + */ +export async function ensureAuthSchema( + options: EnsureAuthSchemaOptions +): Promise { + const { db } = options; + + // All statements use IF NOT EXISTS - safe and idempotent to run every time + // This ensures any new tables (from updated plugins) are always created + await db.query(AGENTUITY_AUTH_BASELINE_SQL); + + return { created: true }; +} diff --git a/packages/auth/src/agentuity/react.ts b/packages/auth/src/agentuity/react.ts new file mode 100644 index 00000000..44d47b44 --- /dev/null +++ b/packages/auth/src/agentuity/react.ts @@ -0,0 +1,86 @@ +/** + * Agentuity BetterAuth React client factory. + * + * Provides a pre-configured BetterAuth React client with sensible defaults. + * + * @module agentuity/react-client + */ + +import { createAuthClient } from 'better-auth/react'; +import { organizationClient, apiKeyClient } from 'better-auth/client/plugins'; + +/** + * Options for creating the Agentuity auth client. + */ +export interface AgentuityAuthClientOptions { + /** + * Base URL for auth API requests. + * Defaults to `window.location.origin` in browser environments. + */ + baseURL?: string; + + /** + * Base path for BetterAuth endpoints. + * Defaults to '/api/auth' (Agentuity convention). + */ + basePath?: string; +} + +/** + * Create a pre-configured BetterAuth React client. + * + * This factory provides sensible defaults for Agentuity projects: + * - Uses `/api/auth` as the default base path + * - Automatically uses `window.location.origin` as base URL in browsers + * + * @example Basic usage (zero config) + * ```typescript + * import { createAgentuityAuthClient } from '@agentuity/auth/agentuity'; + * + * export const authClient = createAgentuityAuthClient(); + * export const { signIn, signUp, signOut, useSession, getSession } = authClient; + * ``` + * + * @example With custom base path + * ```typescript + * export const authClient = createAgentuityAuthClient({ + * basePath: '/auth', // If mounted at /auth instead of /api/auth + * }); + * ``` + * + * @example With explicit base URL (for SSR or testing) + * ```typescript + * export const authClient = createAgentuityAuthClient({ + * baseURL: 'https://api.example.com', + * basePath: '/api/auth', + * }); + * ``` + */ +export function createAgentuityAuthClient(options: AgentuityAuthClientOptions = {}) { + const baseURL = options.baseURL ?? (typeof window !== 'undefined' ? window.location.origin : ''); + const basePath = options.basePath ?? '/api/auth'; + + return createAuthClient({ + baseURL, + basePath, + plugins: getDefaultClientPlugins(), + }); +} + +/** + * Get the default client plugins for Agentuity auth. + * + * These mirror the server-side plugins: + * - organizationClient: Multi-tenancy support + * - apiKeyClient: Programmatic API key management + * + * Note: jwt() and bearer() are server-only plugins. + */ +function getDefaultClientPlugins() { + return [organizationClient(), apiKeyClient()]; +} + +/** + * Type helper for the auth client return type. + */ +export type AgentuityAuthClient = ReturnType; diff --git a/packages/auth/src/agentuity/server.ts b/packages/auth/src/agentuity/server.ts new file mode 100644 index 00000000..670511c4 --- /dev/null +++ b/packages/auth/src/agentuity/server.ts @@ -0,0 +1,695 @@ +/** + * Agentuity BetterAuth Hono middleware and handlers. + * + * Follows BetterAuth's recommended Hono integration patterns: + * @see https://www.better-auth.com/docs/integrations/hono + * + * @module agentuity/server + */ + +import type { Context, MiddlewareHandler } from 'hono'; +import { context, trace, SpanStatusCode } from '@opentelemetry/api'; +import type { Session, User } from 'better-auth'; +import type { AgentuityAuthUser } from '../types'; +import type { AgentuityAuthInstance } from './config'; +import type { + AgentuityOrgContext, + AgentuityApiKeyContext, + AgentuityAuthMethod, + AgentuityBetterAuthAuth, +} from './types'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Configuration for OpenTelemetry span attributes. + * All attributes are included by default. Set to `false` to opt-out of specific PII. + */ +export interface OtelSpansConfig { + /** + * Include user email in spans (`auth.user.email`). + * @default true + */ + email?: boolean; + + /** + * Include organization name in spans (`auth.org.name`). + * @default true + */ + orgName?: boolean; +} + +export interface AgentuityMiddlewareOptions { + /** + * If true, don't return 401 on missing auth - just continue without auth context. + * Useful for routes that work for both authenticated and anonymous users. + */ + optional?: boolean; + + /** + * Configure which attributes are included in OpenTelemetry spans. + * All PII attributes are included by default. Use this to opt-out of specific fields. + * + * @example Disable email in spans + * ```typescript + * createSessionMiddleware(auth, { otelSpans: { email: false } }) + * ``` + */ + otelSpans?: OtelSpansConfig; +} + +export interface AgentuityApiKeyMiddlewareOptions { + /** + * If true, don't return 401 on missing/invalid API key - just continue without auth context. + */ + optional?: boolean; + + /** + * Configure which attributes are included in OpenTelemetry spans. + * All PII attributes are included by default. Use this to opt-out of specific fields. + * + * @example Disable email in spans + * ```typescript + * createApiKeyMiddleware(auth, { otelSpans: { email: false } }) + * ``` + */ + otelSpans?: OtelSpansConfig; +} + +/** + * Hono context variables set by the middleware. + */ +export type AgentuityAuthEnv = { + Variables: { + auth: AgentuityBetterAuthAuth; + user: User | null; + session: Session | null; + org: AgentuityOrgContext | null; + }; +}; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Derive minimal org context from session. + * Full org data is fetched lazily via getOrg(). Later we might add more to org context so keeping this here. + */ +function deriveOrgContext(session: Session): AgentuityOrgContext | null { + const s = session as Record; + const id = s.activeOrganizationId as string | undefined; + if (!id) return null; + return { id } as AgentuityOrgContext; +} + +/** + * Extract API key from request headers. + * Checks x-agentuity-auth-api-key header and Authorization: ApiKey header. + */ +function getApiKeyFromRequest(c: Context): string | null { + const customHeader = + c.req.header('x-agentuity-auth-api-key') ?? c.req.header('X-Agentuity-Auth-Api-Key'); + if (customHeader) return customHeader.trim(); + + const authHeader = c.req.header('Authorization'); + if (!authHeader) return null; + + const lower = authHeader.toLowerCase(); + if (lower.startsWith('apikey ')) { + return authHeader.slice('apikey '.length).trim() || null; + } + + return null; +} + +/** + * Determine auth method from request headers. + */ +function getAuthMethod(c: Context): AgentuityAuthMethod { + const apiKey = getApiKeyFromRequest(c); + if (apiKey) return 'api-key'; + + const authHeader = c.req.header('Authorization'); + if (authHeader?.toLowerCase().startsWith('bearer ')) return 'bearer'; + + return 'session'; +} + +function buildAgentuityAuth( + c: Context, + auth: AgentuityAuthInstance, + user: User, + session: Session, + org: AgentuityOrgContext | null, + authMethod: AgentuityAuthMethod, + apiKeyContext: AgentuityApiKeyContext | null +): AgentuityBetterAuthAuth { + let cachedUser: AgentuityAuthUser | null = null; + let cachedFullOrg: AgentuityOrgContext | null | undefined = undefined; + const permissions = apiKeyContext?.permissions ?? null; + + return { + async getUser() { + if (cachedUser) return cachedUser; + cachedUser = { + id: user.id, + name: user.name ?? undefined, + email: user.email ?? undefined, + raw: user, + }; + return cachedUser; + }, + + async getToken() { + const header = c.req.header('Authorization'); + if (!header) return null; + return header.replace(/^Bearer\s+/i, '') || null; + }, + + raw: { + user, + session, + org, + }, + + org, + + async getOrg() { + if (cachedFullOrg !== undefined) { + return cachedFullOrg; + } + + if (!org) { + cachedFullOrg = null; + return null; + } + + // Fetch full org data lazily + try { + const fullOrg = await auth.api.getFullOrganization({ + headers: c.req.raw.headers, + }); + + if (!fullOrg) { + cachedFullOrg = null; + return null; + } + + // Find the current user's role in the org + const members = (fullOrg.members ?? []) as Array<{ + userId: string; + role: string; + id: string; + }>; + const currentMember = members.find((m) => m.userId === user.id); + + cachedFullOrg = { + id: fullOrg.id, + slug: fullOrg.slug ?? null, + name: fullOrg.name ?? null, + role: currentMember?.role ?? null, + memberId: currentMember?.id ?? null, + metadata: (fullOrg as Record).metadata ?? null, + }; + + return cachedFullOrg; + } catch { + cachedFullOrg = org; + return org; + } + }, + + async getOrgRole() { + const org = await this.getOrg(); + return org?.role ?? null; + }, + + async hasOrgRole(...roles: string[]) { + const role = await this.getOrgRole(); + return !!role && roles.includes(role); + }, + + // API key helpers + authMethod, + apiKey: apiKeyContext, + + hasPermission(resource: string, ...actions: string[]) { + if (!permissions) return false; + const resourcePerms = permissions[resource] ?? []; + + if (actions.length === 0) { + return resourcePerms.length > 0; + } + + return actions.every( + (action) => resourcePerms.includes(action) || resourcePerms.includes('*') + ); + }, + }; +} + +/** + * Build a null/anonymous auth wrapper for optional middleware. + */ +function buildAnonymousAuth(): AgentuityBetterAuthAuth { + return { + async getUser() { + throw new Error('Not authenticated'); + }, + + async getToken() { + return null; + }, + + raw: { + user: null as unknown as User, + session: null as unknown as Session, + org: null, + }, + + org: null, + + async getOrg() { + return null; + }, + + async getOrgRole() { + return null; + }, + + async hasOrgRole() { + return false; + }, + + authMethod: 'session', + apiKey: null, + + hasPermission() { + return false; + }, + }; +} + +// ============================================================================= +// Session Middleware +// ============================================================================= + +/** + * Create Hono middleware that validates BetterAuth sessions. + * + * Sets both BetterAuth standard context variables (`user`, `session`, `org`) and + * the Agentuity `auth` wrapper for consistency with other providers. + * + * OpenTelemetry spans are automatically enriched with auth attributes: + * - `auth.user.id` - User ID (always included) + * - `auth.user.email` - User email (included by default, opt-out via `otelSpans.email: false`) + * - `auth.method` - 'session' or 'bearer' (always included) + * - `auth.provider` - 'BetterAuth' (always included) + * - `auth.org.id` - Active organization ID (always included if set) + * - `auth.org.name` - Organization name (included by default, opt-out via `otelSpans.orgName: false`) + * + * @example Basic usage + * ```typescript + * import { createSessionMiddleware } from '@agentuity/auth/agentuity'; + * import { auth } from './auth'; + * + * const app = new Hono(); + * app.use('/api/*', createSessionMiddleware(auth)); + * + * app.get('/api/me', (c) => { + * const user = c.var.user; + * if (!user) return c.json({ error: 'Unauthorized' }, 401); + * return c.json({ id: user.id }); + * }); + * ``` + * + * @example Using Agentuity auth wrapper + * ```typescript + * app.get('/api/me', async (c) => { + * const user = await c.var.auth.getUser(); + * const org = await c.var.auth.getOrg(); + * const isAdmin = await c.var.auth.hasOrgRole('admin', 'owner'); + * return c.json({ id: user.id, org, isAdmin }); + * }); + * ``` + */ +export function createSessionMiddleware( + auth: AgentuityAuthInstance, + options: AgentuityMiddlewareOptions = {} +): MiddlewareHandler { + const { optional = false, otelSpans = {} } = options; + const includeEmail = otelSpans.email !== false; + const includeOrgName = otelSpans.orgName !== false; + + return async (c, next) => { + const span = trace.getSpan(context.active()); + + try { + const result = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!result) { + if (optional) { + c.set('user', null); + c.set('session', null); + c.set('org', null); + c.set('auth', buildAnonymousAuth()); + span?.addEvent('auth.anonymous'); + await next(); + return; + } + + span?.addEvent('auth.unauthorized', { reason: 'no_session' }); + span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Unauthorized' }); + return c.json({ error: 'Unauthorized' }, 401); + } + + const { user, session } = result; + const org = deriveOrgContext(session); + const authMethod = getAuthMethod(c); + + c.set('user', user); + c.set('session', session); + c.set('org', org); + + if (span) { + span.setAttributes({ + 'auth.user.id': user.id ?? '', + 'auth.method': authMethod, + 'auth.provider': 'BetterAuth', + }); + + if (includeEmail && user.email) { + span.setAttribute('auth.user.email', user.email); + } + + if (org) { + span.setAttribute('auth.org.id', org.id); + if (includeOrgName && org.name) { + span.setAttribute('auth.org.name', org.name); + } + } + } + + c.set('auth', buildAgentuityAuth(c, auth, user, session, org, authMethod, null)); + await next(); + } catch (error) { + console.error('[Agentuity Auth] Session validation failed:', error); + + span?.recordException(error as Error); + span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Auth validation failed' }); + + if (optional) { + c.set('user', null); + c.set('session', null); + c.set('org', null); + c.set('auth', buildAnonymousAuth()); + await next(); + return; + } + return c.json({ error: 'Unauthorized' }, 401); + } + }; +} + +// ============================================================================= +// API Key Middleware +// ============================================================================= + +/** + * Create Hono middleware that validates API keys. + * + * This middleware ONLY accepts API key authentication via: + * - `x-agentuity-auth-api-key` header (preferred) + * - `Authorization: ApiKey ` header + * + * It does NOT use sessions. For routes that accept both session and API key, + * compose with createSessionMiddleware using `{ optional: true }`. + * + * @example API key only route + * ```typescript + * import { createApiKeyMiddleware } from '@agentuity/auth/agentuity'; + * + * app.post('/webhooks/*', createApiKeyMiddleware(auth)); + * + * app.post('/webhooks/github', async (c) => { + * const hasWrite = c.var.auth.hasPermission('webhook', 'write'); + * if (!hasWrite) return c.json({ error: 'Forbidden' }, 403); + * // ... + * }); + * ``` + * + * @example Either session OR API key (compose with optional) + * ```typescript + * app.use('/api/*', createSessionMiddleware(auth, { optional: true })); + * app.use('/api/*', createApiKeyMiddleware(auth, { optional: true })); + * + * app.get('/api/data', async (c) => { + * // Works with session OR API key + * if (!c.var.user) return c.json({ error: 'Unauthorized' }, 401); + * return c.json({ data: '...' }); + * }); + * ``` + */ +export function createApiKeyMiddleware( + auth: AgentuityAuthInstance, + options: AgentuityApiKeyMiddlewareOptions = {} +): MiddlewareHandler { + const { optional = false, otelSpans = {} } = options; + const includeEmail = otelSpans.email !== false; + + return async (c, next) => { + const span = trace.getSpan(context.active()); + const apiKeyToken = getApiKeyFromRequest(c); + + if (!apiKeyToken) { + if (optional) { + await next(); + return; + } + + span?.addEvent('auth.unauthorized', { reason: 'no_api_key' }); + span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Unauthorized' }); + return c.json({ error: 'Unauthorized: API key required' }, 401); + } + + try { + const result = await auth.api.verifyApiKey({ + body: { key: apiKeyToken }, + }); + + if (!result.valid || !result.apiKey) { + if (optional) { + await next(); + return; + } + + span?.addEvent('auth.unauthorized', { reason: 'invalid_api_key' }); + span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Unauthorized' }); + return c.json({ error: 'Unauthorized: Invalid API key' }, 401); + } + + const keyData = result.apiKey; + const userId = keyData.userId; + + const apiKeyContext: AgentuityApiKeyContext = { + id: keyData.id, + name: keyData.name ?? null, + permissions: keyData.permissions ?? {}, + userId: userId ?? null, + }; + + let user: User | null = null; + if (userId) { + try { + const session = await auth.api.getSession({ + headers: new Headers({ 'x-user-id': userId }), + }); + if (session?.user) { + user = session.user; + } + } catch { + // User fetch failed, continue with null user + } + } + + c.set('user', user); + c.set('session', null); + c.set('org', null); + + if (span) { + span.setAttributes({ + 'auth.method': 'api-key', + 'auth.provider': 'BetterAuth', + 'auth.api_key.id': apiKeyContext.id, + }); + + if (user?.id) { + span.setAttribute('auth.user.id', user.id); + } + + if (includeEmail && user?.email) { + span.setAttribute('auth.user.email', user.email); + } + } + + if (user) { + c.set( + 'auth', + buildAgentuityAuth( + c, + auth, + user, + null as unknown as Session, + null, + 'api-key', + apiKeyContext + ) + ); + } else { + const anonAuth = buildAnonymousAuth(); + c.set('auth', { + ...anonAuth, + authMethod: 'api-key', + apiKey: apiKeyContext, + hasPermission(resource: string, ...actions: string[]) { + const perms = apiKeyContext.permissions[resource] ?? []; + if (actions.length === 0) return perms.length > 0; + return actions.every((a) => perms.includes(a) || perms.includes('*')); + }, + }); + } + + await next(); + } catch (error) { + console.error('[Agentuity Auth] API key validation failed:', error); + + span?.recordException(error as Error); + span?.setStatus({ code: SpanStatusCode.ERROR, message: 'API key validation failed' }); + + if (optional) { + await next(); + return; + } + return c.json({ error: 'Unauthorized: API key validation failed' }, 401); + } + }; +} + +// ============================================================================= +// Auth Handler +// ============================================================================= + +/** + * Default headers to forward from BetterAuth responses. + * This prevents unintended headers from leaking through while preserving auth-essential ones. + */ +const DEFAULT_FORWARDED_HEADERS = [ + 'set-cookie', + 'content-type', + 'location', + 'cache-control', + 'pragma', + 'expires', + 'vary', + 'etag', + 'last-modified', +]; + +/** + * Configuration options for mounting BetterAuth routes. + */ +export interface MountBetterAuthRoutesOptions { + /** + * Headers to forward from BetterAuth responses to the client. + * Only headers in this list will be forwarded (case-insensitive). + * `set-cookie` is always forwarded with append behavior regardless of this setting. + * + * @default ['set-cookie', 'content-type', 'location', 'cache-control', 'pragma', 'expires', 'vary', 'etag', 'last-modified'] + */ + allowList?: string[]; +} + +/** + * Mount BetterAuth routes with proper cookie handling and header filtering. + * + * This wrapper is required because of how Hono handles raw `Response` objects + * returned from route handlers. When BetterAuth's `auth.handler()` returns a + * `Response` with `Set-Cookie` headers, those headers are not automatically + * merged with headers set by Agentuity's middleware (like the `atid` thread cookie). + * + * This function copies only allowed headers from BetterAuth's response into + * Hono's context, ensuring both BetterAuth's session cookies AND Agentuity's + * cookies are preserved while preventing unintended headers from leaking through. + * + * @example Basic usage + * ```typescript + * import { mountBetterAuthRoutes } from '@agentuity/auth/agentuity'; + * import { auth } from './auth'; + * + * const api = createRouter(); + * + * // Mount all BetterAuth routes (sign-in, sign-up, sign-out, session, etc.) + * api.on(['GET', 'POST'], '/api/auth/*', mountBetterAuthRoutes(auth)); + * ``` + * + * @example With custom header allowlist + * ```typescript + * api.on(['GET', 'POST'], '/api/auth/*', mountBetterAuthRoutes(auth, { + * allowList: ['set-cookie', 'content-type', 'location', 'x-custom-header'] + * })); + * ``` + */ +export function mountBetterAuthRoutes( + auth: AgentuityAuthInstance, + options: MountBetterAuthRoutesOptions = {} +): (c: Context) => Promise { + const allowList = new Set( + (options.allowList ?? DEFAULT_FORWARDED_HEADERS).map((h) => h.toLowerCase()) + ); + // Always ensure set-cookie is in the allowlist + allowList.add('set-cookie'); + + return async (c: Context): Promise => { + const response = await auth.handler(c.req.raw); + + response.headers.forEach((value, key) => { + const lower = key.toLowerCase(); + + // Only forward headers in the allowlist + if (!allowList.has(lower)) { + return; + } + + if (lower === 'set-cookie') { + // Preserve existing cookies (e.g., Agentuity thread cookies) + c.header('set-cookie', value, { append: true }); + } else { + c.header(key, value); + } + }); + + const body = await response.text(); + return new Response(body, { + status: response.status, + headers: c.res.headers, + }); + }; +} + +// ============================================================================= +// Type Augmentation +// ============================================================================= + +declare module 'hono' { + interface ContextVariableMap { + auth: AgentuityBetterAuthAuth; + user: User | null; + session: Session | null; + org: AgentuityOrgContext | null; + } +} diff --git a/packages/auth/src/agentuity/types.ts b/packages/auth/src/agentuity/types.ts new file mode 100644 index 00000000..d241f1b8 --- /dev/null +++ b/packages/auth/src/agentuity/types.ts @@ -0,0 +1,175 @@ +/** + * Agentuity BetterAuth integration types. + * + * @module agentuity/types + */ + +import type { Session, User } from 'better-auth'; +import type { AgentuityAuth } from '../types'; + +/** + * BetterAuth context containing user, session, and org data. + * This is the raw auth context from BetterAuth's session validation. + */ +export interface AgentuityAuthContext { + user: TUser; + session: TSession; + org: AgentuityOrgContext | null; +} + +/** + * Organization context derived from BetterAuth's organization plugin. + */ +export interface AgentuityOrgContext { + /** Organization ID */ + id: string; + /** Organization slug (URL-friendly identifier) */ + slug?: string | null; + /** Organization display name */ + name?: string | null; + /** Member's role in this organization (e.g., 'owner', 'admin', 'member') */ + role?: string | null; + /** Member ID for this user in this organization */ + memberId?: string | null; + /** Organization metadata (if enabled) */ + metadata?: unknown; +} + +// ============================================================================= +// API Key Types +// ============================================================================= + +/** + * API key permissions in BetterAuth's native format. + * Maps resource names to arrays of allowed actions. + * + * @example + * ```typescript + * const permissions: AgentuityApiKeyPermissions = { + * project: ['read', 'write'], + * user: ['read'], + * admin: ['*'], // wildcard - all actions + * }; + * ``` + */ +export type AgentuityApiKeyPermissions = Record; + +/** + * API key context when request is authenticated via API key. + */ +export interface AgentuityApiKeyContext { + /** API key ID from BetterAuth */ + id: string; + /** Display name of the API key */ + name?: string | null; + /** Permissions associated with this API key */ + permissions: AgentuityApiKeyPermissions; + /** User ID the API key belongs to */ + userId?: string | null; +} + +/** + * Authentication method used for the current request. + */ +export type AgentuityAuthMethod = 'session' | 'api-key' | 'bearer'; + +// ============================================================================= +// Extended Auth Interface (Agentuity-specific) +// ============================================================================= + +/** + * Organization helpers available on the auth context. + */ +export interface AgentuityOrgHelpers { + /** Active organization context if available, null otherwise */ + org: AgentuityOrgContext | null; + + /** Returns active org or null (never throws) */ + getOrg(): Promise; + + /** Convenience accessor for the member's role on the active org */ + getOrgRole(): Promise; + + /** True if the current member's role is one of the provided roles */ + hasOrgRole(...roles: string[]): Promise; +} + +/** + * API key helpers available on the auth context. + */ +export interface AgentuityApiKeyHelpers { + /** How this request was authenticated */ + authMethod: AgentuityAuthMethod; + + /** API key context when request is authenticated via API key, null otherwise */ + apiKey: AgentuityApiKeyContext | null; + + /** + * Check if the API key has the required permissions. + * All specified actions must be present for the resource. + * Supports '*' wildcard which matches any action. + * + * @param resource - The resource to check (e.g., 'project', 'user') + * @param actions - Actions required (e.g., 'read', 'write') + * @returns true if all actions are permitted, false otherwise + * + * @example + * ```typescript + * // Check for specific permission + * if (c.var.auth.hasPermission('project', 'write')) { ... } + * + * // Check for multiple permissions (all required) + * if (c.var.auth.hasPermission('project', 'read', 'write')) { ... } + * ``` + */ + hasPermission(resource: string, ...actions: string[]): boolean; +} + +/** + * Agentuity BetterAuth auth interface. + * Extends the generic AgentuityAuth with org and API key helpers. + */ +export type AgentuityBetterAuthAuth = AgentuityAuth & + AgentuityOrgHelpers & + AgentuityApiKeyHelpers; + +// ============================================================================= +// withSession Types (Unified wrapper for agents) +// ============================================================================= + +/** + * Options for withSession wrapper. + */ +export interface WithSessionOptions { + /** + * If true, allow unauthenticated execution (auth will be null). + * If false (default), throws error when no auth is present. + */ + optional?: boolean; +} + +/** + * Context passed to withSession handlers. + * + * This unified context works across all execution environments: + * - HTTP requests (session or API key auth) + * - Agent-to-agent calls (inherits parent auth) + * - Cron jobs (auth is null) + * - Standalone invocations (auth is null unless manually set) + */ +export interface WithSessionContext { + /** + * BetterAuth auth context if authenticated, null otherwise. + * + * Contains the user and session data from BetterAuth. + * For API key auth with enableSessionForAPIKeys, this contains + * a mock session representing the API key's user. + */ + auth: AgentuityAuthContext | null; + + /** + * Active organization context if the user has one set. + * Populated from BetterAuth's organization plugin. + */ + org: AgentuityOrgContext | null; +} diff --git a/packages/auth/test/agentuity/agent.test.ts b/packages/auth/test/agentuity/agent.test.ts new file mode 100644 index 00000000..b269dbb5 --- /dev/null +++ b/packages/auth/test/agentuity/agent.test.ts @@ -0,0 +1,274 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import * as runtimeModule from '@agentuity/runtime'; + +describe('withSession', () => { + let inAgentContextSpy: ReturnType; + let getAgentContextSpy: ReturnType; + let inHTTPContextSpy: ReturnType; + let getHTTPContextSpy: ReturnType; + + function createMockAgentContext(stateEntries: [string, unknown][] = []) { + const state = new Map(stateEntries); + return { + sessionId: 'mock-session', + agentName: 'mock-agent', + state, + logger: { + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + child: () => ({}), + }, + }; + } + + beforeEach(() => { + inAgentContextSpy = spyOn(runtimeModule, 'inAgentContext'); + getAgentContextSpy = spyOn(runtimeModule, 'getAgentContext'); + inHTTPContextSpy = spyOn(runtimeModule, 'inHTTPContext'); + getHTTPContextSpy = spyOn(runtimeModule, 'getHTTPContext'); + }); + + afterEach(() => { + inAgentContextSpy.mockRestore(); + getAgentContextSpy.mockRestore(); + inHTTPContextSpy.mockRestore(); + getHTTPContextSpy.mockRestore(); + }); + + test('throws when not in agent context', async () => { + inAgentContextSpy.mockReturnValue(false); + + const { withSession } = await import('../../src/agentuity/agent'); + + const handler = withSession(async (_ctx, _session, input: string) => input); + const mockCtx = createMockAgentContext(); + + await expect(handler(mockCtx as any, 'test')).rejects.toThrow( + 'withSession must be used inside an Agentuity agent' + ); + }); + + test('throws when auth required but not present', async () => { + const agentContext = createMockAgentContext(); + inAgentContextSpy.mockReturnValue(true); + getAgentContextSpy.mockReturnValue(agentContext); + inHTTPContextSpy.mockReturnValue(false); + + const { withSession } = await import('../../src/agentuity/agent'); + + const testHandler = async (_ctx: any, session: { auth: any; org: any }, input: string) => { + return { input, auth: session.auth }; + }; + + const wrapped = withSession(testHandler, { optional: false }); + + await expect(wrapped(agentContext as any, 'test-input')).rejects.toThrow( + 'Unauthenticated: This agent requires authentication' + ); + }); + + test('allows null auth when optional: true', async () => { + const agentContext = createMockAgentContext(); + inAgentContextSpy.mockReturnValue(true); + getAgentContextSpy.mockReturnValue(agentContext); + inHTTPContextSpy.mockReturnValue(false); + + const { withSession } = await import('../../src/agentuity/agent'); + + const testHandler = async (_ctx: any, session: { auth: any; org: any }, input: string) => { + return { input, auth: session.auth, org: session.org }; + }; + + const wrapped = withSession(testHandler, { optional: true }); + const result = await wrapped(agentContext as any, 'test-input'); + + expect(result.input).toBe('test-input'); + expect(result.auth).toBeNull(); + expect(result.org).toBeNull(); + }); + + test('extracts auth from cached state', async () => { + const authData = { + user: { id: 'user-123', email: 'test@example.com' }, + session: { id: 'sess-456' }, + }; + const agentContext = createMockAgentContext([['@agentuity/auth', authData]]); + inAgentContextSpy.mockReturnValue(true); + getAgentContextSpy.mockReturnValue(agentContext); + inHTTPContextSpy.mockReturnValue(false); + + const { withSession } = await import('../../src/agentuity/agent'); + + const testHandler = async (_ctx: any, session: { auth: any; org: any }, _input: string) => { + return { + userId: session.auth?.user?.id, + email: session.auth?.user?.email, + }; + }; + + const wrapped = withSession(testHandler, { optional: true }); + const result = await wrapped(agentContext as any, 'test-input'); + + expect(result.userId).toBe('user-123'); + expect(result.email).toBe('test@example.com'); + }); + + test('extracts organization context from auth', async () => { + const agentContext = createMockAgentContext([ + [ + '@agentuity/auth', + { + user: { id: 'user-123' }, + session: { id: 'sess-456' }, + org: { + id: 'org-789', + slug: 'test-org', + name: 'Test Organization', + role: 'admin', + memberId: 'member-456', + }, + }, + ], + ]); + inAgentContextSpy.mockReturnValue(true); + getAgentContextSpy.mockReturnValue(agentContext); + inHTTPContextSpy.mockReturnValue(false); + + const { withSession } = await import('../../src/agentuity/agent'); + + const testHandler = async (_ctx: any, session: { auth: any; org: any }, _input: string) => { + return { + orgId: session.org?.id, + orgSlug: session.org?.slug, + orgName: session.org?.name, + orgRole: session.org?.role, + memberId: session.org?.memberId, + }; + }; + + const wrapped = withSession(testHandler, { optional: true }); + const result = await wrapped(agentContext as any, 'test-input'); + + expect(result.orgId).toBe('org-789'); + expect(result.orgSlug).toBe('test-org'); + expect(result.orgName).toBe('Test Organization'); + expect(result.orgRole).toBe('admin'); + expect(result.memberId).toBe('member-456'); + }); + + test('caches auth in agent state for subsequent calls', async () => { + const authData = { + user: { id: 'user-123' }, + session: { id: 'sess-456' }, + }; + const agentContext = createMockAgentContext([['@agentuity/auth', authData]]); + inAgentContextSpy.mockReturnValue(true); + getAgentContextSpy.mockReturnValue(agentContext); + inHTTPContextSpy.mockReturnValue(false); + + const { withSession } = await import('../../src/agentuity/agent'); + + const testHandler = async (_ctx: any, session: { auth: any; org: any }, _input: string) => { + return { userId: session.auth?.user?.id }; + }; + + const wrapped = withSession(testHandler, { optional: true }); + + const result1 = await wrapped(agentContext as any, 'test-1'); + const result2 = await wrapped(agentContext as any, 'test-2'); + + expect(result1.userId).toBe('user-123'); + expect(result2.userId).toBe('user-123'); + }); + + test('returns null org when no active organization', async () => { + const agentContext = createMockAgentContext([ + [ + '@agentuity/auth', + { + user: { id: 'user-123' }, + session: { id: 'sess-456' }, + }, + ], + ]); + inAgentContextSpy.mockReturnValue(true); + getAgentContextSpy.mockReturnValue(agentContext); + inHTTPContextSpy.mockReturnValue(false); + + const { withSession } = await import('../../src/agentuity/agent'); + + const testHandler = async (_ctx: any, session: { auth: any; org: any }, _input: string) => { + return { org: session.org }; + }; + + const wrapped = withSession(testHandler, { optional: true }); + const result = await wrapped(agentContext as any, 'test-input'); + + expect(result.org).toBeNull(); + }); + + test('extracts auth from HTTP context when available', async () => { + const agentContext = createMockAgentContext(); + inAgentContextSpy.mockReturnValue(true); + getAgentContextSpy.mockReturnValue(agentContext); + inHTTPContextSpy.mockReturnValue(true); + getHTTPContextSpy.mockReturnValue({ + var: { + auth: { + raw: { + user: { id: 'http-user-123', email: 'http@example.com' }, + session: { id: 'http-sess' }, + }, + }, + }, + }); + + const { withSession } = await import('../../src/agentuity/agent'); + + const testHandler = async (_ctx: any, session: { auth: any; org: any }, _input: string) => { + return { + userId: session.auth?.user?.id, + email: session.auth?.user?.email, + }; + }; + + const wrapped = withSession(testHandler, { optional: true }); + const result = await wrapped(agentContext as any, 'test-input'); + + expect(result.userId).toBe('http-user-123'); + expect(result.email).toBe('http@example.com'); + }); + + test('falls back to raw user/session when auth.raw not present', async () => { + const agentContext = createMockAgentContext(); + inAgentContextSpy.mockReturnValue(true); + getAgentContextSpy.mockReturnValue(agentContext); + inHTTPContextSpy.mockReturnValue(true); + getHTTPContextSpy.mockReturnValue({ + var: { + user: { id: 'fallback-user', email: 'fallback@example.com' }, + session: { id: 'fallback-sess' }, + }, + }); + + const { withSession } = await import('../../src/agentuity/agent'); + + const testHandler = async (_ctx: any, session: { auth: any; org: any }, _input: string) => { + return { + userId: session.auth?.user?.id, + email: session.auth?.user?.email, + }; + }; + + const wrapped = withSession(testHandler, { optional: true }); + const result = await wrapped(agentContext as any, 'test-input'); + + expect(result.userId).toBe('fallback-user'); + expect(result.email).toBe('fallback@example.com'); + }); +}); diff --git a/packages/auth/test/agentuity/api-key-storage.test.ts b/packages/auth/test/agentuity/api-key-storage.test.ts new file mode 100644 index 00000000..55f7c150 --- /dev/null +++ b/packages/auth/test/agentuity/api-key-storage.test.ts @@ -0,0 +1,179 @@ +import { describe, test, expect } from 'bun:test'; +import { + createAgentuityApiKeyStorage, + AGENTUITY_API_KEY_NAMESPACE, +} from '../../src/agentuity/api-key-storage'; + +describe('AGENTUITY_API_KEY_NAMESPACE', () => { + test('has correct default value', () => { + expect(AGENTUITY_API_KEY_NAMESPACE).toBe('_agentuity_auth_apikeys'); + }); +}); + +describe('createAgentuityApiKeyStorage', () => { + const createMockKv = () => { + const store = new Map(); + const namespaceCreated = { value: false }; + + return { + store, + namespaceCreated, + kv: { + get: async (namespace: string, key: string) => { + const fullKey = `${namespace}:${key}`; + if (store.has(fullKey)) { + return { exists: true, data: store.get(fullKey) }; + } + return { exists: false }; + }, + set: async ( + namespace: string, + key: string, + value: unknown, + _params?: { ttl?: number } + ) => { + const fullKey = `${namespace}:${key}`; + store.set(fullKey, value); + }, + delete: async (namespace: string, key: string) => { + const fullKey = `${namespace}:${key}`; + store.delete(fullKey); + }, + createNamespace: async (_namespace: string) => { + namespaceCreated.value = true; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }; + }; + + test('get returns null when key does not exist', async () => { + const { kv } = createMockKv(); + const storage = createAgentuityApiKeyStorage({ kv }); + + const result = await storage.get('nonexistent-key'); + expect(result).toBeNull(); + }); + + test('set and get work correctly', async () => { + const { kv } = createMockKv(); + const storage = createAgentuityApiKeyStorage({ kv }); + + await storage.set('test-key', 'test-value'); + const result = await storage.get('test-key'); + + expect(result).toBe('test-value'); + }); + + test('delete removes key', async () => { + const { kv } = createMockKv(); + const storage = createAgentuityApiKeyStorage({ kv }); + + await storage.set('test-key', 'test-value'); + await storage.delete('test-key'); + const result = await storage.get('test-key'); + + expect(result).toBeNull(); + }); + + test('uses default namespace', async () => { + const { kv, store } = createMockKv(); + const storage = createAgentuityApiKeyStorage({ kv }); + + await storage.set('my-key', 'my-value'); + + // Check the store has the key with the default namespace + expect(store.has(`${AGENTUITY_API_KEY_NAMESPACE}:my-key`)).toBe(true); + }); + + test('uses custom namespace when provided', async () => { + const { kv, store } = createMockKv(); + const customNamespace = 'custom_api_keys'; + const storage = createAgentuityApiKeyStorage({ kv, namespace: customNamespace }); + + await storage.set('my-key', 'my-value'); + + expect(store.has(`${customNamespace}:my-key`)).toBe(true); + }); + + test('auto-creates namespace when autoCreateNamespace is true (default)', async () => { + const { kv, namespaceCreated } = createMockKv(); + const storage = createAgentuityApiKeyStorage({ kv }); + + await storage.get('any-key'); + + expect(namespaceCreated.value).toBe(true); + }); + + test('does not auto-create namespace when autoCreateNamespace is false', async () => { + const { kv, namespaceCreated } = createMockKv(); + const storage = createAgentuityApiKeyStorage({ + kv, + autoCreateNamespace: false, + }); + + await storage.get('any-key'); + + expect(namespaceCreated.value).toBe(false); + }); + + test('set with TTL passes TTL to KV (with minimum enforcement)', async () => { + let capturedTtl: number | undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const kv: any = { + get: async () => ({ exists: false }), + set: async ( + _namespace: string, + _key: string, + _value: unknown, + params?: { ttl?: number } + ) => { + capturedTtl = params?.ttl; + }, + delete: async () => {}, + createNamespace: async () => {}, + }; + + const storage = createAgentuityApiKeyStorage({ kv }); + + // TTL below minimum (60000ms) should be raised to minimum + await storage.set('key1', 'value1', 1000); + expect(capturedTtl).toBe(60000); // Minimum TTL enforced + + // TTL above minimum should be passed through + await storage.set('key2', 'value2', 120000); + expect(capturedTtl).toBe(120000); + }); + + test('handles errors gracefully in get', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const kv: any = { + get: async () => { + throw new Error('Network error'); + }, + createNamespace: async () => {}, + }; + + const storage = createAgentuityApiKeyStorage({ kv }); + const result = await storage.get('any-key'); + + expect(result).toBeNull(); + }); + + test('handles errors in delete without throwing', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const kv: any = { + get: async () => ({ exists: false }), + delete: async () => { + throw new Error('Delete failed'); + }, + createNamespace: async () => {}, + }; + + const storage = createAgentuityApiKeyStorage({ kv }); + + // Should not throw + await storage.delete('any-key'); + }); +}); diff --git a/packages/auth/test/agentuity/config.test.ts b/packages/auth/test/agentuity/config.test.ts new file mode 100644 index 00000000..298d959f --- /dev/null +++ b/packages/auth/test/agentuity/config.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { createAgentuityAuth } from '../../src/agentuity/config'; + +describe('Agentuity Auth Config', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.BETTER_AUTH_URL; + delete process.env.AGENTUITY_DEPLOYMENT_URL; + delete process.env.AGENTUITY_AUTH_TRUSTED_ORIGINS; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe('resolveBaseURL', () => { + it('returns explicit baseURL when provided', () => { + process.env.BETTER_AUTH_URL = 'https://env-url.com'; + process.env.AGENTUITY_DEPLOYMENT_URL = 'https://agentuity-url.com'; + + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + baseURL: 'https://explicit-url.com', + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + expect(auth.options.baseURL).toBe('https://explicit-url.com'); + }); + + it('falls back to BETTER_AUTH_URL when no explicit baseURL', () => { + process.env.BETTER_AUTH_URL = 'https://better-auth-url.com'; + process.env.AGENTUITY_DEPLOYMENT_URL = 'https://agentuity-url.com'; + + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + expect(auth.options.baseURL).toBe('https://better-auth-url.com'); + }); + + it('falls back to AGENTUITY_DEPLOYMENT_URL when no BETTER_AUTH_URL', () => { + process.env.AGENTUITY_DEPLOYMENT_URL = 'https://p1234.agentuity.run'; + + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + expect(auth.options.baseURL).toBe('https://p1234.agentuity.run'); + }); + }); + + describe('trustedOrigins', () => { + it('uses default trustedOrigins function when not provided', () => { + process.env.AGENTUITY_DEPLOYMENT_URL = 'https://p1234.agentuity.run'; + + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + expect(typeof auth.options.trustedOrigins).toBe('function'); + }); + + it('respects user-provided trustedOrigins', () => { + const customOrigins = ['https://custom.example.com']; + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + trustedOrigins: customOrigins, + }); + + expect(auth.options.trustedOrigins).toEqual(customOrigins); + }); + + it('default trustedOrigins includes baseURL origin', async () => { + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + baseURL: 'https://myapp.example.com', + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + const trustedOrigins = auth.options.trustedOrigins as ( + request?: Request + ) => Promise; + const origins = await trustedOrigins(); + + expect(origins).toContain('https://myapp.example.com'); + }); + + it('default trustedOrigins includes AGENTUITY_DEPLOYMENT_URL origin', async () => { + process.env.AGENTUITY_DEPLOYMENT_URL = 'https://p5678.agentuity.run'; + + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + const trustedOrigins = auth.options.trustedOrigins as ( + request?: Request + ) => Promise; + const origins = await trustedOrigins(); + + expect(origins).toContain('https://p5678.agentuity.run'); + }); + + it('default trustedOrigins includes request origin (same-origin)', async () => { + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + const trustedOrigins = auth.options.trustedOrigins as ( + request?: Request + ) => Promise; + const mockRequest = new Request('https://deployed-app.agentuity.run/api/auth/sign-up'); + const origins = await trustedOrigins(mockRequest); + + expect(origins).toContain('https://deployed-app.agentuity.run'); + }); + + it('default trustedOrigins includes extra origins from AGENTUITY_AUTH_TRUSTED_ORIGINS', async () => { + process.env.AGENTUITY_AUTH_TRUSTED_ORIGINS = + 'https://extra1.example.com,https://extra2.example.com'; + + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + const trustedOrigins = auth.options.trustedOrigins as ( + request?: Request + ) => Promise; + const origins = await trustedOrigins(); + + expect(origins).toContain('https://extra1.example.com'); + expect(origins).toContain('https://extra2.example.com'); + }); + + it('handles malformed URLs gracefully in trustedOrigins', async () => { + // Test the trustedOrigins function directly without creating a full auth instance + // to avoid BetterAuth's async URL validation errors + process.env.AGENTUITY_DEPLOYMENT_URL = 'not-a-valid-url'; + process.env.AGENTUITY_AUTH_TRUSTED_ORIGINS = 'https://valid.example.com'; + + // Create auth with a VALID baseURL to avoid BetterAuth URL validation errors + // The malformed AGENTUITY_DEPLOYMENT_URL will be handled gracefully by safeOrigin() + const db = new Database(':memory:'); + const auth = createAgentuityAuth({ + database: db, + baseURL: 'https://valid-base.example.com', + basePath: '/api/auth', + secret: 'test-secret-minimum-32-characters-long', + }); + + const trustedOrigins = auth.options.trustedOrigins as ( + request?: Request + ) => Promise; + const origins = await trustedOrigins(); + + // Should contain the valid origins but gracefully skip the malformed one + expect(Array.isArray(origins)).toBe(true); + expect(origins).toContain('https://valid-base.example.com'); + expect(origins).toContain('https://valid.example.com'); + // The malformed URL should be silently skipped (not included) + expect(origins).not.toContain('not-a-valid-url'); + }); + }); +}); diff --git a/packages/auth/test/agentuity/e2e.test.ts b/packages/auth/test/agentuity/e2e.test.ts new file mode 100644 index 00000000..75c07374 --- /dev/null +++ b/packages/auth/test/agentuity/e2e.test.ts @@ -0,0 +1,258 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * End-to-end tests for Agentuity BetterAuth integration. + * + * These tests verify the complete auth flow using mocked BetterAuth responses. + */ + +import { describe, test, expect, mock } from 'bun:test'; +import { Hono } from 'hono'; +import { createSessionMiddleware } from '../../src/agentuity/server'; + +describe('Agentuity BetterAuth E2E flow', () => { + const mockUser = { + id: 'user_e2e_123', + name: 'E2E Test User', + email: 'e2e@example.com', + emailVerified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const mockSession = { + id: 'session_e2e_456', + token: 'e2e_session_token_abc123', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + userId: mockUser.id, + scopes: ['read', 'write'], + }; + + const createMockAuth = (sessionResult: unknown) => ({ + api: { + getSession: mock(() => Promise.resolve(sessionResult)), + }, + }); + + describe('Public routes (no middleware)', () => { + test('public route works without auth middleware', async () => { + const app = new Hono(); + app.get('/health', (c) => c.json({ status: 'ok' })); + + const res = await app.request('/health'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + }); + }); + + describe('Protected routes', () => { + test('authenticated user can access protected routes', async () => { + const mockAuth = createMockAuth({ user: mockUser, session: mockSession }); + const app = new Hono(); + + app.use('/api/*', createSessionMiddleware(mockAuth as any)); + app.get('/api/me', async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ + id: user.id, + name: user.name, + email: user.email, + }); + }); + + const res = await app.request('/api/me'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: 'user_e2e_123', + name: 'E2E Test User', + email: 'e2e@example.com', + }); + }); + + test('unauthenticated user gets 401 on protected routes', async () => { + const mockAuth = createMockAuth(null); + const app = new Hono(); + + app.use('/api/*', createSessionMiddleware(mockAuth as any)); + app.get('/api/me', async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ id: user.id }); + }); + + const res = await app.request('/api/me'); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + }); + + describe('Optional auth routes', () => { + test('anonymous request works with optional auth', async () => { + const mockAuth = createMockAuth(null); + const app = new Hono(); + + app.use('/greeting', createSessionMiddleware(mockAuth as any, { optional: true })); + app.get('/greeting', async (c) => { + const user = c.var.user; + if (user) { + return c.json({ message: `Hello, ${(user as any).name}!` }); + } + return c.json({ message: 'Hello, anonymous!' }); + }); + + const res = await app.request('/greeting'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ message: 'Hello, anonymous!' }); + }); + + test('authenticated request works with optional auth', async () => { + const mockAuth = createMockAuth({ user: mockUser, session: mockSession }); + const app = new Hono(); + + app.use('/greeting', createSessionMiddleware(mockAuth as any, { optional: true })); + app.get('/greeting', async (c) => { + const user = c.var.user; + if (user) { + return c.json({ message: `Hello, ${(user as any).name}!` }); + } + return c.json({ message: 'Hello, anonymous!' }); + }); + + const res = await app.request('/greeting'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ message: 'Hello, E2E Test User!' }); + }); + }); + + describe('Auth method detection', () => { + test('detects session-based auth', async () => { + const mockAuth = createMockAuth({ user: mockUser, session: mockSession }); + const app = new Hono(); + + app.use('/api/*', createSessionMiddleware(mockAuth as any)); + app.get('/api/me', async (c) => { + const apiKeyHeader = c.req.header('x-api-key') ?? c.req.header('X-API-KEY'); + const authMethod = apiKeyHeader ? 'api-key' : 'session'; + return c.json({ authMethod }); + }); + + const res = await app.request('/api/me'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ authMethod: 'session' }); + }); + + test('detects API key auth via x-api-key header', async () => { + const mockAuth = createMockAuth({ user: mockUser, session: mockSession }); + const app = new Hono(); + + app.use('/api/*', createSessionMiddleware(mockAuth as any)); + app.get('/api/me', async (c) => { + const apiKeyHeader = c.req.header('x-api-key') ?? c.req.header('X-API-KEY'); + const authMethod = apiKeyHeader ? 'api-key' : 'session'; + return c.json({ authMethod }); + }); + + const res = await app.request('/api/me', { + headers: { 'x-api-key': 'ag_test_key_12345' }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ authMethod: 'api-key' }); + }); + }); + + describe('Token extraction', () => { + test('extracts bearer token from Authorization header', async () => { + const mockAuth = createMockAuth({ user: mockUser, session: mockSession }); + const app = new Hono(); + + app.use('/api/*', createSessionMiddleware(mockAuth as any)); + app.get('/api/token', async (c) => { + const token = await c.var.auth.getToken(); + return c.json({ token }); + }); + + const res = await app.request('/api/token', { + headers: { Authorization: 'Bearer my-jwt-token-12345' }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ token: 'my-jwt-token-12345' }); + }); + + test('returns null when no Authorization header', async () => { + const mockAuth = createMockAuth({ user: mockUser, session: mockSession }); + const app = new Hono(); + + app.use('/api/*', createSessionMiddleware(mockAuth as any)); + app.get('/api/token', async (c) => { + const token = await c.var.auth.getToken(); + return c.json({ token }); + }); + + const res = await app.request('/api/token'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ token: null }); + }); + }); + + describe('Raw session data access', () => { + test('exposes raw user and session on auth.raw', async () => { + const mockAuth = createMockAuth({ user: mockUser, session: mockSession }); + const app = new Hono(); + + app.use('/api/*', createSessionMiddleware(mockAuth as any)); + app.get('/api/session', async (c) => { + return c.json({ + userId: c.var.auth.raw.user.id, + sessionId: c.var.auth.raw.session.id, + sessionToken: c.var.auth.raw.session.token, + }); + }); + + const res = await app.request('/api/session'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + userId: 'user_e2e_123', + sessionId: 'session_e2e_456', + sessionToken: 'e2e_session_token_abc123', + }); + }); + }); + + describe('Full app simulation', () => { + test('simulates complete app with multiple route types', async () => { + const mockAuth = createMockAuth({ user: mockUser, session: mockSession }); + const app = new Hono(); + + // Public route - no middleware + app.get('/health', (c) => c.json({ status: 'ok' })); + + // Protected routes + app.use('/api/*', createSessionMiddleware(mockAuth as any)); + + app.get('/api/me', async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ id: user.id, name: user.name }); + }); + + app.get('/api/data', (c) => c.json({ data: 'allowed' })); + + // Test all routes + const healthRes = await app.request('/health'); + expect(healthRes.status).toBe(200); + expect(await healthRes.json()).toEqual({ status: 'ok' }); + + const meRes = await app.request('/api/me'); + expect(meRes.status).toBe(200); + expect(await meRes.json()).toEqual({ id: 'user_e2e_123', name: 'E2E Test User' }); + + const dataRes = await app.request('/api/data'); + expect(dataRes.status).toBe(200); + expect(await dataRes.json()).toEqual({ data: 'allowed' }); + }); + }); +}); diff --git a/packages/auth/test/agentuity/migrations.test.ts b/packages/auth/test/agentuity/migrations.test.ts new file mode 100644 index 00000000..462aa3fe --- /dev/null +++ b/packages/auth/test/agentuity/migrations.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect } from 'bun:test'; +import { AGENTUITY_AUTH_BASELINE_SQL, ensureAuthSchema } from '../../src/agentuity/migrations'; + +describe('AGENTUITY_AUTH_BASELINE_SQL', () => { + test('contains core BetterAuth tables', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "user"'); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "session"'); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "account"'); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "verification"'); + }); + + test('contains organization plugin tables', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "organization"'); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "member"'); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "invitation"'); + }); + + test('contains JWT plugin table', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "jwks"'); + }); + + test('contains API key plugin table', () => { + // Note: BetterAuth expects lowercase table name "apikey" + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS apikey'); + }); + + test('contains indexes', () => { + // Note: Index names are lowercase (without quotes) for PostgreSQL compatibility + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain( + 'CREATE INDEX IF NOT EXISTS session_userId_idx' + ); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain( + 'CREATE INDEX IF NOT EXISTS account_userId_idx' + ); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE INDEX IF NOT EXISTS apikey_userId_idx'); + }); + + test('uses IF NOT EXISTS for idempotency', () => { + // Count occurrences of IF NOT EXISTS + const matches = AGENTUITY_AUTH_BASELINE_SQL.match(/IF NOT EXISTS/g) || []; + // Should have at least one for each table and index + expect(matches.length).toBeGreaterThan(10); + }); +}); + +describe('ensureAuthSchema', () => { + test('always returns created: true (idempotent SQL)', async () => { + let sqlExecuted = false; + + const mockDb = { + query: async (text: string) => { + if (text.includes('CREATE TABLE')) { + sqlExecuted = true; + } + return { rows: [] }; + }, + }; + + const result = await ensureAuthSchema({ db: mockDb }); + expect(result).toEqual({ created: true }); + expect(sqlExecuted).toBe(true); + }); + + test('executes baseline SQL with all required tables', async () => { + const executedSql: string[] = []; + + const mockDb = { + query: async (text: string) => { + executedSql.push(text); + return { rows: [] }; + }, + }; + + await ensureAuthSchema({ db: mockDb }); + + // Should have executed the baseline SQL + expect(executedSql.length).toBe(1); + expect(executedSql[0]).toContain('CREATE TABLE IF NOT EXISTS "user"'); + expect(executedSql[0]).toContain('CREATE TABLE IF NOT EXISTS apikey'); + }); +}); diff --git a/packages/auth/test/agentuity/server.test.ts b/packages/auth/test/agentuity/server.test.ts new file mode 100644 index 00000000..53e499e6 --- /dev/null +++ b/packages/auth/test/agentuity/server.test.ts @@ -0,0 +1,528 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, test, expect, mock } from 'bun:test'; +import { Hono } from 'hono'; +import { createSessionMiddleware, mountBetterAuthRoutes } from '../../src/agentuity/server'; + +const createMockAuth = (sessionResult: unknown) => ({ + api: { + getSession: mock(() => Promise.resolve(sessionResult)), + }, +}); + +describe('Agentuity BetterAuth server middleware', () => { + test('returns 401 when session is null', async () => { + const mockAuth = createMockAuth(null); + const app = new Hono(); + + app.use('/protected', createSessionMiddleware(mockAuth as any)); + app.get('/protected', (c) => c.json({ success: true })); + + const res = await app.request('/protected', { + method: 'GET', + }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: 'Unauthorized' }); + }); + + test('returns 401 when getSession throws', async () => { + const mockAuth = { + api: { + getSession: mock(() => Promise.reject(new Error('Session error'))), + }, + }; + const app = new Hono(); + + app.use('/protected', createSessionMiddleware(mockAuth as any)); + app.get('/protected', (c) => c.json({ success: true })); + + const res = await app.request('/protected', { + method: 'GET', + }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: 'Unauthorized' }); + }); + + test('allows request and attaches auth when session is valid', async () => { + const mockSession = { + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + session: { + id: 'session_456', + token: 'abc123', + }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/protected', createSessionMiddleware(mockAuth as any)); + app.get('/protected', async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ + success: true, + userId: user.id, + userName: user.name, + userEmail: user.email, + }); + }); + + const res = await app.request('/protected', { + method: 'GET', + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + success: true, + userId: 'user_123', + userName: 'Test User', + userEmail: 'test@example.com', + }); + }); + + test('caches user on multiple getUser calls', async () => { + const mockSession = { + user: { id: 'user_123', name: 'Test', email: 'test@example.com' }, + session: { id: 'session_456' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/protected', createSessionMiddleware(mockAuth as any)); + app.get('/protected', async (c) => { + const user1 = await c.var.auth.getUser(); + const user2 = await c.var.auth.getUser(); + return c.json({ + same: user1 === user2, + userId: user1.id, + }); + }); + + const res = await app.request('/protected'); + const body = await res.json(); + expect(body.same).toBe(true); + expect(body.userId).toBe('user_123'); + }); + + test('getToken returns bearer token from Authorization header', async () => { + const mockSession = { + user: { id: 'user_123' }, + session: { id: 'session_456' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/protected', createSessionMiddleware(mockAuth as any)); + app.get('/protected', async (c) => { + const token = await c.var.auth.getToken(); + return c.json({ token }); + }); + + const res = await app.request('/protected', { + headers: { Authorization: 'Bearer my-token-123' }, + }); + + const body = await res.json(); + expect(body.token).toBe('my-token-123'); + }); + + test('getToken returns null when no Authorization header', async () => { + const mockSession = { + user: { id: 'user_123' }, + session: { id: 'session_456' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/protected', createSessionMiddleware(mockAuth as any)); + app.get('/protected', async (c) => { + const token = await c.var.auth.getToken(); + return c.json({ token }); + }); + + const res = await app.request('/protected'); + const body = await res.json(); + expect(body.token).toBe(null); + }); + + test('exposes raw session on auth.raw', async () => { + const mockSession = { + user: { id: 'user_123', customField: 'custom-value' }, + session: { id: 'session_456', expiresAt: '2024-12-31' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/protected', createSessionMiddleware(mockAuth as any)); + app.get('/protected', async (c) => { + return c.json({ + userId: c.var.auth.raw.user.id, + sessionId: c.var.auth.raw.session.id, + customField: c.var.auth.raw.user.customField, + }); + }); + + const res = await app.request('/protected'); + const body = await res.json(); + expect(body).toEqual({ + userId: 'user_123', + sessionId: 'session_456', + customField: 'custom-value', + }); + }); + + describe('optional mode', () => { + test('allows unauthenticated requests when optional=true', async () => { + const mockAuth = createMockAuth(null); + const app = new Hono(); + + app.use('/public', createSessionMiddleware(mockAuth as any, { optional: true })); + app.get('/public', (c) => { + return c.json({ + hasAuth: c.var.auth !== undefined, + }); + }); + + const res = await app.request('/public'); + expect(res.status).toBe(200); + }); + + test('attaches auth when session exists in optional mode', async () => { + const mockSession = { + user: { id: 'user_123' }, + session: { id: 'session_456' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/public', createSessionMiddleware(mockAuth as any, { optional: true })); + app.get('/public', async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ userId: user.id }); + }); + + const res = await app.request('/public'); + const body = await res.json(); + expect(body.userId).toBe('user_123'); + }); + + test('sets user and session to null when unauthenticated in optional mode', async () => { + const mockAuth = createMockAuth(null); + const app = new Hono(); + + app.use('/public', createSessionMiddleware(mockAuth as any, { optional: true })); + app.get('/public', (c) => { + return c.json({ + user: c.var.user, + session: c.var.session, + }); + }); + + const res = await app.request('/public'); + const body = await res.json(); + expect(body.user).toBe(null); + expect(body.session).toBe(null); + }); + }); + + describe('BetterAuth pattern (user/session on context)', () => { + test('sets user and session directly on context', async () => { + const mockSession = { + user: { id: 'user_123', name: 'Test', email: 'test@example.com' }, + session: { id: 'session_456', token: 'abc' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/api', createSessionMiddleware(mockAuth as any)); + app.get('/api', (c) => { + return c.json({ + userId: (c.var.user as any)?.id, + sessionId: (c.var.session as any)?.id, + }); + }); + + const res = await app.request('/api'); + const body = await res.json(); + expect(body.userId).toBe('user_123'); + expect(body.sessionId).toBe('session_456'); + }); + }); + + describe('organization enrichment', () => { + test('provides minimal org from session and fetches full org lazily', async () => { + const mockSession = { + user: { id: 'user_123', name: 'Test' }, + session: { + id: 'session_456', + activeOrganizationId: 'org_789', + }, + }; + const mockFullOrg = { + id: 'org_789', + name: 'Test Org', + slug: 'test-org', + metadata: null, + members: [{ userId: 'user_123', role: 'admin', id: 'member_123' }], + }; + const mockAuth = { + api: { + getSession: mock(() => Promise.resolve(mockSession)), + getFullOrganization: mock(() => Promise.resolve(mockFullOrg)), + }, + }; + const app = new Hono(); + + app.use('/api', createSessionMiddleware(mockAuth as any)); + app.get('/api', async (c) => { + const user = c.var.user as any; + const minimalOrg = c.var.org; + const fullOrg = await c.var.auth.getOrg(); + return c.json({ + userId: user?.id, + minimalOrgId: minimalOrg?.id, + minimalOrgName: minimalOrg?.name, + fullOrgId: fullOrg?.id, + fullOrgName: fullOrg?.name, + fullOrgSlug: fullOrg?.slug, + fullOrgRole: fullOrg?.role, + }); + }); + + const res = await app.request('/api'); + const body = await res.json(); + expect(body.userId).toBe('user_123'); + expect(body.minimalOrgId).toBe('org_789'); + expect(body.minimalOrgName).toBeUndefined(); + expect(body.fullOrgId).toBe('org_789'); + expect(body.fullOrgName).toBe('Test Org'); + expect(body.fullOrgSlug).toBe('test-org'); + expect(body.fullOrgRole).toBe('admin'); + }); + + test('returns null org when no activeOrganizationId in session', async () => { + const mockSession = { + user: { id: 'user_123' }, + session: { id: 'session_456' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/api', createSessionMiddleware(mockAuth as any)); + app.get('/api', (c) => { + const user = c.var.user as any; + return c.json({ + userId: user?.id, + hasOrg: c.var.org !== null, + }); + }); + + const res = await app.request('/api'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.userId).toBe('user_123'); + expect(body.hasOrg).toBe(false); + }); + }); + + describe('API key detection', () => { + test('detects x-api-key header', async () => { + const mockSession = { + user: { id: 'user_123' }, + session: { id: 'session_456' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/api', createSessionMiddleware(mockAuth as any)); + app.get('/api', (c) => c.json({ success: true })); + + const res = await app.request('/api', { + headers: { 'x-api-key': 'ak_test_123' }, + }); + + expect(res.status).toBe(200); + }); + + test('detects Authorization: ApiKey header', async () => { + const mockSession = { + user: { id: 'user_123' }, + session: { id: 'session_456' }, + }; + const mockAuth = createMockAuth(mockSession); + const app = new Hono(); + + app.use('/api', createSessionMiddleware(mockAuth as any)); + app.get('/api', (c) => c.json({ success: true })); + + const res = await app.request('/api', { + headers: { Authorization: 'ApiKey ak_test_123' }, + }); + + expect(res.status).toBe(200); + }); + }); +}); + +describe('mountBetterAuthRoutes', () => { + test('forwards requests to auth handler', async () => { + const mockHandler = mock(async (req: Request) => { + return new Response(JSON.stringify({ path: new URL(req.url).pathname }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const mockAuth = { handler: mockHandler }; + const app = new Hono(); + + app.on(['GET', 'POST'], '/auth/*', mountBetterAuthRoutes(mockAuth as any)); + + const res = await app.request('/auth/session'); + expect(res.status).toBe(200); + expect(mockHandler).toHaveBeenCalled(); + }); + + test('preserves Set-Cookie headers from auth handler', async () => { + const mockHandler = mock(async () => { + const headers = new Headers(); + headers.append('Set-Cookie', 'session=abc123; Path=/; HttpOnly'); + headers.append('Set-Cookie', 'csrf=xyz789; Path=/'); + return new Response('{"ok": true}', { + status: 200, + headers, + }); + }); + const mockAuth = { handler: mockHandler }; + const app = new Hono(); + + app.on(['GET', 'POST'], '/auth/*', mountBetterAuthRoutes(mockAuth as any)); + + const res = await app.request('/auth/sign-in', { method: 'POST' }); + expect(res.status).toBe(200); + + const cookies = res.headers.getSetCookie(); + expect(cookies.length).toBeGreaterThanOrEqual(1); + }); + + test('returns response body from auth handler', async () => { + const mockHandler = mock(async () => { + return new Response(JSON.stringify({ user: { id: 'user_123' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const mockAuth = { handler: mockHandler }; + const app = new Hono(); + + app.on(['GET', 'POST'], '/auth/*', mountBetterAuthRoutes(mockAuth as any)); + + const res = await app.request('/auth/session'); + const body = await res.json(); + expect(body).toEqual({ user: { id: 'user_123' } }); + }); + + test('preserves status code from auth handler', async () => { + const mockHandler = mock(async () => { + return new Response('{"error": "Unauthorized"}', { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const mockAuth = { handler: mockHandler }; + const app = new Hono(); + + app.on(['GET', 'POST'], '/auth/*', mountBetterAuthRoutes(mockAuth as any)); + + const res = await app.request('/auth/session'); + expect(res.status).toBe(401); + }); + + test('merges headers set by Hono middleware', async () => { + const mockHandler = mock(async () => { + return new Response('{"ok": true}', { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Header': 'from-auth', + }, + }); + }); + const mockAuth = { handler: mockHandler }; + const app = new Hono(); + + app.use('/auth/*', async (c, next) => { + c.header('X-Middleware-Header', 'from-middleware'); + await next(); + }); + app.on(['GET', 'POST'], '/auth/*', mountBetterAuthRoutes(mockAuth as any)); + + const res = await app.request('/auth/session'); + // X-Auth-Header is NOT forwarded because it's not in the default allowlist + expect(res.headers.get('X-Auth-Header')).toBeNull(); + // Content-Type IS forwarded because it's in the allowlist + expect(res.headers.get('Content-Type')).toBe('application/json'); + // Middleware headers are preserved + expect(res.headers.get('X-Middleware-Header')).toBe('from-middleware'); + }); + + test('filters out non-allowlisted headers', async () => { + const mockHandler = mock(async () => { + return new Response('{"ok": true}', { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': 'session=abc123', + Location: '/redirect', + 'X-Internal-Debug': 'should-not-forward', + Server: 'should-not-forward', + }, + }); + }); + const mockAuth = { handler: mockHandler }; + const app = new Hono(); + app.on(['GET', 'POST'], '/auth/*', mountBetterAuthRoutes(mockAuth as any)); + + const res = await app.request('/auth/session'); + + // Allowlisted headers are forwarded + expect(res.headers.get('Content-Type')).toBe('application/json'); + expect(res.headers.get('Set-Cookie')).toBe('session=abc123'); + expect(res.headers.get('Location')).toBe('/redirect'); + + // Non-allowlisted headers are filtered out + expect(res.headers.get('X-Internal-Debug')).toBeNull(); + expect(res.headers.get('Server')).toBeNull(); + }); + + test('allows custom header allowlist', async () => { + const mockHandler = mock(async () => { + return new Response('{"ok": true}', { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + }, + }); + }); + const mockAuth = { handler: mockHandler }; + const app = new Hono(); + app.on( + ['GET', 'POST'], + '/auth/*', + mountBetterAuthRoutes(mockAuth as any, { + allowList: ['content-type', 'x-custom-header'], + }) + ); + + const res = await app.request('/auth/session'); + + expect(res.headers.get('Content-Type')).toBe('application/json'); + expect(res.headers.get('X-Custom-Header')).toBe('custom-value'); + }); +}); diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 1d9075fb..828eb89c 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -4,7 +4,8 @@ "composite": true, "outDir": "./dist", "rootDir": "./src", - "jsx": "react-jsx" + "jsx": "react-jsx", + "lib": ["ESNext", "DOM"] }, "include": ["src/**/*"], "exclude": ["test/**/*"], diff --git a/packages/cli/package.json b/packages/cli/package.json index 13bad32e..490efdb9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,7 @@ "prepublishOnly": "bun run clean && bun run build" }, "dependencies": { + "@agentuity/auth": "workspace:*", "@agentuity/core": "workspace:*", "@agentuity/server": "workspace:*", "@datasert/cronjs-parser": "^1.4.0", diff --git a/packages/cli/src/cmd/project/auth/index.ts b/packages/cli/src/cmd/project/auth/index.ts new file mode 100644 index 00000000..bee52d33 --- /dev/null +++ b/packages/cli/src/cmd/project/auth/index.ts @@ -0,0 +1,16 @@ +import { createCommand } from '../../../types'; +import { initSubcommand } from './init'; +import { getCommand } from '../../../command-prefix'; + +export const authCommand = createCommand({ + name: 'auth', + description: 'Manage project authentication (Agentuity Auth)', + tags: ['slow', 'requires-auth'], + examples: [ + { + command: getCommand('project auth init'), + description: 'Set up Agentuity Auth for an existing project', + }, + ], + subcommands: [initSubcommand], +}); diff --git a/packages/cli/src/cmd/project/auth/init.ts b/packages/cli/src/cmd/project/auth/init.ts new file mode 100644 index 00000000..658cf907 --- /dev/null +++ b/packages/cli/src/cmd/project/auth/init.ts @@ -0,0 +1,212 @@ +import { z } from 'zod'; +import { createSubcommand } from '../../../types'; +import * as tui from '../../../tui'; +import { getCommand } from '../../../command-prefix'; +import { + selectOrCreateDatabase, + ensureAuthDependencies, + runAuthMigrations, + generateAuthFileContent, + printIntegrationExamples, +} from './shared'; +import enquirer from 'enquirer'; +import * as fs from 'fs'; +import * as path from 'path'; + +export const initSubcommand = createSubcommand({ + name: 'init', + description: 'Set up Agentuity Auth for your project', + tags: ['mutating', 'slow', 'requires-auth'], + requires: { auth: true, org: true, region: true }, + idempotent: false, + examples: [ + { + command: getCommand('project auth init'), + description: 'Set up Agentuity Auth with database selection', + }, + ], + schema: { + options: z.object({ + skipMigrations: z + .boolean() + .optional() + .describe('Skip running database migrations (run ensureAuthSchema at runtime instead)'), + }), + response: z.object({ + success: z.boolean().describe('Whether setup succeeded'), + database: z.string().optional().describe('Database name used'), + authFileCreated: z.boolean().describe('Whether auth.ts was created'), + migrationsRun: z.boolean().describe('Whether migrations were run'), + }), + }, + + async handler(ctx) { + const { logger, opts, auth, orgId, region } = ctx; + + tui.newline(); + tui.info(tui.bold('Agentuity Auth Setup')); + tui.newline(); + tui.info('This will:'); + console.log(' • Ensure you have a Postgres database configured'); + console.log(' • Install @agentuity/auth and better-auth'); + console.log(' • Run database migrations to create auth tables'); + console.log(' • Show you how to wire auth into your API and UI'); + tui.newline(); + + const projectDir = process.cwd(); + + // Check for package.json + const packageJsonPath = path.join(projectDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + tui.fatal('No package.json found. Run this command from your project root.'); + } + + // Step 1: Check for DATABASE_URL or select/create database + let databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + // Check .env file + const envPath = path.join(projectDir, '.env'); + if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8'); + const match = envContent.match(/^DATABASE_URL=(.+)$/m); + if (match) { + databaseUrl = match[1].trim().replace(/^["']|["']$/g, ''); + } + } + } + + // Show database picker (with existing as first option if configured) + const dbInfo = await selectOrCreateDatabase({ + logger, + auth, + orgId, + region, + existingUrl: databaseUrl, + }); + + const databaseName = dbInfo.name; + + // Update .env with database URL + const envPath = path.join(projectDir, '.env'); + let envContent = ''; + + if (fs.existsSync(envPath)) { + envContent = fs.readFileSync(envPath, 'utf-8'); + if (!envContent.endsWith('\n') && envContent.length > 0) { + envContent += '\n'; + } + } + + // Check if DATABASE_URL already exists + const hasDatabaseUrl = envContent.match(/^DATABASE_URL=/m); + + if (dbInfo.url !== databaseUrl || !hasDatabaseUrl) { + if (hasDatabaseUrl) { + // DATABASE_URL exists, use AUTH_DATABASE_URL instead + envContent += `AUTH_DATABASE_URL="${dbInfo.url}"\n`; + fs.writeFileSync(envPath, envContent); + tui.success('AUTH_DATABASE_URL added to .env'); + tui.warning( + `DATABASE_URL already exists. Update your ${tui.bold('src/auth.ts')} to use AUTH_DATABASE_URL.` + ); + } else { + envContent += `DATABASE_URL="${dbInfo.url}"\n`; + fs.writeFileSync(envPath, envContent); + tui.success('DATABASE_URL added to .env'); + } + } else { + tui.success(`Using database: ${databaseName}`); + } + + // Add BETTER_AUTH_SECRET if not present + // Re-read envContent to get latest state + envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : ''; + if (!envContent.endsWith('\n') && envContent.length > 0) { + envContent += '\n'; + } + + const hasBetterAuthSecret = envContent.match(/^BETTER_AUTH_SECRET=/m); + if (!hasBetterAuthSecret) { + const devSecret = `dev-${crypto.randomUUID()}-CHANGE-ME`; + envContent += `BETTER_AUTH_SECRET="${devSecret}"\n`; + fs.writeFileSync(envPath, envContent); + tui.success('BETTER_AUTH_SECRET added to .env (development default)'); + tui.warning( + `Replace ${tui.bold('BETTER_AUTH_SECRET')} with a secure value before deploying.` + ); + tui.info(`Generate one with: ${tui.muted('openssl rand -hex 32')}`); + } + + // Step 2: Install dependencies + tui.newline(); + await ensureAuthDependencies({ projectDir, logger }); + + // Step 3: Generate auth.ts if it doesn't exist + const authFilePath = path.join(projectDir, 'src', 'auth.ts'); + let authFileCreated = false; + + if (fs.existsSync(authFilePath)) { + tui.info('src/auth.ts already exists, skipping generation'); + } else { + const { createFile } = await enquirer.prompt<{ createFile: boolean }>({ + type: 'confirm', + name: 'createFile', + message: 'Create src/auth.ts with default configuration?', + initial: true, + }); + + if (createFile) { + // Ensure src directory exists + const srcDir = path.join(projectDir, 'src'); + if (!fs.existsSync(srcDir)) { + fs.mkdirSync(srcDir, { recursive: true }); + } + + fs.writeFileSync(authFilePath, generateAuthFileContent()); + tui.success('Created src/auth.ts'); + authFileCreated = true; + } + } + + // Step 4: Run migrations + let migrationsRun = false; + + if (opts.skipMigrations) { + tui.info('Skipping migrations (use ensureAuthSchema() at runtime)'); + } else if (databaseName) { + tui.newline(); + const { runMigrations } = await enquirer.prompt<{ runMigrations: boolean }>({ + type: 'confirm', + name: 'runMigrations', + message: 'Run database migrations now? (idempotent, safe to re-run)', + initial: true, + }); + + if (runMigrations) { + await runAuthMigrations({ + logger, + auth, + orgId, + region, + databaseName, + }); + migrationsRun = true; + } + } else { + tui.warning( + 'Could not determine database name. Run migrations manually or call ensureAuthSchema() at runtime.' + ); + } + + // Step 5: Print integration examples + printIntegrationExamples(); + + return { + success: true, + database: databaseName, + authFileCreated, + migrationsRun, + }; + }, +}); diff --git a/packages/cli/src/cmd/project/auth/shared.ts b/packages/cli/src/cmd/project/auth/shared.ts new file mode 100644 index 00000000..4df5dc67 --- /dev/null +++ b/packages/cli/src/cmd/project/auth/shared.ts @@ -0,0 +1,372 @@ +/** + * Shared helpers for Agentuity Auth setup + */ + +import { listResources, createResources, dbQuery } from '@agentuity/server'; +import * as tui from '../../../tui'; +import { getCatalystAPIClient } from '../../../config'; +import type { Logger } from '../../../types'; +import type { AuthData } from '../../../types'; +import enquirer from 'enquirer'; + +/** + * Database info returned from selection + */ +export interface DatabaseInfo { + name: string; + url: string; +} + +/** + * Select an existing database or create a new one + */ +export async function selectOrCreateDatabase(options: { + logger: Logger; + auth: AuthData; + orgId: string; + region: string; + existingUrl?: string; +}): Promise { + const { logger, auth, orgId, region, existingUrl } = options; + const catalystClient = getCatalystAPIClient(logger, auth, region); + + const resources = await tui.spinner({ + message: `Fetching databases for ${orgId} in ${region}`, + clearOnSuccess: true, + callback: async () => listResources(catalystClient, orgId, region), + }); + + const databases = resources.db; + + // Extract existing database name from URL if provided + let existingDbName: string | undefined; + if (existingUrl) { + const urlMatch = existingUrl.match(/\/([^/?]+)(\?|$)/); + if (urlMatch) { + existingDbName = urlMatch[1]; + } + } + + type Choice = { name: string; message: string }; + const choices: Choice[] = []; + + // Add "use existing" option first if we have an existing URL + if (existingUrl && existingDbName) { + choices.push({ + name: '__existing__', + message: `${tui.tuiColors.success('✓')} Use existing (found in .env): ${existingDbName}`, + }); + } + + // Add create new option + choices.push({ name: '__create__', message: tui.bold('+ Create new database') }); + + // Add other databases + choices.push( + ...databases + .filter((db) => db.name !== existingDbName) // Don't duplicate existing + .map((db) => ({ + name: db.name, + message: db.name, + })) + ); + + const response = await enquirer.prompt<{ database: string }>({ + type: 'select', + name: 'database', + message: 'Select a database for auth:', + choices, + }); + + // Handle "use existing" selection + if (response.database === '__existing__' && existingUrl && existingDbName) { + return { name: existingDbName, url: existingUrl }; + } + + if (response.database === '__create__') { + const created = await tui.spinner({ + message: `Creating database in ${region}`, + clearOnSuccess: true, + callback: async () => createResources(catalystClient, orgId, region, [{ type: 'db' }]), + }); + + if (created.length === 0) { + tui.fatal('Failed to create database'); + } + + const newDb = created[0]; + tui.success(`Created database: ${tui.bold(newDb.name)}`); + + const updatedResources = await listResources(catalystClient, orgId, region); + const dbInfo = updatedResources.db.find((d) => d.name === newDb.name); + + if (!dbInfo?.url) { + tui.fatal('Failed to retrieve database connection URL'); + } + + return { name: newDb.name, url: dbInfo.url }; + } + + const selectedDb = databases.find((d) => d.name === response.database); + if (!selectedDb?.url) { + tui.fatal('Failed to retrieve database connection URL'); + } + + return { name: selectedDb.name, url: selectedDb.url }; +} + +/** + * Required auth dependencies + */ +export const AUTH_DEPENDENCIES = { + '@agentuity/auth': 'latest', + 'better-auth': '^1.4.9', + pg: '^8.13.0', +} as const; + +/** + * Check and install auth dependencies + */ +export async function ensureAuthDependencies(options: { + projectDir: string; + logger: Logger; +}): Promise { + const { projectDir } = options; + const fs = await import('fs'); + const path = await import('path'); + + const packageJsonPath = path.join(projectDir, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + tui.fatal('No package.json found in project directory'); + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const deps = packageJson.dependencies || {}; + + const missingDeps: string[] = []; + + for (const [dep, version] of Object.entries(AUTH_DEPENDENCIES)) { + if (!deps[dep]) { + missingDeps.push(`${dep}@${version}`); + } + } + + if (missingDeps.length === 0) { + return false; + } + + tui.info(`Installing auth dependencies: ${missingDeps.join(', ')}`); + + const { spawn } = await import('child_process'); + + await new Promise((resolve, reject) => { + const proc = spawn('bun', ['install', ...missingDeps], { + cwd: projectDir, + stdio: 'inherit', + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`bun install failed with code ${code}`)); + } + }); + + proc.on('error', reject); + }); + + tui.success('Dependencies installed'); + return true; +} + +/** + * Import the baseline SQL from @agentuity/auth to ensure single source of truth. + * This SQL is used by both the CLI (for initial setup) and ensureAuthSchema() at runtime. + * + * Note: We import directly from the migrations module to avoid pulling in client-side + * JSX code that would cause TypeScript errors in the CLI build. + */ +import { AGENTUITY_AUTH_BASELINE_SQL } from '@agentuity/auth/agentuity/migrations'; +export { AGENTUITY_AUTH_BASELINE_SQL }; + +/** + * Split SQL into individual statements for sequential execution + * The dbQuery API only supports single statements + */ +export function splitSqlStatements(sql: string): string[] { + // Split on semicolons, but be careful about edge cases + const statements: string[] = []; + let current = ''; + + for (const line of sql.split('\n')) { + const trimmed = line.trim(); + + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('--')) { + continue; + } + + current += line + '\n'; + + // If line ends with semicolon, it's end of statement + if (trimmed.endsWith(';')) { + const stmt = current.trim(); + if (stmt && stmt !== ';') { + statements.push(stmt); + } + current = ''; + } + } + + // Handle any remaining content + if (current.trim()) { + statements.push(current.trim()); + } + + return statements; +} + +/** + * Run auth migrations against a database + */ +export async function runAuthMigrations(options: { + logger: Logger; + auth: AuthData; + orgId: string; + region: string; + databaseName: string; +}): Promise { + const { logger, auth, orgId, region, databaseName } = options; + const catalystClient = getCatalystAPIClient(logger, auth, region); + + // Split into individual statements since dbQuery only supports single statements + const statements = splitSqlStatements(AGENTUITY_AUTH_BASELINE_SQL); + + await tui.spinner({ + message: `Running auth migrations on ${databaseName} (${statements.length} statements)`, + clearOnSuccess: true, + callback: async () => { + for (const statement of statements) { + await dbQuery(catalystClient, { + database: databaseName, + query: statement, + orgId, + region, + }); + } + }, + }); + + tui.success(`Auth tables created in ${tui.bold(databaseName)}`); +} + +/** + * Generate the auth.ts file content + */ +export function generateAuthFileContent(): string { + return `import { Pool } from 'pg'; +import { + createAgentuityAuth, + createSessionMiddleware, +} from '@agentuity/auth/agentuity'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL! }); + +export const auth = createAgentuityAuth({ + database: pool, + basePath: '/api/auth', + // Secret used by BetterAuth for signing/verifying tokens and cookies. + // Generate with: openssl rand -hex 32 + secret: process.env.BETTER_AUTH_SECRET!, +}); + +// Required auth middleware - returns 401 if not authenticated +export const authMiddleware = createSessionMiddleware(auth); + +// Optional auth middleware - allows anonymous access, sets null auth +export const optionalAuthMiddleware = createSessionMiddleware(auth, { optional: true }); +`; +} + +/** + * Print integration examples to the console + */ +export function printIntegrationExamples(): void { + tui.newline(); + tui.info(tui.bold('Next Steps - Add these to your project:')); + tui.newline(); + + console.log(tui.muted('━'.repeat(60))); + console.log(tui.bold(' 1. Set up your API routes (e.g., src/api/index.ts):')); + console.log(tui.muted('━'.repeat(60))); + console.log(` +import { createRouter } from '@agentuity/runtime'; +import { mountBetterAuthRoutes } from '@agentuity/auth/agentuity'; +import { auth, authMiddleware } from '../auth'; + +const api = createRouter(); + +// Mount BetterAuth routes (sign-in, sign-up, sign-out, session, etc.) +// Must match the basePath configured in createAgentuityAuth (default: /api/auth) +api.on(['GET', 'POST'], '/api/auth/*', mountBetterAuthRoutes(auth)); + +// Protect your API routes with auth middleware +api.use('/api/*', authMiddleware); + +api.get('/api/me', async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ id: user.id, email: user.email }); +}); + +export default api; +`); + + console.log(tui.muted('━'.repeat(60))); + console.log(tui.bold(' 2. Wrap your React app with AuthProvider:')); + console.log(tui.muted('━'.repeat(60))); + console.log(` +import { AgentuityBetterAuth } from '@agentuity/auth/agentuity/client'; + +function App() { + return ( + + {/* your app */} + + ); +} +`); + + console.log(tui.muted('━'.repeat(60))); + console.log(tui.bold(' 3. Protect agents with withSession:')); + console.log(tui.muted('━'.repeat(60))); + console.log(` +import { createAgent } from '@agentuity/runtime'; +import { withSession } from '@agentuity/auth/agentuity'; + +export default createAgent('my-agent', { + schema: { input: s.object({ name: s.string() }), output: s.string() }, + handler: withSession(async (ctx, { auth, org }, input) => { + // ctx = AgentContext, auth = { user, session } | null, org = org context + if (auth) { + const email = (auth.user as { email?: string }).email; + return \`Hello, \${email}!\`; + } + return 'Hello, anonymous!'; + }, { optional: true }), // optional: true allows unauthenticated access +}); +`); + + tui.newline(); + console.log(tui.muted('━'.repeat(60))); + tui.info('Checklist:'); + console.log(` ${tui.tuiColors.success('✓')} DATABASE_URL configured`); + console.log(` ${tui.muted('○')} BETTER_AUTH_SECRET configured (openssl rand -hex 32)`); + console.log(` ${tui.tuiColors.success('✓')} Auth tables migrated`); + console.log(` ${tui.tuiColors.success('✓')} Dependencies installed`); + console.log(` ${tui.muted('○')} Wire Hono middleware`); + console.log(` ${tui.muted('○')} Add auth routes (mountBetterAuthRoutes at /api/auth/*)`); + console.log(` ${tui.muted('○')} Wrap app with AgentuityBetterAuth`); + tui.newline(); +} diff --git a/packages/cli/src/cmd/project/index.ts b/packages/cli/src/cmd/project/index.ts index 234d22d0..5223cc73 100644 --- a/packages/cli/src/cmd/project/index.ts +++ b/packages/cli/src/cmd/project/index.ts @@ -3,6 +3,7 @@ import { createProjectSubcommand } from './create'; import { listSubcommand } from './list'; import { deleteSubcommand } from './delete'; import { showSubcommand } from './show'; +import { authCommand } from './auth'; import { getCommand } from '../../command-prefix'; export const command = createCommand({ @@ -12,6 +13,13 @@ export const command = createCommand({ examples: [ { command: getCommand('project create my-agent'), description: 'Create a new project' }, { command: getCommand('project list'), description: 'List all projects' }, + { command: getCommand('project auth init'), description: 'Set up Agentuity Auth' }, + ], + subcommands: [ + createProjectSubcommand, + listSubcommand, + deleteSubcommand, + showSubcommand, + authCommand, ], - subcommands: [createProjectSubcommand, listSubcommand, deleteSubcommand, showSubcommand], }); diff --git a/packages/cli/src/cmd/project/template-flow.ts b/packages/cli/src/cmd/project/template-flow.ts index 5d85ee63..b67dc336 100644 --- a/packages/cli/src/cmd/project/template-flow.ts +++ b/packages/cli/src/cmd/project/template-flow.ts @@ -30,6 +30,12 @@ import { splitEnvAndSecrets, } from '../../env-util'; import { promptForDNS } from '../../domain'; +import { + ensureAuthDependencies, + runAuthMigrations, + generateAuthFileContent, + printIntegrationExamples, +} from './auth/shared'; type ResourcesTypes = z.infer; @@ -350,6 +356,95 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise { } } + // Auth setup prompt (skip for templates that already have auth configured) + const templatesWithAuth = ['clerk', 'auth0']; + const templateHasAuth = templatesWithAuth.includes(selectedTemplate.id); + + let authEnabled = false; + let authDatabaseName: string | undefined; + let authDatabaseUrl: string | undefined; + + if (auth && catalystClient && orgId && region && !skipPrompts && !templateHasAuth) { + const enableAuth = await prompt.select({ + message: 'Enable Agentuity Authentication?', + options: [ + { value: 'no', label: "No, I'll add auth later" }, + { value: 'yes', label: 'Yes, set up Agentuity Auth (BetterAuth)' }, + ], + }); + + if (enableAuth === 'yes') { + authEnabled = true; + + // If a database was already selected/created above, offer to use it + if (resourceConfig.db) { + const useExisting = await prompt.confirm({ + message: `Use the database "${resourceConfig.db}" for auth?`, + initial: true, + }); + + if (useExisting) { + authDatabaseName = resourceConfig.db; + // Fetch the URL for this database + const resources = await listResources(catalystClient, orgId, region); + const dbInfo = resources.db.find((d) => d.name === resourceConfig.db); + if (dbInfo?.url) { + authDatabaseUrl = dbInfo.url; + } + } + } + + // If no database selected yet, create one for auth + if (!authDatabaseName) { + const created = await tui.spinner({ + message: 'Provisioning database for auth', + clearOnSuccess: true, + callback: async () => { + return createResources(catalystClient, orgId, region!, [{ type: 'db' }]); + }, + }); + authDatabaseName = created[0].name; + + // Fetch the URL + const resources = await listResources(catalystClient, orgId, region); + const dbInfo = resources.db.find((d) => d.name === authDatabaseName); + if (dbInfo?.url) { + authDatabaseUrl = dbInfo.url; + } + + // Also set it as the project's database if not already set + if (!resourceConfig.db) { + resourceConfig.db = authDatabaseName; + } + } + + // Install auth dependencies + await ensureAuthDependencies({ projectDir: dest, logger }); + + // Generate auth.ts + const authFilePath = resolve(dest, 'src', 'auth.ts'); + if (!existsSync(authFilePath)) { + const srcDir = resolve(dest, 'src'); + if (!existsSync(srcDir)) { + await Bun.write(resolve(srcDir, '.gitkeep'), ''); + } + await Bun.write(authFilePath, generateAuthFileContent()); + tui.success('Created src/auth.ts'); + } + + // Run migrations + if (authDatabaseName) { + await runAuthMigrations({ + logger, + auth, + orgId, + region, + databaseName: authDatabaseName, + }); + } + } + } + let projectId: string | undefined; if (auth && apiClient && orgId) { @@ -392,6 +487,55 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise { }, }); + // Write DATABASE_URL and BETTER_AUTH_SECRET to .env after createProjectConfig (which overwrites .env) + if (authDatabaseUrl) { + const envPath = resolve(dest, '.env'); + let envContent = ''; + + if (existsSync(envPath)) { + envContent = await Bun.file(envPath).text(); + if (!envContent.endsWith('\n') && envContent.length > 0) { + envContent += '\n'; + } + } + + // Check if DATABASE_URL already exists + const hasDatabaseUrl = envContent.match(/^DATABASE_URL=/m); + + if (hasDatabaseUrl) { + // DATABASE_URL exists, use AUTH_DATABASE_URL instead + envContent += `AUTH_DATABASE_URL="${authDatabaseUrl}"\n`; + await Bun.write(envPath, envContent); + tui.success('AUTH_DATABASE_URL added to .env'); + tui.warning( + `DATABASE_URL already exists. Update your ${tui.bold('src/auth.ts')} to use AUTH_DATABASE_URL.` + ); + } else { + envContent += `DATABASE_URL="${authDatabaseUrl}"\n`; + await Bun.write(envPath, envContent); + tui.success('DATABASE_URL added to .env'); + } + + // Add BETTER_AUTH_SECRET if not present + // Re-read envContent to get latest state + envContent = existsSync(envPath) ? await Bun.file(envPath).text() : ''; + if (!envContent.endsWith('\n') && envContent.length > 0) { + envContent += '\n'; + } + + const hasBetterAuthSecret = envContent.match(/^BETTER_AUTH_SECRET=/m); + if (!hasBetterAuthSecret) { + const devSecret = `dev-${crypto.randomUUID()}-CHANGE-ME`; + envContent += `BETTER_AUTH_SECRET="${devSecret}"\n`; + await Bun.write(envPath, envContent); + tui.success('BETTER_AUTH_SECRET added to .env (development default)'); + tui.warning( + `Replace ${tui.bold('BETTER_AUTH_SECRET')} with a secure value before deploying.` + ); + tui.info(`Generate one with: ${tui.muted('openssl rand -hex 32')}`); + } + } + // After registration, push any existing env/secrets from .env.production if (projectId) { await tui.spinner({ @@ -461,6 +605,11 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise { await promptForDNS(projectId, _domains, config); } } + + // Print auth integration examples if auth was enabled + if (authEnabled) { + printIntegrationExamples(); + } } /** diff --git a/packages/cli/test/cmd/project/auth/init.test.ts b/packages/cli/test/cmd/project/auth/init.test.ts new file mode 100644 index 00000000..a9bf0230 --- /dev/null +++ b/packages/cli/test/cmd/project/auth/init.test.ts @@ -0,0 +1,239 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { initSubcommand } from '../../../../src/cmd/project/auth/init'; +import { authCommand } from '../../../../src/cmd/project/auth'; + +describe('project auth init', () => { + let testDir: string; + let originalCwd: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + testDir = join(tmpdir(), `auth-init-test-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, 'src'), { recursive: true }); + originalCwd = process.cwd(); + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.chdir(originalCwd); + process.env = originalEnv; + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('initSubcommand definition', () => { + test('should have correct name', () => { + expect(initSubcommand.name).toBe('init'); + }); + + test('should have description', () => { + expect(initSubcommand.description).toBe('Set up Agentuity Auth for your project'); + }); + + test('should require auth', () => { + expect(initSubcommand.requires?.auth).toBe(true); + }); + + test('should require org', () => { + expect(initSubcommand.requires?.org).toBe(true); + }); + + test('should require region', () => { + expect(initSubcommand.requires?.region).toBe(true); + }); + + test('should not be idempotent', () => { + expect(initSubcommand.idempotent).toBe(false); + }); + + test('should have mutating tag', () => { + expect(initSubcommand.tags).toContain('mutating'); + }); + + test('should have slow tag', () => { + expect(initSubcommand.tags).toContain('slow'); + }); + + test('should have requires-auth tag', () => { + expect(initSubcommand.tags).toContain('requires-auth'); + }); + + test('should have skipMigrations option in schema', () => { + expect(initSubcommand.schema?.options).toBeDefined(); + }); + + test('should have response schema with success', () => { + expect(initSubcommand.schema?.response).toBeDefined(); + }); + }); + + describe('authCommand definition', () => { + test('should have correct name', () => { + expect(authCommand.name).toBe('auth'); + }); + + test('should have description', () => { + expect(authCommand.description).toBe('Manage project authentication (Agentuity Auth)'); + }); + + test('should have slow tag', () => { + expect(authCommand.tags).toContain('slow'); + }); + + test('should have requires-auth tag', () => { + expect(authCommand.tags).toContain('requires-auth'); + }); + + test('should have init subcommand', () => { + expect(authCommand.subcommands).toBeDefined(); + expect(authCommand.subcommands).toHaveLength(1); + expect(authCommand.subcommands?.[0].name).toBe('init'); + }); + + test('should have examples', () => { + expect(authCommand.examples).toBeDefined(); + expect(authCommand.examples).toHaveLength(1); + }); + }); + + describe('DATABASE_URL detection', () => { + test('should read DATABASE_URL from environment', () => { + process.env.DATABASE_URL = 'postgresql://localhost:5432/test'; + expect(process.env.DATABASE_URL).toBe('postgresql://localhost:5432/test'); + }); + + test('should parse DATABASE_URL from .env file', () => { + const envPath = join(testDir, '.env'); + writeFileSync(envPath, 'DATABASE_URL="postgresql://localhost:5432/authdb"\n'); + + const envContent = readFileSync(envPath, 'utf-8'); + const match = envContent.match(/^DATABASE_URL=(.+)$/m); + + expect(match).not.toBeNull(); + if (match) { + const url = match[1].trim().replace(/^["']|["']$/g, ''); + expect(url).toBe('postgresql://localhost:5432/authdb'); + } + }); + + test('should handle DATABASE_URL without quotes', () => { + const envPath = join(testDir, '.env'); + writeFileSync(envPath, 'DATABASE_URL=postgresql://localhost:5432/authdb\n'); + + const envContent = readFileSync(envPath, 'utf-8'); + const match = envContent.match(/^DATABASE_URL=(.+)$/m); + + expect(match).not.toBeNull(); + if (match) { + const url = match[1].trim().replace(/^["']|["']$/g, ''); + expect(url).toBe('postgresql://localhost:5432/authdb'); + } + }); + + test('should extract database name from URL', () => { + const databaseUrl = 'postgresql://user:pass@localhost:5432/mydb?sslmode=require'; + const urlMatch = databaseUrl.match(/\/([^/?]+)(\?|$)/); + + expect(urlMatch).not.toBeNull(); + if (urlMatch) { + expect(urlMatch[1]).toBe('mydb'); + } + }); + + test('should handle URL without query parameters', () => { + const databaseUrl = 'postgresql://user:pass@localhost:5432/testdb'; + const urlMatch = databaseUrl.match(/\/([^/?]+)(\?|$)/); + + expect(urlMatch).not.toBeNull(); + if (urlMatch) { + expect(urlMatch[1]).toBe('testdb'); + } + }); + }); + + describe('package.json detection', () => { + test('should detect when package.json exists', () => { + const packageJsonPath = join(testDir, 'package.json'); + writeFileSync(packageJsonPath, JSON.stringify({ name: 'test-project' })); + + expect(existsSync(packageJsonPath)).toBe(true); + }); + + test('should detect when package.json does not exist', () => { + const packageJsonPath = join(testDir, 'package.json'); + expect(existsSync(packageJsonPath)).toBe(false); + }); + + test('should parse package.json dependencies', () => { + const packageJsonPath = join(testDir, 'package.json'); + const packageJson = { + name: 'test-project', + dependencies: { + react: '^18.0.0', + 'better-auth': '^1.2.0', + }, + }; + writeFileSync(packageJsonPath, JSON.stringify(packageJson)); + + const content = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + expect(content.dependencies['better-auth']).toBe('^1.2.0'); + }); + }); + + describe('src directory handling', () => { + test('should detect existing src directory', () => { + expect(existsSync(join(testDir, 'src'))).toBe(true); + }); + + test('should create src directory if not exists', () => { + const newTestDir = join(tmpdir(), `auth-init-no-src-${Date.now()}`); + mkdirSync(newTestDir, { recursive: true }); + + const srcDir = join(newTestDir, 'src'); + expect(existsSync(srcDir)).toBe(false); + + mkdirSync(srcDir, { recursive: true }); + expect(existsSync(srcDir)).toBe(true); + + rmSync(newTestDir, { recursive: true, force: true }); + }); + + test('should detect existing auth.ts', () => { + const authFilePath = join(testDir, 'src', 'auth.ts'); + writeFileSync(authFilePath, 'export const auth = {}'); + + expect(existsSync(authFilePath)).toBe(true); + }); + }); + + describe('.env file handling', () => { + test('should append to existing .env file', () => { + const envPath = join(testDir, '.env'); + writeFileSync(envPath, 'EXISTING_VAR=value\n'); + + let envContent = readFileSync(envPath, 'utf-8'); + if (!envContent.endsWith('\n')) { + envContent += '\n'; + } + envContent += 'DATABASE_URL="postgresql://localhost:5432/newdb"\n'; + writeFileSync(envPath, envContent); + + const finalContent = readFileSync(envPath, 'utf-8'); + expect(finalContent).toContain('EXISTING_VAR=value'); + expect(finalContent).toContain('DATABASE_URL='); + }); + + test('should create .env file if not exists', () => { + const envPath = join(testDir, '.env'); + expect(existsSync(envPath)).toBe(false); + + writeFileSync(envPath, 'DATABASE_URL="postgresql://localhost:5432/newdb"\n'); + expect(existsSync(envPath)).toBe(true); + }); + }); +}); diff --git a/packages/cli/test/cmd/project/auth/shared.test.ts b/packages/cli/test/cmd/project/auth/shared.test.ts new file mode 100644 index 00000000..bde428a3 --- /dev/null +++ b/packages/cli/test/cmd/project/auth/shared.test.ts @@ -0,0 +1,176 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + AUTH_DEPENDENCIES, + AGENTUITY_AUTH_BASELINE_SQL, + generateAuthFileContent, + splitSqlStatements, +} from '../../../../src/cmd/project/auth/shared'; + +describe('project auth shared', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `auth-shared-test-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('AUTH_DEPENDENCIES', () => { + test('should include @agentuity/auth', () => { + expect(AUTH_DEPENDENCIES['@agentuity/auth']).toBe('latest'); + }); + + test('should include better-auth', () => { + expect(AUTH_DEPENDENCIES['better-auth']).toBe('^1.2.0'); + }); + + test('should include pg', () => { + expect(AUTH_DEPENDENCIES['pg']).toBe('^8.13.0'); + }); + + test('should have exactly 3 dependencies', () => { + expect(Object.keys(AUTH_DEPENDENCIES)).toHaveLength(3); + }); + }); + + describe('AGENTUITY_AUTH_BASELINE_SQL', () => { + test('should include user table creation', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "user"'); + }); + + test('should include session table creation', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "session"'); + }); + + test('should include account table creation', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "account"'); + }); + + test('should include verification table creation', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "verification"'); + }); + + test('should include organization table creation', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "organization"'); + }); + + test('should include member table creation', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "member"'); + }); + + test('should include invitation table creation', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "invitation"'); + }); + + test('should include jwks table creation', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS "jwks"'); + }); + + test('should include apiKey table creation', () => { + // Note: BetterAuth expects lowercase table name "apikey" (not "apiKey") + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE TABLE IF NOT EXISTS apikey'); + }); + + test('should include indexes', () => { + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('CREATE INDEX IF NOT EXISTS'); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('session_userId_idx'); + expect(AGENTUITY_AUTH_BASELINE_SQL).toContain('apikey_key_idx'); + }); + + test('should be idempotent (uses IF NOT EXISTS)', () => { + const createStatements = AGENTUITY_AUTH_BASELINE_SQL.match(/CREATE TABLE/g) || []; + const ifNotExistsCount = + AGENTUITY_AUTH_BASELINE_SQL.match(/CREATE TABLE IF NOT EXISTS/g) || []; + expect(createStatements.length).toBe(ifNotExistsCount.length); + }); + }); + + describe('generateAuthFileContent', () => { + test('should return valid TypeScript content', () => { + const content = generateAuthFileContent(); + expect(content).toContain("import { Pool } from 'pg'"); + }); + + test('should import from @agentuity/auth/agentuity', () => { + const content = generateAuthFileContent(); + expect(content).toContain("from '@agentuity/auth/agentuity'"); + }); + + test('should create pool with DATABASE_URL', () => { + const content = generateAuthFileContent(); + expect(content).toContain('connectionString: process.env.DATABASE_URL'); + }); + + test('should export auth instance', () => { + const content = generateAuthFileContent(); + expect(content).toContain('export const auth = createAgentuityAuth'); + }); + + test('should export authMiddleware', () => { + const content = generateAuthFileContent(); + expect(content).toContain('export const authMiddleware = createSessionMiddleware'); + }); + + test('should set basePath to /api/auth', () => { + const content = generateAuthFileContent(); + expect(content).toContain("basePath: '/api/auth'"); + }); + }); + + describe('splitSqlStatements', () => { + test('should split simple statements', () => { + const sql = 'SELECT 1;\nSELECT 2;'; + const statements = splitSqlStatements(sql); + expect(statements).toHaveLength(2); + expect(statements[0]).toBe('SELECT 1;'); + expect(statements[1]).toBe('SELECT 2;'); + }); + + test('should handle multi-line statements', () => { + const sql = `CREATE TABLE test ( + id INT +);`; + const statements = splitSqlStatements(sql); + expect(statements).toHaveLength(1); + expect(statements[0]).toContain('CREATE TABLE test'); + }); + + test('should skip comments', () => { + const sql = '-- This is a comment\nSELECT 1;'; + const statements = splitSqlStatements(sql); + expect(statements).toHaveLength(1); + expect(statements[0]).toBe('SELECT 1;'); + }); + + test('should skip empty lines', () => { + const sql = 'SELECT 1;\n\n\nSELECT 2;'; + const statements = splitSqlStatements(sql); + expect(statements).toHaveLength(2); + }); + + test('should handle AGENTUITY_AUTH_BASELINE_SQL', () => { + const statements = splitSqlStatements(AGENTUITY_AUTH_BASELINE_SQL); + // Should have 9 tables + 7 indexes = 16 statements + expect(statements.length).toBeGreaterThanOrEqual(15); + // Each statement should be valid (not empty, ends with semicolon) + for (const stmt of statements) { + expect(stmt.trim()).not.toBe(''); + expect(stmt.trim().endsWith(';')).toBe(true); + } + }); + + test('should not include standalone semicolons', () => { + const sql = 'SELECT 1;\n;\nSELECT 2;'; + const statements = splitSqlStatements(sql); + expect(statements).toHaveLength(2); + }); + }); +}); diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index ab59faf4..cbac5b7f 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -47,6 +47,7 @@ src/ - No test framework configured yet - When adding tests, use Bun's built-in test runner: `bun test` +- When running tests, prefer using a subagent (Task tool) to avoid context bloat from test output ## Publishing Checklist diff --git a/packages/frontend/AGENTS.md b/packages/frontend/AGENTS.md index 51f7b0ea..0f049ca8 100644 --- a/packages/frontend/AGENTS.md +++ b/packages/frontend/AGENTS.md @@ -71,6 +71,7 @@ src/ - Test with Bun test runner - Mock browser APIs where needed (fetch, WebSocket, EventSource) - Ensure all utilities work without framework dependencies +- When running tests, prefer using a subagent (Task tool) to avoid context bloat from test output ## Publishing Checklist diff --git a/packages/react/AGENTS.md b/packages/react/AGENTS.md index c83c5eef..79baba43 100644 --- a/packages/react/AGENTS.md +++ b/packages/react/AGENTS.md @@ -88,6 +88,7 @@ export interface AgentRegistry { - Mock fetch and WebSocket APIs - Test error boundaries - Test with and without provider +- When running tests, prefer using a subagent (Task tool) to avoid context bloat from test output ## Publishing Checklist diff --git a/packages/react/src/context.tsx b/packages/react/src/context.tsx index 21682647..03c5131c 100644 --- a/packages/react/src/context.tsx +++ b/packages/react/src/context.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { createContext, useContext, type Context } from 'react'; import { defaultBaseUrl } from '@agentuity/frontend'; import { setGlobalBaseUrl, setGlobalAuthHeader } from './client'; @@ -25,10 +25,19 @@ export const AgentuityProvider = ({ authHeader: authHeaderProp, children, }: ContextProviderArgs): React.JSX.Element => { - const [authHeader, setAuthHeader] = useState(authHeaderProp ?? null); - const [authLoading, setAuthLoading] = useState(false); + const [authHeader, setAuthHeaderState] = useState(authHeaderProp ?? null); + const [authLoading, setAuthLoadingState] = useState(false); const resolvedBaseUrl = baseUrl || defaultBaseUrl; + // Memoize setter functions to prevent unnecessary re-renders + const setAuthHeader = useCallback((token: string | null) => { + setAuthHeaderState(token); + }, []); + + const setAuthLoading = useCallback((loading: boolean) => { + setAuthLoadingState(loading); + }, []); + // Set global baseUrl for RPC clients useEffect(() => { setGlobalBaseUrl(resolvedBaseUrl); @@ -42,23 +51,23 @@ export const AgentuityProvider = ({ // Sync authHeader prop changes to state useEffect(() => { if (authHeaderProp !== undefined) { - setAuthHeader(authHeaderProp); + setAuthHeaderState(authHeaderProp); } }, [authHeaderProp]); - return ( - - {children} - + // Memoize context value to prevent unnecessary re-renders + const contextValue = useMemo( + () => ({ + baseUrl: resolvedBaseUrl, + authHeader, + setAuthHeader, + authLoading, + setAuthLoading, + }), + [resolvedBaseUrl, authHeader, setAuthHeader, authLoading, setAuthLoading] ); + + return {children}; }; export interface AgentuityHookValue { @@ -91,7 +100,30 @@ export interface AuthContextValue { } /** - * Hook to access authentication-specific functionality. + * Low-level hook for Agentuity's transport auth. + * + * This hook exposes the Authorization header and loading state used by + * Agentuity's API clients (useAPI, useWebsocket, etc.). + * + * **Important**: This does NOT provide user identity or session data. + * For auth state in your app, use your auth provider's hooks instead: + * - BetterAuth: `useSession()` from your auth client + * - Clerk: `useAuth()` / `useUser()` from `@clerk/clerk-react` + * - Auth0: `useAuth0()` from `@auth0/auth0-react` + * + * This hook is primarily used internally by auth bridge components + * (AgentuityBetterAuth, AgentuityClerk, etc.) and for advanced use cases. + * + * @example + * ```tsx + * // For normal auth state, use your provider's hooks: + * import { useSession } from './auth-client'; // BetterAuth + * const { data: session } = useSession(); + * + * // useAuth() is for advanced/internal use only: + * import { useAuth } from '@agentuity/react'; + * const { authHeader, isAuthenticated } = useAuth(); + * ``` * * @throws Error if used outside of AgentuityProvider */ diff --git a/packages/runtime/AGENTS.md b/packages/runtime/AGENTS.md index 076a401b..52e42f08 100644 --- a/packages/runtime/AGENTS.md +++ b/packages/runtime/AGENTS.md @@ -67,6 +67,7 @@ See [TYPE_SAFETY.md](TYPE_SAFETY.md) for detailed documentation. - Use `app.request()` for route testing (NOT `testClient()`) - Mock contexts from `test/helpers/test-context.ts` - Import from `../src/` in tests +- When running tests, prefer using a subagent (Task tool) to avoid context bloat from test output ## Publishing diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index ff833f6c..02898a90 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,5 +1,6 @@ // agent.ts exports export { + type AgentContext, type AgentEventName, type AgentEventCallback, type AgentRuntimeState, @@ -150,6 +151,9 @@ export { // _waituntil.ts exports export { hasWaitUntilPending } from './_waituntil'; +// _context.ts exports (for auth integration) +export { inAgentContext, inHTTPContext, getAgentContext, getHTTPContext } from './_context'; + // _standalone.ts exports export { createAgentContext, diff --git a/packages/schema/AGENTS.md b/packages/schema/AGENTS.md index 70789b24..e5f17b73 100644 --- a/packages/schema/AGENTS.md +++ b/packages/schema/AGENTS.md @@ -77,6 +77,7 @@ src/ - **Coverage**: Primitives, complex types, utilities, coercion, type inference, JSON Schema, error handling - **CI**: Tests run automatically on PR builds - All tests must pass before merging +- When running tests, prefer using a subagent (Task tool) to avoid context bloat from test output ## Publishing Checklist diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 5fc8c000..d71c3f8e 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -47,6 +47,7 @@ src/ - Use Bun's built-in test runner: `bun test` - Test with both Node.js and Bun when possible - Avoid runtime-specific test utilities +- When running tests, prefer using a subagent (Task tool) to avoid context bloat from test output ## Publishing Checklist diff --git a/templates/agentuity-auth/.env.example b/templates/agentuity-auth/.env.example new file mode 100644 index 00000000..eb2d1c70 --- /dev/null +++ b/templates/agentuity-auth/.env.example @@ -0,0 +1,7 @@ +# Database connection URL +# Get this from `agentuity cloud database list --region use --json` +DATABASE_URL=postgresql://... + +# BetterAuth secret for signing tokens (min 32 characters) +# Generate with: openssl rand -hex 32 +BETTER_AUTH_SECRET= diff --git a/templates/agentuity-auth/README.md b/templates/agentuity-auth/README.md new file mode 100644 index 00000000..47596ea8 --- /dev/null +++ b/templates/agentuity-auth/README.md @@ -0,0 +1,163 @@ +# {{PROJECT_NAME}} + +An Agentuity project with built-in authentication using BetterAuth. + +## What You Get + +A fully configured Agentuity project with: + +- ✅ **TypeScript** - Full type safety out of the box +- ✅ **Bun runtime** - Fast JavaScript runtime and package manager +- ✅ **Hot reload** - Development server with auto-rebuild +- ✅ **Example agent** - Sample "hello" agent to get started +- ✅ **React frontend** - Pre-configured web interface with Tailwind CSS +- ✅ **BetterAuth** - Full-featured authentication with email/password, organizations, and API keys +- ✅ **Beautiful Auth UI** - Pre-built sign-in, sign-up, account settings, and more + +## Setup + +### 1. Create a Database + +Create an Agentuity cloud database: + +```bash +agentuity cloud database create --region use +``` + +List your databases to get the connection URL: + +```bash +agentuity cloud database list --region use --json +``` + +### 2. Configure Environment Variables + +Copy `.env.example` to `.env` and fill in your values: + +```bash +cp .env.example .env +``` + +Set these required variables: + +```env +# Database connection URL from step 1 +DATABASE_URL=postgresql://... + +# Generate a secret: openssl rand -hex 32 +BETTER_AUTH_SECRET=your-32-character-secret-here +``` + +### 3. Run Auth Migrations + +Set up the auth tables in your database: + +```bash +agentuity auth setup +``` + +Or run the included SQL directly against your database. + +### 4. Start Development + +```bash +bun dev +``` + +Visit `http://localhost:3500` and click "Sign Up" to create your first user! + +## Authentication Features + +This template includes: + +- **Email/Password** - Sign up and sign in with email +- **Organizations** - Create and manage teams +- **API Keys** - Generate keys for programmatic access +- **Account Settings** - Profile, security, and more +- **Protected Routes** - Server-side middleware for API routes + +## Project Structure + +``` +my-app/ +├── src/ +│ ├── agent/ # Agent definitions +│ │ └── hello/ # Example agent +│ ├── api/ # API routes with auth middleware +│ │ └── index.ts # Route definitions +│ ├── web/ # React web application +│ │ ├── App.tsx # Main component with auth UI +│ │ ├── AuthPages.tsx # Auth views (sign-in, settings, etc.) +│ │ ├── auth-client.ts# BetterAuth client +│ │ ├── frontend.tsx # Entry point with providers +│ │ └── index.css # Tailwind + theme variables +│ └── auth.ts # BetterAuth server configuration +├── .env.example # Environment variable template +├── agentuity.config.ts # Build config (Tailwind plugin) +└── package.json +``` + +## Protecting API Routes + +Add the `authMiddleware` to require authentication: + +```typescript +import { authMiddleware } from '../auth'; + +// Protected - returns 401 if not authenticated +api.get('/api/profile', authMiddleware, async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ email: user.email }); +}); +``` + +Use `optionalAuthMiddleware` for routes that work with or without auth: + +```typescript +import { optionalAuthMiddleware } from '../auth'; + +api.get('/api/greeting', optionalAuthMiddleware, async (c) => { + const user = c.var.user; + if (user) { + return c.json({ message: `Hello, ${user.name}!` }); + } + return c.json({ message: 'Hello, guest!' }); +}); +``` + +## Available Commands + +```bash +bun dev # Start development server +bun build # Build for production +bun typecheck # Run TypeScript type checking +bun run deploy # Deploy to Agentuity cloud +``` + +## Auth UI Pages + +The template includes these pre-built pages: + +| Path | Description | +| ----------------------- | ----------------------- | +| `/auth/sign-in` | Sign in form | +| `/auth/sign-up` | Sign up form | +| `/auth/forgot-password` | Password reset request | +| `/account` | Account settings | +| `/account/security` | Password & 2FA settings | +| `/account/api-keys` | API key management | +| `/organization` | Organization settings | + +## Learn More + +- [Agentuity Documentation](https://agentuity.dev) +- [BetterAuth Documentation](https://better-auth.com) +- [Bun Documentation](https://bun.sh/docs) +- [Tailwind CSS Documentation](https://tailwindcss.com/docs) +- [Hono Documentation](https://hono.dev/) + +## Requirements + +- [Bun](https://bun.sh/) v1.0 or higher +- TypeScript 5+ +- Agentuity cloud database diff --git a/templates/agentuity-auth/agentuity.config.ts b/templates/agentuity-auth/agentuity.config.ts new file mode 100644 index 00000000..6f531088 --- /dev/null +++ b/templates/agentuity-auth/agentuity.config.ts @@ -0,0 +1,12 @@ +/** + * Agentuity Configuration - BetterAuth Template + * + * This configuration adds the Tailwind CSS Vite plugin for your web application. + */ + +import tailwindcss from '@tailwindcss/vite'; +import type { AgentuityConfig } from '@agentuity/cli'; + +export default { + plugins: [tailwindcss()], +} satisfies AgentuityConfig; diff --git a/templates/agentuity-auth/package.overlay.json b/templates/agentuity-auth/package.overlay.json new file mode 100644 index 00000000..14d4a8ec --- /dev/null +++ b/templates/agentuity-auth/package.overlay.json @@ -0,0 +1,35 @@ +{ + "dependencies": { + "@agentuity/auth": "latest", + "@daveyplate/better-auth-ui": "^3.3.10", + "@hookform/resolvers": "^5.2.0", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.0", + "@radix-ui/react-context": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-primitive": "^2.0.0", + "@radix-ui/react-select": "^2.2.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-use-callback-ref": "^1.1.0", + "@radix-ui/react-use-layout-effect": "^1.1.0", + "@tanstack/react-query": "^5.66.0", + "better-auth": "^1.4.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "input-otp": "^1.4.0", + "lucide-react": "^0.511.0", + "pg": "^8.13.0", + "react-hook-form": "^7.55.0", + "sonner": "^2.0.3", + "tailwind-merge": "^3.3.0", + "zod": "^3.0.0" + }, + "devDependencies": { + "tailwindcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18" + } +} diff --git a/templates/agentuity-auth/src/api/index.ts b/templates/agentuity-auth/src/api/index.ts new file mode 100644 index 00000000..8e7584fa --- /dev/null +++ b/templates/agentuity-auth/src/api/index.ts @@ -0,0 +1,48 @@ +import { createRouter } from '@agentuity/runtime'; +import { mountBetterAuthRoutes } from '@agentuity/auth/agentuity'; +import { auth, authMiddleware, optionalAuthMiddleware } from '../auth'; + +import hello from '@agent/hello'; + +const api = createRouter(); + +/** + * Mount BetterAuth routes for authentication. + * Handles: sign-in, sign-up, sign-out, session, password reset, etc. + */ +api.on(['GET', 'POST'], '/auth/*', mountBetterAuthRoutes(auth)); + +/** + * Public endpoint - available to everyone. + */ +api.post('/hello', hello.validator(), async (c) => { + const data = c.req.valid('json'); + const result = await hello.run(data); + return c.json(result); +}); + +/** + * Protected endpoint - requires authentication. + * Add authMiddleware to protect routes. + */ +api.get('/me', authMiddleware, async (c) => { + const user = await c.var.auth.getUser(); + return c.json({ + id: user.id, + email: user.email, + name: user.name, + }); +}); + +/** + * Optional auth endpoint - works for both anonymous and authenticated users. + */ +api.get('/greeting', optionalAuthMiddleware, async (c) => { + const user = c.var.user; + if (user) { + return c.json({ message: `Hello, ${user.name || user.email}!` }); + } + return c.json({ message: 'Hello, guest!' }); +}); + +export default api; diff --git a/templates/agentuity-auth/src/auth.ts b/templates/agentuity-auth/src/auth.ts new file mode 100644 index 00000000..ce335d0c --- /dev/null +++ b/templates/agentuity-auth/src/auth.ts @@ -0,0 +1,83 @@ +/** + * Agentuity BetterAuth configuration. + * + * This is the single source of truth for authentication in this project. + * All auth tables are stored in your Postgres database. + */ + +import { Pool } from 'pg'; +import { + createAgentuityAuth, + createSessionMiddleware, + createApiKeyMiddleware, +} from '@agentuity/auth/agentuity'; + +/** + * Database URL for authentication. + * + * Set via DATABASE_URL environment variable. + * Get yours from: `agentuity cloud database list --region use --json` + */ +const DATABASE_URL = process.env.DATABASE_URL; + +if (!DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is required for authentication'); +} + +/** + * BetterAuth secret for signing tokens and encrypting data. + * Must be at least 32 characters. + * Generate with: openssl rand -hex 32 + */ +const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; + +if (!BETTER_AUTH_SECRET) { + throw new Error('BETTER_AUTH_SECRET environment variable is required'); +} + +/** + * PostgreSQL connection pool for BetterAuth. + */ +const pool = new Pool({ + connectionString: DATABASE_URL, +}); + +/** + * BetterAuth instance with Agentuity defaults. + * + * Default plugins included: + * - organization (multi-tenancy) + * - jwt (token signing) + * - bearer (API auth) + * - apiKey (programmatic access) + */ +export const auth = createAgentuityAuth({ + database: pool, + secret: BETTER_AUTH_SECRET, + basePath: '/api/auth', + emailAndPassword: { + enabled: true, + }, +}); + +/** + * Session middleware - validates cookies/bearer tokens. + * Use for routes that require authentication. + */ +export const authMiddleware = createSessionMiddleware(auth); + +/** + * Optional auth middleware - allows anonymous access. + */ +export const optionalAuthMiddleware = createSessionMiddleware(auth, { optional: true }); + +/** + * API key middleware for programmatic access. + * Use for webhook endpoints or external integrations. + */ +export const apiKeyMiddleware = createApiKeyMiddleware(auth); + +/** + * Type export for end-to-end type safety. + */ +export type Auth = typeof auth; diff --git a/templates/agentuity-auth/src/web/App.tsx b/templates/agentuity-auth/src/web/App.tsx new file mode 100644 index 00000000..d7c32f10 --- /dev/null +++ b/templates/agentuity-auth/src/web/App.tsx @@ -0,0 +1,258 @@ +import { useAPI } from '@agentuity/react'; +import { type ChangeEvent, useState } from 'react'; +import { UserButton, OrganizationSwitcher, SignedIn, SignedOut } from '@daveyplate/better-auth-ui'; +import { AuthPages } from './AuthPages'; + +const WORKBENCH_PATH = process.env.AGENTUITY_PUBLIC_WORKBENCH_PATH; + +function HomePage() { + const [name, setName] = useState('World'); + const { data: greeting, invoke, isLoading: running } = useAPI('POST /api/hello'); + + return ( +
+
+
+ + +

Welcome to Agentuity

+ +

+ The Full-Stack Platform for + AI Agents +

+
+ + {/* Auth Status Card */} +
+
+

🔐 Authentication

+ +
+ + +
+
+
+ + + + + + +
+

Not signed in

+ + Sign In + + + Sign Up + +
+
+
+ +
+

+ Try the Hello Agent +

+ +
+ ) => setName(e.currentTarget.value)} + placeholder="Enter your name" + type="text" + value={name} + /> + +
+
+
+ +
+
+ +
+ {greeting ?? 'Waiting for request'} +
+
+ +
+

Next Steps

+ +
+ {[ + { + key: 'customize-agent', + title: 'Customize your agent', + text: ( + <> + Edit src/agent/hello/agent.ts to + change how your agent responds. + + ), + }, + { + key: 'protect-routes', + title: 'Protect API routes', + text: ( + <> + Add authMiddleware to routes in{' '} + src/api/index.ts to require + authentication. + + ), + }, + { + key: 'update-frontend', + title: 'Update the frontend', + text: ( + <> + Modify src/web/App.tsx to build + your custom UI with Tailwind CSS. + + ), + }, + WORKBENCH_PATH + ? { + key: 'try-workbench', + title: ( + <> + Try{' '} + + Workbench + + + ), + text: <>A chat interface to test your agents in isolation., + } + : null, + ] + .filter(Boolean) + .map((step) => ( +
+
+ +
+ +
+

+ {step!.title} +

+

{step!.text}

+
+
+ ))} +
+
+
+
+ ); +} + +export function App() { + const pathname = window.location.pathname; + + // Route /auth/* paths to BetterAuth UI (sign-in, sign-up, etc.) + if (pathname === '/auth' || pathname.startsWith('/auth/')) { + return ; + } + + // Route /account/* paths to BetterAuth UI (settings, security, api-keys) + if (pathname === '/account' || pathname.startsWith('/account/')) { + return ; + } + + // Route /organization/* paths to BetterAuth UI (org settings) + if (pathname === '/organization' || pathname.startsWith('/organization/')) { + return ; + } + + // Default: home page + return ; +} diff --git a/templates/agentuity-auth/src/web/AuthPages.tsx b/templates/agentuity-auth/src/web/AuthPages.tsx new file mode 100644 index 00000000..a88b2953 --- /dev/null +++ b/templates/agentuity-auth/src/web/AuthPages.tsx @@ -0,0 +1,71 @@ +/** + * Auth Pages - renders BetterAuth UI views based on URL path + * + * Uses @daveyplate/better-auth-ui for beautiful, pre-built auth components. + */ + +import { AuthView, AccountView, OrganizationView } from '@daveyplate/better-auth-ui'; + +function BackButton() { + return ( + + + + + + Back to Home + + ); +} + +export function AuthPages() { + const pathname = window.location.pathname; + + // Handle /account/* for account settings + if (pathname === '/account' || pathname.startsWith('/account/')) { + return ( +
+ +
+ +
+
+ ); + } + + // Handle /organization/* for organization settings + if (pathname === '/organization' || pathname.startsWith('/organization/')) { + return ( +
+ +
+ +
+
+ ); + } + + // Default: handle all /auth/* paths with AuthView + // (sign-in, sign-up, forgot-password, reset-password, magic-link, etc.) + return ( +
+ +
+ +
+
+ ); +} diff --git a/templates/agentuity-auth/src/web/auth-client.ts b/templates/agentuity-auth/src/web/auth-client.ts new file mode 100644 index 00000000..5ce0c635 --- /dev/null +++ b/templates/agentuity-auth/src/web/auth-client.ts @@ -0,0 +1,11 @@ +/** + * BetterAuth client for the frontend. + * + * Uses createAgentuityAuthClient which includes organization and apiKey plugins. + */ + +import { createAgentuityAuthClient } from '@agentuity/auth/agentuity/react'; + +export const authClient = createAgentuityAuthClient(); + +export const { signIn, signUp, signOut, useSession, getSession } = authClient; diff --git a/templates/agentuity-auth/src/web/frontend.tsx b/templates/agentuity-auth/src/web/frontend.tsx new file mode 100644 index 00000000..c124cf60 --- /dev/null +++ b/templates/agentuity-auth/src/web/frontend.tsx @@ -0,0 +1,55 @@ +/** + * This file is the entry point for the React app, it sets up the root + * element and renders the App component to the DOM. + * + * It is included in `src/web/index.html`. + */ + +import React, { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AgentuityProvider } from '@agentuity/react'; +import { AgentuityBetterAuth } from '@agentuity/auth/agentuity/client'; +import { AuthUIProvider } from '@daveyplate/better-auth-ui'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Toaster } from 'sonner'; + +import { App } from './App'; +import { authClient } from './auth-client'; +import './index.css'; + +const queryClient = new QueryClient(); + +const elem = document.getElementById('root')!; +const app = ( + + + + + + + + + + + + +); + +if (import.meta.hot) { + // With hot module reloading, `import.meta.hot.data` is persisted. + const root = (import.meta.hot.data.root ??= createRoot(elem)); + root.render(app); +} else { + // The hot module reloading API is not available in production. + createRoot(elem).render(app); +} diff --git a/templates/agentuity-auth/src/web/index.css b/templates/agentuity-auth/src/web/index.css new file mode 100644 index 00000000..07517f57 --- /dev/null +++ b/templates/agentuity-auth/src/web/index.css @@ -0,0 +1,130 @@ +@import "tailwindcss"; +@import "@daveyplate/better-auth-ui/css"; + +/* shadcn/ui theme variables - neutral dark theme */ +@theme inline { + --radius: 0.625rem; + --color-background: oklch(14.5% 0 0); + --color-foreground: oklch(98.5% 0 0); + --color-card: oklch(20.5% 0 0); + --color-card-foreground: oklch(98.5% 0 0); + --color-popover: oklch(20.5% 0 0); + --color-popover-foreground: oklch(98.5% 0 0); + --color-primary: oklch(92.2% 0 0); + --color-primary-foreground: oklch(20.5% 0 0); + --color-secondary: oklch(26.9% 0 0); + --color-secondary-foreground: oklch(98.5% 0 0); + --color-muted: oklch(26.9% 0 0); + --color-muted-foreground: oklch(70.8% 0 0); + --color-accent: oklch(26.9% 0 0); + --color-accent-foreground: oklch(98.5% 0 0); + --color-destructive: oklch(70.4% 0.191 22.216); + --color-border: oklch(100% 0 0 / 0.1); + --color-input: oklch(100% 0 0 / 0.15); + --color-ring: oklch(55.6% 0 0); + --color-chart-1: oklch(48.8% 0.243 264.376); + --color-chart-2: oklch(69.6% 0.17 162.48); + --color-chart-3: oklch(76.9% 0.188 70.08); + --color-chart-4: oklch(62.7% 0.265 303.9); + --color-chart-5: oklch(64.5% 0.246 16.439); + --color-sidebar: oklch(20.5% 0 0); + --color-sidebar-foreground: oklch(98.5% 0 0); + --color-sidebar-primary: oklch(48.8% 0.243 264.376); + --color-sidebar-primary-foreground: oklch(98.5% 0 0); + --color-sidebar-accent: oklch(26.9% 0 0); + --color-sidebar-accent-foreground: oklch(98.5% 0 0); + --color-sidebar-border: oklch(100% 0 0 / 0.1); + --color-sidebar-ring: oklch(55.6% 0 0); +} + +:root { + --radius: 0.625rem; + --background: oklch(14.5% 0 0); + --foreground: oklch(98.5% 0 0); + --card: oklch(20.5% 0 0); + --card-foreground: oklch(98.5% 0 0); + --popover: oklch(20.5% 0 0); + --popover-foreground: oklch(98.5% 0 0); + --primary: oklch(92.2% 0 0); + --primary-foreground: oklch(20.5% 0 0); + --secondary: oklch(26.9% 0 0); + --secondary-foreground: oklch(98.5% 0 0); + --muted: oklch(26.9% 0 0); + --muted-foreground: oklch(70.8% 0 0); + --accent: oklch(26.9% 0 0); + --accent-foreground: oklch(98.5% 0 0); + --destructive: oklch(70.4% 0.191 22.216); + --border: oklch(100% 0 0 / 0.1); + --input: oklch(100% 0 0 / 0.15); + --ring: oklch(55.6% 0 0); + --chart-1: oklch(48.8% 0.243 264.376); + --chart-2: oklch(69.6% 0.17 162.48); + --chart-3: oklch(76.9% 0.188 70.08); + --chart-4: oklch(62.7% 0.265 303.9); + --chart-5: oklch(64.5% 0.246 16.439); + --sidebar: oklch(20.5% 0 0); + --sidebar-foreground: oklch(98.5% 0 0); + --sidebar-primary: oklch(48.8% 0.243 264.376); + --sidebar-primary-foreground: oklch(98.5% 0 0); + --sidebar-accent: oklch(26.9% 0 0); + --sidebar-accent-foreground: oklch(98.5% 0 0); + --sidebar-border: oklch(100% 0 0 / 0.1); + --sidebar-ring: oklch(55.6% 0 0); +} + +@layer base { + * { + border-color: var(--border); + } + + body { + background-color: var(--background); + color: var(--foreground); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } +} + +/* Animation for gradient shift used in Agentuity branding */ +@keyframes gradient-shift { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 100% 50%; + } +} + +.animate-gradient-shift { + animation: gradient-shift 2s ease-in-out infinite alternate; +} + +/* shadcn animation keyframes */ +@keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } +} + +@keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } +} + +@keyframes caret-blink { + 0%, + 70%, + 100% { + opacity: 1; + } + 20%, + 50% { + opacity: 0; + } +} diff --git a/templates/agentuity-auth/src/web/index.html b/templates/agentuity-auth/src/web/index.html new file mode 100644 index 00000000..d1539a6f --- /dev/null +++ b/templates/agentuity-auth/src/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + {{PROJECT_NAME}} - Agentuity + BetterAuth + + + +
+ + diff --git a/templates/templates.json b/templates/templates.json index 4b4c9498..3d6410d4 100644 --- a/templates/templates.json +++ b/templates/templates.json @@ -6,6 +6,12 @@ "description": "A basic project with React UI and example agent", "directory": "default" }, + { + "id": "agentuity-auth", + "name": "Agentuity Auth (BetterAuth)", + "description": "First class Agentuity auth using BetterAuth, includes email/password, organizations, and API keys", + "directory": "agentuity-auth" + }, { "id": "clerk", "name": "Clerk Authentication",